프레임워크/스프링

JPA 영속성 컨텍스트(Persistence Context): 1차 캐시와 쓰기 지연이 주는 이점

eodevelop 2025. 12. 28. 18:12
반응형

 JPA를 처음 배울 때 가장 헷갈리는 개념 중 하나가 바로 영속성 컨텍스트(Persistence Context)입니다. "영속성"이라는 단어부터가 일상에서 잘 쓰지 않는 표현이다 보니, 저도 처음엔 이게 대체 뭔가 싶었습니다.

 그런데 알고 보면 영속성 컨텍스트는 꽤 단순한 아이디어입니다. 그리고 이걸 이해하면 JPA가 왜 그렇게 동작하는지, em.persist()를 호출해도 왜 바로 INSERT 쿼리가 나가지 않는지 자연스럽게 이해할 수 있습니다.

 이번 글에서는 영속성 컨텍스트가 무엇인지, 그리고 1차 캐시쓰기 지연이 어떤 이점을 주는지 초보자 눈높이에서 설명해보겠습니다.


영속성 컨텍스트란?

쉽게 말하면 "임시 저장소"

 영속성 컨텍스트를 한마디로 표현하면 "엔티티를 담아두는 임시 저장소"입니다.

마트에서 장을 볼 때를 생각해보세요. 물건을 하나 집을 때마다 계산대로 달려가서 결제하지 않죠? 일단 장바구니에 담아두고, 쇼핑이 끝나면 한 번에 계산합니다.

 영속성 컨텍스트도 똑같습니다. 엔티티를 저장하거나 수정할 때마다 데이터베이스에 바로 반영하지 않고, 일단 영속성 컨텍스트라는 장바구니에 담아둡니다. 그리고 트랜잭션이 커밋되는 시점에 한꺼번에 데이터베이스에 반영합니다.

EntityManager와의 관계

코드에서는 EntityManager를 통해 영속성 컨텍스트에 접근합니다.

EntityManager em = emf.createEntityManager();
em.persist(member);  // member를 영속성 컨텍스트에 저장

 

em.persist(member)를 호출하면 member 엔티티가 영속성 컨텍스트에 들어갑니다. 이 상태를 "영속 상태"라고 합니다. 아직 데이터베이스에 저장된 건 아니에요!


1차 캐시: 같은 걸 두 번 물어보지 않는다

1차 캐시가 뭔가요?

 영속성 컨텍스트 내부에는 1차 캐시라는 공간이 있습니다. 엔티티를 조회하면 이 1차 캐시에 저장해두고, 같은 엔티티를 다시 조회하면 데이터베이스까지 가지 않고 1차 캐시에서 바로 꺼내옵니다.

 도서관에서 책을 빌리는 상황을 생각해보세요. 어떤 책이 필요할 때마다 매번 서고까지 가는 건 비효율적입니다. 한번 빌린 책은 내 책상 위에 놔두고, 다시 필요하면 책상에서 바로 집으면 되죠.

1차 캐시가 바로 그 "책상"입니다.

코드로 확인해보기

EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();

// 첫 번째 조회 - 데이터베이스에서 가져온다
Member member1 = em.find(Member.class, 1L);
System.out.println("첫 번째 조회 완료");

// 두 번째 조회 - 1차 캐시에서 가져온다
Member member2 = em.find(Member.class, 1L);
System.out.println("두 번째 조회 완료");

// 같은 객체인지 확인
System.out.println("동일한 객체인가? " + (member1 == member2));

tx.commit();

 

실행 결과:

Hibernate: select m.id, m.name from Member m where m.id = ?
첫 번째 조회 완료
두 번째 조회 완료
동일한 객체인가? true

 

 SELECT 쿼리가 딱 한 번만 나간 걸 볼 수 있습니다. 두 번째 조회에서는 쿼리가 나가지 않았어요. 1차 캐시에서 바로 가져왔기 때문입니다.

 그리고 member1 == member2true입니다. 자바에서 == 비교는 두 변수가 메모리상 같은 객체를 가리키는지 확인하는 건데, 결과가 true라는 건 두 변수가 완전히 같은 객체를 참조하고 있다는 뜻입니다.

1차 캐시의 이점

1. 성능 향상

데이터베이스 조회는 생각보다 비용이 큽니다. 네트워크를 타고 데이터베이스 서버까지 갔다가 와야 하니까요. 1차 캐시 덕분에 같은 엔티티를 여러 번 조회해도 데이터베이스에는 한 번만 접근합니다.

 

2. 동일성(Identity) 보장

같은 트랜잭션 안에서 같은 엔티티를 조회하면 항상 같은 객체를 반환합니다. 이게 왜 중요할까요?

Member member1 = em.find(Member.class, 1L);
Member member2 = em.find(Member.class, 1L);

// 두 객체가 같으니까, 하나만 수정해도 둘 다 바뀐다
member1.setName("새이름");
System.out.println(member2.getName());  // "새이름" 출력


만약 매번 새로운 객체를 만들어서 반환했다면, member1member2가 서로 다른 객체가 되어 혼란이 생겼을 겁니다. JPA는 1차 캐시를 통해 이런 문제를 방지합니다.


쓰기 지연: 모아서 한 번에 보낸다

쓰기 지연이 뭔가요?

 쓰기 지연(Write-behind)은 SQL을 바로 데이터베이스에 보내지 않고, 모아뒀다가 한꺼번에 보내는 방식입니다.

다시 마트 비유로 돌아가볼게요. 장바구니에 물건을 담을 때마다 "이 물건 샀습니다!" 하고 방송하지 않죠? 계산대에서 한 번에 처리합니다.

 쓰기 지연도 마찬가지입니다. 대부분의 경우 em.persist()를 호출해도 INSERT 쿼리가 바로 나가지 않습니다. 대신 쓰기 지연 SQL 저장소에 쿼리를 모아뒀다가, 트랜잭션이 커밋될 때 한꺼번에 데이터베이스로 보냅니다.

 단, IDENTITY 전략을 사용할 때는 예외입니다. MySQL의 AUTO_INCREMENT처럼 데이터베이스가 ID를 자동 생성하는 IDENTITY 전략에서는 persist() 시점에 즉시 INSERT 쿼리가 나갑니다. 왜냐하면 JPA가 엔티티를 영속성 컨텍스트에서 관리하려면 PK(ID) 값이 반드시 필요한데, IDENTITY 전략은 실제로 INSERT를 해봐야 ID를 알 수 있기 때문입니다.

코드로 확인해보기

EntityManager em = emf.createEntityManager();
EntityTransaction tx = em.getTransaction();
tx.begin();

System.out.println("=== persist 호출 전 ===");

Member member1 = new Member("회원1");
Member member2 = new Member("회원2");
Member member3 = new Member("회원3");

em.persist(member1);
System.out.println("member1 persist 완료");

em.persist(member2);
System.out.println("member2 persist 완료");

em.persist(member3);
System.out.println("member3 persist 완료");

System.out.println("=== 커밋 직전 ===");

tx.commit();  // 이 시점에 INSERT 쿼리가 나간다!

System.out.println("=== 커밋 완료 ===");

 

실행 결과:

=== persist 호출 전 ===
member1 persist 완료
member2 persist 완료
member3 persist 완료
=== 커밋 직전 ===
Hibernate: insert into Member (name, id) values (?, ?)
Hibernate: insert into Member (name, id) values (?, ?)
Hibernate: insert into Member (name, id) values (?, ?)
=== 커밋 완료 ===

 

persist()를 세 번 호출했지만, INSERT 쿼리는 커밋 시점에 한꺼번에 나간 걸 볼 수 있습니다.

쓰기 지연의 이점

1. 데이터베이스 연결 시간 최소화

데이터베이스와 연결을 유지하는 건 비용이 듭니다. 쿼리를 모아서 한 번에 보내면 연결 시간을 줄일 수 있습니다.

 

2. 배치 처리 가능

여러 INSERT 쿼리를 모아서 배치로 처리할 수 있습니다. 설정을 통해 활성화하면 성능이 크게 향상됩니다.

# application.properties
spring.jpa.properties.hibernate.jdbc.batch_size=50

 

이렇게 설정하면 최대 50개의 INSERT를 묶어서 한 번에 보냅니다. 대량 데이터를 입력할 때 특히 유용합니다.

단, 앞서 말한 것처럼 IDENTITY 전략에서는 배치 INSERT가 동작하지 않습니다. 배치 처리가 중요한 상황이라면 SEQUENCE나 TABLE 전략을 고려해보세요.

 

3. 불필요한 쿼리 최적화

같은 엔티티를 여러 번 수정해도 최종 상태만 반영됩니다.

Member member = em.find(Member.class, 1L);
member.setName("이름1");
member.setName("이름2");
member.setName("이름3");  // 최종 이름

tx.commit();  // UPDATE 쿼리는 한 번만 나간다

 

setName()을 세 번 호출했지만, UPDATE 쿼리는 최종 값인 "이름3"으로 한 번만 나갑니다. 중간 과정의 쿼리는 생략되는 거죠.


변경 감지: 수정도 알아서 해준다

쓰기 지연과 함께 알아두면 좋은 게 변경 감지(Dirty Checking)입니다.

영속 상태의 엔티티를 수정하면, 별도로 em.update() 같은 걸 호출하지 않아도 JPA가 알아서 UPDATE 쿼리를 만들어줍니다.

Member member = em.find(Member.class, 1L);  // 영속 상태
member.setName("새로운 이름");  // 값만 바꾼다

// em.update(member) 같은 건 필요 없다!

tx.commit();  // 커밋 시점에 변경 사항이 감지되고 UPDATE 쿼리가 나간다

 

어떻게 이게 가능할까요?

 JPA는 엔티티를 1차 캐시에 저장할 때 스냅샷도 함께 저장합니다. 처음 상태를 사진 찍어두는 거죠. 그리고 커밋 시점에 현재 엔티티와 스냅샷을 비교해서, 달라진 게 있으면 UPDATE 쿼리를 만들어냅니다.


주의할 점

1차 캐시는 트랜잭션 범위

1차 캐시는 트랜잭션이 끝나면 사라집니다. 다른 트랜잭션에서는 같은 엔티티를 조회해도 다시 데이터베이스에 접근해야 합니다.

// 트랜잭션 A
tx1.begin();
Member member1 = em.find(Member.class, 1L);  // DB 조회
tx1.commit();
em.clear();  // 영속성 컨텍스트 초기화

// 트랜잭션 B
tx2.begin();
Member member2 = em.find(Member.class, 1L);  // DB 다시 조회!
tx2.commit();

 

애플리케이션 전체에서 공유하는 캐시가 필요하다면 2차 캐시를 별도로 설정해야 합니다.

flush는 커밋이 아니다

em.flush()를 호출하면 쓰기 지연 SQL 저장소의 쿼리가 데이터베이스로 전송됩니다. 하지만 트랜잭션이 커밋된 건 아닙니다!

em.persist(member);
em.flush();  // INSERT 쿼리 전송 (아직 커밋 아님)

// 여기서 예외가 발생하면?
throw new RuntimeException("에러!");

tx.commit();  // 이 줄은 실행되지 않음
// 결과: 롤백되어 member는 저장되지 않음

 

flush는 데이터베이스에 SQL을 보내는 것이고, 커밋은 트랜잭션을 확정하는 것입니다. 별개의 개념이에요.


정리

영속성 컨텍스트의 핵심 이점을 정리하면 다음과 같습니다.

1차 캐시

  • 같은 엔티티 조회 시 데이터베이스 접근 없이 캐시에서 반환
  • 같은 트랜잭션 내에서 동일한 엔티티는 동일한 객체임을 보장
  • 반복적인 조회 성능 향상

쓰기 지연

  • SQL을 모아뒀다가 커밋 시점에 한꺼번에 전송
  • 배치 처리로 대량 데이터 입력 성능 향상
  • 불필요한 중간 쿼리 생략

변경 감지

  • 엔티티 수정 시 자동으로 UPDATE 쿼리 생성
  • 별도의 update 메서드 호출 불필요

결국 영속성 컨텍스트는 애플리케이션과 데이터베이스 사이에서 중간 다리 역할을 하면서, 성능 최적화와 편의성을 제공하는 녀석입니다. JPA를 제대로 활용하려면 이 개념을 확실히 이해해두는 게 좋습니다.

반응형