Programming/JPA

[JPA] 영속성 컨텍스트, 엔티티 생명주기, @Transactional

soeun2537 2025. 6. 4. 23:26
우아한테크코스 레벨 2에서 학습한 내용을 정리한 글입니다.

 

💭 들어가며

JPA를 처음 배웠을 때는 영속성 컨텍스트라는 개념이 잘 와닿지 않아, 완전히 이해하지 못한 채 사용했었다. 하지만 Spring Data JPA를 사용하면서 영속성 컨텍스트가 어떻게 작동하는지 감을 잡으면서 장점을 체감하게 되었고, 왜 굳이 애플리케이션도 아니고 DB도 아닌 중간 영역에 데이터를 한 번 더 저장하는지 이해하게 되었다. 이번 글에서는 이를 복습하고 정리해보고자 한다.

 

 

✅ 영속성 컨텍스트와 엔티티 매니저

▶ 영속성(Persistence)

  • 사전적 의미는 "오래 계속되는 성질"이라고 한다.
  • 데이터를 DB에 지속적으로 저장할 수 있는 성질이다.

 

▶ 영속성 컨텍스트(Persistence Context)

  • 영속성 컨텍스트는 엔티티를 관리하는 일종의 1차 캐시이다.
  • 엔티티 매니저가 관리하는 영속 상태의 엔티티들이 저장된 공간이다.
  • 엔티티 상태를 추적하며, 변경 감지, 지연 로딩, 쓰기 지연 등 핵심 기능을 제공한다.

 

▶ 엔티티(Entity)

  • DB 테이블과 매핑되는 Java 클래스를 의미한다.
  • @Entity 어노테이션을 통해 JPA가 해당 클래스를 테이블로 인식한다.

 

▶ 엔티티 매니저(Entity Manager)

  • 엔티티를 관리하는 핵심 객체이다.
  • JPA를 통해 DB 작업을 수행할 때, EntityManager를 사용한다.
나는 JPA를 사용하면서 EntityManager를 직접 사용한 적이 없는데?
사실 Spring Data JPA는 SimpleJpaRepository를 구현체로 자동 주입해 주며, 그 내부에서 EntityManager를 사용하고 있기 때문에 개발자가 직접 호출하지 않아도 내부적으로 처리된다.

 

 

✅ 엔티티 생명주기

 

▶ 엔티티 생명주기 4단계

상태 설명
비영속(new/transient) 아직 영속성 컨텍스트에 등록되지 않은 상태
영속(managed) 영속성 컨텍스트에 등록되어 관리되는 상태
준영속(detached) 한때 영속이었으나, 더 이상 JPA가 관리하지 않는 상태
삭제(removed) 트랜잭션 커밋 시 DB에서 삭제되는 상태

 

▶ 엔티티 매니저의 메서드 종류

메서드 설명
persist() 비영속 객체를 영속성 컨텍스트에 등록 → 영속 상태
find() 1차 캐시 or DB에서 엔티티 조회 → 자동으로 영속 상태
detach() 특정 객체를 영속성 컨텍스트에서 제거 → 준영속 상태
clear() 영속성 컨텍스트 초기화 → 모든 객체 준영속 상태
close() EntityManager 종료 → 모든 객체 준영속 상태
merge() 준영속 or 비영속 객체를 복사 → 영속 상태
remove() 영속 객체 → 삭제 요청 상태 (커밋 시 DB에서 DELETE 수행)
flush() 영속성 컨텍스트의 변경 내용을 DB에 반영 (SQL 실행)

 

▶ 생명주기 특징

🔽 비영속(new/transient) 상태

  • 객체는 new로 생성만 된 상태이다.
  • EntityManager의 관리 대상이 아니다.
  • DB와도 무관하며, 아무 영향 없다.
Member member = new Member("미소");

🔽 영속(managed) 상태

  • persist()나 find()를 통해 영속성 컨텍스트에 등록된 상태이다.
  • 변경 감지(dirty checking)가 동작한다. (후술 예정)
  • 트랜잭션 커밋 시 DB에 자동 반영된다.
@Autowired
private EntityManager em;

em.persist(member);
em.find(member);

🔽 준영속(detached) 상태

  • detach() 또는 clear() 호출로 영속성 컨텍스트에서 분리된 상태이다.
  • merge() 전까지는 DB에 반영되지 않는다.
  • JPA의 기능(변경 감지 등) 사용이 불가하다.
@Autowired
private EntityManager em;

em.detach(member); // member 엔티티 -> 준영속 상태
em.clear();        // 영속된 엔티티들 -> 모두 준영속 상태
em.close();        // 영속된 엔티티들 -> 모두 준영속 상태
em.merge(member);  // 준영속 상태인 member 엔티티 -> 영속 상태

🔽 삭제(removed) 상태

  • remove() 호출로 삭제를 요청한 상태이다.
  • 트랜잭션 커밋 시 DELETE 쿼리 실행된다.
@Autowired
private EntityManager em;

em.remove(member); // 삭제를 요청한 상태

 

 

✅ 영속성 컨텍스트의 장점

애플리케이션도 아니고 데이터베이스도 아닌 또 다른 공간에 객체를 따로 저장한다는 것이 비효율적으로 느껴질 수 있다. 하지만 JPA가 이러한 구조를 택한 이유는, 영속성 컨텍스트가 제공하는 강력한 기능들 때문이다.

 

▶ 1차 캐시

  • 영속성 컨텍스트는 조회된 엔티티를 메모리 내 1차 캐시에 저장한다.
  • 동일한 엔티티를 반복 조회하더라도 DB 쿼리는 최초 한 번만 실행되며, 이후에는 캐시에서 즉시 반환된다.

🔽 코드

@Transactional
public void cache(Long id) {
    Post post1 = postRepository.findById(id)
            .orElseThrow(RuntimeException::new);
            
    Post post2 = postRepository.findById(id)
            .orElseThrow(RuntimeException::new);

    Post post3 = postRepository.findById(id)
            .orElseThrow(RuntimeException::new);
}

🔽 결과

  • findById()를 3번 호출했지만, 실제 DB에는 한 번만 쿼리가 전송되었다.
  • 나머지 두 번은 영속성 컨텍스트의 1차 캐시에서 엔티티를 반환한다.

 

▶ 동일성 보장

  • 같은 트랜잭션 또는 같은 EntityManager를 통해 조회한 엔티티는 항상 동일한 객체 인스턴스를 반환한다.
  • 즉, equals()가 아니라 == 비교에서도 true가 반환된다.

🔽 코드

@Test
@Transactional
void test() {
    Post post1 = postRepository.findById(1L).get();
    Post post2 = postRepository.findById(1L).get();

    boolean result = post1 == post2;
    assertThat(result).isEqualTo(true);
}
@Test
@Transactional
void test() {
    Post post1 = em.find(Post.class, 1L);
    Post post2 = em.find(Post.class, 1L);

    boolean result = post1 == post2;
    assertThat(result).isEqualTo(true);
}

🔽 결과

  • 같은 영속성 컨텍스트에서 조회되었기 때문에 동일한 인스턴스로 보장되며, == 비교에서도 true가 반환된다.

 

▶ 지연 로딩(Lazy Loading)

  • 연관된 엔티티는 실제 사용되는 시점까지 DB에서 조회되지 않는다.
  • 이를 통해 초기 로딩 비용을 줄이고, 불필요한 쿼리 실행을 방지할 수 있다.
  • @OneToMany(fetch = LAZY)와 같이 fetch = LAZY로 설정 가능하다.
연관 관계의 기본 로딩 전략
@OneToMany: Lazy
@ManyToMany: Lazy
@ManyToOne: Eager
@OneToOne: Eager

🔽 코드

@Test
@Transactional
void test() {
    Post post = postRepository.findById(1L).get();

    System.out.println("[지연 로딩] 시작");
    post.getMember().getName(); // 이 시점에 쿼리 발생
    System.out.println("[지연 로딩] 끝");
}
@Transactional 어노테이션이 없다면 LazyInitializationException이 발생할 수 있다. 지연 로딩된 프록시 객체를 사용하는 시점에 영속성 컨텍스트가 닫혀 있기 때문이다. (OSIV가 false인 경우)

🔽 결과

  • post.getMember().getName() 호출 전까지는 실제 쿼리가 실행되지 않는다.
  • 내부적으로는 프록시 객체를 반환하고, 필드에 접근하는 시점에 DB 조회 쿼리가 실행된다.
지연 로딩은 성능 최적화에 매우 유용한 기능이지만, 연관된 엔티티를 반복적으로 조회하는 구조에서는 N+1 문제를 유발할 수 있다. 자세한 내용은 여기에 정리해 두었으니 참고하면 좋을 것 같다.

 

▶ 변경 감지(Dirty Checking)

  • JPA는 트랜잭션 범위 내에서 영속 상태 엔티티의 변경을 감지한다.
  • 엔티티의 필드 값이 변경되면, 트랜잭션 커밋 시점에 자동으로 update 쿼리를 생성하여 DB에 반영한다.

🔽 코드

@Transactional
public void dirtyChecking(Long id) {
    Post post = postRepository.findById(id)
            .orElseThrow(RuntimeException::new);

    post.setTitle("변경 감지");
}

🔽 결과

  • setTitle()로 엔티티의 필드를 변경했고, 직접 update 쿼리를 작성하지 않았다.
  • 그러나 트랜잭션이 커밋되는 순간, JPA가 변경 사항을 감지하여 자동으로 update 쿼리를 실행한다.

 

▶ 쓰기 지연

  • JPA는 트랜잭션 안에서 발생한 insert, update, delete 쿼리를 즉시 실행하지 않고, 트랜잭션 커밋 직전까지 SQL 실행을 지연시킨다.

테스트가 종료되면 자동으로 롤백되기 때문에, flush 및 commit이 발생하지 않아 쓰기 지연으로 인해 누적된 SQL 쿼리들이 실제 DB에 반영되지 않는다. 따라서 insert/update 쿼리가 실행 로그에 보이지 않을 수 있어, 쓰기 지연 동작을 눈으로 확인하기 위해 @Rollback(false)를 명시적으로 지정했다.

🔽 코드 (update)

@Test
@Transactional
@Rollback(value = false)
void test() {
    Post post = postRepository.findById(1L).get();

    System.out.println("[쓰기 지연] 시작");
    post.setTitle("새로운 제목");
    System.out.println("[쓰기 지연] 끝");
}

🔽 결과 (update)

  • setTitle()은 즉시 DB에 반영되지 않고, 트랜잭션 커밋 시점에 실행된다.

🔽 코드 (delete)

@Test
@Transactional
@Rollback(value = false)
void test() {
    Post post = postRepository.findById(1L).get();

    System.out.println("[쓰기 지연] 시작");
    postRepository.delete(post);
    System.out.println("[쓰기 지연] 끝");
}

🔽 결과 (delete)

  • delete() 역시 즉시 DB에 반영되지 않고, 트랜잭션 커밋 시점에 실행된다.

🔽 코드 (insert)

@Test
@Transactional
@Rollback(value = false)
void test() {
    Member member1 = new Member("가나");
    Member member2 = new Member("다라");

    System.out.println("[쓰기 지연] 시작");
    em.persist(member1);
    em.persist(member2);
    System.out.println("[쓰기 지연] 끝");
}
주의: Member의 ID 생성 전략을 IDENTITY에서 SEQUENCE로 변경해야 쓰기 지연이 정상적으로 작동한다.
IDENTITY 전략은 기본 키를 DB에서 자동 증가(AUTO_INCREMENT) 방식으로 생성하기 때문에, 엔티티를 persist하는 순간 DB에 INSERT 쿼리를 실행해야만 ID 값을 알 수 있다.
따라서 IDENTITY 전략을 사용할 경우, INSERT 쿼리에서는 쓰기 지연이 적용되지 않고 즉시 DB에 반영된다.

🔽 결과 (insert)

  • em.persist() 역시 즉시 DB에 반영되지 않고, 트랜잭션 커밋 시점에 실행된다.

 

 

✅ @Transactional

▶ @Transactional과 영속성 컨텍스트의 관계

  • 트랜잭션은 비즈니스 로직이 정상적으로 처리되면 커밋, 예외가 발생하면 롤백을 수행한다.
  • @Transactional은 트랜잭션을 시작하도록 선언하며, 영속성 컨텍스트의 생성과 범위도 함께 결정한다.
  • 트랜잭션이 시작되면 영속성 컨텍스트가 함께 생성되어, 해당 범위 내에서 엔티티의 상태 추적, 변경 감지, 쓰기 지연 등의 기능이 동작한다.
@Transactional != 트랜잭션 != 영속성 컨텍스트

 

▶ 일부 기능은 왜 @Transactional 없이도 동작할까?

이와 관련해 GPT에게 질문해 보면, 대부분의 경우 "@Transactional을 명시적으로 사용하는 것이 안정적이다"라는 답변을 준다. "안정적이다"는 말이 너무 추상적이라고 느껴져, 직접 파보았다.

🔽 예시 코드 (변경 감지)

public void dirtyChecking(Long id) {
    Post post = postRepository.findById(id)
            .orElseThrow(RuntimeException::new);

    post.setTitle("변경 후 제목");
}
  • 위 메서드는 @Transactional 없이 작성되었다.
  • postRepository.findById(id)가 내부적으로 트랜잭션이 적용된다.
    • Spring Data JPA는 기본적으로 Repository 메서드에 @Transactional(readOnly = true)을 자동으로 적용한다.
  • 이로 인해 읽기 전용 트랜잭션이 시작되며, 그와 함께 영속성 컨텍스트도 생성된다.
  • 이후 post.setTitle("변경 감지")를 호출하면, 영속 상태의 엔티티에 변경이 발생하지만…

🔽 결과

  • 읽기 전용 트랜잭션이기 때문에 flush 시점에 실제 update 쿼리가 발생하지 않는다.

🔽 결론

@Transactional은 단순히 트랜잭션을 시작하는 것뿐 아니라, 영속성 컨텍스트가 언제 생성되고 언제 종료되는지를 명확히 지정하는 역할을 한다.

Spring 내부에서는 @Transactional 없이도

  • Repository 메서드를 통해 트랜잭션이 암묵적으로 생성되거나,
  • open-in-view(OSIV)가 활성화된 상태에서 요청 전체에 걸쳐 EntityManager가 열려 있기 때문에

일부 기능이 “우연히” 동작할 수 있다.

그러나 위처럼 코드의 흐름이 예측과 다르게 흘러갈 수 있다. 따라서, JPA의 핵심 기능인 변경 감지, 지연 로딩, 쓰기 지연 등을 의도대로 활용하기 위해서는, @Transactional을 명시적으로 선언해 트랜잭션의 범위와 생명주기를 명확히 지정하는 것이 좋다.

 

 

📍 참고