문제 상황
현재 진행하고 있는 프로젝트에서 JPA를 사용하고 있다. 우선 테이블 구조는 다음과 같다.
Member는 신경 쓸 필요 없이 Cycle과 Challenge만 살펴보면 된다. 현재 Cycle은 Challenge를 참조하고 있고 다대일 단방향 매핑으로 구현되어 있다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Cycle {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "cycle_id")
private Long id;
@JoinColumn(name = "member_id")
@ManyToOne(fetch = FetchType.LAZY)
private Member member;
@JoinColumn(name = "challenge_id")
@ManyToOne(fetch = FetchType.LAZY)
private Challenge challenge;
// ...
}
이러한 상황에서 아래의 메서드를 테스트했을 때 문제가 발생한다. (어떤 맥락의 메서드인지 보다는 어떤 쿼리를 보내는 가에 집중하자)
public List<CycleResponse> findAllInProgressOfMine(TokenPayload tokenPayload, LocalDateTime searchTime) {
Member member = searchMember(tokenPayload);
List<Cycle> inProgressCycles = cycleRepository.findAllByMemberAndStartTimeIsAfter(
member, searchTime.minusDays(3L)
)
.stream()
.filter(cycle -> cycle.isInProgress(searchTime))
.collect(toList());
return inProgressCycles.stream()
.map(cycle -> new CycleResponse(cycle, calculateSuccessCount(cycle)))
.collect(toList());
}
private int calculateSuccessCount(Cycle cycle) {
return cycleRepository.countByMemberAndChallengeAndProgress(
cycle.getMember(), cycle.getChallenge(), Progress.SUCCESS
).intValue();
}
일단 메서드만 봤을 때 예상할 수 있는 쿼리는 다음과 같다.
1. Member 조회 쿼리 1번
2. Cycle 조회 쿼리 1번
3. stream 문법에 의해 필터링된 Cycle 개수만큼 count 쿼리 (이 부분도 개선하고 싶다...)
테스트를 해보니 결과는 다음과 같다. Member에 대한 select 쿼리 한 번과 Cycle에 대한 조회 쿼리 한 번까지는 예상대로다.
하지만 stream에 의해 걸러진 Cycle이 2개라고 하면 count 쿼리 2개까지만 실행되어야 하는데 중간중간에 Challenge에 대한 조회 쿼리도 추가적으로 발생한 것을 확인할 수 있다.
Hibernate:
select
member0_.member_id as member_i1_2_0_,
member0_.email as email2_2_0_,
member0_.nickname as nickname3_2_0_,
member0_.password as password4_2_0_
from
member member0_
where
member0_.member_id=?
Hibernate:
select
cycle0_.cycle_id as cycle_id1_1_,
cycle0_.challenge_id as challeng4_1_,
cycle0_.member_id as member_i5_1_,
cycle0_.progress as progress2_1_,
cycle0_.start_time as start_ti3_1_
from
cycle cycle0_
where
cycle0_.member_id=?
and cycle0_.start_time>?
Hibernate:
select
count(cycle0_.cycle_id) as col_0_0_
from
cycle cycle0_
where
cycle0_.member_id=?
and cycle0_.challenge_id=?
and cycle0_.progress=?
Hibernate:
select
challenge0_.challenge_id as challeng1_0_0_,
challenge0_.name as name2_0_0_
from
challenge challenge0_
where
challenge0_.challenge_id=?
Hibernate:
select
count(cycle0_.cycle_id) as col_0_0_
from
cycle cycle0_
where
cycle0_.member_id=?
and cycle0_.challenge_id=?
and cycle0_.progress=?
Hibernate:
select
challenge0_.challenge_id as challeng1_0_0_,
challenge0_.name as name2_0_0_
from
challenge challenge0_
where
challenge0_.challenge_id=?
문제 원인 분석
현재 Cycle과 Challenge는 지연 로딩으로 설정되어 있다 (FetchType.LAZY). 때문에 Cycle을 조회하면 내부의 Challenge 필드에는 프록시 객체가 들어가 있게 된다.
로그로 확인한 Challenge의 클래스 이름은 다음과 같다.
프록시 특징
- 실제 클래스와 겉모양이 같다.
- 사용하는 입장에서는 진짜 객체인지 프록시 객체인지 구분하지 않고 사용하면 됨(이론상)
- 프록시 객체는 실제 객체의 참조(target)를 보관
- 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드 호출
CycleResponse 생성자에서 cycle.getChallengeName()을 호출할 때마다 진짜 Challenge를 찾기 위해 조회 쿼리를 추가적으로 날린 것이 원인으로 보인다.
public CycleResponse(Cycle cycle, Integer successCount) {
this.cycleId = cycle.getId();
this.challengeId = cycle.getChallenge().getId();
this.challengeName = cycle.getChallenge().getName(); //이 시점에서 Challenge 조회 쿼리 발생!
this.progressCount = cycle.getProgress().getCount();
this.startTime = cycle.getStartTime();
this.successCount = successCount;
}
로그를 찍어보니 getId()를 할 때 쿼리를 보내는 것이 아닌 getName()을 할 때 쿼리를 보내는 것을 확인했다. 아마 Cycle 테이블에 Challenge에 대한 외래 키를 이미 가지고 있기 때문에 Challenge의 메서드를 호출해도 getId()를 하는 경우에는 실제 엔티티를 찾지 않는 듯하다.
문제 해결 - FetchType.EAGER
그렇다면 Cycle에서 Challenge를 지연 로딩이 아닌 즉시 로딩으로 가지고 있으면 어떻게 될까? 즉시 로딩으로 설정하면 Cycle이 조회될 때 Challenge도 바로 함께 가져올 것이다. @MayToOne 관계에선 즉시 로딩이 기본 값이기 때문에 LAZY 설정을 지우면 즉시 로딩으로 설정된다.
@JoinColumn(name = "challenge_id")
@ManyToOne
private Challenge challenge;
테스트를 돌렸더니 일단 Challenge의 클래스는 프록시 객체가 아닌 진짜 객체임을 로그로 확인할 수 있었다.
하지만 쿼리를 확인해 보니 결과는 다음과 같았다. Member와 Cycle을 1번씩 조회한 쿼리는 똑같다. 그런데 없어질 줄 알았던 Challenge에 대한 조회 쿼리 2번이 여전히 남아있다. 바뀐 거라면 순서가 달라졌다.
아까는 count 쿼리와 Challenge 조회 쿼리가 번갈아서 나간 반면 이번엔 Challenge 조회 쿼리 2번이 먼저 나가고 count 쿼리 2번이 실행되었다.
Hibernate:
select
member0_.member_id as member_i1_2_0_,
member0_.email as email2_2_0_,
member0_.nickname as nickname3_2_0_,
member0_.password as password4_2_0_
from
member member0_
where
member0_.member_id=?
Hibernate:
select
cycle0_.cycle_id as cycle_id1_1_,
cycle0_.challenge_id as challeng4_1_,
cycle0_.member_id as member_i5_1_,
cycle0_.progress as progress2_1_,
cycle0_.start_time as start_ti3_1_
from
cycle cycle0_
where
cycle0_.member_id=?
and cycle0_.start_time>?
Hibernate:
select
challenge0_.challenge_id as challeng1_0_0_,
challenge0_.name as name2_0_0_
from
challenge challenge0_
where
challenge0_.challenge_id=?
Hibernate:
select
challenge0_.challenge_id as challeng1_0_0_,
challenge0_.name as name2_0_0_
from
challenge challenge0_
where
challenge0_.challenge_id=?
Hibernate:
select
count(cycle0_.cycle_id) as col_0_0_
from
cycle cycle0_
where
cycle0_.member_id=?
and cycle0_.challenge_id=?
and cycle0_.progress=?
Hibernate:
select
count(cycle0_.cycle_id) as col_0_0_
from
cycle cycle0_
where
cycle0_.member_id=?
and cycle0_.challenge_id=?
and cycle0_.progress=?
즉시 로딩으로 설정했고 Cycle 내부의 Challenge도 프록시 객체가 아님을 확인했다. 하지만 여전히 조회 쿼리를 추가로 보내고 있었다.
즉시 로딩일 때 발생하는 N + 1 문제
즉시 로딩임에도 N + 1 문제가 발생하는 건 JPQL이 그대로 SQL로 변환되기 때문이다. 우선 Cycle에 대한 조회 쿼리는 Spring Data JPA가 메서드 이름을 보고 만들어 주는 JPQL로 동작하고 있다는 것을 알아두자.
쿼리 발생 흐름은 다음과 같다.
1. CycleRepository.findAllByMemberAndStartTimeIsAfter() 실행
2. JPQL이 SQL 그대로 번역된다. (이 JPQL(SQL)에는 Challenge도 함께 가져오라는 join 문이 없다.)
3. 조건에 맞는 Cycle 가져오는 SQL이 실행 (Challenge는 가져오지 않음)
4. JPA가 즉시 로딩인 것을 확인하고 Cycle 개수만큼 Challenge 추가 조회 쿼리 실행
결국 지연 로딩이나 즉시 로딩이나 나중에 추가 조회 쿼리를 실행시키냐 바로 실행시키냐의 차이만 있을 뿐 Challenge를 함께 가져와 주지 못했다. 즉 Challenge의 실제 값을 사용하기 전까진 조회 쿼리가 나가지 않는 지연 로딩일 때보다 상황이 더 나빠진 듯하다.
문제 해결 - Fetch Join
페치 조인은 SQL에서의 조인 종류는 아니다. JPA에서 성능 최적화를 위해 제공하는 기능으로 연관된 엔티티나 컬렉션을 한 SQL에 조회하는 것이 가능하다. CycleRepository.findAllByMemberAndStartTimeIsAfter()에 페치 조인을 적용해 보자.
@Query("select c from Cycle c join fetch c.challenge where c.member = :member and c.startTime >= :time")
List<Cycle> findAllByMemberAndStartTimeIsAfter(Member member, LocalDateTime time);
테스트를 돌려보니 결과는 다음과 같다. Member를 조회하는 쿼리는 생략했다.
Hibernate:
select
cycle0_.cycle_id as cycle_id1_1_0_,
challenge1_.challenge_id as challeng1_0_1_,
cycle0_.challenge_id as challeng4_1_0_,
cycle0_.member_id as member_i5_1_0_,
cycle0_.progress as progress2_1_0_,
cycle0_.start_time as start_ti3_1_0_,
challenge1_.name as name2_0_1_
from
cycle cycle0_
inner join
challenge challenge1_
on cycle0_.challenge_id=challenge1_.challenge_id
where
cycle0_.member_id=?
and cycle0_.start_time>=?
Hibernate:
select
count(cycle0_.cycle_id) as col_0_0_
from
cycle cycle0_
where
cycle0_.member_id=?
and cycle0_.challenge_id=?
and cycle0_.progress=?
Hibernate:
select
count(cycle0_.cycle_id) as col_0_0_
from
cycle cycle0_
where
cycle0_.member_id=?
and cycle0_.challenge_id=?
and cycle0_.progress=?
성공적으로 inner join SQL을 통해 Challenge를 한 번에 조회한 후 count 쿼리만 추가적으로 나간 것을 확인할 수 있었다. (페치 조인의 기본 값이 inner join이라고 한다.)
문제 해결 - @EntityGraph
JPQL로 페치 조인 문법을 쓰지 않고 어노테이션으로 페치 조인을 하는 방법도 있다.
@EntityGraph(attributePaths = "challenge")
List<Cycle> findAllByMemberAndStartTimeIsAfter(Member member, LocalDateTime time);
스프링 데이터 JPA는 JPA가 제공하는 엔티티 그래프 기능을 편리하게 사용하게 도와준다. 이 기능을 사용하면 JPQL 없이 페치 조인을 사용할 수 있다. (JPQL + 엔티티 그래프도 가능)
Hibernate:
select
cycle0_.cycle_id as cycle_id1_1_0_,
challenge1_.challenge_id as challeng1_0_1_,
cycle0_.challenge_id as challeng4_1_0_,
cycle0_.member_id as member_i5_1_0_,
cycle0_.progress as progress2_1_0_,
cycle0_.start_time as start_ti3_1_0_,
challenge1_.name as name2_0_1_
from
cycle cycle0_
left outer join
challenge challenge1_
on cycle0_.challenge_id=challenge1_.challenge_id
where
cycle0_.member_id=?
and cycle0_.start_time>?
다른 점이 있다면 inner join이 아닌 left outer join을 실행했다. JPQL로 페치 조인을 사용할 땐 어떤 조인을 할지 선택할 수 있지만 @EntityGraph를 사용하면 outer join만 사용할 수 있다고 한다.
결론
페치 조인을 사용하여 추가적인 조회 쿼리 없이 메서드를 실행할 수 있었다. count 쿼리가 여러 번 나가는 건 다른 문제지만 저 문제도 언젠간 해결하고 싶긴 하다.
페치 조인도 만능은 아니다. 지금은 다대일 관계에서의 페치 조인이었지만 일대다 관계에서 페치 조인을 사용하면 데이터 수가 뻥튀기되는 경우도 있다고 하는데 이 문제는 일대다 관계를 다루게 되면 경험해 보고자 한다.
참고
'우아한테크코스 > 프로젝트-SMODY' 카테고리의 다른 글
운영 서버에서 예외 발생 시 자동 깃헙 이슈 생성 (0) | 2022.10.12 |
---|---|
[Spring] 동시성과 예외, 그리고 트랜잭션 (0) | 2022.10.01 |
[Spring] 알림 기능 비동기 처리하기 (0) | 2022.09.17 |
이벤트 트랜잭션 분리와 테스트에서의 @Transactional 문제 (0) | 2022.08.30 |
결합은 낮추고 전략은 다양하게, 알림 기능 적용기 (0) | 2022.08.15 |