#event

이벤트 발행으로 비즈니스 로직 분리하기

서비스를 만들다 보면, 처음에는 단순한 crud로 시작했던 API도 점차 복잡한 연관 관계가 생기고, 동시에 처리해야 할 일들이 생긴다. 그리고 더욱 복잡한 기능을 구현하기 위해 외부 모듈이나 시스템을 연동하여 사용하면서 하나의 요청에 함께 묶여 수행되는 로직이 점차 많아지는 것을 느껴본 적이 있을 것이다.

하지만 이렇게 요청에 묶인 트랜잭션에서 많은 일을 수행하게 되면, 사용자가 원하는 요청의 의도와 서버에서 실제로 수행되는 로직 간의 차이가 생기게 된다.

다음 예시를 보며 여러분이 작성했던 코드에도 혹시 비슷한 실수가 있는지 돌아보길 바란다.

아래 작성한 코드는 간단하게 사용자가 회원가입 할 때 가입 축하 메일을 발송하는 서비스 코드이다. 외부 모듈을 연동해서 사용하는 간단한 예제이다.

메일 발송 로직을 담당하는 MailService와 회원의 실질적인 데이터베이스로의 저장할 수 있도록 인터페이스를 제공하는 MemberRepository를 필드로 가지고 있다는 것을 파악할 수 있을 것이다.

문제를 찾아보자

‘사용자가 회원가입 요청을 보내고, 데이터베이스에 저장을 완료하면 가입 축하 메일을 보낸다.’ 흐름으로는 큰 문제가 없어 보인다.

그러나 다음과 같은 문제를 낳을 수 있다. 현재 메일 발송을 위해 외부 SMTP 서버를 사용하여 새로운 요청을 보내고 있다. 메일을 발송하는 속도 느려지거나 외부 서버의 문제로 인해 메일을 보내는 요청이 실패한다면 어떻게 될까?

호출되는 MailService의 sendMail 메서드를 보면, MemberService의 save 메서드는 하나의 트랜잭션으로 묶여있다. 그래서 sendMail이 실패하면 당연히 회원가입 로직도 실패하고 정상적으로 회원가입이 되지 않는다.

사용자는 회원가입을 원한 것이지 메일 발송을 요청한 것이 아니다. 요청의 의도와 다른 로직 때문에 속도가 느려지거나, 실패하여 회원가입이 되지 않는다면 이것은 분명 문제로 인식되어야 한다.

여기서 의문을 품어보자. 회원가입과 축하 메일 발송은 반드시 하나의 트랜잭션으로 묶여야만 했을 기능일까?

위에서 살펴본 문제점을 토대로 로직의 실행 순서를 따져본다면 회원 정보 저장은 저장대로 끝나야 하고, 저장된 이후에 추가로 메일 발송이 일어나야 한다. 즉, 메일 발송로직은 회원가입 로직이 수행되는 트랜잭션 밖에서 수행되어야 함을 의미한다.

그럼 어떻게 트랜잭션을 분리할 수 있을까?

이벤트를 사용한 메일 발송 로직 분리

이 문제는 “회원가입이 성공한다”라는 이벤트를 만들고, 이벤트 리스너에서 MailSender의 메서드를 호출한다면, 회원가입이 일어나는 트랜잭션과 분리해서 메일 발송 로직을 수행할 수 있다.

이벤트는 기본적으로 spring에서 제공하는 ApplicationEvent를 사용하여 생성할 수 있고, 생성된 이벤트를 받아들이고 로직을 수행하는 리스너 객체는 어노테이션 기반으로 정의하여 사용할 수 있다.

그리고 회원가입 트랜잭션 안에서 해당 이벤트를 함께 발행한다.

이벤트 발행을 담당하는 publisher는 ApplicationEventPublisher 인터페이스 구현체를 사용한다. 물론 이 구현체는 스프링이 제공해준다.

발행된 이벤트는 아래와 같이 ApplicationContext에서 @EventListener 어노테이션이 붙은 Listener handler를 찾아서 인자로 받아들여지고 특정 로직이 수행된다.

위처럼 단순 EventListener이외에도 이벤트를 트랜잭션 단계에 바인딩할 수 있도록 @TransactionalEventListenr를 사용할 수 있다. 이 어노테이션을 사용하면 실제 트랜잭션 단계에서 정확히 언제 이벤트 핸들러를 수행할지 정할 수 있다.

  • AFTER_COMMIT : 기본값으로 트랜잭션이 성공적으로 완료된 경우에 이벤트를 발생시킨다.
  • AFTER_ROLLBACK : 트랜잭션이 실패하여 롤백 된 경우에 이벤트를 발생시킨다.
  • AFTER_COMPLETION : 트랜잭션의 성공 여부와 상관없이 종료되었을 경우 이벤트를 발생시킨다.
  • BEFORE_COMMIT : 트랜잭션 커밋 직전에 이벤트를 발생시킨다.

여기서는 사용자의 회원가입과 데이터베이스 저장이 모두 완료된 Transaction이 끝난 이후에 메일 보내야 하므로, @TransactionalEventListener(phase = AFTER_COMMIT, fallbackExecution = true) 어노테이션 속성을 사용한다.

fallbackExecution 값이 true이면 만약 트랜잭션이 존재하지 않는 곳에서 이벤트를 발행했을 경우, 예외를 던진다.

이렇게 하면 회원가입 로직 수행 및 트랜잭션에서 성공적으로 커밋된 이후에 이벤트 리스너에 정의된 sendMail 메서드를 호출할 수 있다.

결합도

이와 같이 이벤트를 사용하면 트랜잭션 안의 관심사를 분리할 수 있지만, 결과적으로 멤버도메인에서 MailService를 직접 의존하지 않기 때문에 결합도를 낮출 수 있는 장점이 있다. 만약 이벤트를 사용하지 않아서 MemberService에서 직접 사용한다면, 메일을 보내는 기능 외에 문자를 보내는 기능이 추가로 확장되거나, 메일 보내는 기능에 수정이 일어났을 때, MemberService 코드도 함께 수정해야 하고 이는 유지보수를 안 좋게 만든다.

문자를 보내는 SMS 서비스를 다음 그림과 같이 추가했을 때, 회원가입 요청은 해당 트랜잭션에서 응집도 높은 로직을 수행할 수 있고 이벤트는 이벤트 관리 큐에서 종합적으로 관리할 수 있다. 이는 외부 모듈과의 결합을 약하게 하므로 변경과 확장에 유연해진다는 장점이 있다.


결론

이번 글에서는 이벤트 발행을 통해 특정 트랜잭션과 분리하여 로직을 수행하고, 이는 곧 결합도를 낮추는 역할을 한다는 것을 알아보았다.

이벤트를 사용하는 것은 외부 모듈 간의 낮은 결합도를 유지하면서 협력관계를 유지하고자 할 때 유용하게 사용할 수 있다.

필자는 엘라스틱 서치와 기존 데이터베이스 간의 데이터 동기화를 할 때 이벤트를 발행해서 처리한다.

이처럼 이벤트 발행은 분산 시스템을 효과적으로 관리하게 해주기 때문에 단순히 외부 시스템 간의 소통뿐만 아니라, 도메인 주도 설계나 MSA 관점에서도 의미 있는 방식이라고 할 수 있다.

이벤트를 발행하고 수행하는 방법은 Spring이 제공하는 context를 이용하는 방법 외에도 외부 이벤트 관리 모듈을 사용할 수도 있다. 이벤트 전용 모듈을 사용하는 방법과 이 글에서 제시한 방법을 비교하여 장단점을 수용하는 것은 독자분께 맡기도록 하겠다.