실무에서 바로 쓰는 JPA (2편) - 영속성 컨텍스트와 흔한 실수들
TL;DR
- 문제: N+1 문제 해결했지만, 영속성 컨텍스트 미이해로 예상치 못한 쿼리 발생, 테스트 느림
- 원인: 영속성 컨텍스트가 엔티티를 자동으로 캐싱하는데, 이 메커니즘을 모르고 사용
- 해결: 1차 캐시 이해, 변경 감지, 준영속 상태, 트랜잭션 범위 명확화
- 효과: 불필요한 쿼리 감소, 영속성 컨텍스트 활용한 최적화, 테스트 안정화
- 한계: 개념 이해 필수 (러닝 커브), 1차 캐시는 트랜잭션 내에서만, 대용량 처리 시 메모리 위험
환경
- 프레임워크: Spring Boot 3.x + JPA (Hibernate 6.x)
- DB: MySQL 8.0, InnoDB
- 격리수준: READ COMMITTED
- 트랜잭션: @Transactional (영속성 컨텍스트 자동 관리)
시리즈 안내
이 글은 “실무에서 바로 쓰는 JPA” 시리즈의 두 번째 편입니다.
1편: N+1 문제 해결
연관관계 조회 시 발생하는 성능 문제와 해결 방법을 다룹니다.
2편 (현재 글): 영속성 컨텍스트와 흔한 실수들
JPA의 내부 동작 원리와 실수하기 쉬운 포인트들을 짚어봅니다.
3편: 쿼리 최적화 실전
복잡한 쿼리를 효율적으로 작성하는 노하우를 공유합니다.
4편: 엔티티 설계 마스터하기
유지보수하기 좋은 엔티티 설계 원칙을 다룹니다.
5편: 실무 안티패턴 총정리
실무에서 자주 발생하는 실수와 개선 방법을 총정리합니다.
이전 편 요약:
1편에서는 JPA의 가장 흔한 문제인 N+1 문제를 다뤘습니다. Fetch Join과 Batch Size를 통해 쿼리 횟수를 획기적으로 줄일 수 있었죠. 하지만 N+1를 해결했다고 해서 끝이 아닙니다.
이번 편에서는 JPA의 핵심 개념인 영속성 컨텍스트와 실무에서 자주 하는 실수들을 다룹니다.
서론
1편에서 N+1 문제와 기본적인 해결 방법(Fetch Join, @BatchSize)을 다뤘습니다.
근데 그게 끝이 아니더군요. Fetch Join을 적용했더니 페이징이 안 되고, @BatchSize를 쓰니까 쿼리 수는 줄었는데 메모리가 터지고, 단순 조회인데 UPDATE 쿼리가 날아가는 이상한 상황들을 만났습니다.
이번 편에서는 1편에서 미처 다루지 못한 Fetch Join의 함정들과, JPA의 핵심 개념인 영속성 컨텍스트에서 발생하는 실수들을 다룹니다.
Fetch Join의 함정들
1편에서 N+1 문제와 Fetch Join 해결법을 다뤘습니다. 근데 Fetch Join을 적용하자마자 또 다른 문제가 터졌습니다.
페이징과의 충돌
근데 문제가 또 생겼습니다.
주문이 많아지면서 페이징을 추가했는데, 이상한 경고 로그가 보이더군요.
@Query("SELECT o FROM Order o JOIN FETCH o.customer")
Page<Order> findAllWithCustomer(Pageable pageable);경고 로그:
HHH000104: firstResult/maxResults specified with collection fetch;
applying in memory!이 경고는 “페이징을 DB에서 하지 않고 메모리에서 한다”는 뜻입니다. 데이터가 많으면 Out Of Memory가 발생할 수 있는 치명적인 문제입니다.
왜 이런 일이 발생할까?
graph TB
A[fetch join +<br/>페이징 요청]
B{컬렉션<br/>fetch join?}
C[메모리 페이징<br/>경고 발생!]
D[DB 페이징<br/>가능]
A --> B
B -->|Yes<br/>OneToMany<br/>ManyToMany| C
B -->|No<br/>ManyToOne<br/>OneToOne| D
style C fill:#ffebee
style D fill:#e8f5e9@ManyToOne이나 @OneToOne은 괜찮지만, @OneToMany나 @ManyToMany 같은 컬렉션 fetch join과 페이징을 함께 쓰면 문제가 됩니다.
페이징에서는 @BatchSize 사용
컬렉션 fetch join + 페이징 문제는 @BatchSize로 해결할 수 있습니다.
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@BatchSize(size = 100)
@OneToMany(mappedBy = "order")
private List<OrderItem> items; // 주문 상품 목록
}@BatchSize 동작 방식:
sequenceDiagram
participant App
participant JPA
participant DB
App->>JPA: 주문 10건 조회
JPA->>DB: SELECT * FROM orders LIMIT 10
DB-->>JPA: 10건 반환
Note over JPA: items 컬렉션 조회 필요
JPA->>DB: SELECT * FROM order_item<br/>WHERE order_id IN (1,2,3,...10)
Note over App,DB: 총 2개 쿼리 (1 + 1)<br/>size=100이면 최대 100건씩 묶음실행되는 쿼리:
-- 1번: 주문 조회 (페이징)
SELECT * FROM orders LIMIT 10;
-- 2번: 주문 상품 일괄 조회 (IN 절 사용)
SELECT * FROM order_item
WHERE order_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
-- 총 2개 쿼리로 해결!N+1 문제도 해결하고, 페이징도 정상 작동합니다.
실무 팁: 언제 뭘 쓸까?
저는 이런 기준으로 선택합니다.
| 상황 | 추천 방법 | 이유 |
|---|---|---|
| @ManyToOne 단건 조회 | fetch join | 가장 간단하고 효율적 |
| @ManyToOne + 페이징 | fetch join | DB 페이징 가능 |
| @OneToMany 단건 조회 | fetch join | 데이터 적으면 OK |
| @OneToMany + 페이징 | @BatchSize | 메모리 문제 회피 |
| 복잡한 조건 | @EntityGraph | 동적으로 fetch 전략 변경 |
N+1 해결 방법 비교:
graph TB
Start[N+1<br/>문제 발생]
Q1{컬렉션<br/>조회?}
Q2{페이징<br/>필요?}
Q3{조건<br/>동적?}
FetchJoin[fetch join]
BatchSize[@BatchSize]
EntityGraph[@EntityGraph]
Start --> Q1
Q1 -->|No| Q2
Q1 -->|Yes| BatchSize
Q2 -->|No| Q3
Q2 -->|Yes| FetchJoin
Q3 -->|Yes| EntityGraph
Q3 -->|No| FetchJoin
style FetchJoin fill:#e8f5e9
style BatchSize fill:#e1f5fe
style EntityGraph fill:#fff3e0근데 솔직히 말하면:
처음엔 fetch join으로 시작합니다. 문제가 생기면 @BatchSize로 바꿉니다. 그게 가장 실용적이더군요.
영속성 컨텍스트 - 양날의 검
영속성 컨텍스트란?
영속성 컨텍스트는 JPA가 엔티티를 관리하는 일종의 “캐시 저장소”입니다.
동작 방식:
graph LR
A[애플리케이션]
B[영속성 컨텍스트<br/>1차 캐시]
C[데이터베이스]
A -->|1. 조회 요청| B
B -->|2. 캐시 확인| B
B -.->|3. 없으면 DB 조회| C
C -.->|4. 결과 반환| B
B -->|5. 캐시 저장 후 반환| A
style B fill:#e8f5e9같은 트랜잭션 안에서 같은 ID로 조회하면 DB를 다시 조회하지 않고 캐시에서 가져옵니다.
@Transactional
public void example() {
Member member1 = memberRepository.findById(1L).get(); // DB 조회
Member member2 = memberRepository.findById(1L).get(); // 캐시에서 조회
System.out.println(member1 == member2); // true (같은 인스턴스!)
}이게 영속성 컨텍스트의 “1차 캐시” 기능입니다.
1차 캐시가 독이 될 때 - 대용량 배치
배치 작업에서 10만 건의 데이터를 처리하는 코드를 짰습니다.
@Service
@RequiredArgsConstructor
public class OrderBatchService {
private final OrderRepository orderRepository;
@Transactional
public void processOrders() {
List<Order> orders = orderRepository.findAll(); // 10만 건
for (Order order : orders) {
// 주문 처리 로직
order.process();
orderRepository.save(order);
}
}
}이 코드를 실행하니까 Out Of Memory가 발생했습니다.
원인:
graph TB
A[10만 건 조회]
B[영속성 컨텍스트에<br/>10만 건 모두 저장]
C[메모리 부족]
D[Out Of Memory]
A --> B
B --> C
C --> D
style B fill:#fff3e0
style D fill:#ffebee영속성 컨텍스트는 트랜잭션이 끝날 때까지 모든 엔티티를 메모리에 들고 있습니다. 10만 건이면 당연히 메모리가 터지죠.
해결 방법: clear()와 flush()
일정 주기마다 영속성 컨텍스트를 비워줘야 합니다.
@Service
@RequiredArgsConstructor
public class OrderBatchService {
private final OrderRepository orderRepository;
private final EntityManager em;
@Transactional
public void processOrders() {
int batchSize = 1000;
int count = 0;
List<Order> orders = orderRepository.findAll();
for (Order order : orders) {
order.process();
orderRepository.save(order);
count++;
if (count % batchSize == 0) {
em.flush(); // DB에 반영
em.clear(); // 영속성 컨텍스트 비우기
}
}
// 남은 데이터 처리
em.flush();
em.clear();
}
}동작 과정:
sequenceDiagram
participant Code as 코드
participant PC as 영속성 컨텍스트
participant DB as 데이터베이스
loop 1000건마다
Code->>PC: 데이터 처리
Note over PC: 1000건 누적
Code->>PC: flush()
PC->>DB: INSERT/UPDATE 실행
Code->>PC: clear()
Note over PC: 메모리 정리
end
Note over Code,DB: 메모리 사용량 일정하게 유지이렇게 수정하니까 메모리 사용량이 8GB에서 2GB로 줄었습니다.
성능 비교:
대용량 배치 처리 성능 개선
메모리뿐만 아니라 GC(Garbage Collection) 횟수도 확연히 줄었습니다.
더티 체킹의 함정
영속성 컨텍스트의 또 다른 기능은 “더티 체킹(Dirty Checking)“입니다.
엔티티의 변경을 자동으로 감지해서 UPDATE 쿼리를 날려줍니다.
@Transactional
public void updateOrder(Long orderId) {
Order order = orderRepository.findById(orderId).get();
order.setStatus(OrderStatus.COMPLETED); // setter 호출
// save() 안 해도 UPDATE 쿼리 자동 실행!
}편리하지만, 예상치 못한 UPDATE가 발생할 수 있습니다.
@Transactional
public OrderDto getOrder(Long orderId) {
Order order = orderRepository.findById(orderId).get();
// 단순 조회인데...
order.setLastViewedDate(LocalDateTime.now()); // 실수로 호출
return OrderDto.from(order);
// 트랜잭션 종료 시 UPDATE 쿼리 발생!
}조회 로직인데 UPDATE가 날아갑니다. 실제로 이런 버그를 찾는 데 2시간 걸렸습니다.
방지 방법:
- 엔티티에 setter 쓰지 않기
@Entity
public class Order {
// setter 제거하고 의미있는 메서드 사용
public void complete() {
this.status = OrderStatus.COMPLETED;
}
}- @Transactional(readOnly = true) 사용
@Transactional(readOnly = true) // 변경 감지 안 함
public OrderDto getOrder(Long orderId) {
Order order = orderRepository.findById(orderId).get();
return OrderDto.from(order);
}이렇게 하면 실수로 엔티티를 변경해도 UPDATE가 안 날아갑니다.
EAGER vs LAZY - “무조건 LAZY”는 정답이 아니다
기본 전략
대부분의 JPA 책이나 강의에서는 “무조건 LAZY를 쓰세요”라고 합니다.
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY) // 기본적으로 LAZY
private Customer customer;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items;
}실제로 대부분의 경우 LAZY가 맞습니다. 필요할 때만 조회하니까 성능이 좋죠.
LAZY의 함정 - LazyInitializationException
그런데 이런 경우가 있습니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public OrderDto getOrder(Long orderId) {
Order order = orderRepository.findById(orderId).get();
return OrderDto.builder()
.orderId(order.getId())
.customerName(order.getCustomer().getName()) // 예외 발생!
.build();
}
}에러:
org.hibernate.LazyInitializationException:
could not initialize proxy - no Session트랜잭션 범위 밖에서 프록시 객체(LAZY 로딩된 엔티티)를 초기화하려고 하면 이 예외가 발생합니다. 영속성 컨텍스트가 이미 닫혔기 때문입니다.
원인:
graph TB
A[트랜잭션 시작]
B[Order 조회<br/>Customer는 프록시]
C[트랜잭션 종료<br/>영속성 컨텍스트 닫힘]
D[Customer.getName 호출]
E[LazyInitializationException]
A --> B
B --> C
C --> D
D --> E
style C fill:#fff3e0
style E fill:#ffebee해결 방법 3가지
1. @Transactional 추가 (가장 간단)
@Transactional(readOnly = true)
public OrderDto getOrder(Long orderId) {
Order order = orderRepository.findById(orderId).get();
return OrderDto.builder()
.orderId(order.getId())
.customerName(order.getCustomer().getName()) // OK
.build();
}2. fetch join 사용
@Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.id = :id")
Optional<Order> findByIdWithCustomer(@Param("id") Long id);3. DTO로 직접 조회
@Query("SELECT new com.example.dto.OrderDto(o.id, c.name) " +
"FROM Order o JOIN o.customer c WHERE o.id = :id")
OrderDto findOrderDtoById(@Param("id") Long id);저는 보통 2번(fetch join)을 씁니다. 코드가 간단하고 직관적이거든요.
Open Session In View는 켜야 할까?
Spring Boot는 기본적으로 OSIV(Open Session In View)가 켜져 있습니다.
spring:
jpa:
open-in-view: true # 기본값OSIV를 켜면 컨트롤러나 뷰에서도 LAZY 로딩이 가능합니다. 편리하죠.
하지만 저는 끕니다.
이유:
graph TB
A[HTTP 요청]
B[DB 커넥션<br/>획득]
C[서비스<br/>로직]
D[뷰<br/>렌더링]
E[HTTP 응답]
F[DB 커넥션<br/>반환]
A --> B
B --> C
C --> D
D --> E
E --> F
style B fill:#ffebee
style F fill:#ffebee문제점: OSIV를 켜면 HTTP 요청부터 응답까지 DB 커넥션을 계속 들고 있습니다. 트래픽이 많으면 커넥션 풀이 고갈될 수 있습니다.
설정:
spring:
jpa:
open-in-view: false # 끄기대신 서비스 레이어에 @Transactional을 명확하게 붙이고, 필요한 데이터는 서비스 안에서 모두 조회합니다.
약간 불편하지만, 명확하고 안전합니다.
예상치 못한 UPDATE 쿼리
단순 조회인데 UPDATE가?
이런 경험 있으신가요?
@Transactional
public OrderDto getOrder(Long orderId) {
Order order = orderRepository.findById(orderId).get();
// 단순 조회만 하는데...
return OrderDto.from(order);
}로그:
Hibernate: select * from orders where id=?
Hibernate: update orders set updated_at=? where id=? -- 왜 UPDATE가?조회만 했는데 UPDATE가 날아갑니다.
원인: @PreUpdate, @PrePersist
@Entity
public class Order {
@Id
private Long id;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
public void preUpdate() {
updatedAt = LocalDateTime.now(); // 자동 업데이트!
}
}엔티티를 로드하면서 영속성 컨텍스트에 등록되고, 트랜잭션 종료 시 변경 감지가 작동하면서 @PreUpdate가 호출됩니다.
해결:
조회 전용 API는 @Transactional(readOnly = true)를 씁니다.
@Transactional(readOnly = true) // 변경 감지 안 함
public OrderDto getOrder(Long orderId) {
Order order = orderRepository.findById(orderId).get();
return OrderDto.from(order);
}이렇게 하면 UPDATE가 안 날아갑니다.
언제 JPA를 쓰지 말아야 할까?
JPA는 만능이 아닙니다
2년간 JPA를 쓰면서 느낀 점은, JPA가 모든 상황에 적합하지는 않다는 겁니다.
JPA를 쓰면 안 되는 경우:
- 복잡한 통계 쿼리
-- 이런 쿼리를 JPA로 짜려면 고통스럽습니다
SELECT
DATE_FORMAT(order_date, '%Y-%m') as month,
COUNT(*) as order_count,
SUM(total_amount) as revenue,
AVG(total_amount) as avg_amount
FROM orders
WHERE order_date BETWEEN ? AND ?
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
HAVING COUNT(*) > 100
ORDER BY month DESC;이런 건 그냥 Native Query나 MyBatis를 씁니다.
- 대용량 UPDATE/DELETE
// JPA - 느림 (각 엔티티를 조회 후 변경)
List<Order> orders = orderRepository.findByStatus(OrderStatus.PENDING);
orders.forEach(order -> order.cancel());
// Native Query - 빠름
@Modifying
@Query("UPDATE Order o SET o.status = 'CANCELLED' WHERE o.status = 'PENDING'")
int bulkCancel();대량 작업은 Bulk 연산을 씁니다.
- 성능이 정말 중요한 조회
복잡한 조인과 서브쿼리가 필요한 조회는 Native Query가 더 빠를 때가 많습니다.
@Query(value = "복잡한 Native SQL", nativeQuery = true)
List<Object[]> complexQuery();실무 비율:
저희 팀의 경우:
- 70%: JPA (일반 CRUD)
- 20%: JPQL/QueryDSL (동적 쿼리)
- 10%: Native Query (통계, 대량 작업)
이 정도 비율이 적당하더군요.
선택 기준표:
graph TD
Start{쿼리<br/>작성 필요}
Simple{단순<br/>CRUD?}
Dynamic{동적<br/>쿼리?}
Complex{복잡한<br/>통계?}
Bulk{대량<br/>작업?}
JPA[JPA<br/>Repository]
JPQL[JPQL<br/>QueryDSL]
Native[Native<br/>Query]
Start --> Simple
Simple -->|Yes| JPA
Simple -->|No| Dynamic
Dynamic -->|Yes| JPQL
Dynamic -->|No| Complex
Complex -->|Yes| Native
Complex -->|No| Bulk
Bulk -->|Yes| Native
Bulk -->|No| JPQL
style JPA fill:#e8f5e9
style JPQL fill:#e1f5fe
style Native fill:#fff3e0시스템 점검 체크리스트
저도 배포 전에 이 항목들을 꼭 확인합니다. JPA 영속성 컨텍스트를 사용한다면 참고하시면 좋을 것 같습니다.
- 1차 캐시 활용: 같은 트랜잭션 내에서 동일 ID 조회 시 쿼리가 다시 나가지 않는가?
- 변경 감지 이해: save() 호출 없이도 엔티티 변경이 자동으로 반영되는 것을 이해하는가?
- 준영속 상태 관리: 트랜잭션 밖에서 엔티티를 사용할 때 LazyInitializationException을 방지했는가?
- 대용량 처리: 한 트랜잭션에서 수천 개 엔티티를 처리하지 않는가? (메모리 위험)
- 테스트 트랜잭션: @Transactional 테스트에서 영속성 컨텍스트 동작이 다르다는 것을 이해하는가?
결론
JPA는 만능이 아닙니다.
처음 JPA를 배울 땐 “이제 SQL은 몰라도 되겠구나”라고 생각했습니다. 근데 실무에서 2년 쓰고 나니까 오히려 SQL을 더 잘 알아야 하더군요.
JPA가 어떤 쿼리를 만드는지, 그게 왜 느린지 이해하려면 결국 SQL과 DB를 알아야 합니다.
그래도 제대로 쓰면 정말 생산성이 높아집니다.
저희 팀도 CRUD 개발 시간이 절반으로 줄었거든요. 단순 반복 작업이 확실히 줄어들었습니다.
핵심은 이겁니다.
JPA는 “SQL을 몰라도 되는 기술”이 아니라 “SQL을 알면서 편하게 쓰는 기술”입니다.
그리고 함정을 피하는 법도 알아야 합니다:
- N+1 문제는 fetch join이나 @BatchSize로 해결
- 대용량 배치는 flush()와 clear() 주기적으로 호출
- 조회 전용은 @Transactional(readOnly = true) 필수
- 복잡한 쿼리는 과감하게 Native Query 사용
다음 편에서는 QueryDSL을 활용한 쿼리 최적화를 다룹니다. 동적 쿼리를 깔끔하게 작성하는 방법과 복잡한 조인 쿼리를 리팩토링하는 실전 노하우를 공유하겠습니다.
참고 :
https://docs.spring.io/spring-data/jpa/docs/current/reference/html/
https://vladmihalcea.com/n-plus-1-query-problem/
자바 ORM 표준 JPA 프로그래밍 (김영한)
https://www.baeldung.com/hibernate-initialize-proxy-exception
