[Spring] ApplicationEventPublisher? EDA인가?

Lim jeong woo·2025년 1월 22일

Spring

목록 보기
1/2
post-thumbnail

하나의 서비스 로직에 너무 많은 의존성..

혹시 하나의 서비스 로직에 너무 많은 의존성을 주입해본 경험 없으신가요? 저는 있습니다..ㅠㅠ 위의 사진은 제가 'NFT 기반 티켓 거래 플랫폼' 프로젝트로 캡스톤 디자인을 진행할 때의 코드입니다.

UserEventService라는 로직에 무려 11개의 Repository의 의존성을 부여 받고 있습니다. 당시에도 '이거 좀 아닌거 같은데..'라는 생각이 매우 들었지만, 프로젝트를 기간 안에 마감하는 것이 훨씬 중요했기 때문에 더 깊은 공부를 하지 못하고, 이 상태로 제출하게 되었습니다.

이렇게 하나의 서비스 로직에 너무 많은 의존성을 부여 받으면 각 서비스마다 의존도가 높아지게 되며 의존도가 높아지면서 객체지향 설계 원칙 중 하나인 단일 책임 원칙에도 어긋나는 코드를 작성하게 된다는 점을 알게 되었고, 이를 해결하지 못한 제 마음 한 곳에 찜찜함으로 남아있게 되었습니다.

그러면 어떻게 해결할 수 있을까요?

하지만 해결책을 찾는 과정이 쉽지 않았습니다. "하나의 서비스 로직에 과도한 의존성을 부여한 이유는 로직이 반드시 처리되어야 하기 때문인데, 해당 로직을 분리한다면 어떻게 처리해야 하지?"라는 의문이 지워지지 않았습니다.

하지만, 해결책은 의외의 곳에서 찾을 수 있었습니다. EDA를 공부하기 위해서 이벤트에 대해서 공부를 해보다가 Spring에서 내부 이벤트를 지원한다는 점을 알게 되었고, 메세지 큐와 분산 시스템에 대해서 공부하기 전에 Spring Event에 대해서 공부하기 시작하면서 해결책을 찾을 수 있었습니다.

ApplicationEventPublisher?

ApplicationEventPublisher는 Spring 프레임워크에서 이벤트 발행 기능을 캡슐화한 인터페이스로, ApplicationContext의 상위 인터페이스로서 동작합니다.

이 인터페이스는 애플리케이션 내에서 발생하는 다양한 이벤트를 해당 이벤트에 관심 있는 모든 리스너에게 알리는 역할을 수행합니다. 이벤트는 ContextRefreshedEvent와 같은 프레임워크 수준의 이벤트일 수도 있고, 애플리케이션 특화 이벤트일 수도 있습니다.

ApplicationEventPublisher를 사용하면 애플리케이션 컴포넌트 간의 결합도를 낮추고, 이벤트 기반의 비동기 처리 메커니즘을 구현할 수 있습니다. 이를 통해 시스템의 확장성과 유지보수성을 향상시킬 수 있습니다.

빠르게 코드를 통해 알아봅시다.

ApplicationEventPublisher를 사용하기 위해 간단한 예제를 작성해보았습니다.

예시의 요구사항

  • 사용자가 서비스 내부에서 사용할 포인트(현금성 재화)를 계좌이체 및 카드결제를 통해 충전합니다.(이때 계좌이체나 카드결제 로직은 생략합니다.)
  • 사용자의 포인트 충전내역이 기록됩니다.

1. User.java


사용자 도메인을 의미하는 코드입니다. 연습이기 때문에 최대한 간략하게 작성해봤습니다.

2. DepositEvent


입금 이벤트에 대한 데이터인 이벤트 객체를 생성합니다. 해당 이벤트 객체를 통해서 EventListener에게 전달됩니다.

3. UserService.java


사용자의 서비스 로직을 담당하는 코드입니다. 사진에는 생략됐지만, UserService에 ApplicationEventPublisher에 대한 의존성을 부여해야 합니다.

3. TradeHistory.java


사용자 거래 내역 도메인을 의미하는 코드입니다.

4. TradeHistoryListener.java

UserService에서 ApplicationEventPublisher를 통해 발행한 Event를 구독하여 이벤트를 처리하는 부분입니다.

해당 리스너를 Spring Bean으로 설정하기 위해 @Component 어노테이션과 EventListener로서의 역할을 부여하기 위해 @EventListener 어노테이션을 사용하여 EventListener 코드를 완성합니다.

@TransactionalEventListener?
이 부분은 아랫부분에 Event 발행과 Transaction에 대한 저의 고민과 생각을 정리하면서 자세히 작성하겠습니다!
간단히 언급하자면 @TransactionalEventListener는 트랜잭션의 상태에 따라 이벤트를 처리할 수 있도록 설계된 이벤트 리스너입니다. 따라서, 트랜잭션의 상태에 따라 실행 타이밍을 제어할 수 있습니다.

5. 결과

해당 로그는 위의 코드를 작성한 뒤에, API를 전송했을 때 나타나는 로그입니다. 로그를 확인하면 입금 로직까지 처리한 뒤에 API가 완료되는 것을 확인할 수 있습니다.

여기까지 하게 된다면, 기존의 요구사항이었던 사용자 입금 처리, 거래 내역 저장 로직을 하나의 로직에서 2개의 의존성을 부여 받아 진행했던 것과 동일한 결과를 얻을 수 있습니다.

사실 여기까지는 굉장히 단순하게 보입니다.

여기까지는?

왜 제가 여기까지는 단순히 보인다는 말을 했을까요.. 사용법은 정말 단순합니다.
저는 이 블로그를 작성하는 이유가 제가 공부한 내용을 정리하면서 되짚는 목적도 있지만, 해당 기술을 왜 사용하고, 그에 대한 제 생각을 말씀드리면서 불특정 다수와 소통하며 더 나아지기 위함입니다. 따라서 저는 이 기술을 단순히 사용하는 것에 초점을 맞추기 보다 왜 사용하며 주의점이 무엇인지, 그에 대한 제 생각을 정리하고자 합니다.

ApplicationEventPublisher를 사용하는 결정적인 이유는 의존성 분리라고 생각합니다. ApplicationEventPublisher를 사용하여 각 서비스 로직은 각자의 역할에 집중할 수 다는 점이 가장 큰 장점이라고 생각합니다. 하지만, ApplicationEventPublisher를 사용하여 막무가내로 나눈다면 수많은 에러를 마주할 수 있습니다.

Event의 발행으로 메인로직과 서브로직으로 분리됩니다.

위에 가정했던 요구사항의 메인로직은 사용자의 입금입니다. 사용자의 입금에 따른 거래내역을 저장하는 로직은 서브로직이 됩니다. 따라서, 하나의 메인로직에서 파생된 서브로직은 Event로 진행하여 의존성을 분리하는 것은 꽤 합리적이라고 생각합니다.

하지만, 하나의 로직이 분리되었기 때문에 Transaction에 대한 고민을 해야합니다.

다른 예시를 들어보겠습니다. 사용자가 회원가입을 하고, 해당 사용자에게 환영 메일을 보낸다고 가정하겠습니다. 만약, 사용자가 회원가입을 실패했는데 환영 메일을 보내게 된다면 어떻게 될까요? 그래서는 안됩니다. 사용자에게 신뢰를 잃는 행동이기 때문입니다.

따라서, 메인 로직의 성공을 보장된 뒤에 서브 로직(Event)를 진행하는 것이 Event의 합리성을 지켜준다고 생각합니다.

그러면 어떻게 해야할까요?

단순히, 메인 로직의 Transaction이 성공하고 난 뒤에 Event를 처리하면 됩니다. 이게 제가 예시로 보여드렸던 부분에서 @TransactionalEventListener 어노테이션을 사용한 이유입니다. 해당 어노테이션의 phase 옵션을 사용하여 Transaction의 상태에 따른 이벤트 처리를 진행할 수 있습니다.

@TransactionalEventListener의 phase 옵션
AFTER_COMMIT (트랜잭션 성공시, 실행)
AFTER_ROLLBACK (트랜잭션 롤백시, 실행)
AFTER_COMPLETE (트랜잭션 완료시, (AFTER_COMMIT+AFTER_ROLLBACK))
BEFORE_COMMIT (트랜잭션 commit 되기전에)

해당 옵션을 사용해서 Transaction에 대한 분기 처리를 진행할 수 있습니다. 저는 사용자의 회원가입이 완료된 상황에서 환영 메일을 보내야 하는 상황에서는 AFTER_COMMIT 옵션을 사용하여 진행했습니다.

잠깐! 이대로 괜찮을까요?

메인 로직의 트랜잭션이 Commit되고 난 뒤에 이벤트를 처리하면 메인 로직이 실패하는 경우에는 이벤트가 실행되지 않으니 괜찮을까요?

만약, Event 로직에서 Transaction이 필요하다면 어떻게 될까요? 이미 API에서 하나의 트랜잭션에 대한 Commit까지 진행되었습니다. 그렇다면, Event 로직에서 Transaction Commit이 필요한 로직에 대해서 Commit이 진행될까요?

답은 그렇지 않습니다. 이미 Commit이 진행되었기 때문에 추가적인 Commit이 이뤄지지 않습니다. 따라서 제가 코드 예시로 가정했던 입금과 거래내역 저장에 대한 로직에서 여기까지만 적용한다면, 거래내역 저장에 대한 Commit이 진행되지 않아서 DB에 값이 추가되지 않습니다.

실제로 TransactionSynchronization 의 afterCommit 주석에서 확인할 수 있습니다
/**

  • Invoked after transaction commit. Can perform further operations right
  • after the main transaction has successfully committed.
  • Can e.g. commit further operations that are supposed to follow on a successful

  • commit of the main transaction, like confirmation messages or emails.
  • NOTE: The transaction will have been committed already, but the

  • transactional resources might still be active and accessible. As a consequence,
  • any data access code triggered at this point will still "participate" in the
  • original transaction, allowing to perform some cleanup (with no commit following
  • anymore!), unless it explicitly declares that it needs to run in a separate
  • transaction. Hence: Use {@code PROPAGATION_REQUIRES_NEW} for any
  • transactional operation that is called from here.
  • @throws RuntimeException in case of errors; will be propagated to the caller
  • (note: do not throw TransactionException subclasses here!)
    **/

그러면 또 어떻게 해야할까요?

해당 상황은 Transaction에 대한 커밋이 이미 진행되었기 때문에 이것에 대한 분기처리 또는 작업을 해주면 해당 문제를 해결할 수 있습니다.

해결방법 1. AFTER_COMMIT을 BEFORE_COMMIT으로 바꿔준다.

EventListener에서 실행 조건을 Transaction이 Commit되고 난 뒤에 진행했기 때문에 생기는 문제이기 때문에 해당 사항을 BEFORE_COMMIT으로 바꿔서 한번에 Commit될 수 있게 변경합니다.

하지만, 과연 좋은 해결방법일까요? AFTER_COMMIT으로 지정한 이유는 메인 로직과 서브 로직 간 선수 관계 때문입니다. 하지만 BEFORE_COMMIT으로 진행하게 되면 메인 로직의 성공을 보장할 수 없기 때문에 그닥 좋은 해결방법이 아니라고 생각합니다.

해결방법 2. 새로운 트랜잭션으로 Event 로직을 처리한다.

이 문제는 하나의 트랜잭션으로 메인 로직과 서브 로직을 처리하기 때문에 발생한 문제입니다. 따라서, 해당 로직을 새로운 트랜잭션으로 실행하게 된다면, DB에 값을 모두 업데이트할 수 있다고 생각합니다.
새로운 트랜잭션으로 실행하는 방법은 @Transaction의 propagation 옵션을 REQUIRES_NEW로 적용하여 해결할 수 있습니다.

해결방법 3. 비동기처리로 진행하여 새로운 Thread로 실행한다.

해당 Event 로직을 비동기로 처리하게 되면, 제가 새로운 트랜잭션으로 실행이라는 조건을 주지 않아도 해결할 수 있습니다.
비동기를 적용하는 방법은 Spring Application 실행하는 메인 클래스에 @EnableAsync 어노테이션을 붙이고, 비동기처리를 적용할 부분에 @Async 어노테이션을 붙여 적용할 수 있습니다.

비동기처리에 대한 고민..

비동기처리를 적용하는 것이 과연 맞는 것인가?

사실 이 부분이 이번 Event를 공부하면서 가장 고민했던 부분입니다. 주변에 이 기능을 사용했던 친구들과 대화를 해봐도 무조건 비동기처리를 적용해야 한다고 말을 했기 때문입니다. 뾰족한 논리 없이 단순히 사용해야 한다는 점에서 저는 납득이 가지 않았고, 그 이유를 찾기 위해 고민을 하게 되었습니다.

동기처리가 옳다고 생각한 근거

  • Spring에서 ApplicationEventPublisher를 통한 이벤트 처리를 기본적으로 동기로 제공한다는 점을 고려했을 때, 과연 비동기가 올바른 해결책이 될 수 있는가에 대한 고민을 했습니다.
  • 비동기처리를 적용하게 되면 작업의 종료에 관심 갖지 않고, 실행만 하고 결과값을 클라이언트에 전달하게 됩니다. 따라서, 비동기처리를 적용한 로직이 에러가 발생해도 이를 처리하기 까다롭습니다.

비동기처리가 옳다고 생각한 근거

  • ApplicationEventPublisher의 publishEvent 함수는 리턴이 void입니다. 결국 작업에 대한 결과값을 받을 수 없습니다. 그렇다면, 이 작업의 종료를 기다릴 이유가 없습니다. Event의 목적은 결국 의존성 분리 환경에서의 서브 로직의 실행이라고 확신을 갖게 되었습니다.
  • 서브 로직이 많아지는 상황에서 동기로 로직을 처리하게 된다면, 전체 프로세스의 호흡이 굉장히 길어질 것입니다. 하나의 요청에 대한 응답시간이 길어지게 된다면.. 당연히 안 좋은 결과를 초래하게 됩니다.

나름의 해결방법

사실 바보 같은 생각을 하고 있었습니다. 이벤트 처리에서 동기와 비동기를 고민할 것이 아니라 단순히 로직 자체가 동기로 작동할 것인지, 비동기로 작동할 것인지를 고민한 뒤에 결정된 사항대로 적용하면 가장 단순하고 확실하게 적용할 수 있다는 결론을 내리게 되었습니다.

왜냐하면 Spring Event의 주된 목적은 서비스 로직 간의 의존성 분리입니다. 의존성을 분리하는 것이지 로직이 바뀌는 것이 아니기 때문에 기존 동기 로직은 동기로, 기존 비동기 로직은 비동기로 처리하면 된다는 생각을 하게 되었습니다.

이번 글을 작성하면서 제시한 2가지 예시의 동기와 비동기 예시

  • 사용자 입금 처리 뒤 거래 내역 저장: 동기
  • 사용자 회원가입 뒤 환영 메일 전송: 비동기

그래도 비동기로직에서 실행을 보장해야 하는 상황이 있다면, 그래도 비동기?

일단 각 로직에 대한 동기와 비동기처리를 각 로직의 목적에 맞게 적용한다고 하더라도, 비동기처리 로직에서 유효하게 실행되어야 하는 로직이 있을 것이라고 생각합니다. 예를 들어, 프로모션 쿠폰을 메일로 전송한다던가.. 메일 전송이지만 그에 대한 목적이 중요한 로직 말입니다.

그뿐만 아니라, 비동기처리에 대한 방어가 전혀 없이 비동기처리를 남발하는 것은 옳지 못하다고 생각합니다. 이에 대한 방법을 찾아보던 중, 어떤 분이 작성한 블로그 글을 보고 비동기 커스텀 설정을 알게 되었습니다.

@Async 커스텀하기

@Async 상속/구현 구조
  
 (인터페이스)
    ↑
AnnotationConfigApplicationContext (클래스)
    ↑
Custom Async Configuration Class (AsyncConfigurer를 구현한 사용자 정의 클래스)

저희가 사용한 @Async는 이러한 구조를 갖고 있습니다. 따라서 구현체인 AsyncConfigurer에 설정을 추가하여 비동기처리에 대한 설정을 추가하는 것이 더 효율적이라고 생각하게 되었습니다.

  • 스레드 개수 제한: CorePoolSize, MaxPoolSize 등을 통해 스레드 사용량을 제어.
  • 작업 대기열 관리: QueueCapacity를 설정하여 작업이 대기열에 저장되는 방식을 관리.
  • 스레드 이름 지정: 디버깅과 로깅을 쉽게 하기 위해 스레드 이름을 설정.
  • 거부 정책 설정: RejectedExecutionHandler를 통해 스레드 풀이 과부하 상태일 때의 동작 정의.

    CallRunsPolicy?
    작업이 거부되지 않고, 현재 작업을 실행 중인 스레드에서 직접 실행됩니다. (쓰레드를 동기로 처리합니다!)

Async Test

1. 현재 스레드의 개수보다 많은 요청을 보내보기

활성화되어 있는 스레드의 개수: 1
스레드풀의 크기: 1
작업 대기열의 크기: 0
거부 정책 설정: 없음 -> 디폴트(Discard)

Test 로직은 5개의 event를 발행하여 스레드풀의 크기보다 많은 요청을 보내봤습니다.

사진이 작아서 잘 안보이지만, 로그를 복붙해서 공유해보겠습니다.

두번째 Event 실행되는 순간에 발생한 에러 로그(첫번째 사진)

  • Failed to complete request: org.springframework.core.task.TaskRejectedException: ExecutorService in active state did not accept task: org.springframework.aop.interceptor.AsyncExecutionInterceptor$$Lambda$1848/0x0000000801709820@3d07f2d1

첫번째 Event가 종료되고 발생한 에러 로그(두번째 사진)

  • 2025-01-23T00:56:27.690+09:00 ERROR 19208 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
    [Request processing failed: org.springframework.core.task.TaskRejectedException: ExecutorService in active state did not accept task: org.springframework.aop.interceptor.AsyncExecutionInterceptor$$Lambda$1848/0x0000000801709820@3d07f2d1] with root cause

    java.util.concurrent.RejectedExecutionException: Task java.util.concurrent.FutureTask@5c91f557[Not completed, task = org.springframework.aop.interceptor.AsyncExecutionInterceptor$$Lambda$1848/0x0000000801709820@3d07f2d1] rejected from org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor$1@733ac6b6[Running, pool size = 1, active threads = 1, queued tasks = 0, completed tasks = 0]

에러 로그를 확인해보면, 총 5개의 이벤트를 실행할 때, 스레드풀의 크기를 넘어가기 때문에 에러가 발생한 모습을 확인할 수 있습니다.

2. 현재 스레드 개수보다 많은 요청을 보내지만, 거부 정책 설정 적용하기

Test1과 대부분 동일하지만 거부 정책을 변경했습니다. CallerRunsPolicy를 사용하여 작업을 거부하는 것이 아니라 현재 스레드에서 직접 실행하는 정책으로 변경해보았습니다. ( Test 로직은 동일합니다.)


이렇게 결과를 확인하면 status 200으로 요청은 성공한 것으로 확인되지만, 두번째 사진을 보시면 대부분의 요청이 동기로 작동되는 것을 확인할 수 있습니다.(동기: exec, 비동기: EVENT)

3. 모두 적용해보기

모두 적용을 하게 되면, 이렇게 모두 비동기 스레드로 잘 작동하는 것을 확인할 수 있습니다!

이렇게 @Async를 커스텀하여 비동기로직에 대해 안정성을 더할 수 있었습니다. 비동기처리 로직에 많은 테스팅을 거치고, 비동기로직에 대한 안정성을 더한다면 Event를 비동기로 처리해도 조금은 마음을 놓을 수 있을 것 같습니다!

이렇게 Spring Event를 활용하여 의존성 분리의 주된 목적을 달성하고, Event 처리시에 발생할 수 있는 Transaction 고려사항과 비동기와 동기에 대한 고민, 그에 따른 안정성 추가하기까지 다뤄봤습니다. 진작 알았다면 조금 더 깔끔한 코드를 작성하고, 작업량을 줄일 수 있었겠다 싶네요.. 조금 더 분발하는 제가 될 수 있도록 각오를 다지게 되네요..

0개의 댓글