NextStep에서 JPA 만들기를 진행하면서 고민했던 부분들과 설계들에 관한 고민들을 정리해봤습니다. 작성하기에 앞서 JPA, Hibernate의 구조를 참고해서 만들기는 했지만 실제 설계와는 다를 수 있습니다. 실제 코드에서는 개발된 기간이 길어서인지(?) 제가 느낄 때는 이해하기 어려운 구조들이 있었습니다. 강의 또한 JPA를 그대로 만들기 보다는 대략 어떻게 JPA 구현체들이 만들어지는지, 내부에서 어떤 일을 하고 있는지를 잘 학습할 수 있도록 수정한 내용들이 있었습니다.
김영한님 강의를 통해서 JPA를 처음 배웠고, 실무에서 계속 사용하고 있었기 때문에 익숙한 기술이었습니다. 하지만 매번 연관관계를 맺거나, 사용할 때 까먹어서 다시 학습을 해야하는 경우가 많았습니다. 이번에 만들기를 진행하면서 개념을 한번 다시 정리하고 직접 구현에 대해서 고민해보면서 까먹지 않을 것 같습니다.
이름에서 볼 수 있는 것 처럼 API 명세입니다. Java에서 ORM을 구현하기 위한 인터페이스입니다. 객체와 관계형 데이터 베이스 간의 매핑과 관련된 작업을 수행하기 위한 공통 인터페이스를 제공합니다. 객체 지향적인 애플리케이션의 도메인 모델과 데이터베이스의 레코드 간의 간극을 맞춰주고 중재하는 중개자 역할을 수행합니다.
package jakarta.persistence;
public interface EntityManager extends AutoCloseable {
void persist(Object var1);
<T> T merge(T var1);
void remove(Object var1);
<T> T find(Class<T> var1, Object var2);
...
}
대표적으로 JPA에서 작성된 EntityManager의 인터페이스입니다.
Hibernate는 JPA에서 제공해주는 명세를 기반으로 만든 구현체입니다. JPA Provider로 분류됩니다. 공식 홈페이지에서도 ORM과 구현체임을 최상단에서 소개하고 있습니다.
그런데 구현하면서 느낀건데 Hibernate에서 사용하는 이름과 JPA에서 제공하는 이름의 조금 차이가 있습니다. 그래서 구현을 하고나서 살펴볼 때 헷갈린 부분들이 있었습니다. 대표적으로 EntityManager를 Hibernate에서는 Session이라는 이름을 사용하고 있습니다.
JPA를 사용해서 Entity를 가져오는 구조는 대략적으로 아래와 같은 다이어그램으로 만들어지게 됩니다.
Entity 는 JPA를 사용하여 데이터 베이스의 레코드와 매핑할 엔티티 클래스입니다. 엔티티 안에는 데이터베이스 테이블,매핑되는 필드와 연관관계를 포함하고 있습니다.
EntityManager 는 JPA의 핵심 구성요소로, Entity와 데이터베이스 간의 상호 작용을 관리합니다. Persistence Context를 관리하여 엔티티의 생명주기를 관리하고 데이터베이스와의 CRUD 작업을 진행합니다.
Persistence 는 JPA를 구성하고 사용하기 위한 설정 정보를 포함합니다. 데이터베이스와의 연결 정보, 엔티티 클래스와 데이터베이스 테이블 간의 매핑 정보 등을 설정합니다.
JPA Providers 는 JPA의 구현체를 말합니다. 데이터베이스와의 통신, 쿼리 처리, 트랜잭션 관리등을 진행합니다. 위에서 설명한 Hibernate도 구현체 중 하나입니다. 그 밖에 EclipseLink, Apache OpenJPA 등이 있습니다.
Database 는 말 그대로 데이터 베이스입니다. 실제 데이터를 가지고 있고 애플리케이션은 JPA를 통해서 데이터 베이스와 상호작용합니다. CRUD 작업이 이뤄집니다.
EntityManager는 Query 라는 객체를 만들어서 Database와 상호 작용을 수행합니다.
Hibernate 내부에서는 hql을 사용해서 yml에 저장된 쿼리들을 이용합니다. 하지만 직접 구현했을 때는 String 값들을 이용해서 쿼리를 만들어내는 방식을 이용했습니다.
아래는 대상이 될 Entity 입니다. 사용되는 애노테이션은 전부 jakarata 패키지, 즉 JPA에서 제공하는 애노테이션입니다.
@Table(name = "users")
@Entity
public class Person {
public Person() {
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "nick_name")
private String name;
@Column(name = "old")
private Integer age;
@Column(nullable = false)
private String email;
@Transient
private Integer index;
}
생각보다 쿼리를 만들 때 고려해야하는 사항들이 많았습니다.
DDL 쿼리와 DML 쿼리를 나눠서 구현을 진행하였는데 @Column
내부에 주어진 옵션에 따라서 추가로 작성해야하는 쿼리가 있었습니다.
CREATE TABLE users
(id BIGINT AUTO_INCREMENT PRIMARY KEY,
nick_name VARCHAR,
old INTEGER,
email VARCHAR NOT NULL)
테이블 이름부터 만약 @Table
애노테이션이 존재한다면 이름을 변경해줘야했고,
컬럼도 마찬가지로 이름에 대한 변경사항이 생깁니다. 그 이후로 컬럼의 타입까지는 공통된 입력값이지만 경우에 따라서 조건이 생깁니다.
Transient인 값들은 컬럼에서 제외되어야 합니다.
Nullable이 false 이면 NOT NULL
이라는 조건이 생겨야 하고,
Id 일 경우 PRIMARY KEY
그리고 생성 전략에 따라서 조건이 발생하게 됩니다.
추가로 발생해야 하는 쿼리의 경우 저는 전략 패턴을 사용해서 해결했습니다.
public interface AdditionalColumQueryStrategy {
boolean isRequired(Field field);
String fetchQueryPart();
}
그래서 Create 쿼리를 만들 때 만약 해당 필드에 추가 쿼리가 isRequired 상태면 List로 가지고 있다가 한번에 fetch 해서 더해주는 방식을 선택했습니다.
JPA에서는 쿼리를 작성하고 실행하는 다양한 방법을 제공하는데 Criteria API가 일반적으로 사용됩니다. 경우에 따라서 JPQL, Native SQL Query를 이용해서 쿼리를 작성할 수 있습니다.
애초에 Criteria API는 동적으로 객체 지향적인 방식으로 쿼리를 작성할 수 있도록 되어있습니다. 코드 기반으로 쿼리를 작성하기 때문에 런타임시 에러를 잡을 수 있습니다.
EntityManager는 엔티티의 생명주기와 데이터 베이스 와의 동기화를 담당합니다. PersiteceContext를 통해서 엔티티의 생명주기, 1차 캐시, 스냅샷을 저장합니다. 추가로 EntityLoader, EntityPersister를 이용해서 CRUD 작업을 진행합니다.
우선 EntityLoader, EntityPersister를 구현하여 기존에 작업했던 쿼리 빌더에서 쿼리를 호출하는 작업들의 책임을 위임시켜줬습니다. 그 이후로 EntityManager에서 Entity의 생명 주기를 담당하는 기능을 구현해봤습니다.
내부 구현의 영역이기 때문에 hibernate에서 제공하는 인터페이스를 이용해서 구현해봤습니다.
public interface PersistenceContext {
Optional<Object> getEntity(Class<?> clazz, Object id);
void addEntity(Object entity);
void removeEntity(Object entity);
Object getDatabaseSnapshot(Object entity);
Object getCachedDatabaseSnapshot(Object entity);
void addEntityEntry(Object entity, EntityStatus entityStatus);
List<Object> getDirtyEntities();
}
PersistencContext에서는 1차 캐시, SnapShot, 그리고 EntityEntry를 관리해야 합니다.
1차 캐시는 EntityManager가 Entity를 로드할 때 조회할 수 있습니다. 그리고 Persist, Remove할 때 필요에 맞게 추가,수정,삭제가 이뤄져야 합니다. 그러면 애플리케이션에서 영속성 컨텍스트가 관리되는 동안에는 DB를 조회하는 것이 아니라 1차 캐시의 값들을 확인합니다.
SnapShot은 DB에서 조회해왔을 때의 데이터를 가지고 있습니다. 조금 더 정확하게 표현하자면 DB와 동기화된 데이터를 가지고 있습니다. DirtyCheck를 할 때 해당 Snapshot에 있는 값들을 기준으로 진행해서 추가 쿼리를 보내게됩니다.
EntityEntry Entity의 영속성 상태를 관리합니다. CRUD가 일어나는 과정 중에 지속적으로 변합니다. 그 밖에 EntityEntry의 상태도 존재합니다. 현재 PersistenceContext에서 관리되어지고 있는지를 확인합니다.
public enum Status {
MANAGED,
READ_ONLY,
DELETED,
GONE,
LOADING,
SAVING
}
제가 조금 헷갈렸던 부분인데 새로운 Entity를 어떻게 판별할까의 문제였습니다. 이는 근데 Spring Data JPA 영역에서는 id가 null 값이면 새로운 Entity라고 간주합니다. 이를 통해서 EntityManager에서 persist를 호출할 지, Merge를 호출할지 결정을 합니다. 내부에서는 영속화된 상태가 아니면 무조건 Save를 보내려고 할 것입니다.
아래는 제가 구현한 PersistencContext의 일부 입니다.
public class MyPersistenceContext implements PersistenceContext {
private final Map<EntityKey, Object> entities = new HashMap<>();
private final Map<EntityKey, EntitySnapshot> snapshots = new HashMap<>();
private final Map<Object, EntityEntry> entries = new HashMap<>();
@Override
public Optional<Object> getEntity(Class<?> clazz, Object id) {
EntityKey entityKey = new EntityKey(id, clazz);
return Optional.ofNullable(entities.get(entityKey));
}
@Override
public void addEntity(Object entity) {
EntityMeta entityMeta = EntityMeta.from(entity);
entries.put(entity, new EntityEntry(EntityStatus.MANAGED));
entities.put(entityMeta.getEntityKey(entity), entity);
}
...
EntityKey 라는 객체를 새로 만들어서 ID 값과 클래스의 타입을 통해서 구분할 수 있도록 구현해줬습니다. Entries의 경우 객체마다 상태가 부여되기 때문에 객체 자체를 키로 받아서 조회할 수 있습니다.
EntityManager는 이렇게 PersistenceContext를 이용해서 영속성 관리를 할 수 있게 됩니다.
2주동안 Query, Entity를 만들면서 고민했던 부분들을 정리해서 올립니다.
정말 많이 바빴는데 오랜만에 너무 재미있는 개발 요소를 만나서 행복했습니다.
JPA는 항상 학습을 하고 직접 사용해보더라도 모호한 부분들이 있었습니다. 특히 연관관계를 맺을 때 발생하는 문제들에 대해서 고민할 때 더 직관적으로 이해할 수 있을 것 같습니다.
남은 2주 과정에서는 연관관계와 Metadata, Event를 구현해보는 과정을 진행할 예정인데 벌써 기대가 됩니다.