Spring Data JPA 실무 완전 정복 PART 2
N+1 문제 — 언제, 왜, 어떻게 터지는가
이 파트의 목적은 N+1을 쿼리 개수 문제로 보지 않고, 접근 시점, 영속성 컨텍스트, 프록시 초기화, 쓰기 지연, 플러시 타이밍, Aggregate 경계까지 연결된 하나의 구조적 현상으로 설명한다.
N+1은 우연히 발생하는 버그가 아니다. JPA를 사용하면서 연관관계를 엔티티 그래프로 모델링하는 순간, 아무 제어도 하지 않으면 자연스럽게 나타나는 기본 동작이다. 따라서 중요한 질문은 N+1이 왜 생기느냐가 아니라, 언제 생기고 그 발생 지점을 어떻게 통제할 수 있느냐다.
조회 쿼리는 한 번인데, 왜 실행 중에 쿼리가 폭발하는가.
이 질문에 답하려면 N+1을 단순히 연관관계 설정 문제로 보면 안 된다. N+1은 조회 전략, 프록시 초기화 시점, 영속성 컨텍스트의 생명주기, flush 타이밍이 서로 맞물릴 때 나타나는 구조적 현상이다.
1. N+1의 출발점은 조회가 아니라 접근이다
JPA에서 가장 먼저 고쳐야 할 오해는 이것이다. N+1은 findAll 같은 조회 메서드가 문제라는 오해다. 실제로 문제는 조회가 아니라 그 이후에 일어나는 접근이다.
1
List<Member> members = memberRepository.findAll();
이 코드는 아무 문제를 일으키지 않는다. 여기까지는 단 한 번의 쿼리만 실행된다. N+1은 이 다음 코드에서 시작된다.
1
2
3
for (Member m : members) {
System.out.println(m.getTeam().getName());
}
이 코드에서 실행되는 쿼리는 보통 다음과 같다.
- Member 조회 쿼리 1번 이후
- 각 Member마다 Team 조회 쿼리 N번
이 현상을 흔히 연관관계가 LAZY라서 발생한다고 설명한다. 하지만 이 설명은 절반만 맞다.
문제의 핵심은 LAZY 자체가 아니라 프록시가 언제 초기화되느냐다.
Member를 조회하는 시점에는 Team은 프록시다.
이때까지는 쿼리가 추가로 발생하지 않는다.
쿼리가 터지는 시점은 getTeam를 호출하는 순간이다.
즉 N+1은 조회 시점이 아니라 접근 시점 문제다.
Member 엔티티에 매핑된 team 연관관계가 LAZY라면, 이 시점에 프록시가 초기화된다. 프록시는 조회 시점이 아니라 접근 시점에 실제 엔티티를 로딩한다. 이 구조가 N+1의 본질이다.
영속성 컨텍스트는 이미 로딩된 엔티티를 캐시하지만, 아직 로딩되지 않은 연관 엔티티를 미리 가져오지는 않는다. 따라서 members에 포함된 각 Member가 서로 다른 Team을 참조하고 있다면, 접근 횟수만큼 추가 쿼리가 발생한다.
이 지점에서 중요한 결론이 하나 나온다. N+1은 조회 메서드를 바꿔서 해결되는 문제가 아니라, 연관관계 접근을 어떻게 설계했는지를 점검해야 해결된다.
2. 프록시 초기화는 예외가 아니라 기본 동작이다
LAZY 연관관계는 항상 프록시로 주입된다. 이 프록시는 가짜 객체가 아니라, 실제 엔티티로 교체될 수 있는 대리 객체다. 이 교체 작업이 바로 초기화다.
1
2
@ManyToOne(fetch = FetchType.LAZY)
private Team team;
이 설정은 팀을 나중에 쓰겠다는 뜻이 아니라, 지금은 가져오지 않겠다는 뜻이다. 즉 가져올지 말지는 접근 시점에 결정된다.
프록시는 다음 조건이 만족되면 초기화된다.
- 엔티티 매니저가 살아 있고
- 프록시에 실제 엔티티가 없으며
- 프록시의 속성이나 메서드에 접근할 때다.
즉, 프록시가 초기화되기 위한 조건은 속성 컨텍스트가 살아 있어야 한다.
이 조건은 대부분의 서비스 코드에서 자연스럽게 충족된다. 그래서 N+1은 특이 케이스가 아니라, 기본값에 가까운 현상이다.
1
2
3
4
5
@Transactional
public void printOrders() {
List<Order> orders = orderRepository.findAll();
orders.forEach(o -> o.getMember().getName());
}
이 코드는 정상 동작하며 N+1이 발생한다.
하지만 트랜잭션 경계를 벗어나면 상황이 달라진다.
1
2
List<Order> orders = orderRepository.findAll();
orders.forEach(o -> o.getMember().getName());
여기서는 LazyInitializationException이 발생한다.
N+1조차 발생하지 않는다.
프록시를 초기화할 수 없기 때문이다.
이 차이 때문에 OSIV 환경에서는 N+1이 컨트롤러와 뷰 계층까지 흘러가며 터진다. —
3. 영속성 컨텍스트 캐시는 왜 N+1을 막아주지 못하는가
많이 나오는 질문 중 하나는 이것이다. 영속성 컨텍스트가 1차 캐시라면, 왜 같은 Team을 여러 번 조회하지 않느냐는 질문이다.
1
2
3
4
5
6
List<Order> orders = orderRepository.findAll();
orders.forEach(o -> {
Member m = o.getMember();
System.out.println(m.getName());
});
여러 Order가 동일한 Member를 참조한다면 첫 접근 시 쿼리 1번만 발생하고 이후에는 1차 캐시에서 가져온다.
하지만 이 최적화는 구조적 보장이 아니다. 조회 기준이 Order이기 때문이다.
실무에서는 대부분 Order마다 다른 Member를 참조한다. 그래서 N+1은 그대로 발생한다.
따라서 N+1 이 나오는 정답은 간단하다. 같은 엔티티면 캐시가 적용되고, 다른 엔티티면 적용되지 않는다.
1
2
3
4
5
Member m1 = members.get(0);
Member m2 = members.get(1);
m1.getTeam();
m2.getTeam();
두 Member가 같은 Team을 참조하면, 첫 번째 초기화 쿼리 이후에는 캐시가 동작한다. 하지만 서로 다른 Team이라면 캐시는 아무 역할을 하지 못한다. N+1은 바로 이 상황에서 발생한다.
즉 N+1은 캐시 미스 문제가 아니라, 서로 다른 연관 엔티티를 반복 접근하는 구조에서 발생한다.
4. Aggregate 경계를 넘는 순간 N+1은 기하급수적으로 증가한다
단순한 ManyToOne 하나만 있어도 N+1은 충분히 발생한다. 하지만 실무에서 진짜 문제가 되는 순간은 Aggregate 경계를 넘을 때다.
1
2
3
4
5
orders.forEach(order -> {
order.getItems().forEach(item -> {
item.getProduct().getName();
});
});
이 코드에는 세 단계의 접근이 있다. Order, OrderItem, Product다. OrderItem 컬렉션이 LAZY라면 한 번, Product가 LAZY라면 또 한 번 추가 조회가 발생한다.
즉, Order -> OrderItem -> Product 구조에서
모든 연관관계를 따라 접근하면
N x M 구조의 쿼리가 발생한다.
이 구조에서는 단순한 N+1이 아니라, 주문 수 곱하기 아이템 수만큼 쿼리가 발생할 수 있다. 성능 문제가 폭발하는 이유가 여기에 있다. 따라서 이건 단순한 N+1이 아니라 그래프 탐색 문제다.
그래서 Aggregate 경계를 넘는 조회는 DTO 기반 조회로 분리하는 것이 안전하다.
이에 따라 Aggregate Root 기준으로 조회 범위를 나눈다. 목록 조회에서는 Root만 조회하고 상세 조회에서 내부 연관을 함께 로딩한다.
1
2
select o
from Order o
1
2
3
4
select o
from Order o
join fetch o.orderItems
where o.id = :id
이때 즉시 로딩을 사용하고 싶어지는 유혹이 생긴다. 하지만 이는 문제를 해결하는 것이 아니라, 조회 시점으로 문제를 당겨오는 것에 불과하다.
5. 즉시 로딩이 실무에서 위험한 이유
연관관계를 FetchType.EAGER로 바꾸면
겉보기에는 N+1이 사라진다.
접근 시점에 쿼리가 안 나가기 때문이다.
1
2
@ManyToOne(fetch = FetchType.EAGER)
private Member member;
하지만 이 선택은 다음 문제를 만든다.
-
모든 조회에 조인이 강제된다.
Order만 필요해도 Member가 항상 따라온다. -
연관관계가 늘어날수록 조인이 폭증한다.
컬렉션 EAGER는 페이징을 깨뜨린다.
이는 결국 다음과 같이 정리할 수 있다.
- 어떤 조회에서도 연관 엔티티가 무조건 조인된다.
- 연관관계가 늘어날수록 쿼리가 예측 불가능해진다.
- 컬렉션 즉시 로딩은 페이징을 깨뜨린다.
즉시 로딩은 조회 시점에 모든 문제를 숨겨두고 있다가, 어느 날 갑자기 성능 장애로 폭발하는 전형적인 위험 요소다. 그래서 실무에서는 연관관계의 기본 전략을 LAZY로 두는 것이 사실상 규칙이다. EAGER는 문제를 해결하는 것이 아니라 문제가 보이지 않게 만드는 선택이다. —
6. orphanRemoval과 컬렉션 초기화가 만드는 숨은 N+1
orphanRemoval은 편리하지만 비용이 있다. JPA는 어떤 엔티티가 고아가 되었는지 판단하기 위해 컬렉션의 현재 상태를 알아야 한다.
1
2
@OneToMany(mappedBy = "order", orphanRemoval = true)
private List<OrderItem> items;
orphanRemoval은 flush 시점에 컬렉션 비교를 수행한다.
1
order.getItems().remove(item);
따라서 이 상태에서 컬렉션을 수정하면, JPA는 flush 시점에 컬렉션을 초기화한다. 이 초기화 과정에서 추가 조회가 발생할 수 있다.
flush 전에 컬렉션이 초기화되지 않았다면 flush 직전에 컬렉션 로딩이 발생한다.
읽기 쿼리 앞에서도 flush가 발생할 수 있기 때문에 의도하지 않은 N+1이 터진다.
1
order.getItems().removeIf(OrderItem::isExpired);
이 코드는 작성 시점에는 쿼리를 발생시키지 않는다. 하지만 이후 JPQL이나 QueryDSL을 실행하는 순간, flush가 일어나고, 그 과정에서 컬렉션 접근이 한꺼번에 터진다.
이런 N+1은 코드 위치상 전혀 예상하지 못한 지점에서 발생한다. 그래서 orphanRemoval은 Aggregate 내부에서만, 삭제 생명주기가 명확한 경우에만 사용하는 것이 안전하다.
7. 쓰기 지연과 flush 타이밍이 N+1을 증폭시키는 구조
JPA는 쓰기 지연을 한다. 엔티티 상태를 변경해도 SQL은 바로 나가지 않는다.
1
order.changeStatus(CANCELED);
하지만 다음 조회 쿼리를 실행하는 순간 상황이 달라진다.
1
queryFactory.selectFrom(order).fetch();
조회 전에 반드시 flush가 일어나고, 이 flush 과정에서 변경 감지, 연관관계 동기화, 컬렉션 초기화가 모두 수행된다. 이때 프록시가 초기화되면 N+1이 한꺼번에 발생한다.
문제는 이 쿼리가 읽기 쿼리라는 점이다. 읽기 때문에 안전하다고 생각했던 코드가, 사실은 쓰기 작업을 유발하고 있었던 것이다.
8. OSIV 환경에서 N+1이 더 위험해지는 이유
OSIV가 켜져 있으면 영속성 컨텍스트는 컨트롤러 반환 이후까지 살아 있다. 이 상태에서 JSON 직렬화가 시작되면 getter가 호출된다.
1
return member;
이 한 줄은 단순해 보이지만, 직렬화 과정에서 연관관계 접근이 일어나면 그 순간 프록시가 초기화되고 쿼리가 발생한다. 문제는 이 쿼리가 서비스 계층 밖에서 발생한다는 점이다.
이런 쿼리는 로그로 추적하기도 어렵고, 트랜잭션 경계도 불분명하다. 그래서 실무에서는 OSIV를 끄거나, API 응답에서 엔티티를 직접 반환하지 않는 구조를 택한다.
9. N+1 해결책은 기술이 아니라 선택 기준이다
fetch join의 역할
fetch join은 특정 조회에서만 연관 객체를 즉시 로딩하겠다는 선언이다.
1
2
3
4
select o
from Order o
join fetch o.member
where o.status = :status
fetch join은 엔티티 그래프를 확정하므로 남용하면 영속성 컨텍스트가 비대해진다.
따라서 fetch join은 강력하지만 만능은 아니다. 컬렉션 fetch join은 페이징이 불가능하고 결과 row 수가 폭증한다.
1
2
3
@OneToMany(mappedBy = "order")
@BatchSize(size = 100)
private List<OrderItem> orderItems;
1
hibernate.default_batch_fetch_size=100
batch fetch는 접근 시점을 유지하면서 (LAZY의 유연성을 유지하면서) 쿼리 수를 줄여준다. 이 방식은 Aggregate 경계를 넘는 접근이 불가피할 때 가장 안전한 완충 장치다.
- fetch join은 결과 건수가 적고 그래프가 명확할 때 사용한다.
- batch 전략은 목록 조회와 페이징이 필요한 경우 적합하다.
DTO 조회는 읽기 전용 화면에서 가장 확실한 해결책이다. 영속성 컨텍스트, 프록시, flush 문제에서 완전히 벗어날 수 있다.
중요한 것은 이 중 하나를 고르는 것이 아니라, 조회 목적에 맞는 전략을 선택하는 것이다.
DTO 조회는 회피가 아니라 설계다
- 쓰기 모델은 엔티티
- 읽기 모델은 DTO
이 분리가 명확해질수록 N+1은 구조적으로 제거된다. QueryDSL은 이 역할을 가장 잘 수행한다.
정리
N+1은 실수가 아니라 구조다. 접근 시점, 영속성 컨텍스트, 프록시, flush 타이밍이 맞물려 자연스럽게 발생한다. 따라서 이를 막기 위해서는 즉흥적인 fetch join 추가가 아니라, Aggregate 경계 설정, 조회 모델 분리, LAZY 기본 전략이라는 원칙이 필요하다.
이 원칙이 지켜질 때 N+1은 우연히 피하는 문제가 아니라, 의도적으로 통제 가능한 문제가 된다.
QueryDSL — 조회를 문자열이 아닌 코드로 설계한다
Spring Data JPA를 일정 수준 이상 사용하다 보면 반드시 부딪히는 벽이 있다. 엔티티 매핑도 맞고, 연관관계도 깔끔하며, 영속성 컨텍스트의 동작도 이해하고 있는데 조회 코드가 점점 지저분해진다.
처음에는 메서드 이름 쿼리로 시작한다.
1
2
3
4
5
List<Order> findByStatusAndCreatedAtAfterAndMemberNameContaining(
OrderStatus status,
LocalDateTime createdAt,
String name
);
조건이 하나 둘 늘어나면 감당이 안 된다. 그래서 JPQL로 옮긴다.
1
2
3
4
5
6
@Query(
"select o from Order o join o.member m " +
"where (:status is null or o.status = :status) " +
"and (:name is null or m.name like concat('%', :name, '%'))"
)
List<Order> search(OrderStatus status, String name);
이 시점부터 문제가 시작된다. JPQL은 문자열이다. 컴파일 타임에 아무것도 보장되지 않는다. 필드명을 바꿔도, 연관관계를 수정해도, IDE는 침묵한다. 문제는 런타임에만 터진다.
QueryDSL은 이 문제를 전혀 다른 방식으로 해결한다. 조회 조건을 문자열이 아니라 코드로 표현한다. 즉, 조회 자체를 설계 대상으로 끌어올린다.
1. Q 타입과 타입 안정성
1
2
QOrder order = QOrder.order;
QMember member = QMember.member;
이 객체들은 단순한 헬퍼가 아니다. QueryDSL이 엔티티 클래스를 기반으로 생성한 정적 메타 모델이며, 쿼리 작성 시점에 엔티티 구조를 그대로 반영하는 타입 정보를 담고 있다.
Q 타입이 있다는 것은, 이제 조회 쿼리가 문자열이 아니라 자바 코드의 한 부분으로 컴파일된다는 의미다.
1
2
order.status.eq(OrderStatus.ORDERED);
member.name.contains("kim");
이 코드에서 중요한 점은 단순히 편하다는 것이 아니다. 여기에는 다음과 같은 보장이 함께 따라온다.
-
필드 이름이 컴파일 타임에 검증된다.
엔티티에서 status 필드를 제거하거나 이름을 바꾸는 순간, 이 쿼리는 런타임이 아니라 컴파일 단계에서 바로 깨진다. JPQL 문자열에서는 절대 얻을 수 없는 안정성이다. -
타입이 강제된다.
order.status는 OrderStatus 타입이라는 사실을 컴파일러가 알고 있다. 잘못된 비교, 잘못된 상수, 잘못된 연산은 애초에 코드로 작성조차 되지 않는다. -
조인 관계가 명확해진다.
order.member.name 같은 접근은 단순한 점 표기가 아니라, 실제로 엔티티 연관관계를 따라간다는 의도가 코드에 그대로 드러난다. 조인이 필요한 경로인지, 단일 컬럼 접근인지가 코드 레벨에서 구분된다.
이 지점에서 QueryDSL의 가치는 분명해진다. 조회 로직이 더 이상 문자열 조합이 아니라, IDE와 컴파일러가 이해하고 검증할 수 있는 코드가 된다.
결과적으로 쿼리는 다음 성질을 갖게 된다.
- 리팩터링에 안전하고
- 엔티티 변경에 즉시 반응하며
- 잘못된 조회는 실행 전에 차단된다
QueryDSL의 Q 타입은 조회를 편하게 만드는 도구가 아니라, 조회 로직을 타입 시스템 안으로 끌어들이는 장치다. 이 순간부터 조회는 런타임 디버깅의 대상이 아니라, 컴파일 타임에 검증되는 설계 요소가 된다.
2. 동적 조회의 구조적 차이
JPQL
JPQL에서 동적 조회는 본질적으로 문자열을 조합하는 문제다. 조건이 늘어날수록 문자열은 분기문 안에서 늘어나고, 쿼리의 최종 형태는 실행 시점이 되어서야 결정된다. 이 방식에서는 쿼리 구조 자체를 컴파일 타임에 검증할 수 없다.
QueryDSL
QueryDSL은 접근 방식이 다르다. 조건을 문자열이 아니라 값처럼 취급되는 객체로 다룬다. 각 조건은 하나의 표현식(BooleanExpression)으로 분리되고, 이 표현식들은 조합 가능한 구성 요소가 된다.
1
2
3
4
5
6
7
public BooleanExpression statusEq(OrderStatus status) {
return status != null ? order.status.eq(status) : null;
}
public BooleanExpression nameContains(String name) {
return hasText(name) ? member.name.contains(name) : null;
}
여기서 중요한 점은, 이 메서드들이 쿼리를 생성하지 않는다는 것이다. 이들은 단지 조건을 표현하는 객체를 반환할 뿐이다. 즉, 조건 자체가 독립적인 단위로 존재하며, 언제 어디에 쓰일지는 나중에 결정된다.
이 구조는 조회 조립 단계에서 힘을 발휘한다.
1
2
3
4
5
query.select(order)
.from(order)
.join(order.member, member)
.where(statusEq(status), nameContains(name))
.fetch();
where 절에 전달된 조건들은 가변 인자(varargs)로 처리된다. QueryDSL은 이 과정에서 null인 조건을 자동으로 제거한다. 따라서 조건이 없을 때를 위해 별도의 if 문이나 문자열 분기를 만들 필요가 없다.
이에 다라 조회 구조가 고정된 채 조건만 교체된다.
- from / join 구조는 항상 동일하다
- where 절은 조건 조각들의 조합으로만 변한다
- 조건이 늘어나도 쿼리의 전체 형태는 흔들리지 않는다
또 하나 중요한 점은 조건의 재사용성이다.
statusEq, nameContains 같은 조건 메서드는 특정 쿼리에 종속되지 않는다.
같은 조건을 다른 조회에서도 그대로 사용할 수 있고, 테스트 역시 개별적으로 가능하다.
3. BooleanBuilder와 BooleanExpression
BooleanBuilder
BooleanBuilder는 상태를 가진 조건 컨테이너다. 조건을 순서대로 누적하는 방식이라, 코드 흐름이 명령형으로 흘러간다.
1
2
BooleanBuilder builder = new BooleanBuilder();
if (status != null) builder.and(order.status.eq(status));
이 방식은 단순한 조건 몇 개일 때는 문제 없다. 하지만 조건 수가 늘어나면 다음 문제가 생긴다.
- 조건 로직이 한 메서드 안에 뭉친다
- 조건 단위로 분리하거나 재사용하기 어렵다
- 테스트 대상이 조건이 아니라 메서드 전체가 된다
따라서 조건이 늘어나면 재사용이 어렵다.
BooleanExpression
반면 BooleanExpression은 상태가 없는 순수 조건 조각이다.
1
2
3
public BooleanExpression isAdult(Integer age) {
return age != null ? member.age.goe(age) : null;
}
각 조건은 독립적인 함수로 존재하고, 조합은 QueryDSL이 처리한다.
1
.where(isAdult(age), statusEq(status))
실무에서는 이 패턴이 안정적이다. 조건은 작게 쪼개고, 쿼리는 조건을 조립한다.
4. 페이징의 함정
1
2
3
4
5
6
query.select(order)
.from(order)
.leftJoin(order.member, member).fetchJoin()
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
fetch join과 페이징은 구조적으로 충돌한다. 조인으로 인해 결과 행이 늘어나는 순간, limit / offset은 엔티티 기준이 아니라 조인 결과 기준으로 적용된다. 그 결과 페이지 크기가 어긋나거나, 같은 엔티티가 중복으로 포함된다.
이 문제는 튜닝으로 해결되지 않는다. DB가 잘못한 게 아니라, 쿼리 자체가 모순된 요구를 담고 있기 때문이다.
그래서 조회를 두 단계로 분리한다.
1
2
3
4
5
6
7
8
9
10
List<Order> content = query.select(order)
.from(order)
.join(order.member, member)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long total = query.select(order.count())
.from(order)
.fetchOne();
content 쿼리는 페이징에만 집중한다. 연관 로딩은 최소화하고, 결과 수가 예측 가능한 형태를 유지한다.
1
2
3
Long total = query.select(order.count())
.from(order)
.fetchOne();
count 쿼리는 오직 개수 계산만 담당한다. 불필요한 join을 제거하고, 페이징과 분리한다.
QueryDSL은 페이징을 쉽게 만드는 도구가 아니다. 페이징의 함정을 드러내는 도구다.
조회 모델 분리와 DTO Projection
엔티티는 변경을 위한 모델이다. 조회는 다른 요구를 가진다.
1. 엔티티 반환의 문제
엔티티를 그대로 반환하면 다음 요소들이 강하게 결합된다.
- 영속성 컨텍스트 생명주기
- 연관관계 로딩 전략(LAZY/EAGER)
- 직렬화 시점의 프록시 초기화
- API 응답 스펙
조회 결과를 단순히 보여주고 싶은데, 엔티티를 노출하는 순간 JPA 내부 동작까지 함께 노출된다.
특히 LAZY 연관관계는 직렬화 과정에서 예상치 못한 추가 쿼리를 유발하고, EAGER는 조회 시점부터 불필요한 조인을 강제한다.
그래서 조회는 엔티티가 아니라 조회 전용 모델로 분리해야 한다.
2. DTO Projection
QueryDSL은 조회 결과를 엔티티가 아닌 DTO로 바로 매핑할 수 있다.
1
2
3
4
5
6
7
8
9
10
List<OrderDto> result =
query.select(Projections.constructor(
OrderDto.class,
order.id,
member.name,
order.status
))
.from(order)
.join(order.member, member)
.fetch();
이 방식의 가장 큰 장점은 명확하다. 조회 시점에 필요한 데이터만 조인으로 풀어서 가져오며, 연관관계 로딩 전략과 영속성 컨텍스트에서 완전히 분리된다.
N+1 문제는 구조적으로 발생하지 않는다. 이미 조인으로 평탄화된 결과를 DTO로 바로 담기 때문이다.
하지만 생성자 순서에 의존한다는 단점이 있다.
Constructor Projection
이는 DTO 에 Annotaion 이 필요 없고, 가장 단순하다는 장점을 가지고 있다.
1
2
3
4
Projections.constructor(OrderDto.class,
order.id,
member.name,
order.status)
Constructor Projection은 생성자 시그니처에 의존한다.
- 파라미터 순서가 바뀌면 런타임 오류가 발생한다
- 타입은 맞지만 의미가 틀린 매핑도 컴파일 단계에서 잡히지 않는다
- 필드 추가·삭제 시 DTO와 쿼리를 동시에 수정해야 한다
즉, 타입 안정성은 엔티티 필드 접근까지이고 DTO 생성 단계에서는 다시 런타임 신뢰로 돌아간다.
그래서 실무에서는 다음 기준을 따른다.
- 조회 구조가 단순하고 변경 가능성이 낮으면
constructor projection - 필드 수가 많고 변경이 잦으면
QueryProjection또는 명시적 매핑
중요한 건 어떤 방식을 쓰느냐가 아니라, 조회 결과가 엔티티가 아니라는 점이다.
필드 기반 Projection
이는 생성자 순서에 의존하지 않고, 필드명에 기반을 둔다는 장점이 있다.
1
2
3
4
5
6
7
8
9
query.select(Projections.fields(
OrderDto.class,
order.id,
member.name.as("memberName"),
order.status
))
.from(order)
.join(order.member, member)
.fetch();
하지만 as("memberName") 같이 문자열 alias가 들어오면 오타가 런타임까지 간다.
또한 DTO에 기본 생성자/세터가 필요하거나 리플렉션 세팅을 탄다(설계 취향에 따라 싫어하는 경우가 있다)
QueryProjection (@QueryProjection + new QOrderDto(…))
이 방식은 가장 큰 장점을 가지고 있는데 바로 컴파일 타임 안전성 최강이라는 점이다.
- DTO 생성자 시그니처가 바뀌면 Q타입 생성이 깨지고 컴파일이 터짐 → 실수 방지
- 순서/타입 불일치가 바로 드러남
1 2 3 4 5 6 7 8
public class OrderDto { @QueryProjection public OrderDto(Long id, String memberName, OrderStatus status) { this.id = id; this.memberName = memberName; this.status = status; } }
1
2
3
4
query.select(new QOrderDto(order.id, member.name, order.status))
.from(order)
.join(order.member, member)
.fetch();
하지만 DTO가 QueryDSL에 의존 (어노테이션 + QDto 생성 필요)한다는 점과 빌드 설정(apt/annotation processing) 관리가 필요하다는 단점을 가지고 있다.
따라서 안전하게 오래 굴릴 조회 코드에서 가장 많이 사용된다.
요약
- 단발성/간단 DTO: constructor
- 필드 많고 alias 필요한데 DTO 의존 싫음: fields
- 팀이 조회 코드 안정성 중요 + 변경 잦음: QueryProjection
5. 조회 모델 분리의 효과
DTO Projection은 N+1을 구조적으로 차단한다.
연관 객체는 이미 조인으로 풀린 상태다.
조회 모델을 분리하면 N+1은 우연이 아니라 구조적으로 발생할 수 없는 상태가 된다. DTO Projection의 핵심은 단순히 엔티티를 DTO로 바꾼다는 데 있지 않다. 조회 시점에 필요한 연관을 모두 조인으로 확정하고, 그 결과를 영속성 컨텍스트에 올리지 않는다는 점이 본질이다.
엔티티를 그대로 반환하는 조회는 항상 위험을 안고 있다. 컨트롤러나 서비스 계층에서 어떤 연관 필드를 접근할지 알 수 없고, 그 순간 Lazy 로딩이 발동하면서 쿼리가 추가로 나간다. 반면 DTO Projection은 조회 쿼리 자체가 그래프의 끝이다. 이미 join으로 풀린 값만 담고 있기 때문에 이후 단계에서 추가 쿼리가 발생할 여지가 없다.
즉, DTO Projection은 성능 최적화 기법이 아니라 조회 결과의 형태와 범위를 쿼리 시점에 고정하는 설계 방식이다. 이 지점에서 N+1은 튜닝 대상이 아니라, 애초에 설계로 제거되는 문제가 된다.
6. Repository 분리
조회와 쓰기를 같은 Repository에 넣으면 JPA는 점점 불안정해진다. 하나는 엔티티의 생명주기와 변경 감지를 다루고, 다른 하나는 조인·조건·페이징 중심의 조회를 다루기 때문이다. 이 두 관심사는 성격이 다르고, 요구사항 변화의 속도도 다르다.
쓰기와 조회를 분리하면 설계가 단순해진다.
1
2
3
public interface OrderQueryRepository {
List<OrderDto> search(OrderSearchCondition condition);
}
이 분리는 기술적인 트릭이 아니라 책임의 분리다. 엔티티 저장과 수정은 JPA가 가장 잘하는 영역이다. 영속성 컨텍스트, 변경 감지, 쓰기 지연은 이 책임 안에서 강력한 장점을 가진다.
반대로 복잡한 조회는 다르다. 조건은 동적으로 변하고, 조인은 상황마다 달라지며, 결과는 화면이나 API 요구에 따라 계속 바뀐다. 이 영역에서는 엔티티를 그대로 반환하는 순간 설계가 흔들린다. 그래서 조회 전용 Repository에서는 엔티티가 아니라 조회 전용 DTO를 반환하고, QueryDSL로 쿼리 자체를 명시적으로 통제한다.
- 엔티티 저장은 JPA.
- 복잡한 조회는 QueryDSL.
이 분리가 되는 순간 JPA는 안정된다. 엔티티는 변경 모델로만 사용되고, 조회는 영속성 컨텍스트와 느슨하게 분리된다.
결과적으로 JPA는 안정되고, 쿼리는 예측 가능해지며, N+1은 더 이상 우연히 터지는 문제가 아니다.
쓰기 지연과 flush 전략 — JPA는 언제 DB에 쓰는가
JPA를 쓰면서 가장 많이 오해하는 지점 중 하나가 쓰기 동작이다. 많은 사람들은 save()를 호출하는 순간 SQL이 나간다고 생각한다. 하지만 JPA에서 쓰기는 즉시 실행이 아니라 지연되고, 누적되고, 특정 시점에 한꺼번에 반영된다.
이 특성이 바로 JPA의 가장 강력한 장점이자, 동시에 가장 위험한 함정이다.
1. JPA 쓰기 모델의 본질
JPA의 핵심은 ORM이 아니라 영속성 컨텍스트 기반 상태 관리 엔진라는 점이다.
엔티티는 DB 행이 아니라, 현재 트랜잭션 안에서 관리되는 객체 상태다.
JPA에서 엔티티를 저장하거나 수정하면, 그 순간 DB에 SQL이 나가지 않는다. 대신 영속성 컨텍스트 내부에 쓰기 지연 SQL 저장소(write-behind buffer) 에 쿼리가 쌓인다.
1
2
em.persist(order);
order.changeStatus(COMPLETED);
이 시점까지는 INSERT SQL이 실행되지 않고 DB에는 아무 일도 일어나지 않는다.
JPA는 다음 정보만 내부에 기록한다.
- 이 엔티티는 새로 생성되었다
- 이 엔티티의 상태가 변경되었다
- 트랜잭션이 끝날 때 반영해야 한다
즉, JPA는 즉시 실행 모델이 아니라 지연 실행 모델이다.
2. 쓰기 지연 SQL 저장소의 구조
영속성 컨텍스트는 단순히 엔티티를 담아두는 공간이 아니다. 내부적으로는 상태 관리 영역과 SQL 후보를 쌓아두는 영역이 분리되어 있다.
- 1차 캐시 (엔티티 상태 관리)
1차 캐시는 엔티티의 현재 상태를 관리한다. - 쓰기 지연 SQL 저장소 (INSERT / UPDATE / DELETE 큐)
쓰기 지연 SQL 저장소는 아직 실행되지 않은 SQL을 순서대로 보관한다.
1
2
3
4
5
6
7
persist() / dirty checking 발생
↓
영속성 컨텍스트의 엔티티 상태 변경
↓
쓰기 지연 SQL 저장소에 SQL 후보 등록
↓
flush 시점에 실제 SQL 실행
이 구조 덕분에 JPA는 단순한 ORM을 넘어 트랜잭션 단위의 쓰기 최적화 엔진처럼 동작한다. 여러 번의 persist, 여러 필드 변경이 있어도, 최종적으로는 필요한 SQL만 최소 횟수로 실행된다. 이로 인해 다음이 가능해진다.
- 여러 INSERT를 모아서 실행
- 동일 엔티티에 대한 여러 변경을 하나의 UPDATE로 병합
- 트랜잭션 단위로 SQL 실행 순서 보장
하지만 이 구조 때문에 SQL 실행 타이밍을 잘못 이해하면 장애가 난다. SQL이 즉시 실행되지 않기 때문에, 언제 DB에 반영되는지를 잘못 이해하면 시스템 동작을 오판하게 되기 때문이다.
3. flush는 언제 발생하는가
flush는 쓰기 지연 SQL 저장소에 쌓여 있던 SQL을 실제 DB로 밀어 넣는 시점이다. JPA는 개발자가 직접 호출하지 않아도, 특정 순간에 자동으로 flush를 수행한다.
3.1 트랜잭션 커밋 직전
1
2
3
4
5
@Transactional
public void save() {
em.persist(a);
em.persist(b);
} // commit 직전에 flush
트랜잭션이 정상 종료되기 직전에 flush가 발생한다. 이 시점은 가장 직관적이고 예측 가능하다.
대부분의 경우 개발자가 의식하지 않아도 되는 flush는 바로 이 케이스다.
3.2 JPQL 실행 직전
1
2
3
4
5
em.persist(order);
List<Order> orders =
em.createQuery("select o from Order o", Order.class)
.getResultList();
이 코드는 겉으로 보기엔 단순 조회다. 하지만 JPA는 조회 결과의 정합성을 보장해야 한다.
그래서 JPQL이 실행되기 전에, 영속성 컨텍스트에 쌓여 있던 변경 사항을 먼저 DB에 반영한다.
즉, 이 코드는 select 전에 flush가 먼저 실행된다.
이걸 모르면 다음과 같은 착각을 한다.
- 왜 select만 했는데 insert가 나가지?
- 왜 조회 로직에서 성능이 급락하지?
실제로는 조회가 느린 게 아니라, 조회 직전에 밀려 있던 쓰기 SQL이 한꺼번에 실행된 것이다. 이건 JPA가 잘못한 게 아니라, flush 타이밍을 오해한 결과다.
3.3 명시적 flush 호출
1
em.flush();
실무에서 flush를 직접 호출하는 건 아주 제한적인 경우다.
주로 다음 같은 상황에서만 의미가 있다.
- DB 제약 조건을 조기에 검증해야 할 때
- 이후 로직이 DB 반영 결과에 의존할 때
그 외의 경우에 flush를 남발하면, 쓰기 지연이라는 JPA의 핵심 장점을 스스로 포기하게 된다.
4. flush와 commit의 차이
flush는 DB와 동기화일 뿐, 커밋이 아니다. flush와 commit은 전혀 다른 개념이다.
- flush는 영속성 컨텍스트와 DB를 동기화하는 행위다.
- commit은 트랜잭션을 영구 확정하는 행위다.
1
2
em.flush();
throw new RuntimeException();
이 코드에서 SQL은 실제로 DB에 전송된다. 하지만 트랜잭션은 롤백되므로, DB 상태는 원래대로 돌아간다.
이 차이를 모르면 다음 같은 오해가 생긴다.
- flush 했으니까 DB에 영구 반영됐다 ❌
- flush 후에는 rollback 못 한다 ❌
flush는 단지 SQL을 보낸 것일 뿐이다. 트랜잭션이 끝나지 않는 한, 그 결과는 언제든 되돌릴 수 있다.
실무에서 장애를 만드는 코드는 대부분 flush 시점과 commit 시점을 동일하게 착각한 설계에서 나온다.
- 중간에 flush를 강제하고
- 그 이후 로직이 DB에 반영됐다고 가정하고
- 예외 발생 시 전체가 롤백되는 구조
겉으로는 정상 동작처럼 보여도, 운영 환경에서는 타이밍에 따라 전혀 다른 결과를 만든다.
5. clear와 detach의 위험성
5.1 clear의 의미
1
em.clear();
- 영속성 컨텍스트 완전 초기화
1차 캐시, 변경 감지 대상, 프록시 관리까지 모두 제거된다. - 이 시점 이후 모든 엔티티가 준영속 상태
JPA는 더 이상 해당 객체들의 생명주기를 추적하지 않는다.
clear 이후의 엔티티는 더 이상 JPA의 보호를 받지 않는다.
1
order.getItems().size(); // LazyInitializationException 가능성
문제는 여기서부터다. 엔티티는 살아 있는 객체처럼 보이지만, 영속성 컨텍스트와의 연결은 이미 끊어져 있다. 지연 로딩이 필요한 순간, 더 이상 로딩할 수단이 없어서 예외가 발생한다.
그래서 clear는 조회 로직 중간에 쓰면 거의 항상 사고다.
- 아직 연관 객체를 다 읽지 않았는데 clear 호출
- 이후 접근에서 LazyInitializationException 발생
- 디버깅이 어려운 이유는, 코드상으로는 아무 문제 없어 보이기 때문
clear는 조회 흐름을 끊는 연산이지, 단순 메모리 정리가 아니다.
5.2 detach의 의미
1
em.detach(order);
detach는 clear보다 훨씬 국소적인 연산이다. 특정 엔티티 하나만 영속성 컨텍스트의 관리 대상에서 제외한다.
- 대상 엔티티만 준영속 상태로 전환
- 나머지 엔티티는 그대로 영속 상태 유지
이 연산은 매우 제한적인 상황에서만 의미가 있다.
- 특정 엔티티에 대한 변경 감지를 의도적으로 차단하고 싶을 때
- 테스트나 특수한 라이프사이클 제어가 필요할 때
하지만 실무에서 detach를 적극적으로 쓰는 경우는 드물다. 대부분의 경우 detach가 필요하다는 건, 설계나 트랜잭션 경계가 어색하다는 신호다. —
6. 대량 처리에서 flush + clear 패턴
대량 INSERT / UPDATE 배치 작업에서는 flush + clear가 거의 유일한 정답이다.
1
2
3
4
5
6
7
8
for (int i = 0; i < 100_000; i++) {
em.persist(new Order(...));
if (i % 1000 == 0) {
em.flush();
em.clear();
}
}
이 패턴의 목적은 단순하다. 영속성 컨텍스트가 끝없이 비대해지는 걸 막는 것이다.
이 패턴이 없으면 발생하는 문제는 명확하다.
- 1차 캐시에 엔티티 수만 개 누적
- 변경 감지 대상 폭증
- flush 시점에 SQL 수만 개가 한 번에 실행
- GC 압박으로 전체 성능 붕괴
flush는 SQL을 DB로 밀어내고, clear는 메모리를 즉시 회수한다.
flush와 clear는 항상 쌍으로 사용해야 한다. 둘 중 하나만 쓰면 의미가 반쪽이다.
- flush만 하면 메모리는 계속 쌓인다
- clear만 하면 아직 반영되지 않은 변경이 사라질 수 있다
중요한 오해 하나
flush + clear 패턴은 조회 로직에 쓰는 기술이 아니다. 이건 배치성 쓰기 작업을 위한 구조다.
조회 트랜잭션에서 이 패턴을 쓰기 시작하면, 대부분 다음 중 하나로 끝난다.
LazyInitializationException- 예상치 못한 준영속 객체
- 재조회 강제 → N+1 재발
flush와 clear는 강력하지만, 쓰기 흐름을 정리하는 도구지 조회를 최적화하는 도구는 아니다. —
7. 변경 감지와 flush 폭탄
JPA의 UPDATE는 setter 호출 시점이 아니라 flush 시점에 결정된다.
1
2
order.changePrice(1000);
order.changeQuantity(2);
위 코드는 UPDATE가 두 번 나가는 게 아니다. flush 시점에 변경된 필드들을 모아서 UPDATE 1번이 생성된다.
문제는 이 메커니즘이 엔티티 개수가 많아지는 순간 폭탄이 된다는 점이다.
1
2
3
4
5
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
order.expire();
}
이 코드는 단순히 상태를 바꾸는 것처럼 보이지만, JPA 입장에서는 다음을 의미한다.
- 모든 Order 엔티티를 영속성 컨텍스트에 적재
- 모든 엔티티를 변경 감지 대상에 등록 → 변경 감지 대상 수만 개
- flush 시점에 UPDATE N번 생성
즉, 엔티티 수 = UPDATE 수다. 이 패턴이 위험한 이유는 명확하다.
- 조회 시점에 이미 메모리를 크게 사용
- 변경 감지 대상이 폭증
- flush 순간에 SQL이 한꺼번에 실행
- 트랜잭션 종료 직전에 성능 급락
8. 벌크 쿼리와 flush 전략
대량 변경은 엔티티 변경이 아니라 벌크 쿼리의 영역이다.
1
2
3
em.createQuery(
"update Order o set o.status = :status where o.createdAt < :date"
).executeUpdate();
벌크 쿼리는 다음 특징을 가진다.
- 영속성 컨텍스트를 거치지 않는다
- 변경 감지를 사용하지 않는다
- SQL 한 번으로 대량 데이터를 처리한다
성능 관점에서는 정답에 가깝다.
하지만 치명적인 특성이 하나 있다. 영속성 컨텍스트를 완전히 무시한다는 점이다.
즉, DB의 상태는 변경되었지만 영속성 컨텍스트 안의 엔티티는 이전 상태 그대로 남는다
이 상태에서 엔티티를 다시 사용하면 DB와 엔티티 상태가 서로 어긋난다.
그래서 벌크 쿼리 이후에는 반드시 다음이 필요하다.
1
em.clear();
이걸 빼먹으면 DB 상태와 엔티티 상태가 불일치한다.
- 같은 트랜잭션 안에서 조회한 엔티티가 옛 상태를 유지
- 이후 로직이 잘못된 상태를 기준으로 동작
- 디버깅이 매우 어려운 논리 오류 발생
실무 기준 요약
- flush는 SQL 실행 시점 제어 도구다
- clear는 컨텍스트 리셋 버튼이다
- 조회 로직에서 clear는 금지에 가깝다
- 대량 처리에서는 flush + clear 주기 제어
- 벌크 쿼리 후에는 반드시 clear
이 파트를 이해하지 못하면 생기는 사고
- 조회 로직에서 갑자기 INSERT 폭탄
- 단순 조회 API가 메모리 OOM
- 배치 작업 중 DB 락 폭증
- 트랜잭션 안에서 원인 모를 지연
JPA는 편리한 ORM이 아니라 정확한 상태 머신 위에서만 안전한 프레임워크다.
쓰기 지연과 flush를 이해하는 순간, JPA는 예측 가능한 도구가 된다.