JPA

JPA 성능 최적화 실전 - 벌크 연산부터 2차 캐시까지

백엔드 개발자 김승원 2026. 4. 2. 18:11

들어가며

JPA는 생산성 높은 ORM이지만, 제대로 된 최적화 없이 사용하면 심각한 성능 문제가 발생합니다. N+1 문제, 불필요한 더티 체킹, 비효율적인 벌크 처리 등은 실무에서 빈번히 마주치는 이슈입니다. 이 글에서는 JPA 성능 최적화의 핵심 기법들을 Before/After 비교와 함께 실전 수준으로 정리합니다.

1. JPQL 벌크 연산 (UPDATE/DELETE)

엔티티를 하나씩 수정하면 변경 감지(dirty checking)가 각각 발생하여 UPDATE 쿼리가 N번 실행됩니다. 벌크 연산을 사용하면 단일 쿼리로 처리할 수 있습니다.

Before: 엔티티 하나씩 수정 (N번의 UPDATE)

@Transactional
public void deactivateInactiveUsers(LocalDateTime threshold) {
    List<User> users = userRepository.findByLastLoginBefore(threshold);
    // users가 1000명이면 UPDATE 쿼리 1000번 실행!
    for (User user : users) {
        user.setStatus(UserStatus.INACTIVE);
    }
    // flush 시점에 1000개의 UPDATE 발생
}

After: JPQL 벌크 연산 (1번의 UPDATE)

public interface UserRepository extends JpaRepository<User, Long> {

    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query("UPDATE User u SET u.status = :status WHERE u.lastLogin < :threshold")
    int bulkUpdateStatus(
        @Param("status") UserStatus status,
        @Param("threshold") LocalDateTime threshold
    );

    @Modifying(clearAutomatically = true)
    @Query("DELETE FROM User u WHERE u.status = :status AND u.deletedAt < :date")
    int bulkDeleteByStatusAndDate(
        @Param("status") UserStatus status,
        @Param("date") LocalDateTime date
    );
}

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    @Transactional
    public int deactivateInactiveUsers(LocalDateTime threshold) {
        // 단 1번의 UPDATE 쿼리로 처리!
        return userRepository.bulkUpdateStatus(
            UserStatus.INACTIVE, threshold);
    }
}

주의사항: 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 실행됩니다. clearAutomatically = true를 설정하여 벌크 연산 후 영속성 컨텍스트를 초기화해야 데이터 불일치를 방지할 수 있습니다.

2. @QueryHints로 읽기 전용 최적화

조회 전용 데이터는 변경 감지가 불필요합니다. 읽기 전용으로 설정하면 스냅샷을 만들지 않아 메모리와 CPU를 절약합니다.

Before: 기본 조회 (스냅샷 생성됨)

// 기본 findAll은 모든 엔티티의 스냅샷을 만듦
List<User> users = userRepository.findAll();
// 10,000건이면 원본 + 스냅샷 = 20,000개 객체가 메모리에 존재

After: 읽기 전용 최적화

public interface UserRepository extends JpaRepository<User, Long> {

    // 방법 1: @QueryHints
    @QueryHints(value = {
        @QueryHint(name = "org.hibernate.readOnly", value = "true"),
        @QueryHint(name = "org.hibernate.fetchSize", value = "100")
    })
    @Query("SELECT u FROM User u WHERE u.status = :status")
    List<User> findByStatusReadOnly(@Param("status") UserStatus status);

    // 방법 2: @Transactional(readOnly = true) - 서비스 레벨
}
@Service
@Transactional(readOnly = true)  // 클래스 레벨 읽기 전용
@RequiredArgsConstructor
public class UserQueryService {

    private final UserRepository userRepository;

    // 읽기 전용 트랜잭션 - 스냅샷 생성 안 함, flush 안 함
    public List<UserResponse> getActiveUsers() {
        return userRepository.findByStatusReadOnly(UserStatus.ACTIVE)
            .stream()
            .map(UserResponse::from)
            .toList();
    }

    // 통계 조회
    public UserStatistics getStatistics() {
        return userRepository.getUserStatistics();
    }
}
항목 일반 조회 읽기 전용 조회
스냅샷 생성 O (메모리 2배) X
더티 체킹 O X
flush 트랜잭션 종료 시 자동 수행하지 않음
메모리 사용 높음 낮음
적합한 용도 CUD가 필요한 경우 순수 조회

3. Hibernate 2차 캐시 (Second-Level Cache)

1차 캐시는 영속성 컨텍스트(트랜잭션) 범위이지만, 2차 캐시는 애플리케이션 전체에서 공유됩니다. 자주 조회되지만 잘 변경되지 않는 데이터에 적합합니다.

Caffeine 기반 2차 캐시 설정

// build.gradle
dependencies {
    implementation 'org.hibernate.orm:hibernate-jcache'
    implementation 'com.github.ben-manes.caffeine:caffeine'
    implementation 'org.ehcache:ehcache:3.10.8'   // 또는 JCache 구현체
}
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_second_level_cache: true
          use_query_cache: true
          region:
            factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
        javax:
          cache:
            provider: com.github.benmanes.caffeine.jcache.spi.CaffeineCachingProvider
        generate_statistics: true  # 캐시 적중률 모니터링

엔티티에 캐시 적용

@Entity
@Table(name = "categories")
@Cacheable                                    // JPA 표준
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)  // Hibernate 전략
public class Category {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "category")
    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE)  // 컬렉션 캐시
    private List<SubCategory> subCategories = new ArrayList<>();
}

@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
public class CommonCode {
    @Id
    private String code;
    private String name;
    private String groupCode;
}
캐시 전략 설명 적합한 상황
READ_ONLY 읽기만 가능, 변경 불가 코드성 데이터, Enum 테이블
NONSTRICT_READ_WRITE 가끔 변경, 약간의 불일치 허용 카테고리, 설정값
READ_WRITE 읽기/쓰기, 일관성 보장 자주 읽고 가끔 쓰는 데이터
TRANSACTIONAL 트랜잭션 레벨 일관성 JTA 환경

쿼리 캐시 활용

public interface CategoryRepository extends JpaRepository<Category, Long> {

    @QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
    @Query("SELECT c FROM Category c WHERE c.active = true")
    List<Category> findAllActive();
}

4. @BatchSize와 Fetch 전략 최적화

N+1 문제는 JPA에서 가장 흔한 성능 이슈입니다. @BatchSize와 적절한 Fetch 전략으로 해결합니다.

Before: N+1 문제 발생

// Team을 10개 조회하면, 각 Team의 members를 조회하는 쿼리가 10번 추가 실행
// 총 11번 (1 + N) 쿼리
List<Team> teams = teamRepository.findAll();
for (Team team : teams) {
    System.out.println(team.getMembers().size());  // LAZY 로딩 -> 추가 쿼리
}

After: @BatchSize로 IN절 최적화

@Entity
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

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

// 또는 application.yml에서 글로벌 설정
// spring.jpa.properties.hibernate.default_batch_fetch_size: 100

// Team 10개 조회 시:
// SELECT * FROM team                                       -- 1번
// SELECT * FROM member WHERE team_id IN (1,2,3,...,10)      -- 1번
// 총 2번 쿼리!

fetch join으로 한 번에 조회

public interface TeamRepository extends JpaRepository<Team, Long> {

    // fetch join - 1번의 쿼리로 Team + Members 함께 조회
    @Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.members")
    List<Team> findAllWithMembers();

    // 페이징이 필요하면 @EntityGraph 사용
    @EntityGraph(attributePaths = {"members"})
    @Query("SELECT t FROM Team t")
    Page<Team> findAllWithMembersPaged(Pageable pageable);
}

5. Fetch Size 튜닝

JDBC 드라이버가 한 번에 가져오는 행 수를 조절하여 대용량 조회 성능을 개선합니다.

# application.yml
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          fetch_size: 100      # 글로벌 fetch size
          batch_size: 50       # INSERT/UPDATE 배치 사이즈
        order_inserts: true     # INSERT 문 정렬 (배치 최적화)
        order_updates: true     # UPDATE 문 정렬 (배치 최적화)

대용량 데이터 스트림 처리

public interface OrderRepository extends JpaRepository<Order, Long> {

    @QueryHints(value = {
        @QueryHint(name = HINT_FETCH_SIZE, value = "500"),
        @QueryHint(name = "org.hibernate.readOnly", value = "true")
    })
    @Query("SELECT o FROM Order o WHERE o.createdAt BETWEEN :start AND :end")
    Stream<Order> streamByCreatedAtBetween(
        @Param("start") LocalDateTime start,
        @Param("end") LocalDateTime end
    );
}

@Service
@RequiredArgsConstructor
public class OrderExportService {

    private final OrderRepository orderRepository;

    @Transactional(readOnly = true)
    public void exportOrders(LocalDateTime start, LocalDateTime end,
                             OutputStream out) {
        // Stream으로 처리하면 메모리에 전체 결과를 올리지 않음
        try (Stream<Order> stream =
                 orderRepository.streamByCreatedAtBetween(start, end)) {
            stream.map(this::toCsvLine)
                  .forEach(line -> writeLine(out, line));
        }
    }
}

6. OSIV (Open Session In View) 설정

OSIV는 영속성 컨텍스트를 뷰 렌더링까지 유지하는 설정입니다. 기본값이 true이므로 API 서버에서는 반드시 false로 변경해야 합니다.

# application.yml
spring:
  jpa:
    open-in-view: false  # API 서버에서는 반드시 false

OSIV=true의 문제점

  • DB 커넥션을 HTTP 요청 전체 기간 동안 점유
  • 동시 요청이 많으면 커넥션 풀 고갈
  • 서비스 계층 밖에서 LAZY 로딩이 가능해져 계층 분리가 무너짐

OSIV=false 시 해결 패턴

// 서비스 계층에서 필요한 데이터를 모두 로딩
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderQueryService {

    private final OrderRepository orderRepository;

    public OrderDetailResponse getOrderDetail(Long orderId) {
        // fetch join으로 필요한 연관 데이터를 한 번에 로딩
        Order order = orderRepository.findByIdWithItems(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));

        // 트랜잭션 내에서 DTO로 변환
        return OrderDetailResponse.from(order);
    }
}

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.orderItems oi " +
           "JOIN FETCH oi.product " +
           "WHERE o.id = :id")
    Optional<Order> findByIdWithItems(@Param("id") Long id);
}

7. 성능 비교 요약

최적화 기법 Before After 개선 효과
벌크 연산 N번 UPDATE 1번 UPDATE 쿼리 수 1/N
읽기 전용 스냅샷 2배 메모리 원본만 유지 메모리 50% 절감
2차 캐시 매번 DB 조회 캐시 히트 시 즉시 반환 DB 부하 80%+ 감소
@BatchSize N+1 쿼리 1+1 쿼리 쿼리 수 대폭 감소
OSIV off 커넥션 장시간 점유 서비스 계층에서만 사용 커넥션 풀 효율 향상
배치 INSERT N번 INSERT 배치로 묶어서 실행 네트워크 왕복 감소

마치며

JPA 성능 최적화는 "알고 쓰느냐, 모르고 쓰느냐"의 차이가 극명합니다. 실무에서 반드시 적용해야 할 사항을 정리합니다.

  • 벌크 연산: 대량 수정/삭제는 반드시 JPQL 벌크 연산을 사용하세요. clearAutomatically = true를 잊지 마세요.
  • 읽기 전용 최적화: 조회 전용 서비스에는 @Transactional(readOnly = true)를 반드시 설정하세요.
  • 2차 캐시: 코드성 데이터, 카테고리 등 자주 읽히는 데이터에 2차 캐시를 적용하세요.
  • @BatchSize: default_batch_fetch_size를 글로벌로 설정하여 N+1을 기본 방어하세요. 100~500 사이 값을 권장합니다.
  • OSIV: API 서버에서는 spring.jpa.open-in-view=false가 필수입니다.

이 기법들을 조합하면 동일한 기능에서 쿼리 수와 응답 시간을 극적으로 개선할 수 있습니다. 성능 이슈가 발생했을 때 먼저 hibernate.generate_statistics=true로 실행 통계를 확인하고, 병목 지점을 파악한 뒤 적절한 최적화를 적용하세요.