Skip to main content

JPA N+1 이슈 API 개선

작성일: 2025.05.23

서비스 모니터링 중 특정 리스트 조회 API(랭킹, 게시판, 문제 목록)에서 간헐적인 타임아웃과 DB CPU 점유율 상승 현상을 발견했습니다. 로그 확인 결과 JPA의 고질적인 성능 병목인 N+1 문제가 원인임을 파악하고 이를 개선하여 응답 속도를 크게 단축한 과정을 기록합니다.

1. 문제 상황 (As-Is)

현상 파악

사용자가 답변(Answer) 목록을 조회하는 단순한 요청임에도 불구하고, 실제 수행되는 쿼리는 수백 개에 달했습니다.

  • 요청: GET /api/answers (답변 50개 조회)
  • 발생 쿼리 수: 51개 (목록 조회 1회 + 각 답변의 작성자 조회 50회)
  • 평균 응답 시간: 약 920ms

원인 분석: Lazy Loading의 함정

Answer 엔티티와 User 엔티티는 @ManyToOne(fetch = LAZY)로 설정되어 있었습니다.

// AnswerService.java
@Transactional(readOnly = true)
public List<AnswerResponse> getAnswers() {
List<Answer> answers = answerRepository.findAll(); // 1. 여기서 전체 목록 조회 (쿼리 1번)

return answers.stream()
.map(answer -> {
// 2. 여기서 각 Answer의 User에 접근할 때마다 추가 쿼리 발생 (N번)
String writerName = answer.getUser().getNickname();
return new AnswerResponse(answer, writerName);
})
.collect(Collectors.toList());
}
로그 확인

아래와 같이 users 테이블을 조회하는 동일한 쿼리가 무수히 반복되었습니다.

Hibernate: select * from answer limit 50;  -- 목록 조회
Hibernate: select * from users where id=1; -- 작성자 A 조회
Hibernate: select * from users where id=2; -- 작성자 B 조회
Hibernate: select * from users where id=3; -- 작성자 C 조회
...
(계속 반복됨)

2. 해결 전략 (To-Be)

상황에 맞춰 두 가지 최적화 전략을 적용했습니다.

전략 A: Fetch Join 적용 (Entity Graph)

AnswerUser처럼 N:1 관계인 경우, JOIN FETCH를 사용하여 한 번의 쿼리로 데이터를 모두 가져오는 방식이 가장 효율적입니다.

// AnswerRepository.java

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

// 변경 후: Fetch Join을 사용하여 User까지 한 번에 SELECT
@Query("SELECT a FROM Answer a JOIN FETCH a.user")
List<Answer> findAllWithUser();

전략 B: Batch Size & Map 매핑 (1:N 컬렉션)

Wargame -> Flags 처럼 1:N 관계 컬렉션 조회 시, Fetch Join을 쓰면 페이징 처리가 메모리에서 이루어지는 부작용이 있습니다. 따라서 이 경우는 Java 레벨에서 ID 추출 후 IN 쿼리를 사용했습니다.

// 1. 부모 엔티티(Wargame) 조회
List<Wargame> wargames = wargameRepository.findAll();

// 2. ID 추출 및 자식 엔티티(Flag) Bulk 조회 (IN 절 사용)
List<Long> ids = wargames.stream().map(Wargame::getId).toList();
List<WargameFlag> flags = flagRepository.findByWargameIdIn(ids);

// 3. 메모리 상에서 매핑 (O(1) 접근)
Map<Long, List<WargameFlag>> flagMap = flags.stream()
.collect(Collectors.groupingBy(f -> f.getWargame().getId()));

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

3. 성능 개선 결과 (Result)

리팩토링 배포 후 동일한 데이터셋(50건 기준)으로 JMeter 부하 테스트를 진행했습니다.

정량적 지표 비교

구분개선 전 (As-Is)개선 후 (To-Be)개선율
발생 쿼리 수51회1회98% ⬇
평균 응답 속도920ms75ms91.8% ⬇
Conclusion

약 20개의 서비스 API에서 쿼리 수를 N개에서 1개, 2개로 줄임으로써 데이터베이스 커넥션 점유 시간을 획기적으로 줄였다. 동시 접속자가 늘어날수록 더 큰 성능 차이를 만들어낼 것 같다.

4. 배운 점

  • JPA를 사용할 때는 편의성에 취해 나가는 쿼리를 항상 의심해봐야 한다.
  • 로컬 테스트에서는 데이터가 많지 않아 놓치기 쉬우니 항상 Hibernate 로그 필수 확인.
  • 무조건 Fetch Join이 정답은 아니다. Pagination이 필요한 1:N 관계에서는 Batch Size 설정이나 Application 레벨단 조립이 더 나은 선택일 수 있다.