개발자 취업준비/DataBase JPA

영속성 컨텍스트

naspeciallist 2025. 5. 1. 23:25

 

본 글은 https://www.inflearn.com/course/ORM-JPA-Basic/dashboard 강의를 바탕으로 작성한 글입니다.

 

jpa의 내부구조가 어떻게 동작하는지 알아보기 위해 영속성 컨텍스트라는 것에 대해 이해해보겠습니다.

 

영속성 컨텍스트란?


JPA에서 가장 중요한 것 두가지를 뽑아보라고 하면 DB의 설계와 JPA를 어떻게 매핑해서 쓸것인지 에 대한 것과 실제 JPA가 내부에서 어떻게 동작하는지에 대한 부분 입니다. 이 JPA가 내부에서 어떻게 동작하는지 자세히 알기 위해서 영속성 컨텍스트에 대해 알아보겠습니다.

 

[ JPA엔티티 메니저와 JPA 엔티티메니저 팩토리]

PA를 보통 쓰게 되면 엔티티 매너저 팩토리와 엔티티메니저를 생성하게 됩니다. 엔티티매니저 팩토리를 통해 고객의 요청이 올때마다 엔티티 매니저를 생성하게 됩니다.

 

그러면 이제 생성된 엔티티 매니저는 내부적으로 데이터베이스 커넥션을 사용해서 이제 DB를 이용할 수 있게 됩니다.

 

[영속성 컨텍스트]

영속성 컨텍스트는 JPA를 이해하는데 가장 중요한 용어 입니다. 엔티티를 영구 저장하는 환경이라는 뜻으로 만약 EntityManager.persist(entity)라는 메서드를 실행시켰다고 했을 때 이 코드는 DB에 저장한다는게 아니라 영속성 컨텍스트를 통해서 이 엔티티라는 걸 영속화 하겠다는 뜻입니다.

 

엔티티 메니저 영속성 컨텍스트는 엔티티 메니저를 통해서 접근하게 됩니다. 엔티티매니저팩토리를 통해 요청에 따라 엔티티 매니저를 생성하면 그 안에서 1대1로 영속성 컨텍스트가 생성이 됩니다.

 

만약 스프링 프레임워크와 같은 컨테이너 환경이라면 엔티티 매니저와 영속성 컨텍스트가 N대1로 생성되게 됩니다.

 

엔티티는 생명주기가 있습니다. new 객체를 생성했다고 했을 때 이건 최초의 멤버 객체를 생성한 상태로 비영속이라고 합니다. 영속 컨텍스트와 전혀 관계가 없는 새로운 상태입니다.

 

//객체를 생성한 상태(비영속)
Member member = new Member();
member.setId("member1");
member.setUSername("회원1");

 

위와같은 코드처럼 new멤버객체를 만들었습니다. 생성한 상태에서 코드를 보시면 setid, setUsername을 이용하여 세팅만 한 상태에서 jpa를 전혀 실행시키지 않고 객체만 생성한 상태입니다. 엔티티매니저에 아무것도 안넣었기 때문에 영속성 컨텍스트와 관계과 없는 비영속 상태인걸 확인 할 수 있습니다.

 

하지만 만약 새로 생성된 엔티티에 entitymanager.persist 메소드를 이용하게 되면 이제는 managed상태가 되면서 영속성 컨텍스트에서 관리가 되고 있는 상태입니다.

 

이걸 영속상태라고 부르게 됩니다.

 

//객체를 생성한 상태(비영속)
Member member = new Member();
member.setId("member1");
member.setUSername("회원1");

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

//객체를 저장한 상태(영속)
em.persist(member);

 

예를 들어보겠습니다. 방금처럼 객체를 생성 한 뒤에 엔티티메니저를 얻어와서 엔티티매니저에 persist를 통해 멤버 객체를 집어 넣으면 이제 이 엔티티 매니저 안에 있는 영속성 컨텍스트에 이 멤버객체가 저장이 되면서 이제 영속상태가 됩니다.

 

그리고 영속성 컨텍스트에 저장되었다가 분리된 상태를 준영속 상태 삭제되었을 때는 삭제된 상태라고 합니다.

 

만약 회원엔티티에 em.detach(member) 메소드를 실행하게 되면 회원엔티티가 영속성 컨텍스트에서 분리, 준영속인 상태가 됩니다.

 

그리고 만약에 em.remove(member)를 사용하게 된다면 객체를 삭제한 상태가 됩니다.

 

그러면 왜 영속성 컨텍스트라는 이런 메커니즘을 두는지 궁금하실수 있습니다.

이런 메커니즘을 두는 이유는 데이터베이스와 어플리케이션 사이에 중간 계층을 하나 만들기 위해서 입니다. 이걸 가지게 되면 다음과 같은 이점들을 얻을 수 있습니다.

  1. 1차캐시
  2. 동일성(identity) 보장
  3. 트랜잭션을 지원하는 쓰기 지연
  4. 변경 감지
  5. 지연로딩

 

영속성 컨텍스트를 통해 얻는 이점


 

[1차캐시]

영속성 컨텍스트 내부에는 1차캐시라는걸 들고 있습니다. 예를 들어 아래와 같이 코드를 구성해보겠습니다.

 

//엔티티를 생성한 상태(비영속)
Member member = new Member();
member.setId("member1"):
member.setUsername("회원1");

//엔티티를 영속
em.persist(member);

 

먼저 새로운 멤버객체를 생성을 한 뒤 값을 세팅했습니다. 지금은 비영속 상태입니다. 하지만 멤버객체를 영속성 컨택스트에 집어넣음으로 엔티티를 영속상태로 만들수 있습니다.

 

 

영속성 컨텍스트 내부에는 1차 캐시라는 걸 들고 있습니다.

 

엔티티를 영속성 컨텍스트에 넣게 되면 위 사진과 같이 @id가 있고 엔티티가 있습니다.

여기서 키값은 저희가 DB pk로 매핑한 member1이 키값이 되고 값은 엔티티 객체 자체가 값이 되게 됩니다.

이렇게 설정을 하게 되면 다음과 같은 이점을 얻을 수 있게 됩니다.

 

//엔티티를 생성한 상태(비영속)
Member member = new Member();
member.setId("member1"):
member.setUsername("회원1");

//1차 캐시에 저장됨
em.persist(member);

//1차 캐시에서 조회
Member findMember = em.find(member.class, "member1");

 

만약 저장된 멤버를 조회를 할 때 em.find로 조회를 하게 되면 jpa는 우선 컨텍스트에서 1차캐시를 조회하게 됩니다. DB를 탐색하는게 아니라 1차 캐시를 먼저 탐색 한뒤 1차 캐시에 멤버 엔티티가 있으면 그냥 캐시에 있는 값을 조회해 옵니다.

 

그럼 멤버2를 조회한다고 가정하겠습니다. 멤버2를 찾기 위해 jpa는 1차캐시를 조회하게 됩니다. 멤버2는 1차캐시에 없기 때문에 jpa가 이제 db를 조회하게 됩니다.

db에 조회를 한 멤버2를 1차캐시에다가 저장을 한 후 반환을 합니다. 이후에는 이제 멤버2를 다시 조회하게 되면 영속성컨텍스트 안에있는 1차캐시에 있는 멤버2가 반환이 됩니다.

이렇게 되면 DB를 조회하지 않고도 값을 조회할 수 있다는 이점을 얻을 수 있습니다.

 

 

1차캐시에 사용 예시를 코드를 통해 더 자세하게 알아보겠습니다.

 

try{
	//엔티티를 생성한 상태(비영속)
	Member member = new Member();
	member.setId(101L):
	member.setUsername("HelloJPA");

	//1차 캐시에 저장됨
	em.persist(member);

	//1차 캐시에서 조회
	Member findMember = em.find(member.class, 101L);

	System.out.println("findMember.id = " + findMember.getId());
	System.out.println("findMember.name = " + findMember.getName());

	tx.commit();
	} catch(Exception e) {
		tx.rollback();
		}

 

이 코드를 실행하면 결과가 다음같이 나오게 됩니다.

findMember.id = 101
findMember.name = HelloJPA
Hibernate:
insert
into
Member
(name, id)
values
(?,?)

 

과를 보시면 findMember에서 프린트문을 통해 결과를 찍는데 select쿼리가 실행이 안되고 조회된 값이 출력된 뒤 인서트 쿼리가 실행이 되었습니다. 왜냐하면 1차캐시에서 값을 이미 가져왔기 때문입니다. 그렇게 되면 DB에서 조회할 필요가 없이 1차 캐시를 통해 값을 조회할 수 있게 돼 select쿼리가 실행되지 않아도 값을 찾을 수 있습니다.

 

이제 위의 코드를 수정해서 다시 시작해보겠습니다. 다시 시작하기 때문에 위에 코드에서 영속성 컨텍스트는 삭제가 된 후 재생성이 되게 되고 엔티티매니저 또한 다시 생성을 하게 됩니다.

 

try{
	
	member findMember1 = em.find(Member.class,101L);
	member findMember2 = em.find(Member.class,101L);
	tx.commit()
} catch (Exception e) {
	tx.rollback();

 

멤버를 조회하기 위해 findMember1,findMember2로 두번 조회해보겠습니다.

 

Hibernate:
	select
		member0_.id as id1_0_0_,
		member0_.name as name2_0_0_
	from
		Member member0_
	where
		member0_.id=?

 

결과를 확인해보면 쿼리가 한번만 나가는 것을 확인 할 수 있습니다.

 

findMember1를 조회할 때는 먼저 JPA는 1차캐시를 탐색하게 됩니다. 1차캐시에 값이 없기 때문에 쿼리를 실행시켜 데이터베이스 내부에서 조회를 하게 됩니다. 하지만 findMember2부터는 findMember1에서 실행시킨 쿼리의 조회값이 1차 캐시에 저장이 되어 있기 때문에 쿼리를 실행시키지 않고 1차캐시에 저장된 값을 가져오게 됩니다.

 

[영속엔티티의 동일성 보장]

Member a = em.find(Member.class, "member1");  
Member b = em.find(Member.class, "member1");
 System.out.println(a == b); //동일성 비교 true

 

자바 컬렉션에서 똑같은 것을 조회하고 난 뒤 값을 동일성 비교(==)을 하게 되면 똑같은 값이 나오게 됩니다.

JPA도 영속 엔티티의 동일성이라는 걸 보장을 해줍니다.

 

위의 코드를 다시 보겠습니다.

 

try{
	member findMember1 = em.find(Member.class,101L);
	member findMember2 = em.find(Member.class,101L);
	System.out.println("result = "+(findMember1==findMember2));
	tx.commit()
} catch (Exception e) {
	tx.rollback();

 

Member 엔티티에 id가 101인 값을 2번 조회하였습니다. 이제 이 값을 ==비교 해보겠습니다.

그러면 결과값으로 true가 나오는 걸 확인 할 수 있습니다.

자바 컬렉션에서 같은 값을 가져올 때 주소가 같기 때문에 동일성 비교를 했을 때 true가 나오게 됩니다. JPA도 마찬가지로 같은 값을 가져올 때 주소가 같기 때문에 동일성 비교를 했을 때 결과값으로 true가 나오게 됩니다. 이와 같이 JPA가 영속 엔티티의 동일성을 보장해주는 걸 확인할 수 있습니다.

 

이걸 좀 더 어려운 용어로 설명을 하게 되면 1차캐시로 반복 가능한 읽기 데이터베이스의 트랜잭션 격리 수준을 리피터블 리드 등급의 트랜잭션 격리 수준을 데이터베이스가 아닌 어플리케이션 차원에서 제공해준다. 라고 말할 수 있습니다.

 

[엔티티 등록 트랜잭션을 지원하는 쓰기 지연]

EntityManager em = emf.createEntityManager();
 EntityTransaction transaction = em.getTransaction();
 //엔티티 매니저는 데이터 변경시 트랜잭션을 시작해야 한다.
 transaction.begin();  // [트랜잭션] 시작
em.persist(memberA);
 em.persist(memberB);
 //여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.
 //커밋하는 순간 데이터베이스에 INSERT SQL을 보낸다.
 transaction.commit(); // [트랜잭션] 커밋

 

먼저 트랜잭션을 실행하고 memberA,memberB를 저장하는 코드를 실행합니다. 하지만 여기까지는 INSERT SQL을 데이터베이스에 실행시키지 않습니다.

코드를 통해 쿼리를 바로 실행하는게 아니라 JPA에서 SQL을 쌓아놓고 있습니다.

트랜잭션을 커밋하는 순간 데이터 베이스에 INSERT SQL을 실행하게 됩니다.

 

 

먼저 em.persist(memberA)를 통해 멤버A를 넣습니다. 그리고 em.persist로 멤버B를 넣습니다. 순차적으로 넣었을 때 jpa안에서는 위 사진과 같은 일이 벌어지게 됩니다. 영속성 컨텍스트 안에는 1차캐시라는 것도 있지만 지기 지연 SQL 저장소라는 것도 있습니다. 그래서 만약 멤버A를 집어 넣었을 때 1차 캐시에도 저장이 되지만 JPA가 이 엔티티를 분석을 해서 인서트 쿼리를 생성해 쓰기 지연 SQL저장소에 저장을 하게 됩니다. 그 다음에 멤버B를 넣을 때도 똑같이 JPA가 분석을 하여 쿼리문으로 변환해 쓰기 지연 SQL저장소에 쌓아두게 됩니다. 만약 또 따른 멤버C,D,E를 생성하여도 똑같이 순차적으로 SQL문으로 변환이 되어 쓰기 지연 저장소에 쌓이게 됩니다.

 

트랜젝션을 커밋하게 되면 이제 쓰기지연SQL저장소에 쌓여 있던 쿼리에 이제 DB에 실행이 되게 됩니다. 여기서 커밋을 통해 쓰기 지연 SQL 저장소에 있던 쿼리가 DB로 실행되는 걸 JPA에서는 플러쉬라고 합니다.

플러쉬가 실행 된 후 실제 데이터베이스 트랜잭션이 커밋되게 됩니다. 이런 메커니즘으로 JPA가 실행이 되게 됩니다.

코드로 구성을 해보고 실행시켜서 결과를 확인해보겠습니다.

 

try{
	Member member1 = new Member(150L,"A");
	Member member2 = new Member(160L,"B");
	em.persist(member1);
	em.persist(member2);
	System.out.println("-------------")
	tx.commit()
} catch (Exception e) {
	tx.rollback();

 

이렇게 실행이 되면 결과값으로 member에 대한 insert쿼리가 커밋한 시점에서 실행되게 됩니다.

 

그러면 쿼리를 굳이 왜 모아놓았다가 커밋한 시점에 실행시키는 지 궁금 할 수 도 있습니다. 만약 코드가 실행 할 때 바로 쿼리문이 실행이 되게 되면 성능을 최적화 할 수 있는 여지 자체가 없어지게 됩니다. 쿼리가 바로 실행을 하게 되면 성능 최적화의 기회를 잃어버리게 됩니다. 하지만 버퍼를 통해 쓰기를 하게 되면 네트워크 통신 비용을 아낄 수 있는 등 옵션 하나로도 성능상의 이점을 얻을 수 있습니다.

 

[엔티티 수정 변경 감지]

 

try{
	Member member = em.find(Member.class, 150L);
	member.setName("?????")
	
	tx.commit()
} catch (Exception e) {
	tx.rollback();

 

위 코드에서 저장한 Member엔티티에 id가 150인 값을 찾아서 이름을 ?????로 값을 변경을 하였습니다. 이렇게 코드를 짠 뒤 코드를 실행시키면 select쿼리가 나간 뒤 업데이트 쿼리가 실행이 되게 됩니다. 이 코드에서는 값만 바꿨을 뿐 업데이트 쿼리를 실행 시킬 메서드를 구성하지 않았는데도 마치 Java Collection을 다루 듯이 값만 바꿨는데도 업데이트 쿼리가 실행이 되게 됩니다. 이런게 가능한 이유는 JPA에는 Dirty Checking(변경감지)라는 기능이 있습니다. 이 기능을 이용하여 엔티티를 변경 할 수 있게 됩니다. em.update(member)을 이용하지 않아도 마치 Java collection처럼 값만 변경 했는데도 자동으로 업데이트 쿼리가 실행이 되면서 db의 값이 변경이 됩니다.

 

그럼 이런게 어떻게 가능 할까요?

 

 

위 사진을 보시면 JPA가 트랜잭션을 커밋하는 시점에서 flush()라는 것을 호출하게 됩니다.

 

1차캐시 안에는 무엇이 있냐면 이제 PK인 ID값이 있고 Entitiy와 스냅샷이라는 것이 있습니다. 이때 스냅샷이 뭐냐면 값을 딱 읽어 온 그 시점에서 데이터를 스냅샷으로 찍어두는 것입니다. 그리고 스냅샷을 찍어둔 상태에서 위와 같이 멤버의 값을 변경하였습니다. 그리고 커밋을 하였습니다. 그러면 이제 JPA에서 트랜잭션이 커밋이 되는 시점에서 내부적으로 flush()가 호출이 되게 됩니다. 그러면 이제 내가 변경한 값과 JPA가 스냅샷으로 찍어 둔 엔티티 값과 비교를 하게 됩니다. 그리고 멤버A가 바뀐 것을 JPA가 스냅샷과 비교를 통해 확인을 하게 됩니다. 그러면 JPA에서 업데이트 쿼리를 쓰기 지연 SQL저장소에 자동으로 만들게 됩니다. 그러면 이게 데이터 베이스에 커밋한 시점에서 반영이 되게 됩니다. 이걸 이제 변경 감지라 부르게 됩니다.