이번에 팀에서 ORM 도입을 고려하고 있었는데, 구성원 대부분이 JPA 사용 경험이 없어 러닝커브가 우려 되었고 대안으로 사용할 수 있는 Spring Data JDBC에 대해 스터디를 진행했다.
심플한 컨셉을 가진 Spring Data JDBC에 대해 알아보자.
[샘플코드]
Spring Data JDBC
It aims at being conceptually easy.
This makes Spring Data JDBC a simple, limited, opinionated ORM.
- O/R Mapping
- CrudRepository
- Aggregates, References
- No EntityManager (PersistenceContext)
- No Caching
- No Lazy Loading
- No Dirty Tracking
- No Proxy
1. Object Relation Mapping
O / R Mapping - Annotation
- @Table : 매핑 테이블
- @Id : PK 컬럼 매핑
- @Version : Version 컬럼 매핑 (Optimistic Locking)
- @Column : 컬럼 매핑, OneToOne 관계에는 FK 컬럼 매핑
- @Embedded : Embedded 객체 속성 컬럼 매핑
- @MappedCollection : OneToMany 관계 매핑
- idColumn: FK 컬럼 매핑 (default column name: “변수명”)
- keyColumn: OrderBy 컬럼 매핑 (default column name: “변수명_KEY”)
- @PersistenceConstructor : 조회 결과 객체 복원 사용 생성자
- 생성자가 한개만 존재한다면, 선언하지 않아도 된다.
- @ReadOnlyProperty : 읽기 전용 컬럼
- @Transient : 컬럼 매핑 하지 않는다.
- @PersistenceConstructor 생성자 속성에 포함되면 안된다.
- @AccessType : 매핑 대상을 FIELD / PROPERTY 로 지정
참고
- Value Object 지원 (Immutable)
- NamingStrategy 지원
- 상속 / 다형성 매핑 미지원
- 복합키 매핑 미지원
- AttributeConverter 미지원
O / R Mapping – 연관 관계
Spring Data JDBC 는 Entity 설계시 Aggregate 개념 적용을 강하게 주장합니다.
- AggregateRoot 에 하나의 Repository 구성
- 하나의 집합체(Aggregate) 는 하나의 Repository 를 통해서 영속성을 관리합니다.
- OneToOne, OneToMany 매핑 지원
- FK 는 연결 타겟 엔티티의 테이블에서 관리합니다.
- OneToMany 매핑시 Set Collection 타입이라면, @MappedCollection 의 keyColumn 은 매핑 되지 않습니다.
- 연관 관계 클래스에 @Id 는 선언하지 않아도 된다. (Table 에 Column 만 존재하면 된다.)
- ManyToOne, ManyToMany 미지원
- 집합체(Aggregate) 의 개념에 위배되므로 향후에도 지원하지 않을 것으로 보인다.
- 양방향 연관관계 매핑 금지
- Aggregate 간의 관계는 Reference Id 로 관리합니다.
- AggregateReference 로 타입 지원 가능
- Spring Data JDBC, References, and Aggregates
O / R Mapping – 연관 관계
- OneToOne 연관관계 매핑 -> @Column 사용
- OneToMany 연관관계 매핑 -> @MappedCollection 사용
@Builder
@Getter
@EqualsAndHashCode(of = "id")
@ToString
public class Question {
@Id
private Long id;
@PositiveOrZero
@Version
private long version;
private AggregateReference<Channel, @NotBlank @Size(max = 200) Long> channelId;
@NotNull
private Status status;
@NotBlank
@Size(max = 200)
private String title;
@NotBlank
@Size(max = 200)
private String content;
private AggregateReference<User, @NotNull String> createdBy;
@Valid
@MappedCollection(idColumn = "ID", keyColumn = "ID")
@Builder.Default
private List<Answer> answers = new ArrayList<>();
@NotNull
@PastOrPresent
@Builder.Default
private Instant createdAt = Instant.now();
}
O / R Mapping – Embedded
- @Embedded.Nullable : 객체 속성에 매핑되는 Column 값이 모두 존재하지 않으면, NULL 로 복원
- @Embedded.Empty : 객체 속성에 매핑되는 Column 값이 모두 존재하지 않으면, 빈 객체로 복원
Embedded 클래스 설계 및 설정 전략
- 가능하면 Value Object(불변 객체)로 설계합니다.
- 모든 속성 컬럼이 NULLABLE 하다면, NoArgsConstructor 를 제공하고 @Embedded.Empty 를 선언합니다.
- NOT-NULL 컬럼이 존재한다면, AllArgsConstructor 를 제공하고, @Embedded.Nullable 을 선언합니다.
2. Create
Create - CrudRepository
@NoRepositoryBean
public interface CrudRepository<T, ID> extends Repository<T, ID> {
<S extends T> S save(S entity);
<S extends T> Iterable<S> saveAll(Iterable<S> entities);
Optional<T> findById(ID id);
boolean existsById(ID id);
Iterable<T> findAll();
Iterable<T> findAllById(Iterable<ID> ids);
long count();
void deleteById(ID id);
void delete(T entity);
void deleteAllById(Iterable<? extends ID> ids);
void deleteAll(Iterable<? extends T> entities);
void deleteAll();
}
ID 전략 (선택)
JPA 의 @GeneratedValue 가 없기 때문에, @Id 속성 값 존재 여부로 INSERT / UPDATE 실행이 분기 된다.
- Persistable 인터페이스 구현
- BeforeSaveEvent / BeforeSaveCallback 등록
- @Version 사용
- Insert 지원 CustomRepository 구현
Create – Persistable 인터페이스 구현 예제
@Getter
@Table("USER")
public class User implements Persistable<String> {
@Id
private final String userId;
@Transient
@Builder.Default
private Boolean isNew = true;
@Builder
public User(String userId) {
this.userId = userId;
}
@Override
public String getId() {
return userId;
}
@Override
public boolean isNew() {
return isNew;
}
public static class UserAfterSaveCallback implements AfterSaveCallback<User>, AfterLoadCallback<User> {
@NotNull
@Override
public User.Entity onAfterSave(User aggregate) {
aggregate.isNew = false;
return aggregate;
}
@NotNull
@Override
public User.Entity onAfterLoad(User aggregate) {
aggregate.isNew = false;
return aggregate;
}
}
}
@Configuration
@EnableJdbcAuditing
public class JdbcConfig {
@Bean
User.UserAfterSaveCallback userAfterSaveCallback() {
return new User.Entity.UserAfterSaveCallback();
}
}
- Persistable 의 “isNew” 메소드 반환 값을 제어해서 INSERT / UPDATE 분기할 수 있다.
- Entity 가 INSERT / LOAD 된 후에 “isNew” 메소드 반환 값이 “false” 가 되게 제어한다.
- AfterSaveEvent 또는 AfterSaveCallback 을 등록해서 INSERT 후 값 변경 가능
- @PersistenceConstructor 또는 AfterLoadEvent 를 등록해서 조회 후 값 변경 가능
Create – @Version 사용 예제
@Builder
@Getter
@EqualsAndHashCode(of = "id")
@Table("TB_BAS_USER")
public class User {
@Id
@Size(max = 100)
private String id;
@PositiveOrZero
@Version
private long version;
@NotBlank
@Size(max = 100)
private String name;
}
- @Version 을 사용하면, Optimistic Locking 을 위해 Aggregate 의 Versioning 을 한다.
- @Version 값이 “0” 또는 NULL 이라면, @Id 값이 존재해도, save 실행 시 INSERT 로 동작한다.
Create – 연관관계 CASCADE
- AggregateRoot 로 부터 연관 관계를 가지는 엔티티들 역시 영속성 LifeCycle 을 Aggregate 와 함께 한다.
- OneToOne / OneToMany 구분 없이 동일한 순서로 INSERT 를 실행한다.
- NESTED 연관관계 역시 연쇄적으로 발생한다.
- 1. INSERT ROOT
- 2. INSERT ALL REFERENCED
- Referenced 테이블은 ROOT 의 PK 를 FK 로 가지고 INSERT 를 실행한다.
3. Update
Update – 연관관계 CASCADE
// parent
UPDATE "QUESTION" SET "VERSION" = ?, "STATUS" = ?, "TITLE" = ?, "CONTENT" = ?, "CREATED_AT" = ?, "CREATED_BY" = ?, "CHANNEL_ID" = ? WHERE "QUESTION"."ID" = ? AND "QUESTION"."VERSION" = ?
// child
DELETE FROM "TAG" WHERE "TAG"."QUESTION_ID" = ?
INSERT INTO "TAG" ("QUESTION_ID", "ID", "NAME") VALUES (?, ?, ?)
- Query 실행 순서
- 1. UPDATE ROOT
- 2. DELETE ALL REFERENCED
- 3. INSERT ALL REFERENCED
- 연관관계를 가지는 테이블 데이터는 모두 삭제 후 현재 상태로 다시 INSERT 한다.
- JPA 의 @ElementCollection 과 비슷하게 동작한다.
- 연관 관계 Entity 와 매핑된 Table 의 PK 가 AUTO_INCREMENT 라면, UPDATE 가 실행될 때마다 @Id 값이 변경될 수 있으므로 유의해야 한다. (@Id 는 Mapping 하지 않고 Value Object 로 관리하는게 안전할 수 있다.)
Update – 동시 수정 데이터 유실 가능성
UPDATE 는 현재 Aggregate 상태를 그대로 저장하기 때문에 데이터에 동시에 접근한 후 UPDATE 하면, 일부 데이터의 유실이 발생할 수 있다. ( JPA 도 동일한 문제 )
해결 방안
- Optimistic Locking (2.0.0.RC2)
- Pessimistic Locking
1. Update – Optimistic Locking (2.0.0.RC2)
@Version 을 지정하면 UPDATE 구문의 WHERE 에서 VERSION Matching 을 한 후 결과를 체크한다.
AFFECTED ROW 가 0 이라면, 다른 UPDATE 가 발생한 것이므로 “OptimisticLockingFailureException” 을 던져서 동시 변경으로 인한 데이터 유실을 방어한다.
2. Update – Pessimistic Locking
Update 를 수행하기 전에 “FOR UPDATE” 구문으로 조회하면, Pessimistic Lock 을 걸 수 있다. (Dialect 마다 구문의 차이가 있다.)
동일한 데이터(ROW)에 Pessimistic Lock 을 잡은 Transaction 이 Commit 되야 LOCK 을 획득하고 데이터를 조회할 수 있기 때문에
동시 데이터 Update 로 인한 데이터 유실을 방어할 수 있다.
Update – 비효율
Aggregate 개념적으로는 집합체 전체를 Merge 하는 동작이 어울린다.
하지만, 연관 관계가 복잡한 Aggregate 의 UPDATE 는 비효율적으로 동작한다.
- 복잡한 연관관계 설계를 지양한다.
- 일부 속성 변경에는 @Modifying 을 사용할 수 있다.
@Modifying
@Query("UPDATE QUESTION SET VERSION = VERSION + 1, STATUS = :status WHERE ID = :id")
boolean changeStatus(@Param("id") String Long, @Param("status") Status status);
- @Version 으로 Optimistic Locking 을 관리하는 Aggregate 라면, UPDATE VERSION 증가를 반영한다.
- 반환 값 (boolean / int) 을 확인해서 UPDATE 실행 결과를 관리한다.
4. Delete
Delete – 연관관계 CASCADE
// parent
DELETE FROM "CONTENT_LIKE" WHERE "CONTENT_LIKE"."QUESTION_ID" = ?
// child
DELETE FROM "TAG" WHERE "TAG"."QUESTION_ID" = ?
- Query 실행 순서
- 1. DELETE ALL REFERENCED
- 2. DELETE ROOT
- @Modifying 으로 ROOT 를 DELETE 할 때는 REFERENCED 를 먼저 삭제해야 한다.
- REFERENCED 를 먼저 삭제할 때 ROOT 에 LOCK 을 걸지 않으면 DEAD LOCK 이 발생할 수 있다.
- REFERENCED 테이블과 FK 설정을 하지 않으면 ROOT 를 먼저 지울 수 있다.
- 또는 REFERENCED 를 지우지 않고 고아(ORPHAN)으로 남겨둘 수도 있다.
Delete – SOFT DELETE
Hibernate 에서는 @SQLDelete 를 사용해서 Delete 실행 시 삭제 판별 Column 을 UPDATE 하는 SOFT DELETE 를 구현할 수 있다.
Spring Data JDBC 에서는 CrudRepository 의 delete operation 을 Override 해서 SOFT DELETE 를 구현할 수 있다.
5. Read
Read – findById
Aggregate 전체를 Load 한다.
// parent
SELECT "QUESTION"."ID" AS "ID", "QUESTION"."TITLE" AS "TITLE", "QUESTION"."STATUS" AS "STATUS", "QUESTION"."CONTENT" AS "CONTENT", "QUESTION"."VERSION" AS "VERSION", "QUESTION"."CHANNEL_ID" AS "CHANNEL_ID", "QUESTION"."CREATED_BY" AS "CREATED_BY", "QUESTION"."CREATED_AT" AS "CREATED_AT", "like"."ID" AS "LIKE_ID", "like"."COUNT" AS "LIKE_COUNT"
FROM "QUESTION"
LEFT OUTER JOIN "CONTENT_LIKE" "like" ON "like"."QUESTION_ID" = "QUESTION"."ID"
WHERE "QUESTION"."ID" = ?
// child
SELECT "TAG"."ID" AS "ID", "TAG"."NAME" AS "NAME"
FROM "TAG"
WHERE "TAG"."QUESTION_ID" = ?
- OneToOne 관계는 JOIN SELECT 실행
- OneToMany 관계는 추가 SELECT 실행 (JPA 의 FetchType.EAGER 와 유사)
Read – findAll ( N + 1 Fetch )
- 추가 SELECT N번 실행