저도 아직 JPA 에 적응 중이지만, 지금 회사에서 실무를 처음 겪으면서 기존 코드에서 JPA 잘못된 사용으로 발생한 트러블슈팅 2건 관련해서 정리해둡니다.
둘 다 JPA 를 사용하면서 실수할 수 있는 기초적인 케이스라, 처음 실무를 하는 분들이 유사한 실수를 하지 않게끔 기록하려고 합니다.
1. OneToOne 단방향 + FK 위치 때문에 LAZY가 기대대로 동작하지 않은 케이스
저희 서비스에서 사용하는 여러 DB 중 메인 DB 의 CPU 가 서비스 성장과 함께 날로 증가하고 있었던 중 CPU 를 줄일 수 있는 방법을 찾고 있었습니다.
그러던 중 호출 빈도가 가장 높은 쿼리 중 서비스에 크게 사용되지 않는 테이블을 조회하는 쿼리가 있는 것을 발견했고, 해당 쿼리가 왜 많이 호출되는지 분석을 시작했습니다.
초기 코드는 아래와 같았습니다.
// Content.java
// ... 기존코드
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "contentId")
private ContentTree contentTree;
// ... 기존코드
// ContentTree.java
@Table(name = "`content_tree`")
@Entity
@Getter
@Setter
public class ContentTree {
@Id
private Long contentId;
private Long parentContentId;
private Long rootContentId;
}
간략히 설명하면 Content 는 서로 자식/부모 관계로 이뤄질 수 있고, ContentTree 테이블을 통해서 부모 content 와, root content 의 ID 를 관리를 하는 구조였습니다.
Content 테이블이 업무에 사용되는 메인 테이블이기에 조회할 때 마다 ContentTree 테이블을 조회하지 않기 위해 fetch = FetchType.LAZY 설정을 해두었지만, 실제로는 Content 테이블 조회 시 마다 조회가 되는 문제가 발생하고 있었습니다.
원인은 매핑 의도와 실제 DB 구조가 어긋나 있었다는 점입니다.
- 코드상으로는
Content가 FK를 가진 것처럼 보이지만 - 실제 테이블은
ContentTree.contentId가 PK/FK 역할을 수행 - 즉, 주인(
Content)/외래키 위치(ContentTree)가 현재 단방향 매핑 의도와 맞지 않음
저도 이번에 알았지만 아래 참고 자료에 잘 설명된 것과 같이 JPA 의 OneToOne 관계에서 FK가 주인 테이블(Content)이 아닌 대상 테이블(ContentTree)에 존재하는 경우 기능적 한계로 LAZY 로딩 불가한 것이었습니다.
아마 이런 지식을 잘 모르는 상태에서 막연하게 LAZY 로딩을 기대하며 코드를 짜고 그 이후 모니터링을 별도로 하지 않았던 게 아닐까 싶네요.
참고 자료 : [JPA] @OneToOne, 일대일[1:1] 관계
대응
대응을 위해 몇 가지 안을 찾아 아래와 같이 정리했습니다.
content -> contentTree직접 연관은 끊고 필요 시 별도 조회contentTree에contentId외 별도idPK 추가 ->content테이블에contentTreeIdFK 컬럼 추가- 실제 데이터는 1:1 이지만 코드 영향 최소화를 위해 일부 접근부는 1:N 형태를 수용하는 방식으로 완충
위에 설명드린 대로 해당 테이블은 서비스의 메인 테이블이라, 1,2번 작업을 하기엔 데이터 양도 많고, 연관된 코드가 많아 수정 범위가 넓어 side effect 이 우려되었습니다.
그래서 3번 방향으로 수정하는 것으로 가닥을 잡고 아래와 같이 수정하였습니다.
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "contentId")
@Builder.Default
private Set<ContentTree> contentTree = Collections.emptySet(); // Set 으로 한 이유는 아래 내용 참조!
//-- 기존 코드
public ContentTree getContentTree() {
return this.contentTree.stream()
.findFirst()
.orElse(null);
}
위와 같이 수정 후 기존 대비 45% 가량 호출 건수가 줄어드는 것을 확인하였습니다.
1-1. OneToOne -> OneToMany 변경 후 MultipleBagFetchException 발생
위 대응 코드 private Set<ContentTree> contentTree = Collections.emptySet(); 를 보면, 왜 List가 아니고 굳이 Set을 사용했는지 궁금한 분들이 계실 듯합니다.
저도 사실 처음에는 List<ContentTree> contentTree로 구현했지만, 테스트 과정 중 아래와 같은 에러를 만났습니다.
org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags:
위에 설명한 대로 메인 테이블이다 보니 다양한 Collection 멤버 변수가 존재했고, QueryDSL을 사용해 fetch join으로 여러 테이블을 참조하고 있는 상황이었습니다.
그런 상황에서 위와 같이 Content -> ContentTree 의 관게가 OneToOne에서 OneToMany로 바뀌면서 해당 에러가 발생하게 된 것이었습니다.
원인은 fetch join 시 ToMany 관계를 한 번에 조회하게 되는데, Collection join이 2개 이상이 될 경우 너무 많은 값이 메모리에 적재되어 에러가 발생하는 것이었습니다.
검색해 보니 List -> Set으로 바꾸는 것으로 해결이 가능했습니다. 에러 원인과 Set 변경 시 해결되는 이유에 대한 상세한 설명은 아래 참고 자료를 확인 부탁드립니다.
참고 자료 : [Springboot] JPA fetch join 2개 이상의 Collection Join 해결법
2. fetch join + pagination(limit) Warn 로그 발생
두 번째는 가끔 이슈 확인을 위해 로그를 확인할 때 아래 로그가 너무 빈번하게 발생하여 필요한 로그 확인이 어려운 이슈였습니다.
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory
저희는 Datadog 로 모든 로그를 확인하고 있고, 로그가 많아지면 비용이 증가하기 때문에 해당 로그가 왜 남는지 파악해 보기로 했습니다.
원인은 명확했습니다. 이슈 코드는 아래와 같았습니다.
// Content.kt
// ... 기존코드
@OneToMany(fetch = FetchType.LAZY)
@JoinColumn(name = "contentId")
val comments: List<Comment> = emptyList(),
// ... 기존코드
@Entity
@Table(name = "comment")
class Comment(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
val contentId: Long,
// ... 기존코드
class ContentRepositoryImpl : QuerydslRepositorySupport(Content::class.java), ContentRepositoryCustom {
override fun findByContentId(contentId: Long): Content? {
val qContent = QContent.Content
val qComment = QComment.Comment
return from(qContent)
.leftJoin(qContent.comments, qComment).fetchJoin()
.where(
qContent.contentId.eq(contentId)
)
.distinct()
.fetchFirst()
}
// ... 기존코드
해당 코드는 리팩토링 중 N+1 우려되어 Query DSL 변환해서 수정됐는데, 단건의 Content 조회에 과한 fetchJoin과 fetchFirst 이 들어간 상태였습니다.
oneToMany컬렉션(comment)을fetchJoin- 동시에
fetchFirst(limit/pagination 성격) 사용 - Hibernate가 DB 레벨 페이징 대신 메모리에서 적재 후 페이징 작업을 애플리케이션에서 후처리
하나의 Content 에 N건의 Comment 가 있는 경우 메모리에 적재하는 데이터가 많아지기 때문에 이런 Warn 로그를 자동으로 남겨주는 것이었습니다.
대응
fetchJoin + fetchFirst 조합 쿼리를 점검해서, 실제 N+1 위험이 낮거나 불필요한 경우 Comment 조인을 제거했습니다.
정리하면 아래 기준으로 판단했습니다.
- 단건 조회에서 컬렉션을 반드시 즉시 로딩해야 하는가
- 해당 시점에 컬렉션 필드 접근이 실제로 발생하는가
- 필요 시점에 지연 조회 또는 별도 조회로 분리 가능한가
불필요한 컬렉션 fetch join을 걷어내니 Warn가 사라지고, 쿼리 의도도 훨씬 명확해졌습니다.
마무리
이번 두 이슈는 공통적으로 막연하게 기술을 사용하는 게 아니라, 본인의 의도대로 해당 코드가 지원을 하는지, 본인이 작성한 코드가 어떤 결과를 야기할 수 있는지 정확히 알고 사용해야 한다는 점을 다시 확인한 계기였습니다.
- 연관관계는 DB FK 소유 구조와 함께 봐야 하고
- fetch join은 N+1 해결 도구이지 기본 선택지가 아님
다음에는 매핑 단계에서 FK 소유를 먼저 고정하고, 조회 쿼리는 실제 접근 패턴 기준으로 최소 조인부터 시작하려고 합니다.
그럼 이만. 🥕👋🏼🖐🏼
참고자료
[JPA] @OneToOne, 일대일[1:1] 관계 fetch join 과 pagination 을 같이 쓸 때 [HHH000104: firstResult/maxResults specified with collection fetch; applying in memory]