본문 바로가기

프로젝트/티켓팅서비스

[티켓팅 #8] N+1 문제 해결과 인덱스 설계

1. 문제 상황

HOLD 만료 스케줄러를 붙이고 나서 쿼리 로그를 보다가 이상한 걸 발견했다.

만료 대상 Hold가 100개면 쿼리가 101번 나가고 있었다.

스케줄러가 1분마다 실행되는데 만료 대상이 늘어날수록 DB 부하가 선형으로 증가하는 구조였다.

Hibernate: select h from holds where status = 'ACTIVE' and expires_at < ?
Hibernate: select s from showtime_seat where id = ?   -- Hold #1
Hibernate: select s from showtime_seat where id = ?   -- Hold #2
Hibernate: select s from showtime_seat where id = ?   -- Hold #3
... (N번 반복)

문제는 두 가지였다.

첫째 HoldExpirationService의 만료 처리 루프에서 hold.getShowtimeSeat()를 호출할 때마다 추가 쿼리가 나가고 있었다.

전형적인 N+1 문제다.

둘째 ShowtimeSeat의 @ManyToOne이 fetch 타입을 명시하지 않아 JPA 기본값인 EAGER로 동작하고 있었다.

EAGER는 연관 엔티티가 필요 없는 상황에서도 항상 JOIN해서 로드한다는 의미다.


2. 왜 이게 문제였는가

N+1은 데이터가 적을 때는 눈에 잘 띄지 않는다. Hold 10개 처리에 쿼리 11번이면 체감이 안 된다.

그런데 티켓팅 서비스에서 동시에 수백 명이 좌석을 선점하고 그 HOLD가 한꺼번에 만료되는 시나리오를 생각하면 달라진다.

만료 대상 1,000개 → 쿼리 1,001번. 스케줄러가 1분마다 실행되면 분당 1,000번 이상의 추가 쿼리가 발생한다.

EAGER fetch의 문제는 더 본질적이다.

ShowtimeSeat를 조회하는 모든 경우에 Showtime과 Seat가 항상 같이 로드된다.

상태 변경(markAvailable())만 필요한 상황에서도 불필요한 JOIN이 발생하는 것이다.

JPA 공식 문서는 @ManyToOne의 기본값이 EAGER임을 명시하고 있다.

반면 Spring Data JPA 실무에서는 LAZY를 원칙으로 두고 필요한 경우에만 fetch join으로 명시적으로 로드하는 방식을 권장한다.

기본값을 믿지 말고 fetch 전략은 항상 명시적으로 선언해야 예측 가능한 코드가 된다.


3. 오픈소스 / 레퍼런스 분석

Hibernate 공식 문서의 설계 의도

Hibernate는 지연 로딩을 기본 전략으로 권장한다.

연관 엔티티는 실제로 접근하기 전까지 프록시 객체로만 유지되고 접근 시점에 쿼리가 나간다.

이 방식이 "필요할 때만 로드"라는 원칙에 맞다.

EAGER를 @ManyToOne의 기본값으로 둔 이유는 역사적 맥락이 있다.

초기 JPA 스펙에서는 단일 연관 객체(@ManyToOne, @OneToOne)는 "대부분 같이 필요하다"는 가정 하에 EAGER가 기본이었다.

컬렉션(@OneToMany, @ManyToMany)은 전체 로드가 부담이 되므로 LAZY가 기본이었다.

하지만 실무에서는 이 가정이 깨지는 경우가 많다.

연관 엔티티가 필요 없는 케이스에서도 EAGER가 불필요한 쿼리를 만들어내고 N+1 문제의 원인이 된다.

fetch join vs EntityGraph

N+1 해결 방법은 크게 두 가지다.

방식 장점 단점

fetch join JPQL에서 명시적, 쿼리 의도 명확 Repository에 쿼리 직접 작성
EntityGraph 어노테이션 기반, 재사용 가능 복잡한 경우 가독성 저하

이 프로젝트에서 fetch join을 선택한 이유는 명확하다.

만료 스케줄러라는 특정 케이스에서만 showtimeSeat를 같이 로드하면 된다.

모든 Hold 조회에 EntityGraph를 적용하면 오히려 불필요한 로드가 생긴다.

목적이 분명한 메서드 하나를 추가하는 게 더 예측 가능하다.


4. 고민했던 선택지와 최종 결정

선택지 1: findAllByStatusAndExpiresAtBefore 그대로 사용 + 루프 안에서 Hibernate batch fetch 설정

spring.jpa.properties.hibernate.default_batch_fetch_size를 설정하면 N+1을 IN 절로 묶어서 처리할 수 있다.

Hold 100개라면 WHERE id IN (1, 2, 3, ...) 한 번으로 줄어든다.

단점은 설정 하나로 전체 엔티티에 영향을 준다는 점이다.

의도하지 않은 곳에서 IN 절이 발생할 수 있고 쿼리 동작을 Repository만 보고 예측하기 어려워진다.

선택지 2: fetch join 쿼리 추가 (최종 선택)

findAllByStatusAndExpiresAtBeforeWithSeat라는 메서드를 별도로 만들고

JPQL fetch join으로 showtimeSeat를 한 번에 로드한다.

메서드 이름만 봐도 "이 쿼리는 seat를 같이 가져온다"는 의도가 드러난다.

선택지 3: EntityGraph

@EntityGraph(attributePaths = {"showtimeSeat"})를 Repository 메서드에 붙이는 방식이다.

쿼리 없이 어노테이션으로 해결할 수 있지만

기존 findAllByStatusAndExpiresAtBefore에 붙이면 다른 호출 케이스에도 영향을 준다.

전용 메서드를 추가하는 방식은 fetch join과 동일한 결과라서 JPQL 의도가 더 명확한 fetch join을 선택했다.


5. 적용한 방식

ShowtimeSeat LAZY 명시

// Before: fetch 타입 미지정 → EAGER 기본값
@ManyToOne
@JoinColumn(name = "showtime_id", nullable = false)
private Showtime showtime;

// After: LAZY 명시
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "showtime_id", nullable = false)
private Showtime showtime;

HoldRepository fetch join 쿼리 추가

@Query("select h from Hold h join fetch h.showtimeSeat " +
       "where h.status = :status and h.expiresAt < :now")
List<Hold> findAllByStatusAndExpiresAtBeforeWithSeat(
    @Param("status") HoldStatus status,
    @Param("now") LocalDateTime now
);

HoldExpirationService 교체

public void expireHolds(LocalDateTime now) {
    // fetch join으로 showtimeSeat를 한 번에 로드 → N+1 제거
    List<Hold> expiredTargets =
        holdRepository.findAllByStatusAndExpiresAtBeforeWithSeat(HoldStatus.ACTIVE, now);

    for (Hold hold : expiredTargets) {
        hold.expire();
        hold.getShowtimeSeat().markAvailable(); // 추가 쿼리 없이 접근
    }
}

기존 findAllByStatusAndExpiresAtBefore는 남겨뒀다.

다른 케이스에서 showtimeSeat 없이 Hold만 필요한 상황이 생길 수 있기 때문이다.

fetch join 쿼리는 목적이 명확한 전용 메서드로 분리했다.

인덱스 추가 (V9__add_indexes.sql)

-- 회차별 좌석 상태 조회 최적화
CREATE INDEX idx_showtime_seat_showtime_id_status
  ON showtime_seat (showtime_id, status);

-- 만료 HOLD 조회 최적화
CREATE INDEX idx_holds_status_expires_at
  ON holds (status, expires_at);

인덱스 설계 기준은 조회 조건의 선택도(Selectivity)다.

showtime_seat(showtime_id, status): 회차 좌석 조회 시 showtime_id로 범위를 좁히고

status = 'AVAILABLE'로 필터링하는 패턴이 가장 빈번하다.

showtime_id를 선행 컬럼으로 두면 회차별 좌석 전체를 빠르게 스캔할 수 있다.

holds(status, expires_at): 만료 스케줄러는 status = 'ACTIVE' AND expires_at < now 조건으로 조회한다.

status로 ACTIVE Hold만 필터링하고 expires_at으로 범위를 좁히는 순서가 효율적이다.


6. 성능 / 부하 관점 분석

N+1 제거 효과

상황 개선 전 개선 후

만료 Hold 100개 처리 쿼리 101번 쿼리 1번
만료 Hold 1,000개 처리 쿼리 1,001번 쿼리 1번

Hold가 늘어날수록 기존 방식은 DB 커넥션을 그만큼 더 소모한다.

스케줄러가 1분마다 실행되는 환경에서 만료 대상이 수백 개라면 순간적으로 커넥션 풀이 고갈될 가능성도 있다.

fetch join 한 번으로 이 문제를 제거했다.

인덱스 트레이드오프

인덱스는 조회를 빠르게 하는 대신 INSERT/UPDATE 시 인덱스 갱신 오버헤드가 생긴다.

좌석 상태 변경(markHeld, markAvailable, markReserved)은 showtime_seat(showtime_id, status) 인덱스를 갱신해야 한다.

티켓팅 특성상 좌석 상태 변경보다 조회가 압도적으로 많다.

수천 명이 동시에 좌석 목록을 조회하는 상황에서 풀스캔이 발생하면 전체 응답 시간이 급격히 늘어난다.

이 트레이드오프는 조회 최적화 쪽이 명확히 유리하다.

다만 EXPLAIN으로 실제 실행계획을 확인하는 작업은 이후 TASK에서 진행할 예정이다.

인덱스를 추가했다고 옵티마이저가 반드시 사용하는 건 아니기 때문이다.


7. 적용 후 달라진 점

쿼리 로그에서 showtime_seat 조회 쿼리가 Hold 수만큼 반복되던 것이 사라졌다.

스케줄러 실행 시 Hold 조회 1번, 이후 루프는 메모리에서만 동작한다.

ShowtimeSeat LAZY 변경으로 불필요한 JOIN도 제거됐다.

이제 ShowtimeSeat를 조회할 때 Showtime과 Seat는 실제로 접근하는 시점에만 로드된다.

코드 가독성도 개선됐다.

findAllByStatusAndExpiresAtBeforeWithSeat라는 메서드 이름을 보면

이 쿼리가 seat를 같이 가져온다는 사실을 코드 없이도 파악할 수 있다.


8. 배운 점

@ManyToOne 기본값 EAGER는 함정이다

JPA를 처음 배울 때 EAGER/LAZY 차이를 이론으로는 알고 있었다.

실제로 문제를 겪고 나서야 "fetch 타입은 항상 명시해야 한다"는 게 몸에 붙었다.

@ManyToOne에 fetch 타입을 쓰지 않으면 EAGER가 기본이라는 사실을 까먹기 쉽다.

 

N+1은 코드로 보이지 않는다

N+1은 Service나 Repository 코드만 봐서는 발견하기 어렵다.

hold.getShowtimeSeat()는 자연스러운 객체 접근처럼 보인다.

실제로 쿼리가 몇 번 나가는지는 로그나 모니터링 없이는 알 수 없다.

쿼리 로그를 켜놓고 개발하는 습관이 필요하다.

 

fetch join은 목적에 맞게 분리해야 한다

기존 메서드에 fetch join을 붙이면 모든 호출 케이스에 영향을 준다.

특정 케이스에만 연관 엔티티가 필요하다면 목적이 명확한 전용 메서드를 만드는 게 낫다.

메서드 이름에 의도를 담으면 코드만 봐도 동작을 예측할 수 있다.


💬 질문

Q1. N+1 문제가 무엇이고 이 프로젝트에서 어떻게 해결했나요?

N+1은 1번의 조회 쿼리 후 연관 엔티티를 N번 추가로 조회하는 문제입니다.

HoldExpirationService에서 Hold 목록 조회 후 루프에서 getShowtimeSeat()를 호출할 때 발생했고

fetch join 쿼리를 전용 메서드로 분리해서 해결했습니다.

 

Q2. fetch join과 EntityGraph의 차이는 무엇인가요? 왜 fetch join을 선택했나요?

fetch join은 JPQL에서 명시적으로 연관 엔티티를 로드하고

EntityGraph는 어노테이션 기반으로 동적으로 로드 전략을 지정합니다.

이 프로젝트에서는 특정 케이스(만료 스케줄러)에서만 showtimeSeat가 필요했기 때문에

목적이 명확한 전용 메서드로 분리하는 fetch join 방식이 더 적합했습니다.

 

Q3. @ManyToOne의 기본 fetch 타입은 무엇이고, 왜 LAZY로 변경했나요?

@ManyToOne의 기본값은 EAGER입니다.

EAGER는 연관 엔티티가 필요 없는 경우에도 항상 JOIN해서 로드하기 때문에 불필요한 쿼리가 발생합니다.

LAZY로 변경하면 실제 접근 시점에만 쿼리가 나가고 필요한 경우 fetch join으로 명시적으로 로드할 수 있습니다.

 

Q4. 복합 인덱스 컬럼 순서는 어떻게 결정했나요?

선택도(Selectivity)가 높은 컬럼을 선행 컬럼으로 두는 원칙을 따랐습니다.

holds(status, expires_at)의 경우 status = 'ACTIVE'로 전체 Hold 중 만료 대상만 필터링하고

expires_at으로 범위를 좁히는 순서가 가장 효율적입니다.

반대로 expires_at을 선행으로 두면 전체 시간 범위를 스캔한 후 status로 필터링해야 합니다.

 

Q5. 인덱스를 추가하면 항상 성능이 좋아지나요?

그렇지 않습니다.

인덱스는 조회 성능을 높이는 대신 INSERT/UPDATE 시 인덱스 갱신 오버헤드가 생깁니다.

또한 옵티마이저가 인덱스를 사용하지 않는 경우도 있어서

EXPLAIN으로 실제 실행계획을 확인해야 합니다.

자주 조회되는 컬럼과 조회/변경 비율을 고려해서 선별적으로 추가해야 합니다.


9. 참고 자료