개발 공부/프로젝트

[Spring Boot] 실전 프로젝트 고도화 (5): N+1 해결부터 100만 건 인덱싱 튜닝까지

baby-t 2026. 1. 26. 11:58

지난 포스팅들에서는 **동시성 제어(Redis Lock)**와 **캐싱(Redis)**을 통해 데이터 정합성과 메인 페이지 조회 성능을 확보했습니다.

하지만 서비스가 성장하여 데이터가 쌓이는 상황을 가정해보니, 또 다른 병목 지점이 발견되었습니다. 바로 **"유저의 개인 거래 내역 조회"**입니다. 메인 페이지와 달리 개인화된 데이터라 캐싱이 어렵고, 데이터 양이 많아질수록 DB가 직접적인 부하를 받기 때문입니다.

이번 글에서는 가상으로 100만 건의 주문 데이터를 생성하고, 발생한 **두 가지 치명적인 성능 문제(N+1, Slow Query)**를 해결하여 조회 속도를 0.14s → 0.000s로 최적화한 과정을 정리합니다.

0. 들어가는 글: 쿼리 최적화란 무엇인가?

서비스를 개발하다 보면 기능 구현에만 집중한 나머지 **"데이터가 많아졌을 때의 성능"**을 놓치기 쉽습니다. **쿼리 최적화(Query Optimization)**란, 데이터베이스가 최소한의 자원(CPU, Memory)으로 최대한 빠르게 데이터를 찾아오도록 돕는 과정을 말합니다.

특히 **인덱싱(Indexing)**은 책의 '찾아보기(색인)'와 같습니다. 1,000페이지짜리 책에서 특정 단어를 찾을 때, 첫 장부터 다 읽는 것(Full Scan)과 색인을 보고 바로 펼치는 것(Index Scan)의 속도 차이는 어마어마합니다.

이번 글에서는 가상으로 구축한 100만 건의 주문 데이터 환경에서 발생한 성능 문제를 진단하고, Workbench 실험을 거쳐 실제 코드에 적용하는 과정을 단계별로 정리합니다.

 


1. 문제 상황 1: N+1 문제 (쿼리 폭탄) 💣

상황: 사용자의 주문 내역(Order)을 조회하는 findAllByUserId 메서드를 호출했습니다. 주문 엔티티는 주식(Stock) 엔티티와 ManyToOne 관계를 맺고 있습니다.

증상: 분명 주문 목록 조회 쿼리는 1번 날렸는데, 로그를 보니 각 주문에 해당하는 주식 정보를 가져오기 위해 **수십 번의 추가 쿼리(SELECT * FROM stock ...)**가 발생하고 있었습니다.

  • 1번(주문 목록) + N번(각 주문의 주식 정보) = N+1 문제

orderService에 임의로 추가
테스트 코드

해결: Fetch Join 적용 JPA의 지연 로딩(Lazy Loading)으로 인해, 프록시 객체가 실제 사용될 때마다 쿼리가 나가는 것이 원인이었습니다. 이를 해결하기 위해 JPQL의 JOIN FETCH를 사용하여, 주문을 조회할 때 연관된 주식 정보까지 한 방의 쿼리로 가져오도록 변경했습니다.

Java
 
public interface OrderRepository extends JpaRepository<Order, Long> {

    // [Before] 일반 조회 (N+1 발생 가능성 있음)
    // List<Order> findAllByUserId(Long userId);

    // [After] Fetch Join 적용 (쿼리 1방) ⚡
    // 주문(Order)을 가져올 때 주식(Stock)도 조인해서 한 번에 가져온다.
    @Query("SELECT o FROM Order o JOIN FETCH o.stock WHERE o.user.id = :userId")
    List<Order> findAllByUserIdWithStock(@Param("userId") Long userId);
}

결과: 주문 내역이 아무리 많아도 단 1번의 쿼리로 깔끔하게 조회되는 것을 확인했습니다.

추가 쿼리가 없음


2. 문제 상황 2: 100만 건 데이터와 Slow Query 🐢

상황: N+1 문제를 해결한 후, 극한의 상황을 테스트하기 위해 DB에 더미 데이터 100만 건을 밀어 넣었습니다. 그리고 가장 일반적인 패턴인 **"내 주문 내역을 최신순으로 조회"**하는 쿼리를 실행해 보았습니다.

(처음에 IDE에서 직접 데이터 100만건을 넣다가 오류가 생겨, Workbench를 통해 직접 데이터를 더미 유저와 타겟 유저의 99:1 비율로 생성하였습니다.)

유저
주문 개수

SQL
SELECT * FROM orders WHERE user_id = 47 ORDER BY order_date DESC;

증상:

  • 소요 시간: 약 0.156초 (Duration 0.140s + Fetch 0.016s)
    • *"0.14초면 빠른 거 아냐?"*라고 할 수 있지만, 동시 접속자가 늘어나면 DB CPU 사용률이 급증하여 서비스 장애로 이어질 수 있는 수치입니다.
    • Duration vs Fetch 차이가 뭔가요?
      • Duration (0.140s): MySQL 엔진이 쿼리를 해석하고, 데이터를 찾아서, 정렬하는 데 걸린 **"순수 계산 시간"**입니다.
      • Fetch (0.016s): 계산된 데이터를 Workbench(클라이언트)로 **"배달하는 시간"**입니다.
      • 해석: 0.14초 동안 DB가 열심히 일하고 있다는 뜻입니다. 트래픽이 몰리면 바로 서버가 뻗을 수 있는 수치입니다.
  • 실행 계획(Explain) 분석 (쿼리 앞에 EXPLAIN 붙일때 나오는 것) :
    • type: ALL (Full Table Scan) → 100만 개를 전부 뒤짐.
    • 원래 나와야 할 Extra: Using filesort → 메모리에서 강제로 정렬 수행.
    • 실제 Extra: Using where → 100만 개를 다 읽으면서 조건에 맞는지 억지로 검사 중.

쿼리문 EXPLAIN
실제 실행 시간

인덱스가 없어서 DB가 100만 개의 행을 처음부터 끝까지 모두 읽어들이며(Full Table Scan) 조건에 맞는지를 일일이 검사(Using where)하고 있었습니다.


4. 해결 과정: Workbench 실험부터 코드 적용까지 🛠️

무작정 코드를 고치는 것이 아니라, DB에서 먼저 검증 후 코드에 반영하는 안전한 워크플로우를 따랐습니다.

Step 1. Workbench에서 인덱스 설계 및 테스트

user_id로 데이터를 찾고, order_date로 정렬해야 하므로 두 컬럼을 묶은 복합 인덱스를 직접 생성해 보았습니다.

SQL
-- Workbench에서 직접 실행하여 효과 검증
CREATE INDEX idx_user_date ON orders (user_id, order_date DESC);

Step 2. 효과 검증 (Verification)

인덱스 생성 후 다시 EXPLAIN을 실행했습니다.

  • Type: ALL → REF (인덱스 참조)
  • Key: idx_user_date (생성한 인덱스를 정상적으로 탐)
  • Rows: 1,000,000개 → 약 15,000개 (탐색 범위가 획기적으로 줄어듦)
  • 속도: 0.141s → 0.000s
  • Extra: Null (별도의 추가 작업 없음)
쿼리문 EXPLAIN
실제 실행 시간



Step 3. Spring Boot 코드에 반영 (Codify)

실험이 성공했으므로, 이 변경 사항을 애플리케이션 코드에 영구적으로 반영합니다. JPA 엔티티에 @Table 어노테이션을 사용하여 인덱스를 명시했습니다. 이렇게 하면 팀원들이 코드를 봤을 때 인덱스 존재를 알 수 있고, 유지보수가 쉬워집니다.

Java
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
// 1. orders 테이블에
// 2. user_id, order_date(내림차순) 복합 인덱스 적용
@Table(name = "orders", indexes = {
    @Index(name = "idx_user_date", columnList = "user_id, order_date DESC")
})
public class Order {
    // ... 필드 생략 ...
}

4. 최종 결과 검증 (Result) 📊

인덱스 적용 후, 동일한 조건으로 조회를 수행하고 실행 계획을 비교해 보았습니다.

✅ 실행 계획(Explain) 변화

  • Type: ALL (전체 탐색) → REF (인덱스 참조)
  • Extra: Using filesort → NULL (정렬 과정 생략됨)
    • DB가 미리 정렬된 인덱스를 그대로 가져오기만 하면 되므로, 부하가 큰 정렬 작업이 사라졌습니다.

✅ 성능 수치 변화

  • 튜닝 전: 0.140 sec
  • 튜닝 후: 0.000 sec
  • 👉 DB 연산 시간이 사실상 '0'에 수렴했습니다. (Fetch 시간 제외)  

5. 마무리 및 회고

이번 단계에서는 Query Optimization을 통해 대용량 데이터 환경에서도 안정적인 조회 성능을 확보했습니다.

  1. Application Level: Fetch Join으로 N+1 쿼리 폭탄 제거.
  2. Database Level: Composite Index로 Full Scan 및 File Sort 제거.

특히 **"코드가 논리적으로 맞아도, 데이터 구조(인덱스)가 받쳐주지 않으면 성능은 나락으로 간다"**는 것을 뼈저리게 느꼈습니다. 단순히 기능을 구현하는 것을 넘어, EXPLAIN으로 실행 계획을 확인하는 습관이 백엔드 개발자에게 얼마나 중요한지 배웠습니다.

💡 실무 Tip: 프로젝트 초기에는 위처럼 Entity에 인덱스를 명시하는 것이 좋지만, 실제 운영 중인 대규모 서비스에서는 FlywayLiquibase 같은 DB 형상 관리 도구를 사용해 SQL 파일로 인덱스를 관리하는 것이 표준입니다. (배포 시 실수로 인덱스가 변경되는 것을 막기 위함)

 

다음 단계인 Step 4에서는, 주문 트래픽이 폭주할 때 서버가 다운되지 않도록 Kafka/RabbitMQ를 이용한 비동기 처리를 도입해 볼 예정입니다.