Spring Data JDBC

Spring Data JDBC에 대해 알아보자

PI.314 2022. 12. 13. 23:07

이번에 팀에서 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 – 연관 관계

  1. OneToOne 연관관계 매핑 -> @Column 사용
  2. 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 실행이 분기 된다.

  1. Persistable 인터페이스 구현
  2. BeforeSaveEvent / BeforeSaveCallback 등록
  3. @Version 사용
  4. 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

 
WritingContext
  • 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 도 동일한 문제 )

해결 방안

  1. Optimistic Locking (2.0.0.RC2)
  2. 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 는 비효율적으로 동작한다.

  1. 복잡한 연관관계 설계를 지양한다.
  2. 일부 속성 변경에는 @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

RelationalEntityDeleteWriter
// 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번 실행