오랜만에 프로젝트를 진행하다보니 복습도 되고 참 좋은 것 같다. 무엇보다 그동안 공부한 내용이 있기에 이전의 로직을 더 발전시켜 작성할 수 있게 되었다.
그런데 프로젝트를 진행하다가 문제를 하나 맞닥뜨리게 되었다. 비즈니스적으로는 동작을 잘 하지만 최적화의 문제였다.

그것은 바로 JPA를 사용하면서 엔티티 조회를 한 번만 했지만 쿼리가 따로 나가는 문제이다.
이 문제를 해결하는 과정을 기록해 두기로 했다.
문제의 원인 알아보고 해결하기

이것이 현재 상황을 나타낸 ERD이다.
코딩테스트 문제를 불러오면 테스트 케이스와 그림을 같이 불러올 수 있도록 구성했다.
위에서 쿼리가 세 번 나간 이유도 엔티티가 세 개여서 그런 것 같다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(name = "problems")
@Entity
public class Problem extends BaseTimeDate {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Enumerated
private Level level;
private String description;
private String limitation;
@Column(name = "input_output")
private String inputOutput;
@OneToMany(mappedBy = "problem")
private List<ProblemPicture> problemPictureList;
@OneToMany(mappedBy = "problem")
private List<Testcase> testcaseList;
}
문제가 된 Problem 엔티티 클래스이다.
연관 관계 매핑이 되어있는 problemPictureList와 testcaseList에 문제가 있을 것이다.
그런데 여기서 한 가지를 생각해볼 수 있다.
JPA를 배우던 시점으로 다시 돌아가 보면... @OneToMany는 1:N에서 1을 가져올 때 N의 객체를 모두 가져오는 것은 비효율적이므로 FetchType.LAZY로 지연로딩 되도록 기본값이 설정되어 있다.

이 부분 때문에 지연로딩 되어 일단 Problem을 가져온 후에 나머지 연관 객체들을 가져오는 것이 아닐까?
@OneToMany(mappedBy = "problem", fetch = FetchType.EAGER)
private List<ProblemPicture> problemPictureList;
@OneToMany(mappedBy = "problem", fetch = FetchType.EAGER)
private List<Testcase> testcaseList;
그래서 결과를 바로 가져올 수 있도록 FetchType.EAGER로 설정해보았다.

예상은 하나의 쿼리로 나갈 줄 알았는데 뭔가 이상하다.
조인이 한 번만 이루어진 것이다.
이에 대해서 찾아보니 List는 중복 가능하고, Set은 중복 불가능이기 때문에 내부적으로 중복 처리 관련해서 엔티티를 별도로 로드한다고 한다.
https://stackoverflow.com/questions/30122786/can-a-jpa-entity-have-multiple-onetomany-associations
Can a JPA entity have multiple OneToMany associations?
Adding two OneToMany associations to my entity class seems not to work. It works fine if I remove one of them. @Entity @Table(name = "school") public class School { private List<Teacher>
stackoverflow.com
그래서 다음과 같이 Set으로 변경 후 다시 실행해보았다.
@OneToMany(mappedBy = "problem", fetch = FetchType.EAGER)
private Set<ProblemPicture> problemPictureList;
@OneToMany(mappedBy = "problem", fetch = FetchType.EAGER)
private Set<Testcase> testcaseList;

이제 원하던대로 한 줄로 쿼리가 나가는 것을 확인할 수 있다!
그런데 뭔가 찝찝하다...
뭔가 근본적인 원인을 해결하지 못하고 즉시 로딩 전략을 사용했기 때문에 N + 1 문제와 같은 부가적인 문제가 추가로 발생할 수 있다.
그래서 다른 방법을 생각해보기로 했다.
FetchJoin 사용하기
이전 프로젝트에서도 비슷한 상황에 사용했던 FetchJoin이다.
이번에는 FetchType을 LAZY로 그대로 두고, 같이 조회해야 하는 부분만 JPQL로 직접 가져오도록 해보자.
@OneToMany(mappedBy = "problem")
private Set<ProblemPicture> problemPictureList;
@OneToMany(mappedBy = "problem")
private Set<Testcase> testcaseList;
기본값인 지연 로딩 전략을 사용하도록 수정했다.
@Repository
public class ProblemRepositoryImpl implements ProblemRepository {
private final EntityManager entityManager;
private final JPAQueryFactory queryFactory;
public ProblemRepositoryImpl(EntityManager entityManager) {
this.entityManager = entityManager;
this.queryFactory = new JPAQueryFactory(entityManager);
}
// 다른 부분 생략
@Override
public Problem find(Long id) {
return entityManager.find(Problem.class, id);
}
}
나는 전체 문제 조회 로직에서 Querydsl을 사용해서 직접 EntityManager를 사용해서 Repository를 구현했다.
단건 조회는 간단하기 때문에 기존에는 위와 같이 작성했다.
이미 Querydsl을 사용하고 있으니 굳이 문자열로 JPQL을 작성하지 말고 Querydsl을 이용해서 fetchJoin을 수행해보자.
@Override
public Problem find(Long id) {
return queryFactory.selectFrom(problem)
.leftJoin(problem.problemPictureList).fetchJoin()
.leftJoin(problem.testcaseList).fetchJoin()
.where(problem.id.eq(id))
.fetchFirst();
}
단순히 EntityManager를 통해 find()를 호출하는 것이 아닌 fetchJoin을 수행하는 방식으로 변경했다.
이제 다시 요청을 보내서 어떤 쿼리가 나가는지 확인해보자!

길어서 뒤에는 잘랐지만 제대로 한 줄의 쿼리로 실행되는 것을 알 수 있었다.
그럼 이번에는 과연 fetchJoin 덕에 잘 동작하는건지 확인해보도록 하자.
@Override
public Problem find(Long id) {
return queryFactory.selectFrom(problem)
.leftJoin(problem.problemPictureList)
.leftJoin(problem.testcaseList)
.where(problem.id.eq(id))
.fetchFirst();
}
fetchJoin()을 제거해보았다. 이러면 그냥 join만 수행하는 쿼리가 만들어질 것이다.

초기에 그냥 EntityManager를 통해 find()했던 것처럼 쿼리가 세 번 수행되는 것을 알 수 있었다.
결론
JPA의 장점인 지연 로딩을 유지하면서 한 객체를 조회하면서 쿼리가 여러 번 나가는 문제를 해결해보았다.
사실 N + 1 문제는 fetchJoin 말고도 @BatchSize나 엔티티 그래프를 통해서도 해결할 수 있다.
하지만 간단하게 해결할 수 있는 상황이라면 fetchJoin만으로도 충분히 좋은 해결책이 될 수 있을 것이다.
'공부 > JPA' 카테고리의 다른 글
| [JPA][Querydsl] 동적 쿼리 작성, 정렬해서 Slice 반환하기 (0) | 2024.05.20 |
|---|---|
| [JPA] JPA 테스트 중 available: expected at least 1 bean which qualifies as autowire candidate. 에러 해결하기 (0) | 2024.05.08 |
| [JPA] 엔티티 그래프(EntityGraph) 사용하기 (0) | 2024.03.11 |
| [JPA] 스프링 OSIV (Open Session In View) 사용하기 (0) | 2024.03.02 |
| [JPA] LazyInitializationException 해결하기 (0) | 2024.02.28 |