Skip to content

실무에서 바로 쓰는 JPA (2편) - 영속성 컨텍스트와 흔한 실수들

실무에서 바로 쓰는 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 joinDB 페이징 가능
@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로 줄었습니다.


성능 비교:

대용량 배치 처리 성능 개선

⚠️단순 반복 처리
15분 / 8GB
GC 200회 이상 발생
⚡️flush() + clear()
12분 / 2GB
GC 30회로 감소
결과메모리 사용량 75% 감소, 안정성 확보

메모리뿐만 아니라 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시간 걸렸습니다.


방지 방법:

  1. 엔티티에 setter 쓰지 않기
@Entity
public class Order {
    // setter 제거하고 의미있는 메서드 사용
    public void complete() {
        this.status = OrderStatus.COMPLETED;
    }
}
  1. @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
🚨LazyInitializationException

트랜잭션 범위 밖에서 프록시 객체(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를 쓰면 안 되는 경우:

  1. 복잡한 통계 쿼리
-- 이런 쿼리를 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를 씁니다.


  1. 대용량 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 연산을 씁니다.


  1. 성능이 정말 중요한 조회

복잡한 조인과 서브쿼리가 필요한 조회는 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




읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site