JPA/Hibernate 성능 최적화 — N+1 문제부터 캐시까지

N+1 문제란?

JPA에서 가장 흔하고 치명적인 성능 문제입니다. 부모 엔티티 1건을 조회할 때, 연관된 자식 엔티티를 건별로 추가 쿼리하여 총 N+1번의 쿼리가 발생하는 현상입니다.

배달 앱에 비유하면 이해가 쉽습니다. 10개 가게의 메뉴를 확인하는데, 가게 목록 1번 조회 + 각 가게 메뉴 10번 조회 = 총 11번 API 호출이 발생하는 것과 같습니다. 한 번에 “가게와 메뉴를 함께” 요청하는 것이 효율적입니다.

엔티티 설정과 N+1 재현

// Team.java / Member.java — N+1 문제 재현용 엔티티
package com.example.entity;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    // 기본 페치 전략: LAZY (지연 로딩)
    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();

    public Team() {}
    public Team(String name) { this.name = name; }

    // getter/setter 생략
    public Long getId() { return id; }
    public String getName() { return name; }
    public List<Member> getMembers() { return members; }
}

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    @ManyToOne(fetch = FetchType.LAZY) // LAZY 권장
    @JoinColumn(name = "team_id")
    private Member team;

    public Member() {}
    public Member(String username, Team team) {
        this.username = username;
        this.team = team;
    }

    // getter 생략
    public String getUsername() { return username; }
    public Team getTeam() { return team; }
}

// N+1 발생 코드
// List<Team> teams = teamRepository.findAll();
// 실행 쿼리: SELECT * FROM team (1번)
//
// for (Team team : teams) {
//     team.getMembers().size(); // 각 팀마다 추가 쿼리
//     // SELECT * FROM member WHERE team_id = 1 (N번)
//     // SELECT * FROM member WHERE team_id = 2
//     // SELECT * FROM member WHERE team_id = 3 ...
// }
// 팀이 100개면 총 101번 쿼리 실행!

해결법 1: Fetch Join (JPQL)

// TeamRepository.java — Fetch Join으로 N+1 해결
package com.example.repository;

import com.example.entity.Team;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;

public interface TeamRepository extends JpaRepository<Team, Long> {

    // Fetch Join — 한 번의 쿼리로 Team + Member 함께 조회
    @Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members")
    List<Team> findAllWithMembers();
    // 실행 쿼리: SELECT t.*, m.* FROM team t
    //           INNER JOIN member m ON t.id = m.team_id
    // → 쿼리 1번으로 완료!

    // 주의: 컬렉션 2개 이상 Fetch Join 시 MultipleBagFetchException 발생
    // 해결: Set 사용 또는 @BatchSize 병행
}

// 서비스 계층에서 사용
// @Service
// @Transactional(readOnly = true)
// public class TeamService {
//     private final TeamRepository teamRepository;
//
//     public List<TeamDto> getAllTeamsWithMembers() {
//         return teamRepository.findAllWithMembers()
//             .stream()
//             .map(TeamDto::from)
//             .toList();
//         // 결과: 쿼리 1번, 모든 팀+멤버 로드 완료
//     }
// }

해결법 2: @BatchSize와 EntityGraph

// BatchSize와 EntityGraph 활용
package com.example.entity;

import jakarta.persistence.*;
import org.hibernate.annotations.BatchSize;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    // @BatchSize — IN 절로 묶어서 조회
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private List<Member> members = new ArrayList<>();

    // BatchSize 적용 시 쿼리 변화:
    // 기존(N+1): SELECT * FROM member WHERE team_id = ?  (100번)
    // BatchSize: SELECT * FROM member WHERE team_id IN (1,2,...,100)  (1번)
    // → 101번 → 2번으로 감소!
}

// EntityGraph — 어노테이션으로 페치 전략 지정
// TeamRepository.java
// public interface TeamRepository extends JpaRepository<Team, Long> {
//
//     @EntityGraph(attributePaths = {"members"})
//     @Query("SELECT t FROM Team t")
//     List<Team> findAllWithMembersGraph();
//     // Fetch Join과 동일한 효과, JPQL 수정 없이 적용 가능
//
//     // 동적 EntityGraph
//     @EntityGraph(attributePaths = {"members", "members.tasks"})
//     List<Team> findByNameContaining(String name);
// }

해결법 3: DTO 프로젝션

// TeamSummaryDto.java — DTO 프로젝션으로 필요한 데이터만 조회
package com.example.dto;

// 인터페이스 기반 프로젝션 (Spring Data JPA)
public interface TeamSummary {
    String getName();
    int getMemberCount();
}

// 클래스 기반 프로젝션
public record TeamDetailDto(
    Long id,
    String name,
    long memberCount
) {}

// Repository에서 사용
// public interface TeamRepository extends JpaRepository<Team, Long> {
//
//     // 인터페이스 프로젝션 — 프록시 생성
//     @Query("SELECT t.name AS name, SIZE(t.members) AS memberCount FROM Team t")
//     List<TeamSummary> findTeamSummaries();
//
//     // 생성자 프로젝션 — new 연산자 사용
//     @Query("SELECT new com.example.dto.TeamDetailDto(t.id, t.name, COUNT(m)) " +
//            "FROM Team t LEFT JOIN t.members m GROUP BY t.id, t.name")
//     List<TeamDetailDto> findTeamDetails();
//
//     // 결과: 엔티티가 아닌 DTO로 직접 매핑
//     // → 영속성 컨텍스트 관리 비용 없음
//     // → 필요한 컬럼만 SELECT
// }

2차 캐시 설정

// 2차 캐시 설정 — Ehcache 3 기반
// build.gradle
// implementation 'org.hibernate.orm:hibernate-jcache'
// implementation 'org.ehcache:ehcache:3.10.8'

// application.properties
// spring.jpa.properties.hibernate.cache.use_second_level_cache=true
// spring.jpa.properties.hibernate.cache.region.factory_class=jcache
// spring.jpa.properties.hibernate.cache.use_query_cache=true
// spring.jpa.properties.jakarta.persistence.sharedCache.mode=ENABLE_SELECTIVE

// Team.java — 캐시 대상 엔티티
package com.example.entity;

import jakarta.persistence.*;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;

@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // 2차 캐시 활성화
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // 컬렉션 캐시
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    // 캐시 동작:
    // 1차 조회: DB 쿼리 실행, 결과를 2차 캐시에 저장
    // 2차 조회: 2차 캐시에서 즉시 반환 (DB 쿼리 없음)
    // 엔티티 수정: 캐시 자동 무효화

    public Team() {}
    public Long getId() { return id; }
    public String getName() { return name; }
    public List<Member> getMembers() { return members; }
}

// 캐시 전략 선택 가이드:
// READ_ONLY        → 변경 없는 코드 테이블 (국가, 카테고리)
// READ_WRITE       → 읽기 많고 쓰기 적은 일반 엔티티
// NONSTRICT_READ_WRITE → 동시 수정 가능성 낮은 경우
// TRANSACTIONAL    → JTA 환경에서 트랜잭션 캐시 보장

성능 모니터링 설정

// application.properties — 쿼리 분석용 설정
// spring.jpa.show-sql=true                           // SQL 출력
// spring.jpa.properties.hibernate.format_sql=true     // SQL 포맷팅
// spring.jpa.properties.hibernate.generate_statistics=true // 통계 수집
// logging.level.org.hibernate.SQL=DEBUG               // SQL 로깅
// logging.level.org.hibernate.orm.jdbc.bind=TRACE     // 바인드 파라미터

// 통계 출력 예시:
// Session Metrics {
//     1234 nanoseconds spent acquiring 1 JDBC connections;
//     5678 nanoseconds spent executing 2 JDBC statements;
//     0 nanoseconds spent executing 0 JDBC batches;
//     12345 nanoseconds spent performing 3 L2C hits;
//     0 nanoseconds spent performing 0 L2C misses;
// }

실전 팁

JPA 성능 최적화의 우선순위를 정리하면 다음과 같습니다.

  • 1순위: N+1 감지spring.jpa.show-sql=true로 개발 중 쿼리 수를 항상 확인합니다. 쿼리가 예상보다 많으면 N+1을 의심합니다
  • 2순위: Fetch 전략 — 연관 관계는 기본 LAZY로 설정하고, 필요한 곳에서만 Fetch Join 또는 EntityGraph로 즉시 로딩합니다
  • 3순위: BatchSize — 글로벌 default_batch_fetch_size를 100~1000 사이로 설정하여 N+1을 일괄 방지합니다
  • 4순위: DTO 프로젝션 — 조회 전용 API는 엔티티 대신 DTO로 직접 매핑하여 영속성 컨텍스트 비용을 제거합니다
  • 5순위: 2차 캐시 — 변경이 적고 조회가 빈번한 엔티티에 선택적으로 적용합니다
  • @Transactional(readOnly = true)를 조회 메서드에 적용하면 Hibernate 스냅샷 비교를 건너뛰어 성능이 향상됩니다

이 글이 도움이 되었나요?