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를 사용하고 있기 때문에 개발자가 직접 호출하지 않아도 내부적으로 처리된다.
@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)를 명시적으로 지정했다.
@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을 명시적으로 선언해 트랜잭션의 범위와 생명주기를 명확히 지정하는 것이 좋다.