쿼리 파라미터 추가할 때 DTO -> LinkedMultiValueMap으로 변환해주는 유틸리티 클래스 고려해보기
최대한 메인 패키지 코드를 사용하고, 메인 패키지와 분리되어 별도로 작성하는 부분을 최소화하자. 가령 쿼리 파라미터의 경우 변경되는 경우 테스트 코드에 바로 반영 안되는 부분 있으므로... 메인 코드베이스와 연결된 유틸리티 사용하여 변경 발생 시 바로 테스트 실패로 체크할 수 있도록 만들어보자.
https://jojoldu.tistory.com/478
다대일 양방향을 만드는 경우 연관관계 편의 메서드를 만들라는 말을 많이 한다. 이는 애플리케이션 단에서는 DB로부터 매핑해온 객체들의 동기화를 유지해주기 위함이다.
그렇다면 다대일 양방향이라면 항상 연관관계 편의 메서드가 필요할까? 그건 아니라고 생각한다. 구체적으로는 '연관관계가 수정되고 + 일(1)쪽에서 다(m)쪽 접근하는(e.g. team.getMemeberList()
)' 경우에만 이를 동기화해줘야 한다고 본다. 일(1)쪽에서는 다(m)쪽에 있는 리스트를 지연로딩해서 관리할 것이다. 센터에서 신청서를 작성할 때 신청서는 센터 pk를 외래키로 가지고 있다. 즉 Application
은 Center
프로퍼티를 가진다.
우리 서비스의 경우 Center
에서 신청서 목록에 접근해야할 필요가 있었고, 이것이 신청서 서비스가 아닌 센터 서비스에서 처리하면 좋은 상황이었기 때문에 두 가지 선택지 중 고민이 생겼다.
-
ApplicationRepository
에서findByCenter
로 조회하는 방법-> 이 경우
CenterService
에서ApplicationRepository
를 의존하게 된다. 하나의 서비스에서 다른 도메인의 레포지터리를 참조하는 것은 문제가 아니지만, 레포지터리에 최대한 덜 의존하는게 좋지 않을까? -
center.getApplications()
로 조회하는 방법-> 이 경우
CenterService
가ApplicationRepository
를 의존할 필요가 없으므로 의존관계가 깔끔해진다. 하지만 양방향 관계이므로 매핑 로직을 작성해야 할 지도 모른다.
특히 우리는 신청서를 '현재 로그인한 교사의 소속 센터' 기준으로 쿼리해오는 경우가 많았기 때문에 2번이 더 유용하게 사용될 수 있을 거라고 생각했다. 그래서 처음에는 2번을 생각했었지만 실제 기능을 개발하면서 점점 1번으로 생각이 바뀌게 되었다. 그 이유는 아래와 같다.
물론 N+1 문제의 경우 페치 조인 / 엔티티 그래프 / 배치 사이즈 조절 등으로 해결할 수 있다. 이러한 리스트의 경우 해당 목록을 전부 가져와야 할 때는 이점을 가지지만, "fk가 아닌 특정 조건으로 조회해야 하는 경우 / 연관관계를 타고 들어가야 하는 경우"에는 손해보는 경우가 더 많다.
가령 센터 도메인의 경우 List<Application>
을 가질 것이다. 센터 당 신청서가 많지 않은 경우에는 이것이 큰 문제가 되지 않는다. 하지만 신청서가 많아진다면? 전체를 조회해온 다음 Stream
등을 사용하여 한번 더 필터링해줘야 한다. 특정 센터의 신청서 중 특정 일자 이후의 신청서만 조회
하는 경우가 대표적이다. 이 경우 신청서 레포지터리에서 쿼리 메서드를 사용하는 편이 더 좋을 것이다. WHERE
절은 뒀다가 어따 써먹을려고?
N+1을 해결하는 방법은 '특정 센터의 신청서를 "모두" 가져온다' 라는 전제를 기반으로 한다. 저 전제를 어기는 순간 양방향을 사용한 조회는 쓸모없어진다.
하지만 이 정도를 생각하지 못했겠는가? 여기에 대해서도 반박할 수 있는 포인트가 있다.
-
현재 서비스 정책 상 센터 당 신청서가 많이 쌓일 수가 없다.
센터 당 최대 2~3건의 신청서만 접수할 수 있다. 그래서 전체를 다 가져와서 다루더라도 큰 문제가 아니다.
-
조회 정책에 대해 유연해진다.
내가 처음에 2번 해결책을 밀었던 이유기도 하다. 아까 예시를 생각해보자. 기존 요구사항은
특정 센터의 신청서를 모두 조회
해오는 것이라고 하자. 그러면user.getCenter()
후center.getApplications()
를 통해 신청서 목록을 반환할 수 있을 것이다.이후 요구사항이 바뀌어
특정 센터의 신청서 중 특정 일자 이후의 신청서만 조회
해오는 것으로 변경되었다고 하자. 그렇다면 신청서 목록을 이렇게 필터링해주면 된다.// retrieve the center to which the current user belongs List<Application> applications = center.getApplications(); List<Application> filteredApplications = applications.stream() .filter(applicationFilterStrategy) .toList();
쿼리 메서드는 조회 요구사항 변화에 유연하게 대응할 수 없다. 만약 동적 쿼리가 필요하다면? QueryDsl을 도입해야 한다. 쿼리가 복잡해진다면? 쿼리 메서드가 아닌 JPQL을 직접 짜야할 수도 있다. 특히 쿼리 메서드를 사용하는 경우 서비스 레이어에서 호출하는 코드가 직접적으로 바뀐다. 서비스 레이어 테스트에서 쿼리 메서드르 stubbing 해주었다면 이를 적절히 수정해줘야 한다 (만약 당신이 mockist라는 가정 하에!).
여기서 한 술 더 떠서 이제는
특정 센터의 신청서 중 특정 담당자 이름을 가진 특정 일자 이후의 신청서만 조회
해오는 것으로 요구사항이 바뀌었다고 하자. 쿼리 메서드를 쓰든 JPQL을 쓰든 서비스 레이어를 쓰든하지만 신청서 목록을 전부 들고온 다음 스트림을 사용하여 필터링한다면? 필터링 로직을 동적으로 결정할 수 있다. 또한, 필터링 정책이 바뀌더라도 유연하게 코드를 수정함으로써 대응할 수 있다.
즉, 둘의 차이는
요구사항 변화에 대해 메서드 시그니쳐가 바뀌는지
의 여부라 할 수 있다. 신청서의 전체 값을 들고 있다면 신청서를 인자로 받아서 내부 값 기준으로 필터링 해주면 되니까, 필터링 로직이 바뀌더라도 내부 구현만 바뀌고 메서드 시그니쳐(파라미터, 리턴타입)는 바뀌지 않는다. 레포지터리는 메서드 파라미터로 '이러한 조건에 해당하는지'를 넘긴다. 필터링 로직이 바뀌면 넘겨주는 파라미터 타입도 달라진다.이해가 되는가?
여기까지만 본다면 이런 생각을 할 수 있겠다.
아, 레포지터리를 의존하는 것과 도메인에 양방향 관계를 추가하는 것 사이에는 '성능' vs '유연성' 간의 trade-off 관계에 있구나!
하지만 아니다! (물론 100% 틀린 말도 아니다) 조금만 더 생각해보자.
먼저 '신청서 -> 신청서의 특정 임베디드 타입 -> 임베디드 타입의 특정 프로퍼티'를 기준으로 쿼리를 날려야 한다고 하자. center.getApplications().get(someIndex).getApplicationEmbeddedInstance().getSomeProperty()
와 같이 타고타고 들어가서 해당 값에 접근해야 할 것이다. 하지만 우리는 객체지향을 준수하는 멋진 개발자이므로 디미터 법칙을 지키기 위해 dotted를 여러 번 쓰고 싶지 않을 것이다. 그럼 이렇게 수정할 수 있겠다.
List<Application> applications = center.getApplications();
Application selectedApplication = applications.get(someIndex);
SomeEmbeddedType someEmbeddedInstance = selectedApplication.getSomeEmbeddedInstance();
SomeProperty someProperty = someEmbeddedInstance.getSomeProperty();
정말로 이걸로 괜찮은걸까? 이 코드를 Stream API를 사용할 때 우아하게 집어넣는 것이 과연 가능할까?
그냥 applicationRepository.findBySomeProperty()
를 쓰면 안되는걸까?
때로는 코드를 더럽히는 것보다 SQL의 기능을 쓰는 것이 더 편리할 때도 있다.
아! 그거 참 기가 막히는군요! 그래서 그 로직은 어디에 배치하나요?
CUD에 해당하는 커맨드는 도메인에, R에 해당하는 조회는 레포지터리에 배치한다. 도메인 모델 패턴 사용 시 서비스 레이어에서는 이렇게 분산된 책임을 바탕으로 적절한 메서드를 호출하여 '트랜잭션의 순서만' 보장하고 구체적인 로직을 수행하지는 않는다.
위에서 getApplications()
를 호출하여 필터링하는 로직은 일종의 조건에 따른 조회라고 볼 수 있지만 그렇다고 해서 데이터 엑세스라고 보기는 어려우니 레포지터리 레이어에 넣기도 애매하다.
먼저 김영한 선생님의 답변이다.
ORM 설계에서는 다대일 단방향 관계로 설계가 끝납니다.
- 보통 다대일, 일대다 양방향 관계가 필요한 경우는 일대다 방향으로 fetch join이 필요할 때 입니다.
fetch join이 성능상 매우 편리하기 때문에 현실적으로 이것 때문에 많이 사용됩니다.
- 그래고 객체지향 설계에서 Order -> OrderItem으로 관계가 걸리는 것이 더 나은 경우에도 양방향 연관관계가 사용됩니다.
연관관계는 적을 수록 좋지만 말씀드린 이유들 때문에 추가를 어느정도 하는 편입니다.
다른 인프런 지식공유자 분이 남긴 답변 중 일부이다.
그런데 실제로는 도메인이 훨씬 중요하고, 도메인을 잘 설계한 다음 Jpa를 나중에 갖다 붙이는 방식으로 개발이 되어야 합니다.
양방향 연관 관계는 순환 참조를 써도 된다는 의미가 아니라, "도메인 설계를 하다가 어쩔 수 없이 나오는 순환 참조 문제는 이거라도 써서 해결하세요." 라는 의미로 나온겁니다
염려하신 것처럼 간접 참조를 하면 쿼리 몇 줄이 더 추가될 수 있습니다. 그런데 순환 참조를 없애고 도메인과 DB의 연결을 끊었을 때 얻을 수 있는 혜택들이 너무나 명확합니다. 복잡도가 줄어들고 데이터 접근 경로가 한방향으로 통일됩니다. 규모가 있는 서비스를 다룰 때 시스템 복잡도는 굉장히 큰 문제입니다. 원하는 데이터를 접근해야 할 때 어떤 지점에서 시작해서 어떻게 접근하도록 만들 것이냐도 굉장히 큰 문제고요. 데이터 접근 경로가 다양해지면 마냥 좋을 것 같지만 경로가 여러 개면 오히려 콜스택이 복잡해져서 추적하기 더 힘들어집니다.
-
양방향 연관관계를 쓰는 경우는 1) 일(1)쪽에서 다(m)쪽을 전부 조회해오는 경우거나 2) 일(1)쪽에서 관계를 관리하는 것이 편한 경우이다.
내가 제시한 방법의 경우 두 경우 모두 해당되지 않으며, 설사 사용한다 하더라도 많은 제약조건을 가지고 있고 해당 로직을 가진 클래스가 적절한 책임을 가진다고 보기도 어렵다. 일관되지 않고 특정한 케이스에 의존하는 설계는 좋은 설계가 아니다. -
서비스의 규모가 커질수록 일관성이 중요해진다. 요구사항 변화가 빈번하게 발생하는 경우 유연성 스탯을 많이 찍어야겠지만, 서비스 규모가 크다면 일관성 스탯을 많이 찍어야 한다. 결국은 이 역시도 trade-off에 대한 이야기이다.
코드 리뷰를 달다 보면 설계나 로직에 대한 부분보다 conventional하지 않은 코드 스타일이 먼저 눈에 들어오는 경우가 있다. 파일 마지막에 LF를 추가한다는 굉장히 관행적인 부분부터 가독성에 꽤 크리티컬하게 작용하는 부분도 있고... 아무튼 이런 부분에 대해서 일일히 '아 이 부분 개행 빠트리셨어요' 라고 리뷰 달기도 애매하고 그렇다고 그냥 넘어가기도 애매하고 참으로 난감한 이슈다.
가령 우리 팀원의 경우 클래스 선언부 아래에 newline을 하고 그 다음에 멤버변수를 나열하는데, 이게 어떤 경우에는 개행을 할 때도 있고 어떤 경우에는 개행을 안할 때도 있어서 코드 포매터같은 툴을 써서 통일을 해야겠다는 생각이 있었지만 일이 바빠서 항상 넘기고 말았다. 오늘 리뷰를 달려고 들어갔다가 작업한 모든 클래스의 선언부와 멤버변수들이 한 줄씩 띄어져있는 걸 보고 아 이거 더 늦게 전에 코딩 스타일 정해야겠구나 하는 생각이 들어서 바로 이슈로 파버렸다.
코딩 스타일의 경우 팀마다 다르고 정하기 나름이라고 하지만 글쎄. 개중에서는 분명히 메인스트림이라는 게 있을 것이고, 변경에 큰 비용이 발생하는 게 아니라면 최대한 표준에 맞게 가져가는 게 좋지 않을까 하는 생각이다.
내일 쓰는 걸로...