금요일 오후, 며칠을 공들인 기능을 드디어 배포했다고 해 보자. 로컬에서는 눈 깜짝할 새에 응답하던 화면이다. 사용자 목록을 누르면 그 사람이 맡은 할 일이 주르륵 따라 나오는, 별것 아닌 화면. 로컬에서는 사용자 세 명에 할 일 몇 개씩만 넣고 테스트했으니 당연히 빨랐다. 그런데 운영에 올리고 나니 같은 화면이 3초, 5초씩 버벅인다. 사용자 항의가 들어오기 시작한다. 코드는 한 글자도 안 고쳤는데 말이다.
도대체 무슨 일이 벌어진 걸까? 로컬에서 멀쩡하던 게 왜 운영에서만 느려질까? 코드가 잘못된 걸까, 서버가 약한 걸까, 아니면 데이터베이스가 문제일까? 짐작 가는 데가 없으니 더 막막하다. 이 답답한 상황의 범인은 대개 하나다. 입문자가 JPA를 쓰면서 가장 흔하게, 그리고 가장 아프게 밟는 지뢰. 바로 N+1 문제다.
6·7장에서 누린 ORM의 편안함, 이제 그 청구서가 날아올 차례다. 겁먹을 필요는 없다. 청구서 읽는 법만 알면 오히려 JPA를 한층 자신 있게 다룰 수 있다. 이 장에서는 그 지뢰의 정체를 SQL 로그로 직접 확인하고, 네 가지 해법을 익힌다. 그리고 JPA가 보이지 않게 일하다 운영에서 터뜨리는 함정 몇 가지를 더 짚는다.
데이터가 적을 땐 보이지 않는다 — N+1의 정체
7장에서는 TaskBoard에 “사용자 한 명이 여러 할 일을 거느리는” 연관관계를 붙였다. User가 @OneToMany로 Task 목록을 들고, 그 연결은 지연 로딩(LAZY)으로 설정했다. 사용자를 꺼낼 때 굳이 할 일까지 매번 끌고 오지 않도록, 필요한 순간에만 가져오게 했다. 합리적인 선택이었다.
이제 “모든 사용자와, 각 사용자의 할 일 개수”를 화면에 뿌리는 서비스를 만든다고 해 보자. 코드는 너무도 자연스럽게 이렇게 나온다.
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Transactional(readOnly = true)
public List<UserSummary> summarize() {
List<User> users = userRepository.findAll(); // ①
return users.stream()
.map(u -> new UserSummary(
u.getName(),
u.getTasks().size())) // ②
.toList();
}
}
읽어 보면 이보다 더 멀쩡할 수가 없다. 사용자를 전부 꺼내(①), 각 사용자마다 할 일 개수를 센다(②). 자바 코드로만 보면 흠잡을 데가 없다. 로컬에서 돌려 봐도 잘 된다. 그런데 여기에 지뢰가 묻혀 있다.
사용자가 3명일 때 summarize()를 한 번 부르면 SQL은 몇 번 나갈까?
왜 이런 일이 벌어질까? 범인은 그 합리적이던 지연 로딩이다. findAll로 사용자만 먼저 가져왔을 때, 각 사용자의 할 일은 아직 데이터베이스에서 꺼내 오지 않은 상태다(지연 로딩이니까). 그러다 u.getTasks().size()로 할 일 목록을 처음 건드리는 순간, JPA가 부랴부랴 “아, 이 사용자의 할 일이 필요하구나” 하고 그제야 별도 쿼리를 날린다. 사용자가 세 명이면 이 일이 세 번, 천 명이면 천 번 반복된다.
같은 코드를 사용자 1,000명이 있는 운영 DB에서 돌리면 SQL은 몇 번 나갈까?
국내외 여러 회사 기술 블로그에서, N+1 때문에 응답이 느려졌다가 쿼리를 크게 줄여 응답 시간을 개선한 사례를 어렵지 않게 찾을 수 있다. N+1은 특정 회사의 실수가 아니라, JPA를 쓰는 거의 모든 팀이 한 번쯤 밟고 지나가는 통과의례에 가깝다.
여기서 가장 중요한 교훈을 먼저 못 박아 두자. N+1은 SQL 로그를 켜 두면 개발 단계에서 미리 잡을 수 있다. 자바 코드만 노려봐서는 절대 안 보인다. 멀쩡해 보이니까. 오직 실제로 나가는 SQL을 눈으로 봐야 “어, 왜 똑같은 SELECT가 여러 번 나가지?” 하고 알아챈다. 6장에서 show-sql을 켜 두자던 그 약속이 여기서 제값을 한다.
findAll() 뒤에 사용자마다 u.getTasks() → SELECT N번ch08-1 사용자별 할 일 개수와 N+1명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun
curl http://localhost:8080/api/users/summary[{"name":"ara","taskCount":2},{"name":"bogum","taskCount":2},{"name":"chulsoo","taskCount":1}]
요청 직후의 콘솔에서 SELECT를 센다.
Hibernate: select u1_0.id,u1_0.name from users u1_0
Hibernate: select t1_0.user_id,t1_0.id,… from task t1_0 where t1_0.user_id=?
Hibernate: select t1_0.user_id,t1_0.id,… from task t1_0 where t1_0.user_id=?
Hibernate: select t1_0.user_id,t1_0.id,… from task t1_0 where t1_0.user_id=?
사용자 목록 1번(①) + 사용자마다 할 일 1번씩 3번(②) = 4번. 사용자가 N명이면 1 + N번이다.
자바 코드에는 findAll() 한 번과 getTasks().size()밖에 없다. tasks가 지연 로딩이라 사용자마다 따로 읽힌다.
사용자가 3명일 땐 티가 안 나지만, 3천 명이면 SELECT 3,001번이다.
해법 네 가지 — 상황 따라 골라 쓰자
N+1의 정체를 봤으니, 이제 어떻게 처리할지 알아보자. 다행히 해법은 잘 정리돼 있다. 크게 네 가지인데, 하나씩 짚어 보면서 언제 무엇을 고를지 감을 잡아 보자.
① JOIN FETCH — 한 번에 조인해서 가져오기
가장 직관적인 해법이다. 사용자 따로, 할 일 따로 가져와서 쿼리가 쪼개진다면, 처음부터 조인(JOIN)으로 한 방에 가져오면 된다. JPQL이라는 JPA의 쿼리 언어로 이렇게 적는다.
public interface UserRepository extends JpaRepository<User, Long> {
@Query("select u from User u join fetch u.tasks")
List<User> findAllWithTasks();
}
join fetch가 핵심이다. “사용자를 가져오되, 연결된 할 일도 같은 쿼리에서 조인해 함께 끌고 와라”는 뜻이다. 이러면 SQL 로그에는 단 한 줄, 조인된 쿼리만 찍힌다.
select u1_0.id, u1_0.name, t1_0.user_id, t1_0.id, t1_0.title
from users u1_0 join task t1_0 on u1_0.id = t1_0.user_id
쿼리 N+1번이 단 1번으로 줄었다. 통쾌하지 않은가? 다만 이 방법에는 함정이 하나 숨어 있다. 페이징과 만나면 탈이 난다. 그 이야기는 조금 뒤에 하자.
사용자 3명의 할 일이 각각 2개, 2개, 0개다. findAllWithTasks()(join fetch)가 돌려주는 List<User>의 크기는?
② @EntityGraph — 어노테이션으로 같은 일을
JPQL을 직접 쓰지 않고, 어노테이션 선언만으로 JOIN FETCH와 사실상 같은 효과를 내는 방법이다.
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = "tasks")
@Query("select u from User u")
List<User> findAllWithTasks();
}
@EntityGraph(attributePaths = "tasks")는 “이 쿼리를 실행할 때 tasks도 함께 가져와라”는 지시다. 내부적으로는 JOIN FETCH와 비슷하게 조인 쿼리를 만들어 준다. JPQL을 손으로 쓰기보다 어노테이션 한 줄이 깔끔하다고 느낀다면 이쪽이 편하다. 둘 중 어느 쪽이 절대적으로 낫다기보다는 취향과 상황의 문제다.
③ 배치 페치 — 지연 로딩을 똑똑하게
세 번째는 결이 좀 다르다. 앞의 둘이 “처음부터 조인해서 한 번에 가져오자”였다면, 배치 페치는 “지연 로딩은 그대로 두되, 추가 쿼리를 한 방에 모아서 날리자”는 발상이다. application.yml에 설정 한 줄을 추가한다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
이 설정을 켜 두면 JPA는 아까처럼 사용자마다 할 일 쿼리를 한 번씩 날리지 않고, “필요한 사용자들의 할 일을 한꺼번에” 가져온다. SQL 로그에는 이런 in 절 쿼리가 찍힌다.
select u1_0.id, u1_0.name from users u1_0
select t1_0.user_id, t1_0.id, t1_0.title from task t1_0 where t1_0.user_id in (?, ?, ?)
쿼리가 N+1번에서 단 2번으로 줄었다. 사용자 조회 1번, 그리고 그 사용자들의 할 일을 in으로 한 번에 묶어 가져오는 쿼리 1번. (정확히는 사용자가 배치 크기를 넘으면 그만큼 쿼리가 더 나가지만, 천 번이 두세 번으로 줄어드는 것만으로도 세상이 달라진다.) 이 방법은 곧 보게 될 페이징 함정에서 자유롭다는 큰 장점이 있다. 기억해 두자.
원문 보정 · 앱 보충Spring Boot 3.5에 딸린 Hibernate 6.6을 H2에서 돌리면
in절의?는 사용자 수가 아니라 배치 크기만큼(이 설정이면 100개) 찍히고, 남는 자리는null로 채워 바인딩된다. 쿼리 모양을 하나로 고정해 재사용하려는 것이다. 로그에?가 길게 늘어서도 쿼리가 2번이면 제대로 동작한 것이다.
load(id)들을 모아 batch 함수를 한 번만 호출default_batch_fetch_size: 아직 안 읽은 할 일 컬렉션들을 모아 where user_id in (…) 한 번ch08-2 배치 페치로 N+1을 2번으로명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun
curl http://localhost:8080/api/users/summary결과는 ch08-1과 같다. SQL을 다시 센다.
Hibernate: select u1_0.id,u1_0.name from users u1_0
Hibernate: select t1_0.user_id,t1_0.id,… from task t1_0 where t1_0.user_id in (?,?,?,?,?, … ?)
1 + 3번이 1 + 1번이 됐다. 사용자가 3천 명이어도 1 + 30번이다.
in 절의 ?를 세어 보면 3개가 아니라 100개다. 「앱 보충」 8장 2절의 원문 보정 상자대로, Hibernate 6.6은 쿼리 모양을 하나로 고정해 재사용하려고 배치 크기만큼 자리를 만들고 남는 자리는 null로 채운다. 쿼리가 2번이면 제대로 동작한 것이다.
이 설정은 코드 한 줄 안 바꾸고 프로젝트 전체에 걸린다. 13장 검색 기능도 이 설정의 덕을 본다.
④ DTO 프로젝션 — 필요한 것만 콕 집어서
네 번째는 가장 근본적인 방법이다. 앞의 셋은 어쨌든 엔티티를 통째로 가져온다. 그런데 화면에 실제로 필요한 게 “사용자 이름과 할 일 개수”뿐이라면? 엔티티를 다 끌고 올 이유가 없다. 처음부터 필요한 값만 콕 집어 가져오면 된다.
public interface UserRepository extends JpaRepository<User, Long> {
@Query("""
select new com.example.taskboard.UserSummary(u.name, count(t))
from User u left join u.tasks t
group by u.id, u.name
""")
List<UserSummary> findSummaries();
}
데이터베이스에 “이름과 할 일 개수만 세서 돌려줘”라고 시키고, 그 결과를 곧장 UserSummary라는 DTO로 받는다. 엔티티도, 영속성 컨텍스트도 거치지 않으니 군더더기가 없고 가장 빠르다. 다만 이 방법은 “단순 조회 전용”이라는 점을 기억하자. 이렇게 받아 온 UserSummary는 엔티티가 아니라서, 7장에서 배운 더티 체킹(값을 바꾸면 자동으로 UPDATE)이 동작하지 않는다. 즉, 읽어서 보여 주기만 할 때 어울리는 해법이다.
그렇다면 넷 중 무엇을 골라야 할까? 정답은 “상황에 따라 다르다”이다. 단순 조회 화면이라면 DTO 프로젝션이 가장 깔끔하다. 엔티티를 수정까지 해야 한다면 JOIN FETCH나 @EntityGraph가 적합하다. 그리고 다음에 볼 페이징이 끼면 배치 페치가 거의 유일하게 마음 편한 답이 된다.
UserService.summarize()가 사용자마다 u.getTasks().size()를 부릅니다. 사용자 i의 할 일은 2, 2, 0개가 반복됩니다(셋째마다 할 일 없음). 페이징을 켜면 첫 페이지 10명을 가져오며, Page의 전체 개수(count) 쿼리는 세지 않습니다.
지금 리포지토리
사용자 3명일 때
화면에 나온 사용자 3명
운영 1,000명이면
- 해볼 것
- 해법 없이 사용자 100명으로 실행해 SQL이 몇 번 나가는지 보기
- 페이징 없이 SQL을 단 1번으로 줄이기
- 배치 페치로 할 일 조회를
in절 한 번에 모으기 - 페이징 + 컬렉션 JOIN FETCH 조합에서 HHH90003004 경고 띄우기
JOIN FETCH와 페이징은 사이가 나쁘다
방금 JOIN FETCH를 보며 “이거면 다 해결되겠네” 싶었을 것이다. 그런데 잠깐 멈추자. 실무에서 목록을 보여 줄 때는 거의 항상 페이징을 한다. “한 페이지에 20개씩” 잘라서 보여 주는 방식이다. 그렇다면 JOIN FETCH에 페이징을 얹으면 어떻게 될까?
@Query("select u from User u join fetch u.tasks")
Page<User> findPageWithTasks(Pageable pageable);
사용자가 수만 명인 운영 DB에서 이 메서드로 첫 페이지(20명)를 달라고 하면 Hibernate 6는 어떻게 할까?
원문 보정 · 앱 보충Hibernate 6에서는 첫째 문단처럼 “부풀려진 행을 20개씩 잘라 사용자 수가 뒤죽박죽이 되는” 일은 실제로 일어나지 않는다. Hibernate가 바로 그 사고를 피하려고 SQL에 limit을 아예 붙이지 않고 둘째처럼 메모리에서 자르기 때문이다. 그래서 페이지 내용은 맞게 나오고, 대가는 전부 읽어 오는 메모리와 시간이다. 행 기준으로 잘리는 사고는 네이티브 SQL처럼 Hibernate가 끼어들지 않는 쿼리에서 생긴다. 경고 대신 아예 실패하게 해 두려면
spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch: true를 켜자.
그렇다면 페이징이 필요할 땐 어떻게 해야 할까? 여기서 아까 잠깐 칭찬했던 배치 페치가 빛난다. 배치 페치는 조인으로 행을 부풀리지 않는다. 사용자는 사용자대로 정상적으로 페이징하고, 그 페이지에 담긴 사용자들의 할 일만 in 절로 한 번에 모아 가져온다. 행이 부풀지 않으니 페이징이 멀쩡히 동작하고, 쿼리도 몇 번으로 줄어든다. 그래서 현장에서는 “목록 + 페이징 + 연관관계”라는 흔한 조합에서 배치 페치를 기본값처럼 권하는 편이다. 기억해 두자. 컬렉션을 조인 페치하면서 동시에 페이징하지는 말자.
반대로 할 일 목록 select t from Task t join fetch t.user(Task → User, 하나 쪽)에 페이징을 붙이면?
ch08-3 JOIN FETCH, 그리고 페이징과의 불화명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun
curl http://localhost:8080/api/users/summary이번엔 SQL이 1번이다. 사용자와 할 일을 JOIN 한 번으로 읽었다.
Hibernate: select u1_0.id,u1_0.name,t1_0.user_id,t1_0.id,… from users u1_0 join task t1_0 on u1_0.id=t1_0.user_id
join(inner join)이라 할 일이 하나도 없는 사용자는 결과에서 빠진다. 그런 사용자까지 세려면 left join fetch를 쓴다.
이제 같은 쿼리를 페이징해 본다. 한 페이지에 2명.
curl "http://localhost:8080/api/users/summary/page?page=0&size=2"[{"name":"ara","taskCount":2},{"name":"bogum","taskCount":2}]
결과는 맞다. 그런데 콘솔에 경고가 있고, SQL에는 offset·fetch first(limit)가 없다.
WARN … org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
Hibernate: select u1_0.id,u1_0.name,t1_0.user_id,… from users u1_0 join task t1_0 on u1_0.id=t1_0.user_id
컬렉션을 JOIN하면 사용자 한 명이 할 일 수만큼 행으로 부풀려진다. SQL에서 행을 2개로 자르면 사용자 수가 틀어진다. 그래서 Hibernate는 SQL에서 자르지 않고 전부 읽어 와서 메모리에서 자른다. 사용자가 100만 명이면 100만 명을 다 읽는다.
「앱 보충」 8장 3절의 원문 보정 상자대로, Hibernate 6에서는 "사용자 수가 뒤죽박죽"이 되는 일은 없다. 대가는 메모리와 시간이다. 페이징에는 ch08-2의 배치 페치가 마음 편한 답이다.
직접 깨뜨려 보기: 경고 대신 실패하게
application.yml의 default_batch_fetch_size: 100 아래에 한 줄을 더하고 같은 페이지를 요청한다.
query.fail_on_pagination_over_collection_fetch: true이번엔 500이다. 로그:
org.hibernate.HibernateException: setFirstResult() or setMaxResults() specified with collection fetch join (in-memory pagination was about to be applied, but 'hibernate.query.fail_on_pagination_over_collection_fetch' is enabled)
경고는 놓치기 쉽다. 이 설정을 켜 두면 개발 중에 바로 터져서 알 수 있다. 확인했으면 git checkout -- .으로 되돌린다.
SQL 로그를 켜는 규율 — 마법을 가시화하기
지금까지의 이야기를 관통하는 한 가지가 있다. N+1을 발견할 수 있었던 것도, 페이징 함정의 경고를 알아챌 수 있는 것도, 전부 SQL 로그를 눈으로 봤기 때문이다. 자바 코드만 봐서는 이 중 무엇도 보이지 않는다. JPA가 보이지 않는 곳에서 SQL을 만들어 보내는 한, 그 SQL을 눈앞으로 끌어내지 않으면 영영 깜깜이로 남는다.
6장에서 켠 show-sql: true는 가장 기본적인 첫걸음이다. 다만 이건 SQL을 콘솔에 평평하게 찍어 주기만 한다. 조금 더 읽기 좋게 다듬으려면 다음 두 줄을 곁들이는 편이 낫다.
spring:
jpa:
properties:
hibernate:
format_sql: true # SQL을 보기 좋게 줄바꿈
logging:
level:
org.hibernate.SQL: debug # 어떤 SQL이 나가는지 로그로
원문 보정 · 앱 보충6장의
show-sql: true를 켠 채org.hibernate.SQL: debug까지 켜면 같은 SQL이 두 번 찍힌다. 하나는show-sql이 표준 출력에 찍는Hibernate: …줄이고, 하나는 로거가 남기는 줄이다. 로거 쪽만 남기고show-sql은 끄는 편이 깔끔하다.?자리에 들어간 값까지 보려면 Hibernate 6 기준으로org.hibernate.orm.jdbc.bind: trace를 더한다.
여기서 한 가지는 분명히 해 두자. 로그를 켜는 것은 협박이 아니라 습관의 문제다. SQL 로그를 켜 두고 코드를 짜다 보면, “어? 화면 하나 띄웠을 뿐인데 SELECT가 왜 이렇게 많이 나가지?” 하는 위화감을 스스로 느끼게 된다. 그 위화감이 N+1을 운영이 아니라 개발 단계에서 잡아내는 감각이다. 입문자일수록 이 습관을 일찍 들이는 게 좋다. 마법처럼 보이던 JPA를, “내가 짠 코드가 어떤 SQL을 만드는지 추적할 수 있는 도구”로 바꿔 주는 가장 값싸고 확실한 방법이니까.
테드 네워드가 ORM을 “컴퓨터 과학의 베트남”이라 부른 이유(6장에서 잠깐 언급했다)가 여기서 와닿는다. ORM은 객체와 표 사이의 간극을 메워 주지만, 그 추상화는 완벽하지 않고 어느 지점에서 누수(leak)된다. N+1도, 페이징 함정도, 결국 “객체처럼 다뤘더니 표의 세계가 기대와 다르게 반응한” 누수의 사례다. 그러니 추상화에 끝까지 기대지 말고 결정적인 순간엔 그 아래 SQL을 직접 보자. 이것이 JPA를 오래, 안전하게 쓰는 사람의 자세다.
ch08-4 SQL 로그를 켜는 규율명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun
curl http://localhost:8080/api/tasks/3/owner2026-… DEBUG … [nio-8080-exec-2] org.hibernate.SQL :
select
t1_0.id,
t1_0.created_at,
…
from
task t1_0
where
t1_0.id=?
이제 8장 앞 단계들의 요청(/api/users/summary 등)을 다시 보내며 SQL 개수를 세어 보자. 한 줄로 늘어선 SQL보다 훨씬 읽기 쉽다.
더 보기: ?에 무슨 값이 들어갔나
application.yml의 logging.level 아래에 한 줄을 더한다.
org.hibernate.orm.jdbc.bind: trace같은 요청을 보내면 SQL 아래에 바인딩된 값이 찍힌다.
TRACE … org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [3]
TRACE … org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [2]
할 일 id 3을 읽고, 그 주인 id 2를 읽었다. 값까지 찍히니 로그가 많아진다. 확인했으면 git checkout -- .으로 되돌린다.
JPA가 조용히 터뜨리는 함정 세 가지
N+1 말고도 JPA에는 입문자가 자주 밟는 함정이 몇 개 더 있다. 7장에서 영속성 컨텍스트라는 “출석부”와 더티 체킹·지연 로딩의 마법을 봤으니, 이제 그 마법이 어긋나는 지점을 짚어 두자. 미리 알아 두면 운영에서 식은땀 흘릴 일을 크게 줄일 수 있다.
첫째, 더티 체킹이 동작하지 않는 타이밍. 7장에서는 save()를 부르지 않아도 트랜잭션 안에서 엔티티 값을 바꾸면 자동으로 UPDATE가 나간다는 걸 봤다. 더티 체킹이다. 그런데 이건 트랜잭션 안에서 영속 상태인 엔티티에만 해당한다. 트랜잭션 밖에서 객체 값을 바꿔 봐야 아무 일도 일어나지 않는다. “분명 값을 바꿨는데 데이터베이스엔 그대로네?” 하고 당황하는 일이 여기서 생긴다. 변경 감지는 영속성 컨텍스트가 살아 있는 동안에만 일어난다는 걸 기억하자.
둘째, @Transactional의 self-invocation 무효. 이건 많은 사람이 당하는 함정이다. 같은 클래스 안에서 메서드가 다른 메서드를 직접 부를 때, @Transactional이 통째로 무시될 수 있다.
@Service
public class TaskService {
public void doWork() {
updateTask(); // 같은 클래스 안에서 직접 호출 → 트랜잭션 안 걸림!
}
@Transactional
public void updateTask() {
// ...
}
}
바깥에서 taskService.doWork()를 불렀다. 그 안의 updateTask()가 트랜잭션 없이 도는 이유는?
셋째, 트랜잭션 밖 지연 로딩 — LazyInitializationException. 아마 입문자가 가장 자주 마주칠 예외다. 지연 로딩으로 설정된 연관 객체를, 트랜잭션이 이미 끝난 뒤에 건드리면 터진다. 예컨대 서비스에서 트랜잭션을 닫고 사용자만 컨트롤러로 넘겼는데, 컨트롤러나 뷰에서 뒤늦게 user.getTasks()를 부르는 경우다.
org.hibernate.LazyInitializationException:
could not initialize proxy [com.example.taskboard.User.tasks] - no Session
원문 보정 · 앱 보충컬렉션(
User.tasks)을 늦게 건드렸을 때 Hibernate 6가 실제로 찍는 메시지는failed to lazily initialize a collection of role: com.example.taskboard.User.tasks: could not initialize proxy - no Session이다. 위 형태는 7장의task.getUser()같은 엔티티 프록시 쪽 메시지다. 또 7장에서 짚었듯, Spring Boot 기본값spring.jpa.open-in-view=true에서는 요청이 끝날 때까지 영속성 컨텍스트가 열려 있어 컨트롤러·뷰에서도 예외 없이 로딩된다(대신 시작 로그에spring.jpa.open-in-view is enabled by default경고가 남는다). 이 예외를 직접 보려면spring.jpa.open-in-view: false를 켜자.
7장에서 본 출석부 비유를 떠올리면 이해가 쉽다. 지연 로딩은 “필요할 때 출석부를 다시 펼쳐 데이터베이스에 물어보겠다”는 약속이다. 그런데 트랜잭션이 끝나면 그 출석부(영속성 컨텍스트)가 닫혀 버린다. 닫힌 출석부에 대고 “할 일 목록 좀 가져와줘”라고 하니, JPA가 “물어볼 곳이 없는데요”라며 예외를 던진다. 해법은 여러 가지지만, 가장 깔끔하고 권할 만한 길은 앞서 배운 그대로다. 필요한 데이터는 트랜잭션 안에서, JOIN FETCH나 DTO 프로젝션으로 미리 다 가져와 두는 것. 트랜잭션이 살아 있을 때 챙길 걸 다 챙기면, 닫힌 출석부를 두드릴 일도 없다. N+1을 풀던 도구가 이 예외까지 함께 막아 주는 셈이니, 한 번 익혀 두면 두고두고 쓸모가 있다.
SQL을 눈으로 본다는 약속을 테스트로 굳히기 — @DataJpaTest
지금까지 “SQL 로그를 눈으로 보자”고 거듭 다짐했다. 그런데 매번 콘솔을 들여다보며 눈으로 세는 일은, 솔직히 번거롭고 빠뜨리기도 쉽다. 오늘은 챙겨 봤지만 내일 바쁘면 안 본다. 그러다 N+1이 슬그머니 다시 기어든다. 그렇다면 이 “눈으로 확인하기”를 아예 테스트로 못 박아 두면 어떨까? 사람의 의지에 기대지 말고, 빌드할 때마다 기계가 대신 확인하게 만들자.
5장에서는 @WebMvcTest로 컨트롤러만 똑 떼어내 테스트하는 슬라이스 테스트를 맛봤다. 그때 “MockMvc는 프런트의 supertest와 같은 모양”이라고 했던 걸 기억하는가? JPA에도 꼭 그런 짝이 있다. 리포지토리와 데이터베이스 계층만 따로 떼어 내 검증하는 @DataJpaTest다.
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void DTO_프로젝션은_단_한_번의_쿼리로_요약을_가져온다() {
// given — 사용자 2명, 각각 할 일 2개씩 미리 저장
User a = userRepository.save(new User("아라"));
a.addTask(new Task("JPA 익히기"));
a.addTask(new Task("N+1 잡기"));
User b = userRepository.save(new User("보검"));
b.addTask(new Task("러닝"));
b.addTask(new Task("스트레칭"));
// when
List<UserSummary> summaries = userRepository.findSummaries();
// then — 결과가 의도한 대로인지 단언
assertThat(summaries).hasSize(2);
assertThat(summaries)
.extracting(UserSummary::taskCount)
.containsExactlyInAnyOrder(2L, 2L);
}
}
원문 보정 · 앱 보충7장 매핑 그대로는 이 테스트가 통과하지 않는다.
addTask는 7장 어디에도 없고,User.tasks는 cascade 없는mappedBy거울이라 새로 만든Task를 INSERT할 길이 없다. 따라 하려면User에@OneToMany(mappedBy = "user", cascade = CascadeType.PERSIST)를 주고, 주인 쪽까지 채우는 편의 메서드public void addTask(Task task) { tasks.add(task); task.setUser(this); }를 두자(Task에setUser가 필요하다). 그러면findSummaries()를 실행하기 직전의 자동 flush 때 할 일들이 함께 저장된다.
@DataJpaTest는 컨트롤러나 서비스는 빼고, JPA 리포지토리와 데이터베이스에 관한 것만 가볍게 띄워 준다. 게다가 기본으로 H2 같은 인메모리 데이터베이스를 잡아 주고, 테스트가 끝나면 데이터를 알아서 롤백해 준다. 그래서 테스트끼리 데이터가 섞일 걱정 없이, 매번 깨끗한 상태에서 리포지토리만 콕 집어 확인할 수 있다.
이 테스트의 진짜 가치는 단언문 너머에 있다. 테스트를 돌리면 show-sql 덕에 콘솔에 실제 나가는 SQL이 찍힌다. N+1 해법을 적용하기 전 코드로 이 테스트를 돌리면 SELECT가 여러 번 나가는 게 로그에 그대로 보인다. DTO 프로젝션이나 JOIN FETCH로 고친 뒤 다시 돌리면 쿼리가 한두 번으로 줄어든 게 보인다. 고치기 전후로 SQL이 어떻게 달라졌는지를, 추측이 아니라 눈앞의 로그로 확인하는 것, 이것이 리포지토리 슬라이스 테스트가 주는 선물이다.
더 욕심을 내자면, 실제로 나간 쿼리 수를 세어 단언하는 도구(예: 쿼리 카운터 라이브러리)를 붙여 “이 메서드는 쿼리가 2번을 넘으면 실패”처럼 N+1을 자동으로 막는 안전장치를 둘 수도 있다. 입문 단계에서는 거기까지 갈 필요가 없다. 다만 “SQL을 눈으로 보는 일”을 테스트로 굳힐 수 있다는 감각만은 지금 챙겨 두자. 13장에서 AI와 기능을 완주할 때, 이 테스트가 “구현한 다음 무엇으로 검증할 것인가”의 든든한 한 축이 된다.
ch08-5 DTO 프로젝션과 @DataJpaTest명령과 기대 출력 펼치기
실행해 보기
./gradlew bootRun
curl http://localhost:8080/api/users/summary결과는 같고, SQL은 1번이다. ch08-3의 findAllWithTasks는 이제 summarize()가 쓰지 않지만, 해법끼리 비교해 볼 수 있게 리포지토리에 남겨 두었다.
select
u1_0.name,
count(t1_0.id)
from
users u1_0
left join
task t1_0
on u1_0.id=t1_0.user_id
group by
u1_0.id,
u1_0.name
테스트를 돌린다.
./gradlew test --tests '*UserRepositoryTest' -i --rerunBUILD SUCCESSFUL. -i 출력에서 SQL 순서를 보자. 사용자 INSERT 2번 → 할 일 INSERT 4번 → 집계 SELECT 1번이다.
할 일 INSERT는 addTask를 부를 때가 아니라 findSummaries()를 실행하기 직전에 나간다. JPQL을 실행하기 전에 Hibernate가 출석부의 변경을 DB에 먼저 반영(자동 flush)해서, 쿼리가 방금 넣은 데이터를 보게 한다.
@DataJpaTest는 기본으로 show-sql을 켜서 테스트 출력에는 Hibernate: … 줄도 겹쳐 찍힌다. DataInitializer는 뜨지 않아서 테스트 DB에는 테스트가 넣은 데이터만 있다.
직접 깨뜨려 보기: cascade를 빼면
User.java에서 cascade = CascadeType.PERSIST를 지우고 테스트를 다시 돌린다.
Expecting actual:
[0L, 0L]
to contain exactly in any order:
[2L, 2L]
mappedBy 쪽 컬렉션에 넣은 새 할 일은 아무도 저장하지 않는다. 사용자는 2명 있는데 할 일은 0개다. 확인했으면 git checkout -- .으로 되돌린다.
AI가 준 Repository, SQL 로그로 검증하자
이 장의 내용을 AI 페어코딩에 그대로 얹어 보자. AI가 만들어 준 리포지토리와 서비스를 받았다면, 믿고 넘어가기 전에 이 순서로 검증하자.
먼저 그냥 한번 실행해 본다. show-sql을 켜 둔 채로 그 메서드를 부르고 콘솔을 본다. SELECT가 데이터 개수만큼 줄줄이 나간다면, 축하한다 — 농담이 아니라, N+1을 개발 단계에서 잡아낸 것이다. AI는 “동작하는 코드”는 곧잘 주지만, 그 코드가 운영에서 효율적인지까지는 책임지지 않는다. 그건 우리 눈으로 확인할 몫이다.
다음으로, N+1을 발견했다면 AI에게 “이 코드의 N+1 문제를 고쳐줘”라고 다시 부탁해 보자. 그러면 AI는 이 장에서 본 네 가지 해법 중 하나를 골라 고쳐 줄 것이다. 여기서 중요한 건, AI가 무엇을 골랐고 그게 우리 상황에 맞는지 판단하는 일이다. 페이징이 필요한 화면인데 JOIN FETCH로 고쳐 줬다면? 앞서 봤듯 그건 또 다른 사고의 씨앗이다. 단순 조회 화면에 무거운 엔티티 페치를 붙였다면, DTO 프로젝션이 더 나았을 수도 있다. AI는 일반적인 해법을 안다. 하지만 “지금 이 화면의 맥락에서 어떤 해법이 옳은가”는 이 장을 읽은 우리가 더 잘 안다.
그러니 원칙은 6장에서와 똑같다. AI는 해법의 후보를 빠르게 펼쳐 주고, 그중 무엇이 옳은지 가리는 건 SQL 로그를 읽을 줄 아는 사람의 일이다. 이제 우리는 그 로그를 읽을 줄 안다. AI가 준 코드 앞에서 더 이상 막연히 불안해하지 않아도 된다.
“N+1 고쳐줘. 화면은 셋이야. ① 사용자 목록: 20명씩 페이징, 할 일 제목까지 표시 ② 요약: 이름과 할 일 개수만, 조회 전용 ③ 사용자 상세: 할 일까지 수정, 할 일이 하나도 없는 사용자도 열려야 해.”
의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 2개입니다.
마무리
JPA가 운영에서 터뜨리는 가장 흔한 지뢰, N+1을 정면으로 마주했다. 핵심은 하나다. 로컬에선 데이터가 적어 보이지 않다가 운영에서 비로소 터지는 이 문제를 SQL 로그를 눈으로 보는 것으로 잡아낸다. 해법 네 가지(JOIN FETCH·@EntityGraph·배치 페치·DTO 프로젝션) 중 JOIN FETCH는 페이징과 만나면 탈이 나니 그럴 땐 배치 페치로 우회하자. 더티 체킹 타이밍·@Transactional self-invocation·LazyInitializationException이라는 세 함정도 미리 짚었다. 그리고 @DataJpaTest로 그 규율을 테스트로 굳혀 5장 컨트롤러 테스트와 짝을 이루는 두 번째 검증 기둥을 세웠다.
다시 새길 한마디는 이것이다. ORM은 마법이 아니라 SQL을 생성하는 도구다. 그 추상화가 누수되는 지점을 운영 사고로 번지기 전에 막는 유일한 길은, 생성되는 SQL을 직접 보는 습관이다. 이로써 6장에서 8장까지 이어진 JPA 여정이 한 매듭을 짓는다.
이제 질문은 자연스럽게 다음으로 옮겨 간다. TaskBoard는 지금 누구에게나 활짝 열려 있다. “이 요청을 보낸 게 대체 누구이고, 무엇을 할 권한이 있는가?” 다음 장부터 세 장에 걸쳐, 입문자에게 가장 큰 산이라는 보안의 세계로 한 걸음씩 들어가 보자.
1. N+1이 로컬에서 보이지 않았던 진짜 이유는?
2. 사용자 목록 + 페이징 + 각 사용자의 할 일 컬렉션까지 필요하다. 가장 마음 편한 해법은?
3. 이름과 할 일 개수만 보여주는 조회 전용 화면에 가장 군더더기 없는 해법은?
4. Hibernate 6에서 페이징 + 컬렉션 JOIN FETCH의 기본 동작은?
이 장의 실습은 단계마다 커밋으로 준비해 두었고, 각 단계는 본문의 해당 자리에 있습니다. 처음이라면 레포부터 받습니다.
git clone https://github.com/younggeun0/toby-react-to-spring-taskboard.git
cd toby-react-to-spring-taskboard
git checkout ch08-1ch08-1사용자별 할 일 개수와 N+1ch08-2배치 페치로 N+1을 2번으로ch08-3JOIN FETCH, 그리고 페이징과의 불화ch08-4SQL 로그를 켜는 규율ch08-5DTO 프로젝션과@DataJpaTest