[Spring] JPA 영속성 컨텍스트 구조로 보는 이점

지난 포스트에서 JPA의 영속성 컨텍스트와 생명 주기에 대해 알아봤습니다. 간단하게 영속성 컨텍스트의 생명 주기의 관계를 통해서 Java로 구현된 객체가 어떻게 DB로 적재되고 삭제되는지를 알 수 있었습니다.

그런데, 영속성 컨텍스트를 왜 사용하는 것일까요?

처음에 저는 이렇게 생각했습니다. JDBC를 사용하려면 어차피 Connection이 생성되어야 하고, 그에 따른 Statement가 만들어져야 하는데, 서버 애플리케이션 특성상 다양한 사용자의 요청을 한 번에 처리해야 하므로, Connection이 여러개 생기게 됩니다. 이 때문에 Connection Pool이라는 것이 있고, 대표적으로 HikariCP가 사용되고 있죠. 그 다음에는 쿼리문을 작성해야 하는데, 이 부분과 함께 커밋까지 해주는 녀석이 영속성 컨텍스트라고 생각하였습니다.

대충 그럴싸하지만 진짜 목적은 따로 있었습니다..

 

 

영속성 컨텍스트의 이점 1: Cache

먼저 영속성 컨텍스트는 Entity를 영구적으로 저장하는 환경이라고 하였습니다. 즉, DB에 저장하는 것이 아닌 EntityManager 를 이용해서 Entity를 영속성 컨텍스트에 저장하는 것입니다.

이러한 영속성 컨텍스트에는 1차 캐시라는 것이 있습니다. Entity가 DB에 저장되기 전에 사용되는 공간인데, 반대로 DB를 조회하고 날 때에도 저장하게 됩니다. 따라서 한 번 더 같은 Entity를 읽고자 할 때 빠른 읽기 기능을 제공하고 부하를 줄여줍니다.

Cafe americano = new Cafe();
cafe.setName("Americano");
cafe.setPrice(3000);

Cafe espresso = new Cafe();
cafe.setName("Espresso");
cafe.setPrice(3000);

Cafe cafuchino = new Cafe();
cafe.setName("Cafuchino");
cafe.setPrice(4000);

// 캐시에 저장됨
em.persist(americano);
em.persist(espresso);
em.persist(cafuchino);

// 캐시에서 조회
em.find(Cafe.class, 1);

영속성 컨텍스트는 2차 캐시까지 존재하지만 독립적으로 사용되는 것은 1차 캐시이며 2차 캐시의 경우, 애플리케이션 전체 Entity에 사용하는 캐시입니다. 따라서 애플리케이션이 종료될 때까지 2차 캐시는 존재하지만 Entity의 Transaction Thread가 종료되는 경우, 1차 캐시는 삭제됩니다. 즉, 트랜잭션 범위 안에서만 사용하는 짧은 캐시입니다.

DB에서 조회할 때

  • 1차 캐시를 먼저 조회한 후, 있으면 1차 캐시의 내용을 불러온다.
  • 1차 캐시에서 Entity를 찾지 못했을 경우, 2차 캐시에서 조회하며 여기에도 없다면, DB에서 조회한다.
  • DB에서 조회한 Entity를 1차 캐시와 2차 캐시에 저장한다.
  • 조회한 Entity를 반환한다.

DB에서 조회했을 때만으로도 1차 캐시에 저장되는 것을 알 수 있으며 사용자가 다시금 이 요청을 진행했을 때 캐시에서 빠르게 불러올 수 있도록 하는 것이 첫 번째 이점이라고 할 수 있습니다.

DB에 저장할 때

  • 객체로 만든 Entity를 영속 상태로 전환하면 1차 캐시와 2차 캐시에 저장된다. (DB에 없음)
  • 1차 캐시에 저장된 Entity는 트랜잭션 커밋 메소드를 통해 DB로 저장된다.
  • 작업이 끝났으면 준영속 상태로 전환한다.

번거로운 면은 있지만 1차 캐시에 저장됨으로써 중간에 수정할 사항이 있다면 UPDATE 쿼리를 사용하지 않고도 INSERT 쿼리만으로 바로 저장할 수 있다는 점도 나름의 이점이라고 할 수 있습니다.

이런식으로 1차 캐시는 Entity마다 독립된 형태로 사용하며 만약, 1,000명 정도의 사용자가 DB 요청이 오면 EntityManager가 100개 생성되며 Thread가 종료된 뒤에는 모두 소멸됩니다.

반대로 2차 캐시는 Application 전체에서 사용하는 캐시로 애플리케이션이 종료될 때까지 계속 유지되는 캐시입니다. JPA 2.0에서부터 표준이 되었으며 Hibernate의 2차 캐시와 동일하게 동작하는데, 구체적인 내용은 Hibernate 2차 캐시를 다루는 방법으로 차후에 포스트해보도록 하겠습니다.

 

 

영속성 컨텍스트의 이점 2: 동일성 보장

캐시에서 한 번 설명을 드렸지만, Entity를 DB에 저장할 때, 캐시에 한 번 보관을 한다고 하였습니다. 이 말은 캐시에 있는 것과 실제 DB에 존재하는 것이 같으면서 레퍼런스 관계가 된다는 것인데요. 즉, 캐시에 보관하고 있는 데이터와 DB에 있는 데이터가 100% 동일하다는 것을 보장받을 수 있게 됩니다.

트랜잭션 내부에서 persist 메소드가 호출되면, Entity들을 1차 캐시에 생성하고, 논리적으로 구현되어 있는 Write-behind SQL 저장소에 INSERT 쿼리 등의 DML 쿼리를 생성하여 쌓아놓습니다.

최종적으로 commit() 메소드가 호출될 때 저 저장소에 있는 모든 쿼리가 DB로 전달되며 이 때부터 DB에 내용이 반영되는 것입니다.

그런데, 여기에서 함정은 commit() 메소드 내에는 flush() 메소드를 같이 호출합니다. flush() 메소드는 Write-behind SQL 저장소에 있는 모든 쿼리를 DB에 전송하는 메소드이고, 그 내용을 반영하는 것이 commit() 메소드인데, 실제 트랜잭션 내의 commit은 이 2가지 기능을 모두 수행한다는 점을 인지하셔야 합니다.

EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();

// 엔티티 매니저는 데이터 변경시 트랜잭션을 시작해야 한다.
transaction.begin(); // 트랜잭션 시작

em.persist(americano);

// 커밋하는 순간 데이터베이스에 INSERT SQL을 보낸다.
// 이 때 flush()와 commit()이 같이 실행됨
transaction.commit();

그렇다면 Write-behind SQL 저장소에 SQL 쿼리가 100개 쌓이면 100개가 한꺼번에 전송되는 걸까? SQL 저장소에 최대로 쌓일 수 있는 SQL 갯수는 30개이며 application.yml 파일 등을 통해서 hibernate batch size를 조절할 수 있습니다. 

spring.jpa.properties.hibernate.jdbc.batch_size=20

 

 

영속성 컨텍스트 이점 3: 엔티티 수정시 작동하는 Dirty Checking

Entity를 수정해야할 경우에는 어떻게 할 수 있을까? 그냥 우리가 JPA를 사용한다고 생각하고, 코드를 짜본다면 아래와 같이 짤 수 있습니다.

// EntityManager 생성
EntityManager em = emf.createEntityManager();

// 영속 Entity 조회
Cafe cafe = em.find(Cafe.class, 1);

// 영속 Entity 수정
cafe.setName("iceAmericano");

이렇게 짜고 commit()만 수행하면 알아서 될까? 그런데, 우리는 영속성 컨텍스트에 있는 Entity를 transaction begin하고 commit하면 INSERT 쿼리가 나가는 것으로 알고 있는데, 이렇게 하면 새로운 Row가 생성되는 것이 아닐까?

// EntityManager 생성
EntityManager em = emf.createEntityManager();

// 영속 Entity 조회
Cafe cafe = em.find(Cafe.class, 1);

// 영속 Entity 수정
cafe.setName("iceAmericano");

// 트랜잭션 생성 및 커밋
EntityTransaction transaction = em.getTransaction();
transaction.begin();
transaction.commit();

정답은 INSERT가 아닌 UPDATE 쿼리가 잘 실행된다 입니다. 어떻게 변경되었는지를 감지하는 걸까요? 그냥 영속성 컨텍스트 캐시에 Entity를 불러왔을 뿐이고, 우리가 알던대로라면 INSERT 쿼리가 생성되어 DB에 보내져야할텐데 말이죠.

그 비밀은 JPA 캐시에 있는 스냅샷 때문입니다. 1차 캐시에 저장할 때 ID와 Entity, 그리고 Snapshot이 저장되는데, commit() 또는 flush()가 발생했을 때 Snapshot을 비교하여 변경 사항일 경우에는 UPDARE 쿼리를 만들어주게 됩니다. 

이러한 변경 사항을 감지하는 기술을 우리는 Dirty Checking이라고 합니다. 

그런데, 우리가 단순히 Setter 메소드를 이용해서 이렇게 변경하면 UPDATE 쿼리를 작동하는 것은 맞지만 모든 필드에 대해서 변경 쿼리를 처리하게 됩니다. 만약 Entity의 필드가 20개 이상이라면 20개 이상을 모두 반영하는 것이죠. 

이렇게 처리하면 쿼리가 길어지고, 디버깅이 어렵기 때문에 변경 사항에 대해서만 UPDATE 쿼리를 작성하도록 설정할 수 있는 방법에는 @DynamicUpdate 어노테이션을 이용하는 방법이 있습니다. 

위 Annotation을 사용함으로써 원하는 필드에 대해서만 UPDATE 쿼리를 작성하도록 할 수 있습니다.

 

 

마치며...

여기까지 영속성 컨텍스트 구조로 보는 영속성 컨텍스트를 사용하는 이점에 대해 알아봤습니다. 영속성 컨텍스트에서 제공하는 캐시를 이용해 Dirty Checking을 이용하는 방법은 Java 적인 방법을 인용하는 방식이라 아주 괜찮다는 이미지를 많이 가지게 되었습니다. 

그러나 UPDATE 쿼리를 작성할 때, 변경 사항에 대해서만 처리하는 게 기본값이라면 좋았을텐데, 그렇지 않은 이유는 아마 코드가 복잡해지는 데 문제가 있을까 예상해봅니다. 

comments powered by Disqus

Tistory Comments 0