조회는 한 번인데, SQL은 왜 202번 실행될까?
JPA 지연 로딩에서 시작된 N+1 문제를 쿼리의 흐름으로 이해하고, 로딩 전략을 비교합니다.
사이트 초기 콘텐츠로 작성한 샘플 글입니다. 프로젝트 수치는 작성자 제공 자료이며, Java 코드는 원리를 보여주는 예제입니다.
이 글의 목차
1. 연관관계 접근이 쿼리가 되는 순간2. 엔티티 수를 바꿔 직접 확인하기3. 해결책은 조회 목적에서 고르기4. NEMO에서 확인한 변화5. 쿼리가 적으면 무조건 빠를까?더 읽기앨범 목록을 한 번 가져왔을 뿐인데 데이터베이스에는 수십 번 다녀옵니다. 목록 조회가 끝난 뒤, 각 앨범의 사진을 읽는 순간 새로운 조회가 시작되기 때문입니다.
문제의 핵심은 반복문 자체보다 반복문 안에서 일어나는 데이터 접근입니다. 마트에 한 번 다녀올 장보기를 재료마다 한 번씩 왕복하는 셈입니다.
1. 연관관계 접근이 쿼리가 되는 순간
다음 예제는 설명을 위한 단순화 코드입니다. photos가 지연 로딩되는 컬렉션이라면 목록의 각 앨범에서 size()를 호출할 때 추가 SQL이 발생할 수 있습니다.
@Transactional(readOnly = true)
public List<AlbumResponse> findAlbums() {
return albumRepository.findAll().stream()
.map(album -> new AlbumResponse(
album.getId(), album.getPhotos().size()))
.toList();
}영속성 컨텍스트에 해당 데이터가 없는 상태에서 앨범 N개와 각 사진 컬렉션을 읽으면, 앨범 조회 1회에 컬렉션 조회 N회가 더해집니다. 그래서 이름도 N+1입니다.
2. 엔티티 수를 바꿔 직접 확인하기
JPA QUERY EXPERIMENT
SIMULATIONselect a.* from album a;select p.* from photo p where p.album_id = 1;select p.* from photo p where p.album_id = 2;select p.* from photo p where p.album_id = 3;select p.* from photo p where p.album_id = 4;select p.* from photo p where p.album_id = 5;select p.* from photo p where p.album_id = 6;select p.* from photo p where p.album_id = 7;select p.* from photo p where p.album_id = 8;select p.* from photo p where p.album_id = 9;select p.* from photo p where p.album_id = 10;select p.* from photo p where p.album_id = 11;select p.* from photo p where p.album_id = 12;| 전략 | SQL 수 | 고려할 점 |
|---|---|---|
| Lazy Loading | 13 | 연관 컬렉션 접근마다 추가 조회 |
| Fetch Join | 1 | 컬렉션 페이징·중복 행 주의 |
| Batch Fetch | 3 | IN 크기와 데이터량 고려 |
교육용 모델: 빈 영속성 컨텍스트에서 앨범당 하나의 photos 컬렉션을 모두 접근합니다. 캐시·네트워크·DB 실행시간을 측정하지 않습니다. Fetch Join 1회와 실제 프로젝트의 4회는 서로 다른 조회 범위입니다.
숫자를 늘리면 일반 조회의 SQL 수가 선형으로 증가합니다. Fetch Join은 앨범과 사진을 한 SQL에서 읽습니다. Batch Fetch는 여러 앨범의 사진을 IN 쿼리로 묶습니다. 실제 응답시간은 이 숫자만으로 계산할 수 없습니다.
3. 해결책은 조회 목적에서 고르기
@Query("select distinct a from Album a left join fetch a.photos")
List<Album> findAllWithPhotos();Fetch Join
함께 사용할 데이터를 한 번에 가져옵니다. 여러 컬렉션을 동시에 조인하거나 컬렉션 페이징을 적용하면 결과 행 증가와 메모리 비용을 확인해야 합니다.
Batch Fetch
기존 지연 로딩 구조를 유지하면서 추가 쿼리를 묶습니다. 컬렉션 페이징이 필요한 조회에서 검토할 수 있지만 배치 크기와 IN 파라미터 제한을 고려해야 합니다.
distinct의 SQL 전파와 엔티티 중복 제거 방식은 Hibernate 버전에 따라 차이가 있습니다. 컬렉션 페이징은 ID를 먼저 페이지 조회한 뒤 필요한 연관 데이터를 가져오는 두 단계 접근도 검토할 수 있습니다.
4. NEMO에서 확인한 변화
NEMO의 작성자 제공 측정 결과는 SQL 실행 202회 → 4회, 평균 98.91ms → 10.91ms, p95 114.20ms → 15.19ms입니다.
이 수치는 위 모델의 예측값이 아닙니다. 모델은 단일 컬렉션을 읽지만 실제 요청은 여러 조회를 포함할 수 있습니다. 정확한 엔티티 매핑, 데이터 규모, 하드웨어, 반복 횟수, 측정 도구와 원본 결과는 아직 연결되지 않았습니다. 같은 결과의 재현을 주장하려면 이 정보가 필요합니다.
5. 쿼리가 적으면 무조건 빠를까?
큰 조인 한 번이 작은 쿼리 여러 번보다 비쌀 수도 있습니다. 반환 행 수, 전송량, 인덱스, DB 실행 계획과 애플리케이션의 객체 생성 비용까지 함께 봐야 합니다.
- 같은 데이터와 요청 조건으로 전후를 비교합니다.
- 쿼리 수뿐 아니라 반환 행 수와 실행 계획을 확인합니다.
- 평균과 함께 p95, 오류율도 기록합니다.
- 테스트에서 예상 SQL 횟수를 검증하고 회귀를 감시합니다.
최적화의 끝은 “빨라진 것 같다”가 아니라, 다른 사람이 같은 조건에서 다시 확인할 수 있는 기록입니다.