Skip to main content

JPA 고질병 해결하기

프로젝트 중 조회 API들에서 응답속도가 너무 오래걸려서 렌더링이 너무 오래걸리는 문제를 확인했다. 로컬에서 테스트 데이터 몇 개만 놓고 할때는 캐치 못하다가 나중에 데이터가 많아짐에 따라 보였던 문제였다. 로그를 확인하니 비정상적으로 쿼리 개수가 많은 것을 확인했다.

Hibernate: select * from answer limit 50;
Hibernate: select * from users where id=1;
Hibernate: select * from users where id=2;
Hibernate: select * from users where id=3;
...

답변 50개 조회하는데 쿼리가 51개였다.

JPA 고질병 중 하나인 N+1 문제였다.

N+1이란 데이터 N개를 조회할 때 쿼리가 N+1개 나가는 현상이다. 목록 조회 1번(1) + 각 항목마다 연관 데이터 조회(N번) 구조다. 데이터가 50개면 쿼리 51개, 1000개면 1001개가 나간다. 로컬에서는 데이터가 적으니 체감이 안 되다가 운영 환경에서 터진다.


원인은 이 한 줄이었다.

String writerName = answer.getUser().getNickname(); // 이게 50번 반복된다

@ManyToOne(fetch = LAZY) 설정이면 User에 실제로 접근하는 순간 SELECT가 나간다. 리스트를 돌면서 각 Answer마다 User를 꺼내면 그 수만큼 쿼리가 쌓인다.

코드 자체는 아무 문제없어 보인다는 게 함정이다. 로컬에선 데이터가 10개도 안 되니까 그냥 넘어간다.

전체 문제 코드:

@Transactional(readOnly = true)
public List<AnswerResponse> getAnswers() {
List<Answer> answers = answerRepository.findAll(); // 쿼리 1번

return answers.stream()
.map(answer -> {
String writerName = answer.getUser().getNickname(); // N번
return new AnswerResponse(answer, writerName);
})
.collect(Collectors.toList());
}

해결

N:1 관계 → Fetch Join

Answer → User처럼 N:1이면 JOIN FETCH로 한 번에 가져온다.

// 변경 전
List<Answer> findAll();

// 변경 후
@Query("SELECT a FROM Answer a JOIN FETCH a.user")
List<Answer> findAllWithUser();

1:N 컬렉션 → IN 쿼리

Wargame → Flags처럼 1:N 컬렉션은 Fetch Join 쓰면 안 된다. 페이징이 메모리에서 일어나서 데이터 많아질수록 더 위험해진다.

ID 뽑아서 IN 쿼리로 bulk 조회하고 애플리케이션에서 매핑했다.

List<Wargame> wargames = wargameRepository.findAll();

List<Long> ids = wargames.stream().map(Wargame::getId).toList();
List<WargameFlag> flags = flagRepository.findByWargameIdIn(ids);

Map<Long, List<WargameFlag>> flagMap = flags.stream()
.collect(Collectors.groupingBy(f -> f.getWargame().getId()));

wargames.forEach(w -> w.setFlags(flagMap.get(w.getId())));

결과

동일 데이터셋(50건)으로 JMeter 돌린 결과다.

구분개선 전개선 후개선율
발생 쿼리 수51회1회98% ⬇
평균 응답 속도920ms75ms91.8% ⬇

약 20개 API에 동일하게 적용했다.


정리

JPA 쓰면 편한 대신 나가는 쿼리를 놓치기 쉽다. 개발 환경에서 spring.jpa.show-sql=true 켜두는 거 추천한다. 리스트 조회 API 짤 때마다 쿼리 몇 개 나가는지 한 번씩 확인하는 습관이 생겼다.