[JPA 시리즈] 1 - 영속성 컨텍스트(1)
들어가기 앞서
그간 JPA 강의를 들으면서 배운 내용들을 까먹기 전에 정리하고자 포스트를 남기게 되었다.
'여태까지 타입스크립트 하던 사람이 웬 갑자기 자바냐?' 할 수도 있는데 이유는 간단하다.
원래 예전(이라지만 그래봐야 대학생 때지만...)부터 주력으로 하던 언어가 자바였고, 기술 스택 한두 가지 정도 더 늘려놓으면 언젠간 쓰지 않겠나 하는 생각에서다.
포스팅은 아마 JPA, Spring Data JPA, QueryDSL 순으로 올라갈 듯하다. 인프런 김영한님 강의 로드맵[링크]을 따라 올라간다고 생각하면 된다.
위 포스트는 인프런 김영한님 강의(자바 ORM 표준 JPA 프로그래밍 - 기본편)의 내용을 포함하고 있음을 밝힙니다.
JPA?
영속성 컨텍스트를 설명하기 전에 JPA가 무얼 하는 물건인지 간단히 알고 넘어가자.
JPA(Java Persistence API)는 자바 진영에서 사용하는 ORM으로, 객체지향 언어로 작성한 객체들을 관계형 데이터베이스와 연결해 주는 역할을 한다. 타입스크립트에서 TypeORM이 비슷한 역할을 한다 생각하면 된다.
자바에서 객체를 DB에 저장하는 방법에는 두 가지가 있다.
하나는 객체에서 속성을 하나하나 나이브하게 가져다 쿼리를 작성하는 것이다. 그런데 그렇게 일일이 가져다 쓰면 나중에는 쿼리 변경 시에 객체를 수정해야 하는, 오히려 객체가 쿼리문에 의존적이게 되는 상황이 벌어진다. 정말 비효율적인 상황이 아닐 수가 없다.
다른 한가지는 자바의 Collection, 객체지향 설계를 활용하는 것이다. 객체를 List에 저장하고 불러올 때에 DB의 외래키처럼 ID를 기반으로 가져오고, 두 객체 간의 관계는 객체 참조로써 연관관계를 가지게 하는 것이다. 앞전의 것보다는 훨씬 나아 보인다. 이렇게 되면 객체는 자유로이 객체 그래프를 탐색할 수 있게 되며, 그렇게 되어야 한다.
그러나 문제는 처음 실행한 쿼리에 따라 탐색 범위가 결정되어버리기도 하고, 조회한 엔티티에 다른 객체의 참조가 없을 수도 있고, 또 모든 객체를 미리 가져올 수 없어 상황 별로 동일한 기능을 하는 조회 메서드가 여러 개 생기는 문제도 생기는 등 오히려 객체 모델링을 할수록 매핑 작업만 늘어나게 된다.
그렇게 객체를 자바의 Collection에 저장하는 것처럼 DB에 저장하는 방법을 해결하기 위해 나온 것이 JPA다.
JPA에 대한 설명은 나중에 따로 포스팅할 예정이니 일단은 넘어가자.
영속성 컨텍스트
JPA에서 중요한 것 두 가지는 객체-관계형 DB를 매핑해 주는 것이고, 또 하나는 오늘 설명할 영속성 컨텍스트이다.
예시로, 우리가 회원가입 같이 백엔드로 데이터 요청을 보내면 백엔드 어플리케이션에서는 EntityManagerFactory를 통해 EntityManager(이하 EM)을 생성하고, 생성된 EM이 트랜잭션 시점에 생성되어 있는 DB 커넥션풀을 사용해서 DB와 통신해 결괏값을 돌려주게 된다.
그러면 영속성 컨텍스트는 무엇일까?
영속성 컨텍스트는 쉽게 말하면 엔티티를 영구 저장하는 환경인데, 우리가 EntityManager.persist(entity)로 객체를 DB에 저장하는 것이 실은 영속성 컨텍스트에 저장을 하게 된다는 것이다.
사실 영속성 컨텍스트는 논리적인 개념으로, EM이 생성될 때 같이 만들어진다. 이렇게 생성된 영속성 컨텍스트는 EM을 통해 접근하고 관리할 수 있게 된다.
엔티티 생명주기
영속성 컨텍스트에는 비영속 / 영속 / 준영속 / 삭제 의 네 가지 엔티티 생명주기가 있다.
- 비영속: 영속성 컨텍스트와는 전혀 관계없는, 쉽게 말해 객체가 한 번도 영속성 컨텍스트에 저장된 적이 없는 상태
- 영속: 객체를 영속성 컨텍스트가 관리하고 있는,
EntityManager.persist(entity)로 객체를 저장한 상태 - 준영속: 객체가 영속성 컨텍스트에 저장되었다가 컨텍스트에서 분리된 상태
- 삭제: 객체가 영속성 컨텍스트와 DB에서 삭제되어 아예 사라진 상태
이렇게 놓고 보면 언뜻 준영속, 비영속 상태가 영속성 컨텍스트에서 관리를 하지 않는 것이 비슷하게 보일 수 있다.
다만, 준영속 상태는 비영속 상태와 달리 객체에 식별자 값을 가지고 있어 다시 영속 상태로 돌아갈 수 있다는 차이점이 있다.
준영속에 대해서는 뒤에 조금 더 설명하기로 한다.
영속성 컨텍스트의 특징
그렇다면 우리는 왜 영속성 컨텍스트를 쓰며, 그 특징은 무엇일까?
기본적인 CRUD를 해보며 알아보자.
일단 영속성 컨텍스트에는 내부에 1차 캐시라는 것을 가지고 있어서 영속 상태의 엔티티는 전부 여기에 저장한다.
이 1차 캐시는 @Id로 매핑한 식별자와 엔티티 인스턴스를 각각 Key와 Value로 하는 Map 구조이다.
// Entity 생성
Member member = new Member();
member.setId("member");
member.setUsername("회원");
// Entity 영속
em.persist(member);
이렇게 엔티티를 영속화하면 엔티티를 1차 캐시에 저장하게 된다. 주의할 점은 1차 캐시에만 저장되었을 뿐 데이터베이스에는 아직 반영이 되지 않았다는 것이다.
이때 1차 캐시는 위에서 말했다시피 Map 구조를 가지고 있다고 했는데, 여기서 Key값은 데이터베이스의 기본키와 연동된다. 다시 말하면 영속성 컨텍스트에서 데이터를 저장하고 불러오는 기준값은 데이터베이스 기본키값이 된다는 소리이다.
먼저 엔티티 조회를 해보자.
Member member = em.find(Member.class, "member");
여기서 find() 메서드를 보게 되면 각각 엔티티 클래스 타입, 엔티티 식별자 값을 넘겨주고 있다.
이렇게 호출을 하게 되면 우선적으로 1차 캐시에서 값을 찾아 반환을 해주게 된다. 만약 없다면 그때는 데이터베이스에 쿼리를 해서 값을 반환한다.
영속 엔티티에는 동일성 보장이라는 특징이 있는데, find()로 동일 식별자 값으로 조회한 엔티티들은 서로 동일한 객체라는 특징이다.
// Entity 생성
Member member = new Member();
member.setId("member");
member.setUsername("회원");
// Entity 영속
em.persist(member);
Member member = em.find(Member.class, "member"); // 1차 캐시에서 반환
Member member_2 = em.find(Member.class, "member2"); // 1차 캐시에 없음 --> DB에서 조회해 영속화 후 반환
이때 주목해야 할 곳은 데이터베이스에서 조회를 해서 값을 반환할 때이다.
데이터베이스에 쿼리를 보내 값을 받아오게 되면 그 값으로 엔티티를 생성해 먼저 1차 캐시에 저장을 하고, 그다음에 저장한 엔티티를 결괏값으로 반환하게 된다.
이렇게 되면 해당 엔티티는 다음 조회 시에 DB가 아닌 메모리 상에서 값을 불러오기 때문에 성능에서 이득을 보게 된다.
이제 저장한 엔티티들을 DB에 저장해 보자.
EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
// EntityManager는 데이터 변경 시 트랜잭션 시작
transaction.begin();
// 1차 캐시에 저장 / DB에 저장되지 않음
em.persist(memberA);
em.persist(memberB);
// 커밋 수행 시 데이터베이스에 INSERT 쿼리 전달
transaction.commit();
EM은 트랜잭션 커밋 직전까지 엔티티들을 1차 캐시에 저장함과 동시에 엔티티들의 Insert 쿼리를 쓰기 지연 SQL 저장소라 불리는 내부 쿼리 저장소에 저장해 두었다가 커밋 시점에 쿼리를 한 번에 보낸다.
이를 우리는 트랜잭션을 지원하는 쓰기 지연(Transactional write-behind)이라고 부른다.
트랜젝션을 커밋하는 시점에 EM은 영속성 컨텍스트를 flush 하게 되는데, 이는 영속성 컨텍스트의 변경사항을 DB와 동기화하는 과정으로, 이 과정에서 우리가 CRUD 한 엔티티들을 반영, 즉 쓰기 지연 SQL 저장소에 저장되어 있는 쿼리들을 DB에 보낸다. 그다음에 데이터베이스에 걸어둔 트랜잭션을 커밋하게 된다.
저장한 엔티티들을 수정할 때에는 어떻게 해야 할까?
고전적인 방법은 직접 수정 쿼리를 치는 것이다. 가장 간단한 방법이긴 하지만 치명적인 단점이 있으니, 경우에 따라 쿼리 개수가 늘어나는 것은 물론이요 비즈니스 로직에 따라 쿼리를 계속 확인해야 하는 등 쿼리에 비즈니스 로직이 따라가는 문제가 생긴다.
하지만 우리는 JPA를 쓰고 있지 않은가.
transaction.begin(); // 트랜잭션 시작
// 영속 Entity 조회
Member member = em.find(Member.class, "member");
// 영속 Entity 수정
member.setUsername("member1");
member.setAge(20);
transaction.commit(); // 트랜잭션 commit
이런 식으로 엔티티를 조회해서 데이터만 변경하면 나머지는 JPA에서 자동으로 처리해 준다.
이렇게 엔티티의 변경사항을 DB에 자동으로 반영해 주는 것을 Dirty checking(변경 감지)라 한다.
변경 감지의 작동 방식을 보자.
JPA에서 엔티티를 영속성 컨텍스트를 저장할 때 1차 캐시에 식별자와 엔티티 인스턴스에 더해서 스냅샷이라 불리는 엔티티의 최초 상태를 복사해 같이 저장한다.
그 뒤에 flush()를 호출하게 되면 1. 1차 캐시에서 엔티티와 스냅샷을 비교해서 변경된 엔티티를 찾고, 2. 찾은 엔티티들의 Update 쿼리를 쓰기 지연 SQL 저장소에 저장한 다음 3. 쓰기 지연 SQL 저장소에 저장되어 있는 쿼리들을 DB에 보내고 난 뒤 4. 데이터베이스 트랜잭션을 커밋한다.
변경 감지의 특징은 1. 영속 상태의 엔티티에만 적용되며, 2. 변경 쿼리 수행 시 엔티티의 모든 필드를 업데이트한다는 것이다.
마지막으로 삭제는 어떻게 이루어지는지 보자.
Member member = em.find(Member.class, "member"); // 삭제 대상 Entity 조회
em.remove(member); // Entity 삭제
위와 같이 삭제할 엔티티를 조회한 후 remove()에 엔티티를 넘겨주면 영속성 컨텍스트에서 제거되며 삭제가 완료된다.
물론 엔티티 저장 과정과 같이 flush 과정을 거친다.
플러시
플러시는 위에서도 보았다시피 영속성 컨텍스트의 변경사항을 DB에 반영하는 것을 말한다.
구체적으로는 아래의 과정을 거친다.
- 변경 감지가 일어나 영속성 컨텍스트 내의 모든 엔티티를 스냅샷과 비교해 수정된 엔티티를 찾고,
- 수정된 엔티티는 Update 쿼리를 만들어 쓰기 지연 SQL 저장소에 저장한 다음,
- 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 보낸다. 이때 쿼리에는 등록, 수정, 삭제 쿼리도 포함되어 있다.
플러시가 일어나는 조건은 아래와 같다.
EntityManager.flush()함수 직접 호출
함수를 직접 호출해서 강제 플러시 하는 방식. 테스트 등의 일부 경우를 제외하고는 거의 사용하지 않는 방식이다.- 트랜잭션 커밋
DB commit 전에 변경사항이 먼저 반영되어야 하기에 플러시를 자동 수행한다. - JPQL 쿼리 실행
JPQL, Criteria 같은 객체지향 쿼리를 실행하는 경우 그 즉시 SQL로 번역하여 실행하게 되므로 플러시를 먼저 수행하게 된다.
em.persist(member_1);
em.persist(member_2);
em.persist(member_3);
query = em.createQuery("select m from Member m", Member.class); // JPQL 실행 --> 실행 전 persist 수행
List<Member> members= query.getResultList(); // 결과: [member_1, member_2, member_3]
또한, EM에 플러시 모드를 설정할 수 있다.
FlushModeType.AUTO: 커밋이나 쿼리를 실행할 때 플러시FlushModeType.COMMIT: 커밋할 때만 플러시
플러시 모드를 별도로 지정하지 않았다면 기본값은 AUTO이다. COMMIT모드의 경우 일부 상황에서 성능 최적화를 위해 사용 가능하나 잘 모르겠다면 AUTO로 놓고 사용하면 된다.
중요한 점은 플러시는 영속성 컨텍스트를 비우는 것이 아닌 변경사항만 DB에 동기화하는 것이다.
또한 플러시의 메커니즘이 온전히 동작 가능한 것은 DB에 트랜잭션이라는 작업 단위가 있어 커밋 직전에만 변경사항을 동기화하면 된다는 점 때문이다.
쓰다 보니 글이 길어져서 준영속에 관한 내용은 다음 글에 이어서 설명하기로 한다.