JPA 영속성 컨텍스트

김소연·2026년 4월 20일

영속성 컨텍스트

엔티티(Entity)를 영속적으로 저장하는 환경이라는 뜻입니다. 하지만 JPA에서는 엔티티의 생명주기를 관리하고, 애플리케이션과 데이터베이스 사이에서 수많은 최적화 작업을 해주는 논리적인 공간(메모리 공간)이라고 이해해야 합니다.
마치 효율적인 작업을 위해 필요한 도구와 재료들을 미리 꺼내놓는 '작업대'와 같습니다. JPA는 이 작업대 위에서 엔티티들을 관리하다가, 작업이 끝나면(트랜잭션이 끝나면) 최종 결과물만 DB에 반영합니다.


영속성 컨텍스트를 사용하는 이유

1. 1차 캐시와 동일성(Identity) 보장

  • 1차 캐시:
    영속성 컨텍스트는 일종의 Map 형태의 캐시 저장소를 갖고 있습니다.

    • 엔티티가 영속성 컨텍스트의 1차 캐시에 저장됩니다.
    • 먼저 1차 캐시를 확인합니다. 캐시에 동일한 ID의 엔티티 객체가 있으면, DB를 조회하지 않고 캐시의 엔티티를 즉시 반환합니다.
  • 동일성 보장:

    • 1차 캐시 덕분에, 같은 트랜잭션 안에서 같은 ID로 조회한 엔티티는 항상 동일한 메모리 주소의 인스턴스(== 비교 시 true)임이 보장됩니다.
    // 같은 트랜잭션 내에서
    // Member를 저장
    Member savedMember = memberRepository.save(newMember);
    
    // ...
    
    // 위에서 저장한 Member의 id로 Member를 조회
    Member member = memberRepository.findById(dto.getId()).orElseThrow(
            () -> new IllegalStateExcetion("존재하지 않는 멤버입니다.")
    )
    // 여기서 savedMember는 영속성 컨텍스트에 저장되고,
    // 그 이후에 동일한 엔티티를 조회하면 DB를 조회하지 않고 캐시의 엔티티를 반환합니다.

    이 기능 덕분에 불필요한 DB 조회를 줄여 성능을 크게 향상시킬 수 있습니다.


2. 변경 감지 (Dirty Checking)

우리는 UPDATE 쿼리를 직접 작성할 필요가 없습니다.

  • 동작 원리:

    1. 트랜잭션이 커밋되기 직전, 영속성 컨텍스트는 1차 캐시에 있는 모든 엔티티의 최초 상태(스냅샷)와 현재 상태를 비교합니다.
    2. 만약 두 상태가 다르다면(즉, 엔티티의 필드 값이 변경되었다면), JPA는 변경된 내용을 감지하고 자동으로 UPDATE SQL을 생성하여 DB에 반영합니다.
    3. 이 과정을 더티 체킹(Dirty Checking)이라고 합니다.
    @Transactional
    public void updateMemberName(Long id, String newName) {
        Member member = memberRepository.findById(id).orElseThrow(
                () -> new IllegalStateExcetion("존재하지 않는 유저 입니다.")
        ); // 영속성 컨텍스트가 관리 시작
    
        // member.updateName()만 호출했을 뿐이고, update 쿼리는 없는 상황!
        member.updateName(newName); 
    } // 👈🏻 트랜잭션이 끝나는 이 시점에 JPA가 변경을 감지하고 UPDATE 쿼리를 날려준다.

3. 쓰기 지연 (Transactional Write-Behind)

예를 들어, 위에서 memberRepository.save() 를 호출했다고 해서 즉시 INSERT SQL이 DB로 전송되는 것이 아닙니다.

  • 동작 원리:
    1. memberRepository.save() 호출되면, 해당 엔티티는 1차 캐시에 저장되고, JPA는 이 엔티티를 DB에 반영하기 위한 INSERT SQL을 생성하여 차곡차곡 모아둡니다.
    2. UPDATEDELETE 쿼리도 마찬가지로 이 저장소에 쌓입니다.
    3. 트랜잭션이 커밋되는 순간, JPA는 모아뒀던 모든 SQL들을 한 번에 DB로 보냅니다. (이 과정을 Flush라고 합니다.)
  • 장점:
    SQL을 하나씩 보내지 않고 모았다가 한 번에 처리하므로, 네트워크 비용을 줄이고 DB와 상호작용하는 횟수를 최소화하여 성능을 최적화할 수 있습니다. (e.g., JDBC 배치 기능 활용 가능)

Flush란?

영속성 컨텍스트의 변경 내용을 DB에 동기화하는 것을 말합니다.
Flush는 영속성 컨텍스트의 '쓰기 지연 SQL 저장소'에 쌓인 SQL들을 데이터베이스에 전송하여, 컨텍스트의 상태와 DB의 상태를 맞추는 작업이고, 트랜잭션 커밋과는 다릅니다!

Flush는 DB에 SQL을 보내는 것일 뿐, 트랜잭션을 확정(Commit)하는 것은 아닙니다.
즉, 원래대로라면 쓰기 지연으로 인해 모아두었던 SQL을 트랜잭션이 끝나는 시점에 한꺼번에 DB로 보내지만, Flush를 사용하면 그 시점에 바로 SQL을 DB로 보내게 됩니다.

단, Flush를 한다고 하더라도 여전히 트랜잭션으로 관리가 되고 있는 상태이기 때문에 트랜잭션 롤백 시, Flush한 데이터도 롤백됩니다.


EntityManager

EntityManager는 내부에 영속성 컨텍스트(Persistence Context)라는 공간을 가지고 있습니다.
→ 위에서 이야기한 영속성 컨텍스트 관련한 내용은 모두 EntityManager가 해주는 일입니다!

EntityManager는 JPA의 핵심 인터페이스로, 애플리케이션과 데이터베이스 사이에서 엔티티 객체를 관리하고 실제 데이터베이스 작업을 수행하는 객체입니다. Entity(엔티티) Manager(관리자)라는 이름 그대로, 모든 엔티티는 EntityManager를 통해 관리되어야 합니다.

EntityManager는

개념 설명

  • Persistence Unit (설계도):

    • 역할:
      JPA 설정 정보의 단위입니다. 데이터베이스 연결 정보(URL, 사용자, 비밀번호), 어떤 엔티티 클래스를 관리할 것인지, 하이버네이트와 같은 JPA 구현체의 특정 설정 등을 포함하는 설정파일입니다.
      💡 데이터베이스와 통신하기 위한 설계도 또는 명세서와 같습니다. 
      이 설계도에는 어떤 재료(엔티티)를 사용하고, 어떤 공장(데이터베이스)에 연결할지에 대한 모든 정보가 담겨 있습니다.
  • EntityManagerFactory (공장):

    • 역할:
      Persistence Unit의 설정 정보를 바탕으로 EntityManager를 생성하는 팩토리(공장)입니다. 애플리케이션 전체에서 단 하나만 생성되어 공유됩니다.
    • 생성 비용:
      최초에 생성될 때, 설정 파일을 읽고, 데이터베이스 커넥션 풀을 만드는 등 리소스를 많이 사용하는 무거운 작업을 수행합니다. 따라서 여러 번 생성하는 것은 매우 비효율적입니다.
    • 스레드 안전성:
      여러 스레드가 동시에 접근해도 안전합니다 (Thread-Safe).
      💡 설계도(Persistence Unit)를 받아 실제로 부품(EntityManager)을 찍어낼 수 있는 공장 건물을 짓는 것과 같습니다. 
      공장을 짓는 데는 시간과 비용이 많이 들지만, 한번 지어두면 계속해서 제품을 생산할 수 있습니다.
  • EntityManager (작업자):

    • 역할:
      실제 데이터베이스 작업을 처리하는 작업자입니다. 엔티티를 저장(persist), 수정, 삭제(remove), 조회(find)하는 역할을 수행합니다. 내부적으로 데이터베이스 커넥션을 사용하여 SQL을 실행합니다.
    • 생성 비용:
      EntityManagerFactory에 비하면 생성 비용이 매우 저렴합니다.
    • 생명주기:
      데이터베이스 트랜잭션과 생명주기를 같이 합니다. 즉, 하나의 비즈니스 로직(요청)이 시작될 때 생성되고, 끝날 때 닫히는 짧은 생명주기를 가집니다.
    • 스레드 안전성:
      여러 스레드가 동시에 접근하면 안 됩니다 (Not Thread-Safe). 각 요청마다 개별 EntityManager를 사용해야 합니다.
      💡 공장(EntityManagerFactory)에서 나온 로봇 팔과 같습니다. 
      각 로봇 팔은 하나의 특정 작업을 맡아 수행하며, 작업이 끝나면 원래 자리로 돌아가거나 폐기됩니다. 
      여러 작업이 동시에 한 로봇 팔을 사용하면 혼란이 발생합니다.

💡 정리
설계도(Persistence Unit)를 바탕으로
→ 공장(EntityManagerFactory)을 짓고
→ 공장에서 필요할 때마다 작업자(EntityManager)를 생산하여
→ 작업자가 작업대(영속성 컨텍스트)에서 일을 하는 것으로 비유를 할 수 있습니다.

Spring Data JPA는 JPA의 핵심인 EntityManager를 더욱 편리하게 사용하도록 추상화한 것입니다. 즉, 개발자가 EntityManager를 직접 다루는 번거로운 작업을 대신 해주는 친절한 도구입니다. 우리들이 사용하고 있는 JpaRepository 내부적으로는 EntityManager를 사용하고 있는 것입니다.
Spring Data JPA가 EntityManager 관리를 대신 해주고 있으니, 개발자는 필요한 JpaRepository 인터페이스의 메서드만 호출하면 됩니다. (ex. save(), findById(), findAll() 등)
따라서 개발자는 더욱 더 편하게 핵심 로직에만 집중할 수 있게 됩니다.


트랜잭션 전파 (Transaction Propagation)

트랜잭션에 대해서는 배웠습니다. 하지만 만약 트랜잭션 안에 또 따른 트랜잭션이 있거나, 그렇게 관리해야 하는 상황이 있으면 어떨까요?
예를 들면, "새로운 작업(메서드)을 시작해야 하는데, 혹시 이미 진행 중인 다른 작업(트랜잭션)이 있다면 어떻게 해야 할까?” 같은 상황입니다.
이에 대해 답하려면, 트랜잭션 전파에 대해 알아야합니다.

Spring에서는 @Transactional 어노테이션을 사용해 메서드를 트랜잭션 안에서 실행하게 할 수 있습니다. 이때 propagation 옵션으로 여러 가지 전략을 설정할 수 있습니다.

  1. REQUIRED (default)
  2. REQUIRES_NEW
  3. NESTED
  4. SUPPORTS
  5. NOT_SUPPORTED
  6. MANDATORY
  7. NEVER

트랜잭션 격리 수준 (Transaction Isolation Level)

트랜잭션 격리 수준은 동시에 실행되는 여러 트랜잭션들이 서로 어느 정도까지 격리되어 실행될지를 결정하는 데이터베이스 설정입니다.
트랜잭션 격리 수준은 격리(고립) 수준이 낮은 순서대로 READ UNCOMMITED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE가 존재합니다.

  • READ UNCOMMITED

    • 가장 낮은 격리 수준
    • 각 트랜잭션에서의 변경 내용이 COMMIT이나 ROLLBACK 여부에 상관 없이 다른 트랜잭션에서 값을 읽을 수 있음
    • 정합성에 문제가 많은 격리 수준이기 때문에 사용하지 않는 것을 권장
    • DIRTY READ 발생
      * DIRTY READ: 트랜잭션이 작업이 완료되지 않았는데도 다른 트랜잭션에서 볼 수 있게 되는 현상입니다.
        - 예시: `트랜잭션 A` 가 실행되고 update 됐을 때 그 사이에 `트랜잭션 B`가 실행된다면 
          `트랜잭션 A`에서 커밋이 되지 않았음에도 불구하고, 
          `트랜잭션 B`에서 `트랜잭션 A`가 update한 내용을 확인할 수 있게 된다.

  • READ COMMITTED

    • COMMIT이 된 데이터만 읽음
    • RDB에서 대부분 기본적으로 사용되고 있는 격리 수준
    • Dirty Read와 같은 현상은 발생하지 않지만 NON-REPEATABLE READ 발생
      * NON-REPEATABLE READ : 트랜잭션 내에서 같은 쿼리를 두 번 실행할 때, 
      그 사이에 다른 트랜잭션이 해당 데이터를 수정하고 커밋하면 두 쿼리의 결과가 달라지는 현상입니다.
        - 예시: `트랜잭션 A` 가 SELECT로 잔액 1000원을 조회하고, 
          트랜잭션이 아직 끝나지 않은 이 때 `트랜잭션 B`가 해당 계좌에서 200원을 출금하고 COMMIT합니다. 
          그리고 `트랜잭션 A`가 다시 SELECT 실행 시 800원이 조회됩니다. (같은 트랜잭션 내에서 결과가 달라지는 현상)
    • 실제 테이블 값을 가져오는 것이 아니라 Undo 영역에 백업된 레코드에서 값을 가져옴
      * Undo 영역: 간단하게 데이터를 저장하는 버퍼 기능을 한다고 보면 되고, 
        트랜잭션에서 연산(커밋 롤백)이 이루어지지 않아도 질의문에 의해 수정이 생기면 수정되기 이전 값이 저장되는 영역.

  • REPEATABLE READ

    • 자신의 트랜잭션이 생성되기 이전의 트랜잭션에서 COMMIT이 된 데이터만 읽음
    • MySQL의 default 격리 수준
    • MySQL에서는 트랜잭션마다 트랜잭션 ID를 부여하여 트랜잭션 ID보다 작은 트랜잭션 번호에서 변경한 것만 읽게 됨
      * PHANTOM READ: 한 트랜잭션 내에서 같은 범위 쿼리를 실행할 때, 
      다른 트랜잭션이 새로운 레코드를 INSERT하거나 DELETE하여 결과 집합의 행 개수가 달라지는 현상
        - 예시: `트랜잭션 A`가 `SELECT * FROM users WHERE age > 20`의 결과로 5개 행을 반환받았습니다. 
          그리고 `트랜잭션 B`가 age=25인 user를 INSERT 후 COMMIT합니다. 
          그리고 `트랜잭션 A`가 같은 쿼리를 재실행했을 때 5개가 아닌 6개 행이 반환됩니다. 
          (새로운 행이 ‘유령’처럼 나타나는 현상)

  • SERIALIZABLE

    • 가장 엄격한 격리 수준
    • 데이터를 접근할 때, 항상 Lock을 걸고 데이터를 조회
    • 성능 문제로 데이터베이스에서 거의 사용되지 않음

      * NON-REPEATABLE READ와 PHANTOM READ의 차이: 
        NON-REPEATABLE READ는 "기존 데이터의 값 변경" 문제이고, 
        PHANTOM READ는 "데이터 행의 추가/삭제" 문제입니다.

📚 높은 격리 레벨에서 나타나는 문제는 낮은 격리 레벨에서도 똑같이 발생합니다.

격리 수준Dirty ReadNon-Repeatable ReadPhantom Read
READ UNCOMMITTED✅ 발생✅ 발생✅ 발생
READ COMMITTED✅ 발생✅ 발생
REPEATABLE READ✅ 발생
SERIALIZABLE

💡 정리

  • 트랜잭션 전파
    • "이 작업이 부모 작업과 한 몸처럼 움직여야 하는가, 아니면 완전히 별개로 동작해야 하는가?"
    • 대부분의 경우, 기본값인 REQUIRED로 충분합니다.
    • 현재 작업의 성공/실패와 관계없이 반드시 기록을 남겨야 하는 경우 (예: 로그, 실패 이력) REQUIRES_NEW를 사용해주세요.
  • 트랜잭션 격리 수준
    • "데이터의 일관성과 시스템의 동시 처리 성능 중 무엇이 더 중요한가?"
    • 특별한 이유가 없다면 DB의 default 격리 수준을 따르는 것이 가장 좋습니다(따로 설정하지 않음).

0개의 댓글