들어가며
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로 실행 통계를 확인하고, 병목 지점을 파악한 뒤 적절한 최적화를 적용하세요.
'JPA' 카테고리의 다른 글
| Spring Data JPA 고급 기능 - Specification, Projection, Auditing (0) | 2026.04.03 |
|---|---|
| QueryDSL 실전 가이드 - 동적 쿼리부터 페이징까지 (0) | 2026.03.26 |
| JPA 영속성 컨텍스트 완벽 이해 - 1차 캐시부터 변경 감지까지 (0) | 2026.03.26 |
| JPA N+1 문제 완벽 정리 - 원인부터 해결까지 실무 가이드 (0) | 2026.03.24 |