MOMSOFT / 기술 블로그
N+1 문제를 진짜로 없애는 순서
지연 로딩과 프록시가 만드는 N+1을 측정으로 먼저 드러내고, 전역 배치 페치부터 별도 조회까지 순서대로 걷어내는 판단 기준을 정리했습니다.
프로젝트 문의하기 →결론부터: N+1은 도구 문제가 아니라 순서 문제입니다
N+1을 겪은 팀이 다시 겪는 이유는 대부분 fetch join을 몰라서가 아닙니다. 드러내는 장치 없이 도구부터 꺼내 들기 때문입니다. 눈에 보이는 목록 화면 하나를 fetch join으로 막고 나면 그 화면은 조용해지지만, 다음 스프린트에 추가된 연관 하나가 같은 문제를 다시 만듭니다. 아무도 모르는 이유는 아무도 쿼리 수를 세고 있지 않기 때문입니다.
그래서 순서를 제안합니다. 이 글에서 권하는 작업 순서는 다음과 같습니다.
- 측정 장치를 먼저 붙인다. 쿼리 수를 로그가 아니라 테스트에서 숫자로 고정합니다.
- 전역 배치 페치 사이즈를 켠다. 코드를 한 줄도 고치지 않고, 쿼리 수가 데이터 건수에 비례하지 않도록 만듭니다.
- 남은 핫패스만 fetch join 또는 @EntityGraph로 정밀 처리한다.
- 그래도 무거우면 화면 전용 별도 조회(DTO 프로젝션 또는 2단계 조회)로 내린다.
흔히 "fetch join → @BatchSize → @EntityGraph → 별도 조회" 순으로 소개되지만, 실무에서 비용 대비 효과가 가장 큰 것은 두 번째인 배치 페치입니다. 이유는 뒤에서 설명합니다.
왜 생기는가: 프록시는 형제의 존재를 모릅니다
JPA의 지연 로딩은 연관 필드 자리에 실제 엔티티 대신 프록시를 넣어 둡니다. 프록시는 식별자만 가지고 있다가, 식별자 외의 상태에 접근하는 순간 자기 자신을 채우기 위해 SELECT를 한 번 날립니다. 컬렉션이면 지연 컬렉션 래퍼가 같은 역할을 합니다.
여기까지는 설계 의도대로입니다. 문제는 프록시가 혼자만 초기화된다는 점입니다. 프록시는 "지금 나와 같은 처지의 형제가 200개 더 있다"는 사실을 모릅니다. 그래서 반복문이 돌 때마다 각자 한 번씩 SELECT를 발생시킵니다.
List<Order> orders = orderRepository.findAll(); // 쿼리 1회
for (Order o : orders) {
o.getMember().getName(); // 주문 건수만큼 추가 SELECT
}
이것이 1 + N입니다. 주목할 점은 이 코드가 잘못 짠 코드처럼 보이지 않는다는 것입니다. 서비스 계층에서 도메인 객체를 순회하는 것은 지극히 정상적인 코드이고, 목록이 10건일 때는 아무 문제도 일으키지 않습니다. 운영에서 목록이 수천 건이 되는 순간에만 터집니다. 그래서 코드 리뷰로는 잘 잡히지 않고, 부하 테스트나 장애로 발견됩니다.
더 나쁜 형태: 계층이 겹칠 때
주문 목록에서 각 주문의 주문상품을 순회하고, 다시 상품을 참조하면 1 + N + (N × M)이 됩니다. 목록 100건에 주문상품이 평균 3개라고 두면 산술적으로 400개가 넘는 쿼리가 한 요청에서 나갑니다. 개별 쿼리가 아무리 짧아도 왕복 횟수 자체가 응답 시간을 만들어 냅니다. N+1의 비용은 개별 쿼리의 느림이 아니라 왕복 횟수에서 나옵니다. 이 점을 이해하면 해결 방향이 "쿼리를 빠르게"가 아니라 "왕복을 줄이기"라는 것이 자명해집니다. 실제로 DB 슬로우 쿼리 로그에는 아무것도 남지 않는데 API만 느린 상황이 자주 나오는데, 그 대부분이 이 형태입니다.
EAGER로 바꾸는 것은 해결이 아닙니다
가장 흔한 오답입니다. 연관을 FetchType.EAGER로 바꾸면 문제가 사라진 것처럼 보이는 경우가 있지만, 실제로는 두 가지 이유로 상황이 나빠집니다.
첫째, JPQL은 EAGER를 조인으로 자동 변환해 주지 않습니다. 식별자로 단건 조회할 때(find())는 구현체가 조인을 만들어 한 번에 가져오는 경우가 많지만, JPQL이나 QueryDSL로 작성한 쿼리는 일단 작성된 대로 실행된 뒤, EAGER 연관을 채우기 위해 추가 쿼리를 다시 발생시킵니다. 즉 EAGER는 N+1을 없애기는커녕, 필요 없는 화면에서까지 강제로 발생시킵니다.
둘째, EAGER는 끌 수 없습니다. LAZY는 필요할 때 당겨오면 되지만, EAGER는 그 연관이 필요 없는 화면에서도 항상 따라옵니다. 최적화의 선택지를 매핑 단계에서 영구히 잃는 셈입니다.
원칙은 단순합니다. 모든 연관은 LAZY로 두고, 필요한 지점에서 조회 전략으로 해결합니다. @ManyToOne과 @OneToOne의 기본값이 EAGER이므로 명시적으로 지정해야 합니다. 기존 프로젝트에 들어갔다면, 매핑 전체에서 fetch 속성이 생략된 @ManyToOne을 찾는 것이 첫 작업이 됩니다.
먼저 발견하라: 쿼리 수를 숫자로 만들기
도구를 고르기 전에 반드시 이 단계를 거쳐야 합니다. 그러지 않으면 고쳤는지 아닌지를 감으로 판단하게 되고, 다음 사람이 같은 문제를 다시 들여옵니다.
1단계: 로그로 눈에 보이게
개발 환경에서는 SQL을 전부 찍는 것이 가장 빠릅니다. Hibernate의 show_sql보다 로거 레벨을 조정하는 쪽이 포맷과 출력 위치 제어에 유리합니다. 바인딩 파라미터까지 보고 싶다면 파라미터 바인딩 로거를 함께 켜면 되는데, 이 로거의 이름은 버전에 따라 다르므로 사용하는 버전의 문서를 확인하는 편이 안전합니다. 실행 시간과 호출 위치를 같이 찍어 주는 JDBC 프록시 계열 라이브러리(p6spy, datasource-proxy 등)를 붙이면 어느 코드에서 나간 쿼리인지까지 볼 수 있습니다. 목록 화면 하나를 열었을 때 같은 형태의 SELECT가 스크롤을 채우면 그것이 N+1입니다.
2단계: 통계로 세기
로그는 사람이 봐야 하니 금방 방치됩니다. Hibernate 통계를 켜면 실행된 statement 수를 프로그램적으로 읽을 수 있습니다.
spring.jpa.properties.hibernate.generate_statistics=true
수집 비용이 있으므로 운영에서 상시로 켜는 것은 권하지 않지만, 테스트 프로파일에서는 켜 두는 편이 좋습니다.
3단계: 테스트로 못 박기 — 이게 핵심입니다
N+1 대책 중 유일하게 재발을 막아 주는 것은 쿼리 수를 검증하는 테스트입니다.
Statistics stats = emf.unwrap(SessionFactory.class).getStatistics();
em.flush();
em.clear(); // 1차 캐시가 쿼리를 가리지 않도록 반드시 비운다
stats.clear();
orderQueryService.findPage(pageable);
assertThat(stats.getPrepareStatementCount()).isLessThanOrEqualTo(5);
"쿼리 5번 이내"라고 못 박아 두면, 누군가 엔티티에 연관 하나를 더하고 뷰에서 참조하는 순간 CI가 빨간불을 냅니다. 성능 회귀를 기능 회귀와 같은 방식으로 다루는 것입니다. 조회 코드에 붙인 fetch join은 리팩터링 과정에서 조용히 사라지기도 하지만, 이 테스트는 사라지면 빌드가 깨지므로 남습니다.
이 테스트에는 함정이 둘 있습니다. 첫째, 같은 트랜잭션 안에서 미리 저장한 데이터가 영속성 컨텍스트에 남아 있으면 연관 조회가 1차 캐시에서 해결되어 쿼리가 나가지 않습니다. 그러면 N+1이 그대로 있는 코드도 테스트를 통과합니다. 위 코드처럼 측정 직전에 컨텍스트를 비워야 합니다. 둘째, 통계는 세션 팩토리 단위 누적값이므로 테스트를 병렬로 돌리면 값이 섞입니다. 쿼리 수 검증 테스트는 직렬 실행 그룹으로 묶어 두는 편이 안전합니다.
임계값은 지나치게 빡빡하게 잡지 않는 것이 좋습니다. 정확히 2가 아니라 "5 이하"처럼 상한으로 두면, 정당한 리팩터링마다 테스트를 고치는 피로를 줄이면서도 건수에 비례해 늘어나는 형태는 확실히 잡아냅니다.
도구 선택: 무엇이 무엇을 해결하는가
| 도구 | 동작 | 쿼리 수 | 페이징 | 적합한 자리 |
|---|---|---|---|---|
| fetch join | 조인해서 한 번에 적재 | 1 | 컬렉션이면 위험 | 단건, 또는 결과 수가 고정된 조회 |
| @BatchSize / 전역 배치 | 프록시를 모아 IN으로 일괄 초기화 | 1 + (연관 수 / 배치 크기) | 안전 | 기본값. 전역으로 깔아 둔다 |
| @EntityGraph | 조인 페치를 선언적으로 지정 | 1 | fetch join과 동일한 제약 | 같은 조합을 여러 곳에서 재사용할 때 |
| 별도 조회 / DTO | 루트와 컬렉션을 나눠 조회 후 조립 | 고정(2~3) | 안전 | 트래픽 몰리는 목록 API |
fetch join: 강력하지만 한 번만 쓸 수 있는 카드
@Query("select o from Order o join fetch o.member join fetch o.delivery")
List<Order> findAllWithMemberDelivery();
@ManyToOne, @OneToOne 같은 단일 값 연관에 대한 fetch join은 거의 항상 옳습니다. 행 수가 늘어나지 않으니 부작용이 없습니다. 문제는 컬렉션(일대다)입니다. 컬렉션을 조인하면 루트 엔티티가 자식 수만큼 중복된 행으로 부풀어 오릅니다. 과거에는 이 중복을 없애기 위해 JPQL에 distinct를 붙였고, Hibernate 6 계열에서는 엔티티 조회 결과의 부모 중복 제거가 기본 동작이 되어 distinct를 붙일 필요가 줄었습니다. 다만 이 동작은 버전에 따라 다르니 사용하는 버전의 문서를 확인하는 편이 안전합니다. 중요한 것은, SQL 레벨의 행 부풀림 자체는 사라지지 않는다는 점입니다. 애플리케이션이 중복을 걷어낼 뿐, DB는 여전히 큰 결과 집합을 만들어 전송합니다.
@BatchSize: 가장 저평가된 기본기
배치 페치는 프록시를 하나씩 초기화하는 대신, 아직 초기화되지 않은 같은 타입의 프록시를 모아 where id in (?, ?, ...) 한 방으로 채웁니다. N개의 쿼리가 N / 배치크기 개로 줄어듭니다. 100건짜리 목록에서 배치 크기가 100이면 루트 1회 + 연관 1회, 즉 쿼리 2번입니다.
spring.jpa.properties.hibernate.default_batch_fetch_size=100
이 한 줄을 전역으로 켜는 것을 강하게 권합니다. 이유는 세 가지입니다.
- 기존 코드를 고치지 않습니다. 리포지토리 메서드를 하나도 건드리지 않고 전체 애플리케이션에 적용됩니다.
- 페이징과 충돌하지 않습니다. 루트 조회와 연관 조회가 분리되어 있으니
limit이 정상 동작합니다. - 깊이가 늘어도 무너지지 않습니다. 2단계, 3단계 연관도 각 단계에서 IN 쿼리 몇 개로 접힙니다. 쿼리 수가 데이터 건수가 아니라 연관의 종류 수에 비례하게 됩니다.
단점은 쿼리가 1이 아니라 몇 개 남는다는 것, 그리고 IN 절 파라미터 개수가 매번 달라져 실행 계획 캐시 히트율이 떨어질 수 있다는 것입니다. 후자는 파라미터 개수를 일정한 단위로 맞춰 주는 패딩 옵션으로 완화할 수 있는데, 옵션 이름과 기본값이 버전마다 다르므로 릴리스 노트를 확인하고 적용해야 합니다. 배치 크기는 보통 수십에서 수백 사이에서 정하고, DB가 IN 절 항목 수에 상한을 두는 경우가 있으므로 그 값을 넘지 않게 잡습니다. 특정 연관만 다르게 하고 싶으면 필드나 엔티티에 @BatchSize(size = ...)를 붙입니다.
@EntityGraph: fetch join의 선언적 표현
@EntityGraph(attributePaths = {"member", "delivery"})
List<Order> findByStatus(OrderStatus status);
같은 페치 조합을 여러 메서드에서 반복해야 할 때, 그리고 Spring Data의 파생 쿼리를 그대로 쓰고 싶을 때 유용합니다. 다만 내부적으로는 조인 페치이므로 fetch join의 제약을 그대로 물려받습니다. "@EntityGraph는 페이징이 되는 fetch join"이라는 오해가 널리 퍼져 있는데, 컬렉션을 그래프에 넣고 Pageable을 함께 쓰면 똑같은 문제를 만납니다.
엔티티 그래프에는 두 가지 타입이 있다는 점도 알아 둘 필요가 있습니다. FETCH 타입은 그래프에 지정하지 않은 속성을 모두 LAZY로 취급하고, LOAD 타입은 지정하지 않은 속성에 대해 매핑에 선언된 fetch 타입을 그대로 따릅니다. Spring Data의 @EntityGraph는 기본이 FETCH입니다. EAGER 매핑이 남아 있는 프로젝트라면 이 차이 때문에 예상과 다른 쿼리가 나갈 수 있으니, 그래프를 붙인 뒤에는 반드시 쿼리 수를 다시 재야 합니다.
실제로 부딪히는 벽
벽 1. 컬렉션 fetch join + 페이징
가장 자주 만나는 벽입니다. 컬렉션을 조인하면 행이 부풀기 때문에, DB에 limit 20을 걸어도 그것은 루트 20건이 아니라 조인된 행 20개를 의미합니다. 원하는 결과가 나올 수 없습니다. 그래서 Hibernate는 이 경우 페이징을 SQL에 위임하지 않고 전체를 읽어 메모리에서 자릅니다. 경고 로그가 남고, 데이터가 커지면 그대로 OOM으로 이어집니다. 버전과 설정에 따라 경고에 그치기도 하고 아예 예외로 막기도 하니, 사용하는 버전에서 관련 설정이 어떻게 동작하는지 확인해 두는 편이 좋습니다.
해결은 둘 중 하나입니다.
- 컬렉션은 fetch join하지 않고 배치 페치에 맡긴다. 루트만 페이징으로 조회하고, 컬렉션은 IN 쿼리로 채웁니다. 가장 간단하고 대부분 여기서 끝납니다.
- 2단계 조회로 나눈다. 먼저 조건과 페이징으로 ID만 조회하고, 그 ID 집합으로 본문을 fetch join해서 가져옵니다.
// 1단계: 페이징은 루트 ID에만 건다 (조인 없음 → limit이 정상 동작)
select o.id from Order o
where o.status = :status
order by o.orderedAt desc
// 2단계: 확정된 ID 집합으로 필요한 것만 조인
select o from Order o
join fetch o.orderItems
where o.id in :ids
주의할 점은 2단계 쿼리의 결과 순서가 IN 절에 넣은 순서를 따르지 않는다는 것입니다. 1단계에서 얻은 ID 순서대로 애플리케이션에서 다시 정렬하거나, 2단계에도 동일한 order by를 넣어야 합니다. 이 정렬을 빠뜨린 채 배포되어 목록 순서가 들쭉날쭉해지는 사고가 흔합니다. 이 형태는 쿼리 수가 고정(2회 + count)되고 페이징이 DB에서 처리되므로, 트래픽이 몰리는 목록 API에서 가장 안정적입니다.
벽 2. MultipleBagFetchException
컬렉션 둘을 동시에 fetch join하면 "cannot simultaneously fetch multiple bags"라는 예외를 만납니다. bag은 중복을 허용하면서 순서가 보장되지 않는 컬렉션, 즉 @OrderColumn 없는 List입니다. 컬렉션 두 개를 조인하면 카티션 곱이 발생하는데, bag은 중복 여부로 원래 원소를 구분할 수 없어 결과를 복원할 방법이 없습니다. 그래서 구현체가 아예 막습니다.
해결책은 셋입니다.
- 컬렉션 타입을
Set으로 바꾼다. 예외는 사라지지만 권장하지 않습니다. 예외만 사라질 뿐 카티션 곱은 그대로이고, 컬렉션 두 개의 크기가 각각 수십이면 결과 행은 수백 배로 부풀어 오릅니다. 예외가 없어졌다는 이유로 더 나쁜 쿼리를 배포하게 됩니다. 게다가Set은equals/hashCode정의를 요구하고 순서 보장이 사라집니다. - 컬렉션 하나만 fetch join하고 나머지는 배치 페치에 맡긴다. 실무에서 가장 무난한 답입니다.
- 전부 배치 페치로 넘긴다. 전역 설정이 켜져 있다면 아무것도 하지 않아도 됩니다.
이 예외를 만났다면, 그것은 대개 한 쿼리에 너무 많은 것을 담으려 하고 있다는 신호입니다. 쿼리를 나누라는 뜻으로 읽는 편이 맞습니다.
벽 3. 지연 로딩이 되지 않는 연관
모든 LAZY 선언이 실제로 지연되는 것은 아닙니다. 대표적인 것이 연관의 주인이 아닌 쪽(mappedBy)의 @OneToOne입니다. 주인 쪽은 자기 테이블에 외래 키를 들고 있으므로 값의 존재 여부를 알고, 값이 있으면 프록시만 만들어 두면 됩니다. 반대쪽은 자기 테이블에 아무 정보가 없어서, 상대 행이 존재하는지 알아내려면 쿼리를 한 번 던져 볼 수밖에 없습니다. 존재하지 않으면 프록시가 아니라 null을 넣어야 하는데, 조회 없이는 그 판단이 불가능하기 때문입니다.
결과적으로 fetch = LAZY를 붙여도 주인이 아닌 쪽 @OneToOne은 엔티티를 로딩할 때마다 추가 SELECT가 나가고, 목록 조회에서는 그대로 N+1이 됩니다. 프록시 초기화를 모아 주는 배치 페치도 이 경우에는 도움이 되지 않습니다. 대응은 셋입니다. 조회 지점에서 fetch join으로 함께 가져오거나, 연관을 단방향으로 정리해 주인 쪽에서만 접근하거나, 애초에 일대일을 한 테이블로 합치는 것입니다. 바이트코드 인핸스먼트를 이용한 우회책도 존재하지만 설정과 동작이 버전마다 달라 먼저 고를 선택지는 아닙니다.
벽 4. OSIV와 "어디서 터졌는지 모르겠다"
Spring Boot에서 Open Session In View는 기본적으로 켜져 있고, 시작할 때 경고 로그를 남깁니다. 이 설정 덕분에 컨트롤러나 뷰에서 지연 로딩이 성공합니다. 문제는 그래서 N+1이 프레젠테이션 계층에서 조용히 발생한다는 것입니다. 서비스 코드만 봐서는 쿼리가 몇 번 나가는지 알 수 없습니다. 또한 요청이 끝날 때까지 커넥션을 붙들고 있으므로, 외부 API 호출이 섞인 긴 요청에서는 커넥션 풀이 먼저 마릅니다.
OSIV를 끄면 지연 로딩 위치가 LazyInitializationException으로 즉시 드러나고, 조회 책임이 서비스 계층으로 모입니다. 다만 준비 없이 끄면 기존 화면이 대거 깨집니다. 신규 프로젝트라면 처음부터 끄고 시작하고, 운영 중인 프로젝트라면 조회 전용 서비스를 먼저 정리한 뒤 전환하는 것이 순서입니다. 전환 자체를 목표로 삼기보다, 쿼리 수 테스트를 붙일 만한 핵심 조회부터 서비스 계층으로 끌어내리는 작업의 결과로 자연스럽게 끄는 편이 안전합니다.
실행 체크리스트
- 모든
@ManyToOne,@OneToOne에fetch = LAZY가 붙어 있는지 확인합니다. 기본값이 EAGER라 생략된 곳이 곧 문제 지점입니다. - 테스트 프로파일에서 Hibernate 통계를 켜고, 주요 목록 API에 쿼리 수 상한 테스트를 추가합니다. 측정 전에 영속성 컨텍스트를 비웠는지 확인합니다.
default_batch_fetch_size를 전역으로 설정합니다. 여기까지가 짧게 끝나는 작업이고, 효과는 이 글의 나머지를 합친 것보다 큽니다.- 다시 측정합니다. 남은 문제 지점만 목록으로 만듭니다.
- 남은 곳에서 단일 값 연관은 fetch join 또는 @EntityGraph로 붙입니다. 반복 조합이면 @EntityGraph, 한 곳에서만 쓰면 fetch join이 읽기 쉽습니다.
- 컬렉션은 fetch join하지 않는 것을 기본으로 삼습니다. 굳이 해야 한다면 결과 수가 페이징 없이 고정되는 단건 조회에서만 합니다.
- 트래픽 상위 목록 API는 ID 페이징 + IN 조회의 2단계 형태로 옮기거나, 도메인 엔티티를 거치지 않는 DTO 프로젝션으로 내립니다.
- 마지막으로 인덱스를 확인합니다. IN 조회의 대상 컬럼(대개 외래 키)에 인덱스가 없으면, 쿼리를 아무리 줄여도 느립니다. 외래 키를 걸어도 인덱스를 자동으로 만들어 주지 않는 DB가 있다는 점을 기억해야 합니다.
언제는 하지 말아야 하는가
결과 집합이 작고 호출이 드문 곳
관리자 화면에서 하루 몇 번 열리는 목록이 30건이라면, 쿼리 31번은 문제가 아닙니다. 여기에 2단계 조회를 도입하면 코드는 두 배로 복잡해지고 얻는 것은 사람이 인지하지 못할 시간입니다. 최적화는 데이터가 늘어나는 방향을 기준으로 판단해야지, 코드 모양을 기준으로 판단하면 안 됩니다. 다만 "지금 30건"이 "몇 년 뒤 수십만 건"이 될 성격의 데이터라면 이야기가 다릅니다. 판단 기준은 현재 건수가 아니라 증가 함수의 모양입니다.
모든 연관을 미리 당겨오는 fetch join
N+1이 무서워서 리포지토리의 기본 조회에 연관을 전부 fetch join으로 박아 두는 경우가 있습니다. 이러면 그 연관이 필요 없는 화면에서도 매번 커다란 조인을 실행하게 되고, EAGER로 바꾼 것과 실질적으로 같아집니다. fetch join은 조회 지점마다 선택하는 도구지, 엔티티나 기본 리포지토리 메서드에 고정하는 속성이 아닙니다.
쿼리 수 테스트를 모든 API에 도배하는 것
앞에서 측정을 강조했지만, 이것을 규칙으로 만들어 모든 조회 메서드에 쿼리 수 단언을 붙이면 테스트가 구현 세부에 결박됩니다. 조인을 하나 바꾸는 리팩터링마다 무관한 테스트 수십 개가 깨지면, 팀은 숫자를 늘려 통과시키는 습관을 들이고 테스트는 의미를 잃습니다. 트래픽 상위 목록과 데이터가 커지는 방향의 조회에만 붙이고, 단언은 등호가 아니라 상한으로 두는 편이 오래갑니다.
도메인 모델이 아직 흔들리는 초기
연관 구조가 매주 바뀌는 단계에서 리포지토리마다 fetch join과 EntityGraph를 촘촘히 박아 두면, 모델을 고칠 때마다 조회 코드를 함께 고쳐야 합니다. 이 시기에는 전역 배치 페치 하나만 켜 두고 나머지는 미루는 편이 낫습니다. 배치 페치는 구조가 바뀌어도 따라 고칠 것이 없다는 점에서 초기 단계에 특히 잘 맞습니다.
MultipleBagFetchException을 Set 변경으로 덮는 것
앞서 말한 대로 예외는 사라지지만 카티션 곱은 남습니다. 예외가 없어진 것을 문제가 해결된 것으로 착각하는 것이 이 대응의 가장 큰 위험입니다. 컬렉션 타입은 도메인의 의미(중복 허용 여부, 순서 필요 여부)로 정해야지, 예외를 피하려고 바꿀 것이 아닙니다.
캐시로 덮는 것
2차 캐시나 애플리케이션 캐시를 얹으면 쿼리 수가 줄어든 것처럼 보입니다. 하지만 N+1은 캐시가 비었을 때 그대로 돌아옵니다. 배포 직후나 캐시 만료가 몰리는 순간, 즉 부하가 가장 큰 시점에 문제가 재현됩니다. 구조를 고치기 전에 캐시를 얹으면 무효화 정책이라는 새 문제까지 떠안게 됩니다. 캐시는 쿼리 수를 정리한 뒤에 얹는 것이지, 대신 얹는 것이 아닙니다.
모든 조회를 DTO 프로젝션으로 내리는 것
화면 전용 쿼리는 빠르지만, 남발하면 도메인 로직이 갈 곳을 잃고 SQL 조립 코드만 남습니다. 조회는 DTO로, 변경은 엔티티로 나누는 정도가 현실적인 경계입니다. 읽기 경로 전체를 DTO로 바꾸는 것은 트래픽이 그것을 요구할 때 하는 결정이지, 처음부터 세울 규칙은 아닙니다.
정리
N+1은 실력의 문제라기보다 가시성의 문제입니다. 쿼리 수가 보이지 않는 프로젝트에서는 아무리 좋은 도구를 알아도 같은 문제가 반복됩니다. 반대로 쿼리 수를 테스트로 고정해 둔 프로젝트에서는, 도구를 하나만 알아도 문제가 커지기 전에 잡힙니다.
순서를 다시 정리하면 이렇습니다. 측정 장치를 붙이고, 전역 배치 페치를 켜고, 남은 핫패스만 정밀하게 손보고, 그래도 무거운 곳만 별도 조회로 내립니다. 그리고 대부분의 프로젝트는 두 번째 단계에서 이미 충분합니다. 나머지 단계는 트래픽이 요구할 때 꺼내면 되고, 요구하지 않는다면 꺼내지 않는 것이 옳은 판단입니다.
같은 주제의 다른 글
맘소프트는 19년 넘게 금융 · 엔터프라이즈 · 모바일 시스템을 만들어 왔습니다. 여기 적는 글은 그 과정에서 실제로 부딪히고 해결한 것들입니다.