[Spring Data JDBC] Id 삽입 전략

2020. 7. 3. 19:42Java

글을 작성하다 보니, 이명현님의 우아한 테크세미나의 한 부분을 정리한 글이 되었다. Spring Data JDBC에 관심이 있다면 한번쯤 꼭 영상을 시청하는 것을 추천한다.

 

Spring Data jdbc를 사용하다보면 id를 insert(populate, generate)하는 방식을 커스텀하게 가져가고 싶을 때가 있다. DB의 auto-increment 기능을 사용할 수도 있고, 직접 만든 UUID를 넣어주고 싶을 수도 있다. 또, 외부에서 받아온 id를 사용하고 싶을 수도 있다. 각각 방식에 대해서 어떻게 하는지 알아보자.

Auto Increment

DB의 필드에 auto increment를 걸어두고 사용하는 전략이다. 이 경우, 특별히 다른 기술이 필요하지 않다. Spring Data JDBC는 save메서드를 실행할 때, id 필드가 null이면 Insert를 실행하고 null이 아니면 Update를 실행한다.
따라서, Id 필드를 null로 초기화하고 repository.save() 메서드를 통하면 id가 생성된다. 아래 예제 코드로 확인할 수 있다.

// User.java
public class User {
    @Id
    private final Long id;
    private final String name;

    public static User of(Long id, String name) {
        return new User(id, name);
    }
    ...
}

// UserTest.java
@Test
void idAutoGenerate() {
    User user = User.of(null, "효혁");
    User persistedUser = userRepository.save(user);

    assertThat(user.getId()).isNull();
    assertThat(persistedUser.getId()).isNotNull();
}
# schema.sql
create table if not exists user
(
    id   bigint primary key auto_increment,
    name varchar(50)
);

persistedUser의 id가 null이 아님을 확인할 수 있다.

Persistable 인터페이스 활용

유저 정보를 외부로부터 받는 경우, 이미 id 값이 정해져있는 경우가 있다. 위 테스트 코드를 살짝 수정해보자.

// UserTest.java
@Test
void notNullIdInsert() {
    User user = User.of(1L, "효혁");
    User persistedUser = userRepository.save(user);

    assertThat(persistedUser.getId()).isEqualTo(1L);
}

위 코드를 실행하면 다음과 같은 예외를 뿜는다. (Spring Data JDBC의 버전이 낮으면 테스트를 통과할 수도 있다)

예외 로그를 보면 'Failed to update'라고 나온다(기대한 결과는 insert). 이 때, 업데이트 하려는 id가 DB에는 존재하지 않기 때문에 예외가 발생하는 것이다.
우리가 하고 싶은 것은 id 필드가 null이 아니지만 Insert 쿼리가 수행되는 것이다. 이를 위해 Persistable 인터페이스를 활용할 수 있다.

// User.java
public class User implements Persistable<Long> {
    ...

    @Transient
    private boolean isNew = false;

    ...

    public static User newUser(Long id, String name) {
        User user = new User(id, name);
        user.isNew = true;
        return user;
    }

    @Override
    public boolean isNew() {
        return isNew;
    } 
}

// UserTest.java
@Test
void notNullIdInsert() {
    User user = User.newUser(1L, "효혁");
    User persistedUser = userRepository.save(user);

    assertThat(persistedUser.getId()).isEqualTo(1L);
}
# schema.sql
create table if not exists user
(
    id   bigint primary key,
    name varchar(50)
);

만약 엔티티가 Persistable 인터페이스의 구현체라면, Spring Data JDBC는 Insert 또는 Update 쿼리 중 하나를 고를 때 인터페이스의 메서드인 isNew를 확인한다. 이 값이 true면 Insert, false면 Update 쿼리를 수행한다.
이제 기존 코드를 그대로 둔 채 위 코드를 추가하고, DDL에서 auto_increment를 빼주고 테스트를 다시 돌리면 성공한다.

Version 어노테이션 활용

@Version을 활용하면 Persistable 인터페이스를 구현하는 것과 동일한 결과를 좀 더 깔끔하게 얻을 수 있다. @Version이 붙은 필드의 값이 0 또는 null일 경우, id 값이 존재하더라도 insert 쿼리가 실행된다. 따라서 User 클래스를 아래와 같이 수정할 수 있다.

// User.java
public class User {
    @Id
    private final Long id;

    private final String name;

    @Version
    private Long version;

    public User(Long id, String name) {
        this.id = id;
        this.name = name;
    }
    ...
}

// UserTest.java
@Test
void notNullIdInsert() {
    User user = new User(1L, "효혁");
    User persistedUser = userRepository.save(user);

    assertAll(
        () -> assertThat(persistedUser.getId()).isEqualTo(1L),
        () -> assertThat(persistedUser.getVersion()).isEqualTo(1L)
    );
}
# schema.sql
create table if not exists user
(
    id       bigint primary key,
    name     varchar(50),
    version bigint
);

다만 원래 @Version의 용도는 Optimistic Locking을 위해 사용되는 것이니만큼 성능적인 부담이 있을 수 있다. 또한 테이블에 필드도 추가해야되기 때문에 이를 충분히 고려 후에 사용해야한다.

CrudRepository의 Insert 메서드 직접 구현

이번 방법은 CrudRepositorysave 메서드 대신 커스텀한 insert 메서드를 사용하는 방법이다.

// WithInsert.java
public interface WithInsert<T> {
    JdbcAggregateOperations getJdbcAggregateOperations();

    @Transactional
    default T insert(T instance) {
        return getJdbcAggregateOperations().insert(instance);
    }
}

// WithInsertImpl.java
public class WithInsertImpl<T> implements WithInsert<T> {
    private final JdbcAggregateOperations jdbcAggregateOperations;

    public WithInsertImpl(JdbcAggregateOperations jdbcAggregateOperations) {
        this.jdbcAggregateOperations = jdbcAggregateOperations;
    }

    @Override
    public JdbcAggregateOperations getJdbcAggregateOperations() {
        return jdbcAggregateOperations;
    }
}

// UserRepository.java
public interface UserRepository extends CrudRepository<User, Long>, WithInsert<User> {
}

// UserTest.java
@Test
void notNullIdInsert() {
    User user = new User(1L, "효혁");
    User persistedUser = userRepository.insert(user);

    assertThat(persistedUser.getId()).isEqualTo(1L);
}

JdbcAggregateOperationsinsert()save()와 비슷하지만 항상 Insert 쿼리문을 실행한다. 이를 활용해 커스텀 메서드를 작성할 수 있다.

라이프사이클 이벤트 활용

id 값으로 UUID 같은 값을 주고 싶을 경우 BeforeSaveCallback 이벤트를 활용할 수도 있다.
Spring Data JDBC의 save 메서드는 Insert/Update를 결정 → BeforeSaveEvent 처리 → DB에 쿼리 수행 → AfterSaveEvent 처리의 과정을 거친다(이벤트는 나중에 자세히 다룰 예정이다). 따라서 쿼리 수행 전에 id 값을 변경해준다면 DB에 원하는 id 값을 넣어줄 수 있다.

// Order.java
@Table("ORDERS")
public class Order {
    @Id
    private UUID id;
    private String title;

    public static class BeforeSaveOrderCallback implements BeforeSaveCallback<Order> {
        @Override
        public Order onBeforeSave(Order aggregate, MutableAggregateChange<Order> aggregateChange) {
            aggregate.id = UUID.randomUUID();
            return aggregate;
        }
    }
    ...
}

// JdbcConfig.java
@Configuration
public class JdbcConfig extends AbstractJdbcConfiguration {
    @Bean
    BeforeSaveOrderCallback beforeSaveOrderCallback() {
        return new BeforeSaveOrderCallback();
    }
}

// OrderTest.java
@DataJdbcTest
@Import(JdbcConfig.class)
class OrderTest {
    @Autowired
    private OrderRepository orderRepository;

    @Test
    void orderIdUUID() {
        Order order = orderRepository.save(new Order(null, "order_title"));

        assertThat(order.getId()).isNotNull();
    }
}