[Spring Data JDBC] 라이프사이클 이벤트와 콜백(LifeCycle Events & Callback)

2020. 7. 11. 22:38Java

CRUD 라이프 사이클

Spring Data JDBC는 CRUD에 해당하는 메서드를 실행할 때, 각각 알맞은 이벤트들을 순서대로 진행한다.

  • CrudRepository.save()(Create, Update)

  • CrudRepository.find() (Read)

  • CrudRepository.delete() (DELETE)

Insert/Update/Select/Delete는 실제로 DB에 쿼리를 날리는 시점이다.

콜백

이벤트 하나가 실행되는 것을 이벤트가 발행된다고 표현한다. 모든 이벤트는 발행된 이후에 콜백 함수를 리턴한다. 따라서 CrudRepository.save() 메서드의 실제 라이프사이클은 아래 그림과 같을 것이다. (예외로 BeforeConvert는 이벤트 없이 콜백만을 갖는다)

이벤트와 콜백이 연달아서 실행되면 이 둘을 굳이 나누는 이유는 무엇일까?

이벤트 vs 콜백

이벤트는 비동기 방식으로 작동할 수 있다. 다시 말해, 실행 시점을 보장할 수 없다는 말이다. 엔티티를 DB에 저장하기 전에 필드를 수정해서 저장해야 한다면, 필드 수정은 반드시 DB에 쿼리가 날라가기 전에 이뤄져야 할 것이다. 이 때, 필드 수정을 이벤트로 발행하면 DB에 쿼리가 날아간 뒤에야 필드가 수정되는 경우가 발생할 수 있다. 따라서 동기 방식으로 동작하는 콜백이 필요해진 것이다. (실제로 엔티티의 생성 날짜를 자동으로 삽입해주는 @CreatedDate는 BeforeConvertCallback에 등록되어 있다.)
정리하면, 이벤트는 순서가 보장될 필요가 없는 다른 비즈니스를 처리하기 위한 일종의 trigger로서 사용한다. 콜백은 해당 Aggregate 객체를 직접 조작해야 할 경우 사용하면 된다.

예시 코드

직접 이벤트와 콜백에 이벤트를 등록해 보자.

1. 모든 엔티티가 저장되기 전에 로그 남기기

// JdbcConfig.java
@Configuration
public class JdbcConfig extends AbstractJdbcConfiguration {

    @Bean
    ApplicationListener<BeforeSaveEvent<Object>> loggingBeforeSave() {
        return event -> {
            Object entity = event.getEntity();
            log.info(entity + " is now saved");
        };
    }
}

ApplicationListenerBeforeSaveEvent<Object>를 등록한다. Save가 일어나기 전에 모든 객체(Object)에 대하여 아래 이벤트를 발행하겠다고 해석할 수 있다.
이벤트는 람다식으로 표현된다. 위 예시 코드에서는 이벤트에서 엔티티를 꺼내와서 로깅하는 이벤트를 발행하게 된다.

2. 특정 엔티티가 저장된 후에 로그 남기기

// Orders.java
@Getter
public class Orders {

    @Id
    private UUID id;

    private String title;

    public static class AfterSaveOrders 
        extends AbstractRelationalEventListener<Orders> {

        @Override
        protected void onAfterSave(AfterSaveEvent<Orders> event) {
            UUID id = event.getEntity().getId();
            log.info(String.format("Id: %d Orders is just saved!", id));
        }
    }
}

// JdbcConfig.java
@Configuration
public class JdbcConfig extends AbstractJdbcConfiguration {

    @Bean
    AfterSaveOrders afterSaveOrders() {
        return new AfterSaveOrders();
    }
}

특정 엔티티에 대해서만 이벤트를 듣고 싶다면 AbstractRelationalEventListener를 상속하는 구현체를 구현하면 된다. 이후 원하는 이벤트(onAfterSave처럼 on~~ 과 같은 이름을 갖는 메서드)를 오버라이드 해서 구현하면 된다. 이벤트를 구현한 뒤에 반드시 @Configuration 클래스에 빈으로 등록해주는 것을 까먹지 말아야 한다.

3. 특정 엔티티가 저장되기 전에 id 삽입하기

// Orders.java
@Getter
public class Orders {

    @Id
    private UUID id;

    private String title;

    @Order(1)
    public static class BeforeSaveOrderCallback implements BeforeSaveCallback<Orders> {

        @Override
        public Orders onBeforeSave(Orders aggregate,
            MutableAggregateChange<Orders> aggregateChange) {
            aggregate.id = UUID.randomUUID();
            return aggregate;
        }
    }
}

// JdbcConfig.java
@Configuration
public class JdbcConfig extends AbstractJdbcConfiguration {

    @Bean
    BeforeSaveOrderCallback beforeSaveOrderCallback() {
        return new BeforeSaveOrderCallback();
    }
}

객체를 직접 조작하기 때문에 콜백을 사용한다. 콜백은 이벤트와 달리 인터페이스로 구현되어 있다. 따라서 구현체를 구현하고 빈으로 등록해주면 된다.
또 콜백은 @Order을 사용해서 순서를 정할 수 있다. 이 숫자가 작을수록 우선 순위가 높아진다. 순서를 지정하지 않으면 가장 마지막에 동작한다.
참고로 콜백은 인터페이스기 때문에 여러 콜백을 한 클래스에 여러개 등록할 수 있다.

Caution: 이벤트는 Aggregate Root 에서만 발생

// Article.java
public class Article {

    @Id
    private Long id;

    private List<Comment> comments;

    public static class BeforeSaveArticle 
        extends AbstractRelationalEventListener<Article> {

        @Override
        protected void onBeforeSave(final BeforeSaveEvent<Article> event) {
          log.info("Article entity is now saving...");  
        }
    }
}

// Comment.java
public class Comment {
    private String content;

    public static class BeforeSaveComment
        extends AbstractRelationalEventListener<Comment> {

        @Override
        protected void onBeforeSave(final BeforeSaveEvent<Comment> event) {
          log.info("Comment entity is now saving...");  
        }
    }
}

Root 엔티티인 Article과 Root 엔티티가 아닌 Comment 모두 save 되기 전에 로그를 남기도록 이벤트를 등록하였다.
이제 실제 객체를 생성해서 저장해보자.

// ArticleRepositoryTest.java
@Test
void oneToMany() {
    Article article = Article.builder().author("효혁").contents("글 내용").build();
    article.addComment(new Comment("글 잘봤어요!"));
    article.addComment(new Comment("좋은 글이네요:)"));

    articleRepository.save(article);

    // Article entity is now saving...
}

실행 결과를 확인해보면 Article에 대한 로그만 남는다. Aggregate Root가 아닌 엔티티에 대해서는 이벤트와 콜백이 작동하지 않는다는 점을 유의해야한다.
특히 실수할 수 있는 부분은 앞서 예시로 든 @CreatedDate 또는 @LastModifiedDate이다. 시간을 필드에 삽입해주는 두 annotation은 BeforeConvertCallback에 등록된다. 즉, Aggregate Root가 아닌 엔티티에 두 annotation을 사용한다면 필드가 null로 남아있음을 확인 할 수 있을 것이다.