ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring Boot 고급 시리즈 6화 – 트랜잭션 경계와 영속성 컨텍스트: JPA의 진짜 작동 원리
    기술과 산업/언어 및 프레임워크 2025. 6. 22. 13:21
    728x90

    Spring Boot + JPA 환경에서 트랜잭션의 범위와 영속성 컨텍스트의 생명주기를 어떻게 설정해야 하는지 설명합니다. flush 타이밍, LazyInitializationException, 성능 최적화 전략까지 실전 중심으로 구성했습니다.

     

     

    왜 트랜잭션과 영속성 컨텍스트를 구분해야 하는가?

     

    Spring Boot에서 JPA를 사용할 때 @Transactional 어노테이션을 쓰는 것이 익숙하실 겁니다. 그러나 이 어노테이션 하나로 JPA가 “정상 동작한다”고 생각하는 것은 착각입니다.

    실제로 JPA의 핵심 로직은 **영속성 컨텍스트(Persistence Context)**라는 개념 위에서 돌아갑니다.

     

    그리고 이 컨텍스트의 생성과 종료 시점은 트랜잭션 경계와 밀접하게 연결되어 있습니다.

     


     

    1. 트랜잭션과 영속성 컨텍스트의 관계

     

    영속성 컨텍스트는 다음과 같이 정의할 수 있습니다:

     

    엔티티 객체들을 저장하고 관리하는 1차 캐시.
    트랜잭션 내에서 JPA가 사용하는 엔티티 관리 공간입니다.

     

    JPA는 다음 두 조건이 모두 충족될 때 정상적으로 동작합니다:

     

    1. 트랜잭션이 시작되어야 함
    2. 영속성 컨텍스트가 함께 생성되어야 함
    @Transactional
    public void saveMember() {
        Member member = new Member("이철수");
        em.persist(member); // 영속 상태로 전환, 아직 DB 반영은 안 됨
    }

    위 코드에서 @Transactional이 없으면 em.persist()가 작동하더라도 트랜잭션이 없으므로, 실제 DB에는 아무 일도 발생하지 않습니다.

     


     

    2. flush는 언제 일어나는가?

     

    영속성 컨텍스트에 저장된 변경사항은 실제 DB에 곧바로 반영되지 않습니다.

    JPA는 아래 시점에 flush(), 즉 DB에 쿼리를 전송합니다:

     

    • 트랜잭션 커밋 직전
    • JPQL 실행 직전
    • flush() 명시적 호출
    • EntityManager.clear() 호출 시 내부적으로 flush()

     

    이러한 지연된 쓰기 전략은 성능 최적화를 위해 매우 유용하지만, flush 타이밍을 정확히 모르면 예측하지 못한 동작이 발생할 수 있습니다.

     


     

    3. LazyInitializationException – 트랜잭션 밖에서 발생하는 대표적 오류

     

    다음은 실무에서 매우 흔하게 발생하는 실수입니다:

    @GetMapping("/members")
    public List<Member> findAll() {
        List<Member> members = memberRepository.findAll(); // LAZY 관계 포함
        return members;
    }

    이 코드는 컨트롤러 레벨에서 리턴 직전에 연관 객체 접근 시 LazyInitializationException이 발생할 수 있습니다.

    이유는 간단합니다:

     

    • 컨트롤러 단에서는 트랜잭션이 종료됨
    • 따라서 영속성 컨텍스트도 종료됨
    • 이후 지연로딩된 연관 객체를 접근하려 하자 → 오류 발생

     

     

    해결 방법

     

    • 서비스 레이어에서 DTO 변환 후 반환
    • @Transactional(readOnly = true)를 명시해 트랜잭션 경계를 명확히 설정
    • 필요 시 Fetch Join으로 미리 조회

     


     

    4. readOnly = true 의 성능 효과

    @Transactional(readOnly = true)
    public List<Member> findAll() {
        return memberRepository.findAll();
    }

    이 설정은 다음과 같은 이점을 줍니다:

     

    • flush 동작이 생략됨 (쓰기 감지 생략)
    • 일부 DB 드라이버에서는 읽기 전용 최적화 힌트 전송
    • Hibernate 내부에서도 dirty checking 루틴을 건너뜀

     

    → 단순 조회 쿼리에서 반드시 사용해야 하는 설정입니다.

     


     

    5. 트랜잭션 경계를 Service에만 설정해야 하는 이유

     

    많은 개발자들이 @Transactional을 Repository나 Controller에 붙이기도 합니다.

    하지만 서비스 레이어에만 설정하는 것이 원칙입니다.

    레이어트랜잭션 유무이유

    Controller ❌ 없음 요청 흐름을 제어할 뿐, 비즈니스 로직이 없음
    Service ✅ 필수 트랜잭션 단위를 결정하는 핵심 계층
    Repository ❌ 없음 JPA 내부 메서드는 이미 트랜잭션 지원됨

     


     

    6. 영속성 컨텍스트와 동일성 보장

     

    영속성 컨텍스트 내에서는 동일한 엔티티를 두 번 조회해도 같은 객체가 반환됩니다:

    Member m1 = em.find(Member.class, 1L);
    Member m2 = em.find(Member.class, 1L);
    
    System.out.println(m1 == m2); // true

    이 “동일성 보장”은 JPA의 지연 쓰기 전략, 1차 캐시 최적화, 변경 감지(Dirty Checking)의 핵심 기반이 됩니다.

    그러나 컨텍스트가 다르면 동일한 엔티티도 서로 다른 객체로 간주됩니다.

    즉, 트랜잭션 범위에 따라 동작이 완전히 달라질 수 있다는 점을 인식해야 합니다.

     


     

    트랜잭션의 시작과 끝을 내가 통제하고 있는가?

     

    JPA를 사용할 때 @Transactional은 단순한 선언이 아닙니다.

    그 선언을 통해 영속성 컨텍스트가 생성되고, 그 내부에서 모든 데이터 작업이 이루어집니다.

     

    따라서 우리는 항상 다음을 명확히 해야 합니다:

     

    • 트랜잭션 경계는 어디서 시작되고 끝나는가?
    • 그 범위 안에서 flush, commit, clear가 어떻게 작동하는가?
    • 영속성 컨텍스트를 벗어난 접근은 없는가?

     

    이 질문에 답할 수 있어야만, 진짜로 JPA를 ‘제어하고 있다’고 말할 수 있습니다.

    728x90
Designed by Tistory.