이벤트로 결합을 느슨하게

최인준·2024년 9월 5일
0
post-thumbnail

서론

프로젝트에 알림 기능이 있다.

사용자가 리마인드 알림을 받겠다고 하면 Batch용 알림 엔티티 하나를 저장해야 한다.

응용 서비스에서 알림 포트를 의존하여 저장 메소드를 호출하는 방식을 활용하는 방식이 있지만 이벤트를 활용하면 좋겠다고 생각했다.

이벤트를 활용하면 강하고 복잡한 결합을 느슨하게 만들 수 있다. 과한 이벤트 활용은 코드 이해를 어렵게 만들 수 있지만 적절히 활용하면 결합을 느슨하게 만들 수 있다.

요구사항

알림을 저장할 때의 흐름은 다음과 같다.

  • 리마인드 알림을 받겠다고 했다면 배치용 알림 하나를 저장한다.
  • 알림 전송 날짜는 nowDate 기준 7일뒤이다.
  • “잊고있는 링크들이 있어요!” 라는 문구로 알림이 보내지기에 최대 하루 하나의 배치용 알림을 저장한다.

실제 알림이 전송되기 까지는 흐름이 더 있다. 엔티티도 두개가 더 필요하고 스프링 배치도 활용된다.

이번 포스팅에서 이벤트를 활용하며 보여 줄 과정은 저 정도 요구사항까지만 파악하면 된다.

구현

먼저 배치용 알림을 저장하는 로직을 먼저 구현해보자. 그리고 이를 링크 생성, 수정하는 응용 계층에 끼우기만 하면 된다.

@Component
class AlertEventHandler(
    private val now: Supplier<LocalDate>,
    private val alertBatchPort: AlertBatchPort,
    private val alertContentPort: AlertContentPort,
) {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun createAlert(request: CreateAlertRequest) {
        val alertBatch = alertBatchPort.loadByUserIdAndDate(request.userId, now.get().plusDays(7))
            ?: createAlertBatch(request.userId)
            
        //.. 이외의 로직들
        
        alertContentPort.persist(alertContent)
    }

    private fun createAlertBatch(userId: Long): AlertBatch {
        val alertBatch = AlertBatch(
            userId = userId,
            shouldBeSentAt = now.get().plusDays(7)
        )
        return alertBatchPort.persist(alertBatch)
    }
}

첫번째로 주목할 점은 이벤트 핸들러 구현을 @TransactionalEventListener를 활용했다.

이벤트를 처리할 때는 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다.

이 둘을 모두 고려하면 복잡해지므로 경우의 수를 줄이는 것이 좋다.

경우의 수를 줄이는 방법이 바로 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하는 것이고 이는 @TransactionalEventListener를 활용하면 되는 것이다.

두번째로 주목할 점은 @Transactional 어노테이션이다.

위 코드를 보면 전파 옵션을 REQUIRES_NEW 로 설정하였는데 이유가 있다.

옵션을 따로 설정하지 않고 디폴트로 둔다면 이벤트가 실행되지 않는다‼️

디폴트 옵션으로 두면 이전의 트랜잭션에 묶여있는 상태이다. 근데 해당 메소드는 이전의 트랜잭션이 커밋된 이후에 실행이 된다. 이미 커밋된 트랜잭션은 다시 커밋되는게 불가능하다.

그렇기에 해당 메소드가 실행 자체가 되지 않는 것이다.

그렇기에 새 트랜잭션을 열어주어 이벤트가 처리될 수 있도록 하였다.

새 트랜잭션을 열어주는 것이 아니라 하나의 트랜잭션으로 무조건 묶이는 게 맞는 로직이다는 생각이 들면 @EventListener를 활용하면 된다.

이벤트 핸들러는 구현했으니 이제 응용 계층에서 이벤트 발행을 해보자.

@Service
@Transactional(readOnly = true)
class ContentService(
    private val publisher: ApplicationEventPublisher
) : ContentUseCase {
		...
		
    @Transactional
    override fun create(user: User, contentCommand: ContentCommand): ContentResult {
        val category = categoryPort.loadCategoryOrThrow(contentCommand.categoryId, user.id)
        val content = contentCommand.toDomain()
        content.parseDomain()
        val contentResult = contentPort.persist(content)
            .toGetContentResult(false, category)

        **if (contentCommand.alertYn == YES) {
            publisher.publishEvent(CreateAlertRequest(userId = user.id, contetId = contentResult.contentId))
        }**

        return contentResult
    }
    
    ...

다른건 볼 필요 없이 진하게 표시된 if문을 보자.

스프링이 제공하는 ApplicationEventPublisher를 의존하여 publishEvent메소드를 통해 발행하면 끝이다.

파라미터로는 이벤트 핸들러에서 구현한 파라미터에 맞게 넣어주기만 하면 알아서 인식이 되고 핸들러가 실행된다.

이벤트를 활용하지 않았다면 AlertBatchPort를 의존하여 Alert 바운디드 컨텍스트와 Content 바운디드 컨텍스트간의 결합이 생기게 된다. Content는 이미 User, Category, Bookmark 와 결합이 있어서 결합 자체가 무조건 안 좋은 것은 아니지만 Alert는 Content와의 관계를 생각해봤을 때 결합이 되지 않으면 좋겠다는 생각이 들어 이벤트로 처리했다.

결론

스프링 이벤트를 활용하여 이벤트 처리하는 과정을 담아보았다.

이벤트 처리는 이 방법 외에도 몇 가지 더 존재한다.

  • 메세지 큐 활용
  • Transactional Outbox Pattern 활용

각각의 장단점이 존재하는데 확실한건 위의 두 방법은 스프링 이벤트보단 복잡하다.

나의 경우엔 간단한 이벤트 처리이고 시간이 많지 않아 스프링 이벤트를 활용했다.

이벤트 유실률이 있어선 안된다는 상황이면 스프링 이벤트가 아닌 다른 방식을 활용하는 것이 좋은 것 같다.

0개의 댓글