MySQL 공부 9 - 실제 적용: EXPLAIN 분석, N+1 해결, 쿼리 최적화 실전

Chu Sang Yoon·2026년 3월 20일

MySQL

목록 보기
9/9

MySQL 공부 9 - 실제 적용: EXPLAIN 분석, N+1 해결, 쿼리 최적화 실전

8편에서 JPQL 내부 동작과 QueryDSL, Native Query를 다뤘다. 이번 편에서는 이론을 실제 코드에 적용한 사례들을 정리한다. EXPLAIN으로 인덱스 효과를 확인하고, 실제 프로젝트에서 발생한 N+1을 여러 방법으로 해결하면서 성능을 비교한 내용이다.


EXPLAIN으로 쿼리 분석하기

Before — 인덱스 없는 상태

EXPLAIN ANALYZE
SELECT
    c1_0.id, c1_0.company_url, c1_0.created_at, c1_0.industry,
    c1_0.is_recruiting, c1_0.logo, c1_0.name, c1_0.recruit_url,
    c1_0.scale, c1_0.summary, c1_0.talent_profile, c1_0.updated_at
FROM companies c1_0
WHERE c1_0.industry IN ('LIFESTYLE', 'FOOD')
  AND c1_0.scale IN ('SME', 'LARGE')
ORDER BY c1_0.created_at DESC
OFFSET 0 ROWS
FETCH FIRST 8 ROWS ONLY;

결과:

Limit  (cost=353.86..353.88 rows=8 width=357) (actual time=2.796..2.799 rows=8 loops=1)
  ->  Sort  (cost=353.86..355.25 rows=557 width=357) (actual time=2.794..2.795 rows=8 loops=1)
        Sort Key: created_at DESC
        Sort Method: top-N heapsort  Memory: 29kB
        ->  Seq Scan on companies c1_0  (cost=0.00..342.72 rows=557 width=357) (actual time=0.033..2.591 rows=589 loops=1)
              Filter: (((industry)::text = ANY ('{LIFESTYLE,FOOD}'::text[])) AND ((scale)::text = ANY ('{SME,LARGE}'::text[])))
              Rows Removed by Filter: 9459
Planning Time: 0.814 ms
Execution Time: 2.871 ms

핵심 포인트:

  • Seq Scan → 풀 테이블 스캔
  • Rows Removed by Filter: 9459 → 10,048건을 전부 읽고 그중 9459건을 버림. 589건만 사용
  • Execution Time: 2.871ms

인덱스 추가

CREATE INDEX idx_company_industry_scale_created
ON companies (industry, scale, created_at DESC);

After — 인덱스 적용 후

Limit  (cost=... rows=8 width=357) (actual time=... rows=8 loops=1)
  ->  Sort  (...)
        Sort Key: created_at DESC
        Sort Method: top-N heapsort  Memory: 29kB
        ->  Bitmap Index Scan  (...)
              Rows Removed by Filter: 없음
Planning Time: ...
Execution Time: 1.243 ms

핵심 포인트:

  • Seq ScanBitmap Index Scan 전환
  • Rows Removed by Filter 없음 → 인덱스로 필요한 데이터만 읽음
  • Execution Time: 2.871ms → 1.243ms (57% 감소)

💡 : Seq Scan은 데이터가 늘어날수록 선형으로 느려진다. 인덱스는 로그 스케일로 느려지기 때문에 데이터가 많을수록 인덱스의 이점이 커진다. 데이터 10,000건 수준에서 57% 감소라면, 100,000건에서는 격차가 훨씬 벌어진다.

HikariCP 튜닝 후 부하 테스트

인덱스와 커넥션 풀 튜닝 후 실제 부하 테스트 결과:

checks_total.......: 21256   530.162389/s
checks_succeeded...: 100.00%

✓ status is 200

http_req_duration..: avg=5.62ms  min=1.35ms  med=4.62ms
                     max=117.5ms  p(90)=8.42ms  p(95)=11.77ms
http_req_failed....: 0.00%
http_reqs..........: 21256  530.162389/s

100 VU까지 올라가는 Burst 상황에서도 평균 응답시간 5.62ms, 에러율 0%.


실전 N+1 — AI Report 단건 조회

문제 발생

AIReport를 단건 조회하는 API에서 쿼리가 3번 날아가는 상황이 발생했다.

쿼리 1: ai_reports 조회 (LEFT JOIN experience, user)
쿼리 2: companies 조회 (company_id로 추가 SELECT)
쿼리 3: experiences 조회 (experience_id로 추가 SELECT)

원인: Spring Data JPA 메서드 네이밍 쿼리가 company, experience를 LAZY 연관 엔티티로 관리하고 있어서 접근할 때마다 개별 SELECT가 발생했다.

Before: 단건 조회 → 쿼리 3회
After:  단건 조회 → 쿼리 1회 (67% 감소)
리스트 N건 조회 시: 1 + N(company) + N(experience) = 2N+1회 → Fetch Join으로 사전 방지

해결 — Fetch Join

@Query("SELECT a FROM AIReport a " +
        "JOIN FETCH a.company " +
        "JOIN FETCH a.experience " +
        "WHERE a.experience.user.id = :userId AND a.id = :id")
Optional<AIReport> findByExperienceUserIdAndId(
    @Param("userId") Long userId,
    @Param("id") Long id
);

결과: 3개의 SELECT가 JOIN 1번으로 통합됐다.

select
    a1_0.id, a1_0.appeal_point,
    a1_0.company_id,
    c1_0.id, c1_0.company_url, c1_0.created_at, ...  -- company 필드
    a1_0.experience_id,
    e1_0.id, e1_0.action, e1_0.created_at, ...        -- experience 필드
    a1_0.guidance, a1_0.job_description, ...
from
    ai_reports a1_0
join companies c1_0 on c1_0.id = a1_0.company_id
join experiences e1_0 on e1_0.id = a1_0.experience_id
where
    e1_0.user_id = ?
    and a1_0.id = ?

실전 N+1 — Comment + Member 쿼리 최적화

문제 발생

댓글 목록 조회 시 쿼리가 3번 날아갔다.

쿼리 1: article 존재 확인 (count)
쿼리 2: comments 조회
쿼리 3: 각 comment의 member 조회 ← N+1 발생 지점

Fetch Join 적용

@Query("SELECT c FROM Comment c JOIN FETCH c.member WHERE c.article.id = :articleId")
Page<Comment> findByArticleId(Long articleId, Pageable pageable);

결과: comment와 member를 하나의 JOIN 쿼리로 가져온다.

select
    c1_0.id, c1_0.article_id, c1_0.content, c1_0.created_at,
    c1_0.is_update, c1_0.member_id,
    m1_0.id, m1_0.birthday, m1_0.created_at, m1_0.email,
    m1_0.gender, m1_0.modified_at, m1_0.name, m1_0.password, m1_0.role,
    c1_0.modified_at
from comments c1_0
join members m1_0 on m1_0.id = c1_0.member_id
where c1_0.article_id = ?
limit ?

@ManyToOne은 페이징 + Fetch Join 같이 써도 안전하다. 한 Comment당 Member가 하나이기 때문에 카테시안 곱 문제가 없다. @OneToMany에서 발생하는 row 폭발이 일어나지 않는다.

최적화 심화 — 필요한 필드만 가져오기

Fetch Join으로 N+1은 해결됐지만 Member의 모든 필드를 가져오고 있다. 실제로 필요한 건 이름(name) 하나뿐이었다.

Option 1 — JPQL DTO Projection

@Query("""
    SELECT new org.sopt.assignment.comment.dto.response.GetCommentResponseDto(
        c.id,
        c.content,
        c.member.id,
        c.createdAt
    )
    FROM Comment c
    WHERE c.article.id = :articleId
    """)
Page<GetCommentResponseDto> findCommentDtosByArticleId(
    @Param("articleId") Long articleId,
    Pageable pageable
);
  • 장점: Member 테이블 불필요. 필요한 컬럼만 SELECT. 타입 안전.
  • 단점: DTO 생성자 필요. 패키지 경로 하드코딩.

Option 2 — Interface Projection

public interface CommentSummary {
    Long getId();
    String getContent();
    Long getMemberId();
    LocalDateTime getCreatedAt();
    boolean getIsUpdate();  // ← 주의: isUpdate()가 아닌 getIsUpdate()
}

@Query("SELECT c.id as id, c.content as content, c.member.id as memberId, " +
       "c.createdAt as createdAt FROM Comment c WHERE c.article.id = :articleId")
Page<CommentSummary> findCommentSummariesByArticleId(
    @Param("articleId") Long articleId,
    Pageable pageable
);
  • 장점: 패키지 경로 하드코딩 없음. DTO 클래스 불필요. 간단함.
  • 단점: Interface라 생성자/메서드 추가 불가. 테스트 어려움(Mock 필요). 불변성 보장 안 됨.
  • 사용 시기: 간단한 조회용, Controller에서 바로 반환, 변환 로직 불필요한 경우.

Option 3 — Native Query

@Query(value = """
    SELECT
        c.id,
        c.content,
        c.member_id,
        c.created_at
    FROM comments c
    WHERE c.article_id = :articleId
    """, nativeQuery = true)
Page<Object[]> findCommentsByArticleIdNative(
    @Param("articleId") Long articleId,
    Pageable pageable
);
  • 장점: DB 특화 기능 사용 가능. 복잡한 쿼리 작성 쉬움. 성능 최적화 극대화.
  • 단점: DB 종속적. 타입 안전성 없음(Object[]). 수동 매핑 필요. 오타를 컴파일 시 못 잡음.

EntityGraph는 힌트, Fetch Join은 명령

Command vs Hint

Fetch Join: JPQL 표준 키워드. 반드시 이렇게 실행하라는 명령. JPA 구현체가 무조건 따른다.

EntityGraph: 힌트를 제공하는 것. "이렇게 하면 좋을 것 같아"라는 제안. JPA 구현체가 무시할 수도 있다 (실제로는 Hibernate가 대부분 적용한다).

JPA의 힌트 종류:

// Query Hint
@QueryHints({
    @QueryHint(name = "org.hibernate.readOnly", value = "true"),
    @QueryHint(name = "org.hibernate.fetchSize", value = "50")
})
Page<Comment> findAll(Pageable pageable);

// EntityGraph (특별한 힌트)
@EntityGraph(attributePaths = {"member", "article"})
Page<Comment> findAll(Pageable pageable);
// "member랑 article 같이 가져오면 좋겠어~"

// Fetch Mode Hint
@Fetch(FetchMode.JOIN)
private Member member;
// "가능하면 JOIN으로 가져와~"

힌트의 특징:

  • 선택적 적용: JPA 구현체마다 지원 여부가 다를 수 있음
  • 보장 안 됨: Hibernate가 상황에 따라 무시할 수도 있음 (실제론 거의 적용)
  • 성능 최적화 목적: 기능은 바뀌지 않고 성능만 개선

성능 비교 테스트

같은 API를 5가지 방식으로 구현하고 성능을 측정했다.

// V1: Fetch Join만
commentRepository.findByArticleId(articleId, pageable)   // JOIN FETCH c.member

// V2: DTO Projection
commentRepository.findCommentDtoByArticleId(articleId, pageable)

// V3: Interface Projection
commentRepository.findCommentSummariesByArticleId(articleId, pageable)

// V4: Native Query
commentRepository.findCommentsByArticleIdNative(articleId, pageable)

// V5: N+1 해결 안 함 (baseline)
commentRepository.findCommentByArticleId(articleId, pageable)

1차 테스트 (데이터 적은 경우)

방식실행 시간
V1 Fetch Join36ms
V2 DTO Projection31ms
V3 Interface Projection29ms
V4 Native Query32ms
V5 N+1 미해결38ms

데이터가 적을 때는 차이가 미미했다.

2차 테스트 (멤버 1000개 + 댓글 10,000개)

방식실행 시간
V1 Fetch Join37ms
V2 DTO Projection35ms
V3 Interface Projection29ms
V4 Native Query37ms
V5 N+1 미해결76ms

데이터가 늘어나자 차이가 명확해졌다. N+1 미해결(V5)은 76ms로 확연히 느렸고, Interface Projection(V3)이 일관되게 가장 빨랐다.

왜 Interface Projection이 가장 빠른가

각 방식의 객체 생성 과정 차이가 있다.

DTO Projection (V2):
1. 쿼리 실행
2. 각 Row마다 DTO 생성자 호출
3. ResponseDto로 변환

Native Query (V4):
1. 쿼리 실행
2. Object[] → DTO 수동 변환 (타입 캐스팅 과정)
3. ResponseDto로 변환

Interface Projection (V3):
1. 쿼리 실행
2. 경량 프록시 객체 생성 (생성자 호출 없음)
3. ResponseDto 변환 시에만 실제 getter 접근

Interface Projection은 실제 데이터를 담은 객체를 만들지 않고, getter 호출 시점에 실제 값에 접근하는 프록시를 사용하기 때문에 객체 생성 비용이 낮다.


Interface Projection 사용 시 주의점

boolean 필드 매핑 오류

테스트 실행 중 아래 오류가 발생했다:

Null return value from advice does not match primitive return type for:
public abstract boolean org.sopt.assignment.comment.repository.CommentSummary.isUpdate()

원인: Interface Projection은 메서드 이름 기반으로 컬럼을 매핑한다. JPA가 isUpdate() 메서드를 호출하면 is prefix를 제거한 update 컬럼을 찾는다. 하지만 실제 컬럼명은 is_update라서 매핑에 실패하고 null이 반환됐다.

// 문제
public interface CommentSummary {
    boolean isUpdate();  // ← "update" 컬럼을 찾음
}

// 해결
public interface CommentSummary {
    boolean getIsUpdate();  // ← "isUpdate" 컬럼을 정확히 찾음
}

JavaBeans 규칙에서는 boolean 필드의 getter가 isXxx여야 하지만, Interface Projection에서는 메서드 이름 전체가 컬럼 매핑 키가 된다. isUpdate()update 컬럼을 찾고, getIsUpdate()isUpdate 컬럼을 찾는다.

💡 : Interface Projection에서 boolean 필드를 매핑할 때는 isXxx() 대신 getIsXxx() 형태로 메서드를 선언하거나, JPQL의 as alias를 명시해서 컬럼명을 직접 지정하는 게 안전하다.

// alias 명시
@Query("SELECT c.isUpdate as isUpdate FROM Comment c ...")
// + interface에서
boolean isUpdate();  // alias와 정확히 일치

마치며

이번 편에서 실제로 적용한 내용을 정리하면:

EXPLAIN으로 인덱스 효과 확인: Seq ScanBitmap Index Scan 전환으로 실행시간 57% 감소. 데이터가 많아질수록 인덱스의 이점이 커진다.

N+1 해결: Fetch Join으로 3번 쿼리를 1번으로 줄였다. @ManyToOne은 페이징과 Fetch Join을 함께 써도 안전하다. 한 row당 연관 객체가 하나이기 때문에 카테시안 곱 문제가 없다.

쿼리 최적화 비교: 데이터가 충분히 많아지면 방식 간 차이가 드러났다. N+1 미해결 대비 Interface Projection이 약 2.6배 빨랐다. 단, 데이터가 적은 환경에서는 차이가 미미하다.

EntityGraph는 힌트, Fetch Join은 명령: EntityGraph는 JPA 구현체에게 제안하는 것이고, Fetch Join은 명시적으로 JOIN을 지시하는 것이다.

다음 편부터는 인덱스를 본격적으로 다룬다.

0개의 댓글