JPA
JPA는 Java Persistence API로서 Java에서 표준으로 제공하는 추상화된 형식이다.
JPA은 구현체가 아닌 추상화된 내용들이다. java에서 'ORM을 사용할려면 이러한 조건들을 모두 만족해야 해' 라고 가이드를 해준 내용이 JPA이다. 즉 JPA만으로는 ORM을 완전히 사용할 수 없다. 추상화된 JPA 규격을 기반으로 구현한 구현체가 있어야 작동한다. 대표적인 JPA 구현체는 Hibernate, OpenJPA, EclipseLink가 있다.
이처럼 언어가 JPA라는 규격을 정해놓고 이 서비스를 제공할 Service Provider들이 만들어진 규격을 기반으로 구현체를 만든다.
2019년에 Java Persistence는 Jakarta Persistence로 명칭이 변경되었다. 그래서 최신 버전의 hibernate는 package가 javax.persistence 가 아니라 jakarta.persistence로 변경되었다. 구버전을 사용할 시 이 점을 참고 바란다.
JPA와 Spring Data JPA
Spring 진형에는 Spring Data
프로젝트가 있다. 이 프로젝트는 spring의 기술로만 이용하여 데이터 저장소의 접근을 추상화하여 보일러 플레이트 코드를 줄여주고 db access layer역할을 수행하여 변경에 대한 전파를 최소화한다.
이 프로젝트는 목표를 달성하기 위해 repository
를 이용한다. repository
란 spring data에서 entity를 저장, 검색하는 인터페이스이다. 이를 통해 보일러 플레이트 코드를 줄인다. 이유는 repository
인터페이스만 정의해도 Spring Data JPA가 알아서 구현체를 동적으로 만들어주기 때문이다.
spring data jpa는 Spring Data
의 하위 프로젝트이다. 순수 JPA를 직접 다루지 않고 repository
를 이용해 다룸으로서 JPA를 좀 더 쉽게 사용하기 위해 만들어졌다. 직접 JPA를 사용하기 위한 초기 설정들과 구현체 작성 없이도 jpa를 사용할 수 있다. Spring Data JPA에서 사용하는 JPA 구현체는 hibernate이다. 물론 이 구현체는 바꿀 수 있다.
golang을 써본 입장에서 바라본다면 언어가 ORM 표준을 제공하고 서비스 제공자들이 이 표준을 통해 구현체를 만드는 생태계가 신기하다. golang에서의 경험으로는 GORM혹은 XORM중에 원하는 ORM을 사용한다. 이 점에서 바라본다면 언어에서 특정 기술에 대한 표준을 정해 제공하는 것은 특이했다.
Entity Manager 와 Entity Manager Factory
JPA는 Entity Manager 와 Entity Manager Factory를 이용해 db와 연결을 관리한다. 구조는 다음과 같다.
Entity Manager Factory는 말 그대로 Entity Manager를 생성하는 공장이다. 사용자가 Entity Manager를 요청하면 Factory가 만들어서 제공해주는 역할을 한다. 이 객체는 매우 크기 때문에 싱글톤 인스턴스로 만들어서 사용하도록 권장한다.
Entity Manager는 Entity를 관리하는 Manager이다. Persistence Context 를 Manager 내에 두어서 Entity를 관리한다. 관리하는 방식은 Manager는 Persistence Context에 Entity를 저장하는 방식을 이용한다. 이 과정에서 변경이 된 내용을 DB에 적용하는 역할을 수행한다. 이때 Connection Pool(이하 CP)에서 Connection을 가져온다. 즉 Entity Manager는 DB의 연결이 필요한 시점까지 Connection을 획득하지 않고 필요할 때 CP에서 Connection을 획득해서 사용한다.
CP를 이용하면 Connection획득 과정을 줄여준다는 장점이 있다. Connection을 획득하는 과정은 매우 많은 자원을 요구한다. 쿼리를 요청할 때마다 Connection을 획득하고 반환하는 작업을 반복하는 과정은 Connection을 쿼리를 요청한 횟수만큼 할당, 반환하기에 성능에 영향을 줄 수 있다. Connection을 재사용할 수 있지만 Connection Pool이 관리할 수 있는 connection개수가 부족해서 dead lock문제가 발생할 수 있는 문제점이 있다.
Persistence Context(영속성 컨텍스트)
Persistence Context(이하 context)는 Entity를 영구적으로 저장하는 역할을 수행한다.
Entity Manager가 context 저장된다는 의미는 context가 entity를 관리한다는 의미로도 해석할 수 있다. 관리를 위해 entity를 4가지의 상태를 이용해 관리한다.
Entity는 4가지의 상태로 관리되며 상태의 흐름은 다음과 같다.(단 'db'는 상태가 아닌 실제 db를 의미한다.)
entity를 entity manager에 넣어도 즉시 db에 반영되지 않는다. flush를 통해 지금까지 작업한 내용을 중간에 반영하거나 commit을 통해 tx를 종료시키므로서 db에 내용을 반영한다.
이를 설명하기 위해 다음의 코드를 준비한다.
- hibernate: 6.1.17Final
- h2: 1.4.187
- lombok: 1.18.26
// Main.java: 진입점 역할
public class Main {
private static final DbLayer db = new DbLayer();
public static void main(String[] args) {
initAccounts();
execute();
}
private static void initAccounts() {
for (int i = 1; i <= 100; i++) {
db.transientToPersisted(
Account.of(String.format("[%d]name", i), String.format("[%d]mail@mail.com", i))
);
}
}
private static void execute() {
// example code
}
}
// Account.java: JPA의 Entity객체
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.ToString;
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
@Getter
@Entity
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Setter
private String name;
@Setter
private String email;
private Account(String name, String email) {
this.name = name;
this.email = email;
}
public static Account of(String name, String email) {
return new Account(name, email);
}
}
// DbLayer.java: db와 접근하여 데이터를 관리하는 중간 계층 역할
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
public class DbLayer {
private static final EntityManagerFactory emf = Persistence.createEntityManagerFactory(
"unit_name");
private final EntityManager em = emf.createEntityManager();
public void transientToPersisted(Account account) {
em.getTransaction().begin();
em.persist(account);
em.getTransaction().commit();
}
public void persistedToDetached(Account account) {
em.detach(account);
}
public Account detachedToPersisted(Account account) {
return em.merge(account);
}
public void persistedToRemoved(Account account) {
em.getTransaction().begin();
em.remove(account);
em.getTransaction().commit();
}
public Account findById(int id) {
return em.find(Account.class, id);
}
}
new/transient(비영속)
public class Main {
private static final DbLayer db = new DbLayer();
private static void execute() {
// new/transient
Account transientAccount = Account.of("new name", "new_email@mail.com");
}
}
entity manager에 의해 entity가 관리되지 않는 상태이다.
managed(영속)
public class Main {
private static final DbLayer db = new DbLayer();
private static void execute() {
// new/transient
Account transientAccount = Account.of("new name", "new_email@mail.com");
// manage
db.transientToPersisted(transientAccount);
Account managedAccount = db.findById(transientAccount.getId());
}
}
Entity가 Context에 저장(영속)된 상태이다. transient상태에서 managed상태로 변화하면서 jpa가 id를 매핑해준다.
detached(준영속)
public class Main {
private static final DbLayer db = new DbLayer();
private static void execute() {
// new/transient
Account transientAccount = Account.of("new name", "new_email@mail.com");
// manage
db.transientToPersisted(transientAccount);
Account managedAccount = db.findById(transientAccount.getId());
// detach
db.persistedToDetached(managedAccount);
// merge
managedAccount.setName(" update new name");
Account mergeAccount = db.detachedToPersisted(managedAccount);
}
}
Context에 Entity가 저장되었다가 분리된 상태이다. 즉 entity를 관리를 하다가 그만둔 상태이다. 이 의미가 db에 entity가 삭제되었다는 의미는 아니다. db에 entity는 존재하고 entity manager에서만 객체의 관리를 안하는 상태일 뿐이다.
detached 상태로 변화하는 jpa함수는 3가지이다.
- detached(Object object): 객체 1개를 detached 상태로 바꾼다.
- clear(Object object): context를 초기화 한다. 즉 영속된 모든 객체를 detached 상태로 바꾼다.
- close(): context를 종료시킨다. context가 종료되어 객체를 관리하지 않기에 모든 객체가 detached상태로 바뀐다.
detached 상태의 특징은 다음과 같다.
- transient 상태와 거의 같다.
- id값을 가진다.
- lazy loading을 할 수 없다.
detached 상태를 가진 객체는 다시 영속시킬 수 있다. 이를 merge과정이라고 한다. jpa는 새로운 영속된 entity객체를 생성해서 제공한다. 새로 생성된 객체일 뿐이며 detached 상태의 entity를 수정하지 않으면 동일성을 가진다. 단 merge과정은 detached상태, transient 상태를 구분하지 않는다.
removed
public class Main {
private static final DbLayer db = new DbLayer();
private static void execute() {
// new/transient
Account transientAccount = Account.of("new name", "new_email@mail.com");
// manage
db.transientToPersisted(transientAccount);
Account managedAccount = db.findById(transientAccount.getId());
// detach
db.persistedToDetached(managedAccount);
// merge
managedAccount.setName(" update new name");
Account mergeAccount = db.detachedToPersisted(managedAccount);
// delete
db.persistedToRemoved(mergeAccount);
}
}
Context와 db에서 Entity가 제거되어 존재하지 않는다. 삭제된 entity는 비영속 상태로 돌아간다.
Persistence Context특징
까먹었을 까봐 다시 상기시키자면 Entity Manager는 Persistence Context를 가지고 있고 이를 통해 entity를 관리한다. 즉 persistence context는 구성요소를 통해 가지는 특징은 다음과 같다.
- 1차 cache
- 동일성 보장
- tx를 지원하는 쓰기 지연(transactional write-behind)
- 변경 감지(Dirty Check)
- 지연 로딩
1차 cache
Context내부에는 캐시를 가지고 있다. 이를 1차 cache라고 부른다. 모든 entity는 1차 cache에 key-value 형식으로 저장한다. Entity에 정의된 @Id
를 key로 Entity객체를 value로 하여 관리하다. 이 해시맵이 Entity Manager의 Cache역할을 한다.
1차 cache를 이용하여 JPA는 다음의 순서로 entity를 조회한다. 조회 과정은 다음과 같다.
- 1차 cache에 데이터가 존재하면 이를 반환한다.
- db에서 데이터를 찾고 1차 cache에 기록하고 반환한다.
이를 확인하기 위해 코드를 준비했다. 다음의 코드를 보자
private static void execute() {
int id = 1;
Account account = db.findById(id);
System.out.println(account);
}
id가 1인 account를 조회해서 가져오는 내용이다. 이 내용의 수행 결과에 대한 로그는 다음과 같다.
Account(id=1, name=[1]name, email=[1]mail@mail.com)
별도로 select쿼리문을 통해 db의 내용을 조회하지 않았다. 즉 실제 db를 조회한 것이 아닌 내부에 있는 1차 cache를 통해 데이터를 조회하였음을 알 수 있다.
1차 cache를 이용하면 다음의 이점을 얻는다.
- 실제 db에 데이터를 조회하는 요청을 하지 않고 cache hit 만으로 entity를 조회하여 처리 속도를 향상시킬 수 있다.
- 같은 entity manager에서 동일한 조건으로 조회한 entity객체는 동일성을 만족한다.
detached 상태의 entity는 1차 cache에서 어떻게 처리될 될 지 궁금해서 확인해보겠다.
detached 상태의 entity는 transient 상태와 달리 관리되다가 안하는 상태이다. 즉 실제 db에는 entity가 저장되어 있는데 1차 cache에는 저장이 안되어 있다. 이런 경우에는 어떻게 동작하는지 확인해보자.
다음은 entity를 detached상태로 바꾸고 db에서 같은 데이터를 조회하는 코드이다.
private static void execute() {
int id = 1;
Account account = db.findById(id);
db.persistedToDetached(account);
Account newAccount = db.findById(id);
System.out.println("detached account: "+account);
System.out.println("new manged account: " + newAccount);
System.out.println("account, newAccount => "+ (account == newAccount));
for (int i = 0; i < 4; i++) {
Account cachedAccount = db.findById(id);
System.out.println("newAccount, cachedAccount => "+(newAccount==cachedAccount));
}
}
이 코드는 로그는 다음과 같다.
Hibernate:
select
a1_0.id,
a1_0.email,
a1_0.name
from
Account a1_0
where
a1_0.id=?
detached account: Account(id=1, name=[1]name, email=[1]mail@mail.com)
new manged account: Account(id=1, name=[1]name, email=[1]mail@mail.com)
account, newAccount => false
newAccount, cachedAccount => true
newAccount, cachedAccount => true
newAccount, cachedAccount => true
newAccount, cachedAccount => true
이를 통해 3가지 사실을 알 수 있다.
- detach된 entity는 context에서 관리하지 않기에 1차 cache에서 지워버렸다.
- 1차 cache에서 존재하지 않기에 db에서 데이터를 조회했다.
- 조회한 entity는 새로 만들어진 객체이므로 detached된 entity와 동등성을 가지지만 동일성은 가지지 않는다.
- db에서 가져온 entity는 managed 상태이므로 context가 관리하기에 1차 cache에 저장되어 select 쿼리문을 통해 db에 조회하지 않고 cache를 통해 조회한다.
동일성 보장
Context를 통해 같은 조건으로 n번을 검색해서 가져온 n개의 entity는 모두 같다. 이유는 1차 cache때문이다. 1차 cache에서는 entity를 통해 id를 key로, entity객체를 value로 가진다. 즉 같은 조건을 통해 검색한 객체는 새로 만들지 않고 1차 cache에 저장된 entity객체를 조회하므로 동일성을 보장한다.
동일성을 확인해보기 위해 1차 cache를 통해 설명했던 코드의 일부를 가져왔다.
private static void execute() {
int id = 1;
// 생략
Account newAccount = db.findById(id);
// 생략
for (int i = 0; i < 4; i++) {
Account cachedAccount = db.findById(id);
System.out.println("newAccount, cachedAccount => "+(newAccount==cachedAccount));
}
}
이 부분의 로그는 다음과 같다.
newAccount, cachedAccount => true
newAccount, cachedAccount => true
newAccount, cachedAccount => true
newAccount, cachedAccount => true
id=1
이란 같은 조건을 여러번 수행하여 조회한 객체 entity는 동일성을 가짐을 알 수 있다. 또한 이 과정에서 db에 데이터를 조회하는 쿼리문도 작성되지 않았다. 즉 db와의 소통 과정 없이 데이터를 조회하였음을 알 수 있다.
tx를 지원하는 쓰기 지연(transactional write-behind)
db작업중 쓰기 작업에 해당되는 CUD는 즉시 수행하지 않고 tx를 commit이 완료되는 시점에 실제 DB에 작업 내용을 반영한다. 즉 commit 직전까지 쓰기 관련 내용을 캐싱하고 commit이 수행되면 저장된 내용을 실제 db에 적용시킨다.
DB의 쓰기 작업을 tx로 묶어서 한번에 처리하는 작업은 여러번의 Random IO로 수행할 작업을 한번의 Sequential IO로 처리한다는 의미이므로 같은 작업을 tx없이 각각의 쓰기 작업을 수행할 때의 필요한 시간보다 더 빠르게 수행을 완료하여 성능 향상을 할 수 있다.
이 내용을 확인해보기 위해 코드를 작성했다. DbLayer.java에서 transientToPersisted(Account account)
의 코드에서 tx부분을 분리하여 다음의 코드로 변경한다.
// DbLayer.java
public void transientToPersisted(Account account) {
em.persist(account);
}
public void startTx() {
em.getTransaction().begin();
}
public void commitTx(){
em.getTransaction().commit();
}
Main.java의 initAccounts()
를 다음과 같이 변경한다.
private static void initAccounts() {
for (int i = 1; i <= 100; i++) {
db.transientToPersisted(
Account.of(String.format("[%d]name", i), String.format("[%d]mail@mail.com", i))
);
}
}
이 코드를 실행시키면 insert쿼리가 1개도 발생하지 않는다. 왜 그럴까? tx가 생성되지 않았기 때문이다. 그럼 tx를 시작하면 어떻게 될까? 다음의 코드로 바꾼다.
private static void initAccounts() {
db.startTx();
for (int i = 1; i <= 100; i++) {
db.transientToPersisted(
Account.of(String.format("[%d]name", i), String.format("[%d]mail@mail.com", i))
);
}
}
이렇게 바꾸면 insert 쿼리문들이 나온다. 로그만 본다면 각 entity생성에 따라 생성된 것 처럼 보이기에 의문이 생긴다. 이를 확인하는 방법은 실제 db에서 정보를 확인하는 방법이다. 이를 위해 db를 MySQL 8.0.32 버전의 db를 사용하여 확인해본다.
initAccounts()
메소드가 종료한 시점에 BP를 걸고 DB에서 Account의 entity의 개수를 요청하면 다음의 결과를 얻는다.
SELECT count(*) FROM Account;
-- Result: 0
즉 출력된 쿼리문은 로그는 실제 DB에 반영되지 않음을 알 수 있다. 이제 commit을 해보자. commit까지 완료된 코드로 initAccounts()
메소드를 바꾸면 다음과 같다.
private static void initAccounts() {
db.startTx();
for (int i = 1; i <= 100; i++) {
db.transientToPersisted(
Account.of(String.format("[%d]name", i), String.format("[%d]mail@mail.com", i))
);
}
db.commitTx();
}
이 코드를 위에서 사용한 동일한 쿼리문을 이용해 entity 개수를 세면 다음과 같다.
SELECT count(*) FROM Account;
-- Result: 100
100개의 entity개수가 들어간 것을 확인할 수 있다. 즉 tx가 commit이 완료된 후에 db에 내용이 반영되었음을 확인할 수 있다.
변경 감지(Dirty Check)
JPA는 managed상태의 entity 데이터가 변경되면 이를 JPA가 탐지하여 이를 db에 반영한다. 이 과정을 변경 감지(Dirty Check) 라고 불린다.
다음의 코드는 id
가 1인 entity의 name
컬럼의 값을 변경하는 코드이다.
private static void execute() {
Account account = db.findById(1);
db.startTx();
account.setName("update");
db.commitTx();
}
이 코드의 로그는 update 쿼리문이 작성된 것을 확인할 수 있다.
Hibernate:
/* update
unit_name.start.Account */ update Account
set
email=?,
name=?
where
id=?
별도로 update쿼리문을 작성하지 않았지만 JPA에 영속된 account entity의 변화를 감지하여 이를 반영한 것이다.
JPA는 어떻게 변경 감지를 수행할까
변경 감지는 commit되는 시점에서 발생한다. commit이 수행되면 다음의 과정으로 변경 감지 및 cache에 저장된 쿼리들을 적용시키는 작업을 수행한다.
- commit명령 수행 수행시 flush()가 수행된다.
- entity와 snapshot을 비교하여 변경된 entity를 찾는다.
- 변경된 entity에 대한 갱신 쿼리를 만들고 캐싱한다.
- 캐싱된 모든 쿼리문을 DB에 수행시킨다.
- db에 commit완료하여 모든 쿼리문을 적용시킨다.
다음의 코드를 보자
private static void execute() {
Account account = db.findById(1);
db.startTx();
account.setName("update1");
account.setName("update2");
account.setName("update3");
db.commitTx();
System.out.println(db.findById(1));
}
이 코드의 로그는 다음과 같다.
Hibernate:
/* update
unit_name.start.Account */ update Account
set
email=?,
name=?
where
id=?
Account(id=1, name=update3, email=[1]mail@mail.com)
갱신을 3번수행했지만 실제로 작동된 쿼리는 1개이며 갱신된 내용은 commit직전에 수행했던 변경사항 뿐이다. 이는 commit을 수행하기 전에 name이 3번 갱신되었지만 변경 탐지를 수행하지 않고 entity객체를 수정했을 뿐이다. commit이 실행되면서 변경 탐지가 수행되었고 변경된 entity는 1개뿐이므로 update 쿼리를 1번만 수행하였다.
변경 감지 과정에서 flush(), snapshot에 대한 용어가 나온다. 이 용어에 이해가 필요하다고 생각하여 추가로 정리한다.
flush()
flush란 context의 변경 내용을 db에 반영하는 역할을 수행하는 메소드이다. buffer 처럼 작업한 내용을 밀어넣어서 데이터를 지우는 것이 아닌 db와의 동기와 역할을 수행하기에 내용을 지우진 않는다. 이 역할은 다음의 상황에서 수행한다.
- 개발자가 직접 호출시킨다.
- tx가 commit될 때 호출된다.
- JPQL 쿼리 수행
2번과 3번의 경우에는 FlushModeType에 따라 다르다.
- FlushModeType.AUTO(default): tx commit과 JPQL을 실행할 때 commit을 수행한다.
- FlushModeType.Commit: JPQL 쿼리를 실행할 flush를 수행한다.
FlushModeType들은 구현체에서 추가로 작성해서 기능을 제공할 수 있다. hibernate는 2개의 type을 추가로 제공한다.
- FlushModeType.ALWAYS: 변경이 발생하는 즉시 수행한다.
- FlushModeType.MANUAL: 자동으로 flush하는 모든 기능을 멈춘다.
snapshot
context에서의 snapshot는 entity가 영속되는 시점에 값을 복제한 객체이다. entity를 수정하더라도 snapshot은 변경되지 않기에 entity와 snapshot를 비교하여 변경을 감지할 수 있다.
지연 로딩(Lazy Loading)
Lazy Loading은 연관관계를 가진 entity를 사용할 때 모든 entity를 가져오는 것이 아닌 사용할 때 까지 로딩을 지연시키는 기법이다.
이 기법의 장점은 성능 향상이다. 연관된 entity를 사용하지 않는 상황에서도 entity를 로딩해서 가져오는 행위는 불필요한 작업이다. 이 불필요한 작업을 줄여 성능 향상을 도모한다.
Reference
'Spring' 카테고리의 다른 글
[Spring] Bean 객체 (0) | 2023.02.08 |
---|---|
[Test] @DataJpaTest 와 @SpringBootTest (0) | 2023.02.01 |
[Spring] IoC와 DI (0) | 2023.01.26 |