| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- 스프링
- 자바
- 추상클래스
- Spring Batch
- 이펙티브 자바
- 프록시 패턴
- 스케줄러
- DB
- GoF 23
- 백엔드
- 토스
- 코드카타
- 계산기
- lv1
- 프로그래머스
- spring boot
- Spring
- 배치
- 김영한
- 스프링 배치
- 로드밸런서
- Effective Java
- 디자인 패턴
- 템플릿 메서드 패턴
- 트러블슈팅
- Til
- 빌더 패턴
- redis
- 성능 개선
- java
- Today
- Total
김코딩
트러블슈팅 - 전체 일정 조회 시 N+1 문제 발생 본문
불과 하루 전 튜터님의 세션에서
"JPA를 사용하면 N+1 문제가 발생할 가능성이 있어서 이 부분을 조심해주셔야해요!"
라는 말을 들었다.
나는 개발을 진행하면서
"내가 N+1 문제에 직면할 일이 있을까...?"
라고 생각했지만, 바로 다음 날 이 문제에 정통으로 맞았다.
N+1 문제란?
JPA를 사용하다 보면, 조회 쿼리가 생각보다 많이 나가는 상황을 겪게 됩니다.
예를 들어 회원 리스트를 한 번 불러왔을 뿐인데,
각 회원이 가진 일정까지 순회하는 순간 갑자기 쿼리가 수십 번 더 발생하는 일이 있죠.
이 현상이 바로 N+1 문제입니다.
상황 설명
전체 일정 리스트를 조회하는 기능을 개발하고 있었고,
별다른 의심 없이 아래처럼 구현했다.
public List<ScheduleResponseDto> findAll() {
return scheduleRepository.findAll().stream()
.map(ScheduleResponseDto::new)
.toList();
}
하지만 콘솔에 찍힌 Hibernate 로그는 충격적이었다.
Hibernate:
/* <criteria> */ select
s1_0.id,
s1_0.contents,
s1_0.created_at,
s1_0.member_id,
s1_0.modified_at,
s1_0.title
from
schedule_v2 s1_0
Hibernate:
select
m1_0.id,
m1_0.created_at,
m1_0.email,
m1_0.modified_at,
m1_0.password,
m1_0.username
from
member m1_0
where
m1_0.id=?
Hibernate:
select
m1_0.id,
m1_0.created_at,
m1_0.email,
m1_0.modified_at,
m1_0.password,
m1_0.username
from
member m1_0
where
m1_0.id=?
Hibernate:
select
m1_0.id,
m1_0.created_at,
m1_0.email,
m1_0.modified_at,
m1_0.password,
m1_0.username
from
member m1_0
where
m1_0.id=?
Schedule을 한 번에 불러온 다음,
각 Schedule마다 연관된 Member를 반복해서 조회하고 있던 것이다.
문제의 핵심: 연관관계 + LAZY 로딩
Schedule 엔티티는 이렇게 구성되어 있었다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;
그리고 DTO 변환 과정에서 schedule.getMember().getUsername()을 호출하면서,
지연 로딩(LAZY)된 Member에 접근 -> 쿼리 N번 발생
public ScheduleResponseDto(Schedule schedule) {
this.id = schedule.getId();
this.username = schedule.getMember().getUsername(); <- 이 부분이 원인이다!
this.title = schedule.getTitle();
this.contents = schedule.getContents();
}
해결 고민
N+1 문제를 해결하는 방법에 대해서 고민을 해보았다.
join fetch를 이용하거나 @EntityGraph를 이용하면 된다고 하였다.
join fetch를 이용한 방법
JPQL에서 fetch join을 명시해줌으로써,
연관된 엔티티를 한 번의 쿼리로 같이 가져올 수 있다.
- ScheduleRepository
@Query("SELECT s FROM Schedule s JOIN FETCH s.member")
List<Schedule> findAllWithMember();
- ScheduleService
public List<ScheduleResponseDto> findAll() {
return scheduleRepository.findAllWithMember().stream()
.map(ScheduleResponseDto::new)
.toList();
}
결과
Hibernate:
/* SELECT
s
FROM
Schedule s
JOIN
FETCH
s.member */ select
s1_0.id,
s1_0.contents,
s1_0.created_at,
s1_0.member_id,
m1_0.id,
m1_0.created_at,
m1_0.email,
m1_0.modified_at,
m1_0.password,
m1_0.username,
s1_0.modified_at,
s1_0.title
from
schedule_v2 s1_0
join
member m1_0
on m1_0.id=s1_0.member_id
@EntityGraph를 이용한 방법
@EntityGraph는 Spring Data JPA에서 제공하는 방법으로,
JPQL을 직접 작성하지 않아도 연관 엔티티를 미리 로딩할 수 있도록 설정할 수 있다.
- ScheduleRepository
@EntityGraph(attributePaths = "member")
List<Schedule> findAll();
- ScheduleRepository의 메서드 위에 붙여서 사용
- attributePaths에 로딩할 연관 필드를 명시
결과
Hibernate:
/* <criteria> */ select
s1_0.id,
s1_0.contents,
s1_0.created_at,
s1_0.member_id,
m1_0.id,
m1_0.created_at,
m1_0.email,
m1_0.modified_at,
m1_0.password,
m1_0.username,
s1_0.modified_at,
s1_0.title
from
schedule_v2 s1_0
join
member m1_0
on m1_0.id=s1_0.member_id
마지막으로..
N+1 문제는 "언젠가는 마주치겠지" 했던 문제가 바로 다음 날 찾아왔다.
내가 .getMember().getUsername() 딱 한 줄 썼을 뿐인데
이렇게 많은 쿼리가 나갈 줄은 몰랐다.
이번 경험을 통해 느낀 점은:
- JPA에서 연관관계는 단순히 매핑이 아닌 쿼리 성능까지 연결되는 영역이고,
- 항상 쿼리 로그를 확인하며 동작을 검증해야 한다는 것이다.
앞으로는 연관된 엔티티를 다룰 때,
무조건 "이게 N+1 문제를 일으키진 않을까?"를 먼저 의심하는 습관을 가지기로 했다.
'TIL' 카테고리의 다른 글
| 동기식 결제 승인 API의 응답속도 테스트 및 분석 (1) | 2025.08.06 |
|---|---|
| 트러블슈팅 - 모든 에러가 500에러? 원인은 DTO 였다.. (0) | 2025.05.23 |
| 일정 관리 API 프로젝트 회고 (0) | 2025.05.14 |
| Kiosk 프로젝트 회고 (3) | 2025.05.01 |
| 둘 다 객체 못 만든다면서요? 그런데 왜 둘이나 있는 거죠?(인터페이스 VS 추상클래스) (0) | 2025.04.25 |