JPA Basic 2. Persistence Context 동작 방식

zdpk·2024년 3월 12일

JPA Basic

목록 보기
2/11
post-thumbnail

이번에는 JPA를 사용하기 위해 반드시 이해해야 할, Persistence Context에 대해서 알아보겠다.

그 전에, 지난 시간에 작성한 User Entity를 실제로 DB에 저장하는 간단한 테스트부터 작성해보자.

Entity 생성 테스트

Spring 프로젝트는 기본적으로 소스 코드는 src/main 디렉토리 하에, 테스트 코드는 src/test 디렉토리 하에 배치한다.

테스트 코드에 익숙하지 않은 사람도 있을 것이기 때문에 이번에만 정확한 경로를 명시해두었으니 참고하면 좋을 것이다.

// src/test/java/x/jpa_playground/UserTest.java
@SpringBootTest  
@Transactional
class UserTest {  
  
  @Autowired
  private EntityManager em;  
  
  @Test  
  void test() {  
    User user = new User("user", 10);  
    em.persist(user);  
  }  
  
}

EntityManager는 의존성에 Jpa를 추가하면 자동으로 Spring에 의해 주입 받을 수 있다.

new User()로 인스턴스를 생성하면 순수 Java 객체가 만들어지고,

em.persist로 이를 Persistence Context에 저장할 수 있다.

그러나 이것만으로는 DB에 실제 SQL이 전송되지 않는다.

테스트를 실행해보면 insert into가 Console에 출력되지 않는 것을 확인할 수 있다.

em.persist는 그저 Persistence Context에 Java 객체를 저장할 뿐,

em.flush를 호출해야 실제로 DB에 SQL이 전송된다.

em.flush를 통해 Persistence Context에 저장된 객체를 분석하여 SQL문으로 변환 및 전송이 이뤄지는 것이다.

이 과정을 정리하면 다음과 같다.

1. User 인스턴스 생성
User user = new User("user", 10);

2. em.persist(user); 호출 (Persistence Context에 User instance 저장)
-> PersistenceContext[ user ]

3. em.flush(); 호출 (SQL 변환 및 전송)
-> insert into "user" (name, age) values ("user", 10);

참고로 id가 없는 이유는 DB에서 자동으로 id를 넣어주기 때문이다.(상황에 따라 달라진다.)

// UserTest.java
@SpringBootTest  
@Transactional
class UserTest {  
  
  @Autowired
  private EntityManager em;  
  
  @Test  
  void test() {  
    User user = new User("user", 10);  
    em.persist(user); 

    // user instance -> SQL 변환 및 DB에 전송
    em.flush();
  }  
  
}

코드를 위와 같이 수정하고 테스트를 다시 돌려보면 Console에 다음과 같은 SQL이 출력되는 것을 볼 수 있다.

Hibernate: 
    insert 
    into
        "user"
        (name, id) 
    values
        (?, ?)

이번에는 em.find를 통해 DB에 방금 저장한 User를 찾아오는 로직을 추가해보자.

// UserTest.java
@Test  
void test() {  
  User user = new User();  
  em.persist(user);  
  em.flush();  

  // 방금 저장한 User 찾아오기(빈 DB에 저장되었으므로 id는 1)
  User foundUser = em.find(User.class, 1L);  
}

em.find를 처음 보더라도, User를 찾아오는 로직이라는 설명을 봤다면 자연스럽게 select SQL이 출력될 것이라는 사실을 예상할 수 있다.

그런데 Console을 아무리 찾아봐도 select SQL을 찾을 수 없을 것이다.

그렇다고 foundUsernull인 것은 아니다. 디버깅이나 출력을 해보면 정상적으로 foundUser에 데이터가 저장된 것을 확인할 수 있다.

지금은 이상하게 느껴지겠지만, 이것이 정상적인 동작이다. Persistence Context로 인해 비롯된.

지금부터 Persistence Context에 대해 자세히 알아보자.


Persistence Context

Persistence Context는 1차 캐시(First-level Cache)라고도 불리며,

서버에서 만들어진 instance가 실제로 DB로 보내지기 전에 잠시 저장해두기 위한 공간이다.

위 설명만으로 충분히 이해할 수 없는 사람들도 있을 것이기 때문에 비유를 통해 좀 더 쉽게 설명해보겠다.

택배 기사는 하루에도 수많은 주문을 받는다.

그런데 주문이 1번 들어올 때마다 물건을 하나씩 갖다주는 것이 아니라,

수 십, 수 백개의 주문에 대한 물건을 한 번에 모아서 일대에 배송한다.

em.persist 호출은 주문을 넣는 것과도 같다.

주문을 넣자마자 바로 배송이 오는 것이 아니라, 택배 기사의 차량에 물건이 실리기만 할 것이다.

이 택배 배송 차량을 '배송 상품을 임시로 저장하는 공간'이라고 볼 수 있으며,

데이터 저장 관점에서 보면 Persistence Context는 이와 비슷한 역할을 한다.

물건이 충분히 쌓이거나 시간이 되면 택배 기사는 차량을 몰고 배송을 시작한다.

em.flush를 호출하는 것이 목적지에 물건을 가져다 주는 것과 비슷하다.

목적지가 DB이고 물건이 Instance라는 것만 다를 뿐이다.

이해를 돕기 위해 그림으로 살펴보겠다.

1. User instance 생성

User를 생성하면 서버의 메모리 상에 instance가 배치된다.

2. Persistence Context에 instance 저장

사실 이 부분은 설정에 따라 동작 방식이 달라질 수 있다.(persist를 호출하는 것 만으로 Database에 저장될 수도 있음)
게다가 id를 넣은 적이 없는데 왜 1로 설정되어 있나 하는 의문이 들 수 있지만,
자세한 내용은 다른 편에서 다루도록 하겠다.
지금은 id가 자동으로 들어가고, 만든 순서대로 1씩 증가한다고 생각하자.

em.persist를 호출하면 User instance가 Persistence Context에 저장된다.

em.persist 만으로는 DB에 저장되지 않는다.(다시 말하지만, 설정에 따라 달라질 수 있다. 추후 다른 편에서 설명하겠다.)

Persistence Context에 저장될 뿐이다.

3. SQL 변환 및 DB에 전송

em.flush가 호출될 때 비로소 Java instance가 SQL로 변환되고, DB에 전송된다.

일련의 과정을 그저 외우기만 하는 것은 의미가 없다.

왜 이런식으로 설계했는지 생각해보고, 어떤 이득이 있는지 생각해 볼 필요가 있다.


저장 시에 Persistence Context를 통해 얻는 이득

현재는 1개의 User만 생성하고 있기 때문에 감이 잘 오지 않을 수도 있는데,

1000개의 User가 있다고 가정해보자.

만약 em.persist를 할 때마다 INSERT INTO "user" ... SQL이 DB로 전송된다면,

1000번의 네트워크 요청이 발생할 것이다.

그러나 Persistence Context에 1000개를 모아두었다가, 한 번에 내보낸다면 어떨까.

INSERT INTO "user" ... INSERT INTO "user" ... INSERT INTO "user" ...가 총 1000회 반복되는 문자열이 1개 생성되어,

짜잘한 1000개의 요청이 아닌, 1번의 큰 요청만으로 해결될 것이다.

(한 번에 보낼 수 있는 SQL의 수도 설정에 따라 달라질 수 있지만, 지금은 자세한 부분은 생략하겠다.)

코드로 보면 다음과 같다.

// UserTest.java
@Test  
void test() {  
  for (int i = 0; i < 1_000; i++) {
    User user = new User();  
    // Persistence Context에 각 User instance 저장
    em.persist(user);  
  }

  // Persistence Context에 저장된 1000개의 User instance를 1000개의 insert into SQL로 변환
  // 그리고 DB에 전송
  em.flush();
}

em.persist를 할 때마다 1000개의 요청을 보내는 것보다, 한 번에 모아서 em.flush 시에 1번의 요청을 보내는 것이 더 효율적일 것이라는 말이 잘 이해되지 않는다면,

택배 기사가 배송을 할 때마다 창고에서 1개의 상품을 꺼내서 배송하고, 다시 창고에 와서 2번째 상품을 꺼내서 배송하는 것과,

1000개의 상품을 한 번에 택배 차량에 넣고 이동하면서 배송하는 것 중 어떤 것이 더 효율적일지 생각해보면 와닿을 것이다.


em.find

이전에 em.find를 통해 User를 DB에서 찾아오려고 해도 SQL이 Console 상에 출력되지 않는 것을 확인했다.

그 이유가 Persistence Context 때문이라고 했었는데, 좀 더 자세히 알아보겠다.

우선 지금까지의 코드를 실행한 결과, 상황을 도표로 표현하면 다음과 같을 것이다.

User instance를 생성하고, em.persist를 호출하여 Persistence Context에 저장하고, em.flush를 호출하여 instance를 SQL로 변환 후, DB에 전송 및 저장했다.

그래서 위와 같은 상태가 된 것이다.

이 때, 주의할 점은 em.flush를 호출 한다고 해서, Persistence Context 내에 저장되어 있던 User instance가 제거되지는 않는다는 것이다.

즉, em.flush 이후의 UserPersistence Context와 DB에 공존하게 된다.

위 상황에서 em.find를 호출한 결과, Userselect하는 SQL은 Console에 출력되지 않았지만 정상적으로 User instance를 찾아왔었는데, 그 이유는 다음과 같다.

User를 DB가 아닌, Persistence Context에서 찾아왔기 때문이다.

코드도 다시 한 번 살펴보자.

// UserTest.java
@Test  
void test() {  
  User user = new User();  
  em.persist(user);  
  em.flush();  

  // 방금 저장한 User 찾아오기(빈 DB에 저장되었으므로 id는 1)
  User foundUser = em.find(User.class, 1L);  
}

생각해보면 당연한 수순이다.

em.flush를 호출할 때, Persistence Context 내의 instance들이 DB에 저장되는 것은 맞지만,

그렇다고 instance들이 Persistence Context 내에서 사라지는 것은 아니라고 했다.

즉, DB와 Persistence Context 내에 동일한 User가 존재하고, 둘 중 어디에서든 같은 데이터를 가져올 수 있는 상황이라는 뜻이다.

그렇다면 Persistence Context에서 User를 가져오는 것과, DB에서 가져오는 것 중 무엇이 더 빠를까.

당연하게도 Persistence Context가 더 빠르다.

같은 서버의 메모리 내에 위치하기 때문이다.

(여기서 메모리는 RAM을 뜻하며, Application이 만드는 instance들은 모두 메모리 상에 위치한다.)

일반적으로 메모리 접근은 대략 100ns이 소요된다.

1ns는 1억 분의 1초니까 1000만 분의 1초 정도로 볼 수 있다.

그러나 DB 접근은 통상적으로 1000분의 1초 정도가 걸린다.

게다가 보통은 DB와 서버가 다른 컴퓨터에 존재하기 때문에 네트워크 요청을 보내야 하고,

이를 감안하면 약 0.5초 정도의 시간이 소요될 수 있다.

그렇다는 것은 단순 비율로 따지면 1000만 : 2이므로, 500만 배의 속도 차이가 날 수 있다는 것이다.

매우 무식하게 비교한 것이고, 환경에 따라 다르겠지만 어림 짐작한 결과가 저 정도라면 보정을 하더라도 상당한 차이가 날 것이다.

그래서 Persistence Context에서 가져올 수 있는 것은 DB를 굳이 거치지 않고 가져오는 것이 훨씬 이득이다.

마치 지역 변수처럼, 대부분의 캐시는 지역성을 지니기 때문에 가까운 곳에 있는 것을 가져오려는 습성이 있다.

CPU 내에 존재하는 L1 Cache와 RAM, Redis와 RDB 모두 비슷한 관계다.

이런 습성을 잘 알고 있는 사람이라면 위 상황은 너무나 당연하게 느껴지겠지만, 그렇지 않다면 잘 기억해두도록 하자.

이제 em.find가 왜 SQL을 Console에 출력하지 않았는지, 이로 인해 어떤 이득이 있는지 잘 이해했을 것이다.

다음은 em.clear에 대해서 알아보겠다.


em.clear

지금까지 User instance라고 불렀는데, 이제 어느 정도 익숙해졌을 것이라는 생각이 들어서,

instance가 아닌 Entity라는 용어를 사용하겠다.

데이터가 Java Application 상에 있을 때는 instance, DB 상에 있을 때는 Record라 구분했었는데,

이를 뭉뚱그려서 Entity라고 부른다.

표현 방식이 다를 뿐, 결국 데이터의 내용은 동일하며, 데이터가 Application 상에 존재하면 Jpa Entity, DB 상에 존재하면 DB Entity라고 부르기도 한다.

결국 우리의 목적은 데이터를 저장하고 찾아오는 작업일 뿐이며,

이런 작업에 집중할 때는 Persistence Context와 DB 중 어디서 가져오고 저장되는지는 신경 쓰지 않을 수 있다.

물론 공부하는 중이므로 신경을 쓰겠지만

Entity라고 부르면 출처를 신경쓰지 않고 좀 더 본질적인 '작업 내용'에 비중을 두고 편하게 말할 수 있겠다는 생각이 든다.

아니면 항상 데이터가 어디에 있는지 신경쓰면서 위치에 따라 instance, record와 같이 용어를 바꿔가며 불러야 할 것이고, 실수로 반대로 부르면 듣는 사람이 잘못 이해할 수도 있을 것이다.

// UserTest.java
@Test  
void test() {  
  User user = new User();  
  em.persist(user);  
  em.flush();
  // Persistence Context 내의 모든 Entity 제거  
  em.clear();

  // 방금 저장한 User Entity 찾아오기(빈 DB에 저장되었으므로 id는 1)
  User foundUser = em.find(User.class, 1L);  
}

em.clear라는 새로운 함수를 호출했다.

이 함수는 Persistence Context 내의 모든 Entity들을 제거한다.

em.flush 까지의 상황부터 다시 한 번 그림으로 살펴보자.

em.flush까지 호출된 상황은 위와 같다.

여기서 em.clear를 호출하면,

Persistence Context 내의 모든 Entity가 깔끔하게 제거된다.

이제 em.find를 호출하면,

캐시 지역성의 원리에 따라 더 가깝고 빠른 Persistence Context를 일단 먼저 뒤진다.

모든 Entity가 제거 되었으므로 당연히 id가 1인 User Entity를 찾을 수 없다.

고로 DB에 요청을 직접 보내서 User Entity를 가져오는 방법밖에는 없다.

작성한 테스트 코드를 실행해보면 Console 맨 아랫 부분에 다음과 같이 select SQL이 출력되는 것을 확인할 수 있다.

Hibernate: 
    select
        u1_0.id,
        u1_0.name 
    from
        "user" u1_0 
    where
        u1_0.id=?

참고로 em.find는 하나의 Entity 밖에 가져올 수 없으며, 여러 개의 Entity를 가져오고 싶다면 다른 방법을 사용해야 한다.

JPQL이라는 것을 사용한다. 다음 장에서 다뤄보겠다.

0개의 댓글