기술블로그

우아한 형제들의 회원시스템 이벤트기반 아키텍처 구축하기 2

ignuy 2024. 10. 23.

본 포스팅은 아무런 상업적 이익과 금전적 수입에 연관되어 있지 않습니다. 문제 시 삭제 조치하겠습니다. 블로그 프로필의 이메일이나 lion0077v@gmail.com으로 메일 주시기 바랍니다.

스스로를 "배달의 민족"이라 칭하는 사람들의 60%가 선택한 어플, 배민

 

이전 포스팅에서는 물리적인 시스템 분리와 비동기 HTTP 통신, 올바른 이벤트 발행 방식까지 살펴보며 의존 관계의 본질이 무엇인지 확인했다. 메시징 시스템을 사용해 물리적인 의존을 제거하는 방법과 “메시지가 담는 의도”에 따라 전혀 다른 결과를 얻는다는 것을 알 수 있었다.

예시 상황처럼 ‘가족관계를 탈퇴하라’ 라는 달성 목적이 아닌 본인인증을 해제했다는 이벤트를 발송해야 한다. 즉, 우리가 발행해야 하는 이벤트는 도메인 이벤트로 인해 달성할 목적이 아닌 도메인 이벤트 그 자체가 되어야 한다.

도메인이란 해결하고자 하는 문제 영역이며, 도메인 이벤트는 문제 영역에서 발생할 수 있는 핵심 가치나 행위이다. Domain Driven Design과 같은 용어를 사용하지만 이와는 관련이 없다. 도메인의 핵심 가치나 행위를 정의하기 어렵다면 이벤트 스토밍을 추천한다. 이벤트 스토밍은 DDD의 전략적 설계도구 중 하나이지만, 도메인 주도 설계를 위해서가 아니더라도 문제 영역 식별과 해결에 좋은 도구이다.

도메인 지식 탐구를 위한 이벤트 스토밍 Event Storming

이벤트 발행과 구독

배달의 민족, 회원시스템 이벤트 아키텍처 설계 도표(출처 : techblog.woowahan.com/7835/)

위 도표는 배달의 민족이 회원시스템과 다른 시스템의 관계를 보여주는 좋은 자료이다. 배달의 민족의 개발팀은 너무나 감사하게도 이런 좋은 학습 자료를 남겨주었다.

배달의 민족 회원시스템에서는 위 도표에서 보이다시피 3가지의 이벤트 종류(Application Event, Internal Event, External Event)와 3가지의 이벤트 구독자 계층(Firest, Second, Third Subsriber Layer)을 정의하였다. 각 계층과 이벤트가 왜 만들어졌는지 개요를 먼저 확인하고, 무엇을 해결하는지 천천히 살펴보자.

이벤트 종류

  1. Application Event: 애플리케이션 내부에서 발생하는 이벤트로, 특정 트랜잭션 내에서만 유효한 이벤트이다. 이를 통해 애플리케이션의 모듈 간 결합도를 낮추고 독립성을 유지할 수 있다.
  2. Internal Event: 도메인 영역에서 발생하는 핵심적인 이벤트로, 주로 도메인 모델에서 중요한 변화나 상태 전이를 나타낸다. Internal Event는 시스템 전반에 영향을 미치는 중요한 정보로, 해당 이벤트를 통해 여러 서비스가 구독하여 동작하게 된다.
  3. External Event: 시스템 간에 주고받는 이벤트로, 주로 외부 시스템이나 다른 마이크로서비스 간 통신을 통해 발생한다. External Event는 다양한 외부 의존성을 포함하며, 이를 통해 시스템 간 협력이 이루어진다.

구독자 계층

  1. First Subscriber Layer: Application Event를 구독하는 계층으로, 애플리케이션 내부의 모듈 간 비동기 작업을 처리한다. 이 계층에서는 주로 단순한 비즈니스 로직이나 모듈 간 메시지를 처리하며, 시스템의 독립성과 모듈성을 강화한다.
  2. Second Subscriber Layer: Internal Event를 구독하는 계층으로, 주로 도메인 내에서 발생한 중요한 이벤트를 처리한다. 이 계층에서는 다양한 서비스들이 Internal Event에 반응하여 비즈니스 로직을 실행하며, 도메인 간 통합을 담당한다.
  3. Third Subscriber Layer: External Event를 구독하는 계층으로, 외부 시스템이나 마이크로서비스에서 발생한 이벤트를 처리한다. 이 계층에서는 주로 외부 의존성과 관련된 작업을 처리하며, 시스템 간의 결합도를 낮추고 협력을 유지한다.

3가지 이벤트, 3가지 계층

Spring Application Event

배달의 민족, 회원시스템 이벤트 아키텍처 설계 도표(출처 : techblog.woowahan.com/7835/)

스프링 애플리케이션 이벤트는 내부에서 비동기 방식으로 이벤트를 발행하고 처리하는 구조를 제공한다. 이를 통해 분산된 트랜잭션을 관리하며, 메시징 시스템을 통한 도메인 비관심사를 처리할 수 있다. 특히, 배달의 민족에서 사용한 방식은 메시징 시스템을 통해 각 도메인 간의 의존성을 줄이고, 이벤트 기반 아키텍처를 효율적으로 운영하는 데 중점을 두었다. (https://dev-ignuy.tistory.com/103)

배달의 민족이 생각한 어플리케이션 내에서 반드시 해결해야만 하는 대표적인 도메인의 비관심사는 “메시징 시스템으로 이벤트를 발행하는 것”이다. 구체적인 동작에 대한 로직의 구현이 없이 단지 해당 이벤트를 발행하고 리스너가 소비하는 것은 애플리케이션의 관심사에서 확실히 벗어난 일이다. 이벤트 구독은 발행 시스템에 영향 없이 자유롭게 확장이나 변경이 가능하므로, 도메인에 영향 없이 메시징 시스템에 대한 연결을 쉽게 작성하고 확장하고 변경할 수 있다.

배달의 민족, 회원시스템 이벤트 아키텍처 설계 도표(출처 : techblog.woowahan.com/7835/)

또한 스프링 애플리케이션 이벤트를 통해 트랜잭션을 제어할 수 있다. 도메인에서 정의된 트랜잭션의 범위가 외부로부터 제어될 수 있다는 것을 도메인에 대한 침해로 볼 수 있지만, 이 침해를 감수하는 대신 강력한 구독자를 만들 수 있다.

 

배달의 민족 개발팀은 “상태의 변경을 야기하는 모든 도메인 행위는 메시징 시스템으로 전달해야 한다”는 시스템 정책을 세웠다. 이벤트를 메시징 시스템으로 전달하는 것은 도메인에게는 관심사가 아니지만 시스템에서는 중요한 정책이다. 이 경우 도메인 정책에 변경 없이 트랜잭션을 확장하여 구독자의 행위를 트랜잭션 내에서 처리되도록 변경할 수 있다.

배달의 민족 회원 시스템은 메시징 시스템으로 AWS SNS와 SQS를 사용하고 있으므로, 첫 번째 구독 계층의 SNS 발행을 책임지는 이벤트 구독자가 만들어졌다.

 

/**
 * K-L1VERSE의 로그인 보상(포인트) 정책 처리 이벤트
 */
public void signIn(User user) {
    // 로그인 상태로 변경 코드
    publisher.publishEvent(new UserSignedInEvent(user));    // 사용자 로그인 이벤트 발행
}
 
@Async(EVENT_HANDLER_TASK_EXECUTOR)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendSignedInNotification(UserSignedInEvent event) {
    // 이벤트에서 사용자 정보를 가져와서 알림 전송
    log.info("[NotificationListener - sendSignedInNotification]")
    User user = event.getUser();
    notificationService.sendNotification(event.getUser().getId());
}

위 코드는 필자가 개발하고 있는 K리그 SNS 서비스, K-L1VERSE의 로그인 보상 정책 처리 이벤트의 코드이다. 배달의 민족의 기술블로그에서 영감을 받아 서비스의 핵심 로직인 ‘사용자를 로그인 상태로 변경’하는 코드만 유지하고 나머지 비관심사(ex. 최초 로그인 시 로그인 보상 포인트 적립, 최초 로그인 시 알림)는 분리하였다. 이때, @Async와 @TransactionalEventListener를 통해 트랜잭션과 비동기 작업을 효율적으로 처리하는 방식을 강조했다. Spring Application Event를 활용하면 이렇게 앱 내에서도 비관심사를 효율적으로 분리할 수 있다.

내부 이벤트 & 두번째 구독자 계층

배달의 민족, 회원시스템 이벤트 아키텍처 설계 도표(출처 : techblog.woowahan.com/7835/)

어플리케이션애플리케이션 이벤트로 내부 이벤트를 처리할 수 있지만, 애플리케이션 이벤트 처리기는 애플리케이션의 리소스를 사용하기 때문에 도메인의 주요 기능 처리 성능에 영향을 미치게 된다. 또한 Application Event가 잘 구현되어 있다지만, 메시지 유실과 장애 복구를 최소화해주는 메시징 시스템의 장점을 가져갈 수는 없다.

첫 번째 구독자 계층이 애플리케이션 내에서 해결해야 하는 비관심사를 처리했다면, 내부 이벤트를 구독하는 두 번째 구독자 계층은 이외의 모든 도메인 내의 비관심사를 처리한다.

 

여기서부터는 배달의 민족이 공유해준 코드라인의 백본을 함께 공부해보자.

 

비관심사 분리

도메인에서 특정 행위가 수행될 때 추가적인 정책들이 따라붙는 경우가 많다. 이때, 부가적인 정책들이 도메인의 핵심 행위처럼 보일 수 있어 혼란을 줄 수 있으며, 이로 인해 도메인의 응집도를 떨어뜨리고 불필요한 의존 관계를 만들어낸다.

배달의 민족에서는 회원이 로그인을 할 때의 서비스 플로우는 다음과 같다.

  1. 회원을 로그인한 상태로 변경
  2. “동일 계정 로그인 수 제한” 규칙에 다라 동일 계정이 로그인된 타 디바이스에서 로그아웃 처리
  3. 회원이 어느 디바이스에서 로그인되었는지 기록
  4. 동일 디바이스의 다른 계정 로그아웃 기록
@Transactional
public void login(MemberNumber memberNumber, DeviceNumber deviceNumber) {
    devices.login(memberNumber, deviceNumber);
    devices.logoutMemberOtherDevices(memberNumber, deviceNumber);
    devices.logoutOtherMemberDevices(memberNumber, deviceNumber);
    member.login(memberNumber);
    applicationEventPublisher.publishEvent(
        MemberLoginApplicationEvent.from(memberNumber, deviceNumber)
    );
}

위 코드를 살펴보면 부가적인 정책들이 도메인 로직과 함께 작성되어 있기 때문에 실제로 중요한 로직이 무엇인지 혼란스럽다.

따라서 주요 기능을 찾고 비관심사를 분리하여 도메인 행위의 응집을 높이고 비관심사에 대한 결합을 느슨하게 만들어야 한다. 도메인의 주요 행위는 정책을 살펴보았을 때 알 수 있을 것이다. 정책마저 모호하다면 즉시 처리되어야 하는 것과 언젠가 처리되어야 하는 것을 분리함으로써 도메인의 주요 기능을 찾아보자.

로그인의 주 행위는 “회원을 로그인 상태로 변경”하는 것이다. 이 외의 행위들은 로그인 행위에 부가적으로 붙어있는 정책들이다. 부가적인 정책들을 도메인 로직에서 분리시켜야 한다.

@Transactional
public void login(MemberNumber memberNumber, DeviceNumber deviceNumber) {
    member.login(memberNumber);
    applicationEventPublisher.publishEvent(
        MemberLoginApplicationEvent.from(memberNumber, deviceNumber)
    );
}

여기서 3가지 부가적인 작업은 서로 독립적이다. 따라서 AWS의 SNS-SQS 같은 메시징 시스템을 활용하여 하나의 이벤트를 여러 작업으로 나누어 처리할 수 있다.

배달의 민족, 회원시스템 이벤트 아키텍처 설계 도표(출처 : techblog.woowahan.com/7835/)

@SqsListener(value = "${sqs.login-device-login}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
public void loginDevice(@Payload MemberLoginApplicationEvent payload) {
    devices.login(payload.getMemberNumber(), payload.getDeviceNumber());
}

@SqsListener(value = "${sqs.login-member-other-device-logout}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
public void logoutMemberOtherDevices(@Payload MemberLoginApplicationEvent payload) {
    devices.logoutMemberOtherDevices(payload.getMemberNumber(), payload.getDeviceNumber());
}

@SqsListener(value = "${sqs.login-other-member-device-logout}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
public void logoutOtherMemberDevices(@Payload MemberLoginApplicationEvent payload) {
    devices.logoutOtherMemberDevices(payload.getMemberNumber(), payload.getDeviceNumber());
}

이렇게 비관심사 분리를 통해 도메인의 응집도를 높이고, 각 부가 로직은 독립적으로 관리되며 재사용성을 높일 수 있다.

 

이때, SQS와 SNS란?

  • AWS SQS (Simple Queue Service):

AWS의 완전관리형 메시지 대기열 서비스로, 분산 시스템 간의 비동기 통신을 가능하게 한다. SQS는 메시지를 큐에 저장하고, 수신자는 이 큐에서 메시지를 읽어 처리함으로써 시스템 간의 결합도를 낮출 수 있다. 이를 통해 메시지 손실 없이 안정적인 데이터 전송이 가능하다.

  • AWS SNS (Simple Notification Service):

AWS의 완전관리형 메시지 전송 서비스로, 이벤트 발행 후 여러 구독자에게 메시지를 동시에 전달할 수 있는 기능을 제공한다. SNS는 주로 발행-구독 모델을 지원하여, 특정 이벤트에 대한 알림을 여러 시스템에 동시에 전송할 수 있다. SNS와 SQS를 함께 활용하면, 이벤트 기반 아키텍처를 구성하고 시스템 간의 통신을 효율적으로 관리할 수 있다.

 

Spring Application Event를 사용하는 이유와 동일하게 Second Subscriber Layer의 핵심도 '비관심사(관심 밖의 요소)를 분리'하는 것이다. 즉, 도메인의 주된 행위에 부수적인 정책들이 혼재되어 있을 때, 이를 분리하여 도메인 로직의 명확성과 응집도를 높이는 방법을 설명하고 있다. 예를 들어, 회원 로그인을 처리하는 과정에서 다양한 부가 작업들이 함께 섞여 있는데, 이러한 작업들은 주된 로직과는 관련이 적은 '비관심사'다. 이런 비관심사를 도메인 로직에서 분리해 이벤트 기반으로 처리하면, 코드가 더 명확해지고 확장성이 좋아진다.

 

외부 이벤트 발생

배달의 민족에서는 외부 시스템과의 통합을 위해 외부 이벤트를 발행하는 작업도 비관심사의 일환으로 봤다. 외부 시스템에 이벤트를 전파하는 과정은 시스템의 본질적 로직과는 무관한 요소로, 이는 도메인 내에서의 비관심사로 볼 수 있다.

 

배달의 민족, 회원시스템 이벤트 아키텍처 설계 도표(출처 : techblog.woowahan.com/7835/)

@SqsListener(value = "${sqs-join-broadcast}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
public void handleBroadcast(@Payload MemberJoinApplicationEvent payload) {
    messageBroadcastExecutor.broadcast(MemberBroadcastMessage.from(payload));
}

다른 내부 이벤트 처리와 동일하게 두 번째 구독자 계층의 SNS 발행을 책임지는 이벤트 구독자로부터 외부 이벤트가 발행되게 된다.

 

외부 이벤트 & 세 번째 구독자 계층

배달의 민족, 회원시스템 이벤트 아키텍처 설계 도표(출처 : techblog.woowahan.com/7835/)

내부 이벤트를 외부에서 구독하도록 할 수 있지만, 내부 이벤트와 외부 이벤트를 명확히 구분함으로써 내부에는 열린 이벤트, 외부에는 닫힌 이벤트를 제공하는 이점이 있다. 동일한 이벤트를 수신하더라도 각 구독자는 저마다의 목적을 가지고 있다. 이로 인해 각 구독자는 이벤트를 인지하는 것을 넘어, 추가적인 데이터가 필요할 수 있다.

 

열린 내부이벤트, 닫힌 외부이벤트

내부 이벤트는 구독자가 필요한 데이터를 페이로드에 포함시켜 이벤트 처리의 효율성을 높일 수 있다. 페이로드의 확장이 가능한 것은 해당 이벤트가 시스템 내부에서 발생하기 때문이다. 내부 이벤트는 도메인 내에서 존재하므로, 이벤트 발행이 구독자에게 미치는 영향을 명확히 관리할 수 있다. 더불어, 외부에 알릴 필요 없는 내부적인 요소도 이벤트에 포함시킬 수 있다. 이러한 확장 가능성은 내부 이벤트의 본질적인 특성이다.

반면, 외부 이벤트는 내부 이벤트와는 다르다. 내부 이벤트는 도메인 내에서 비관심사를 분리하여 도메인의 응집도를 높이고 비관심사를 효율적으로 처리하는 데 중점을 두고, 외부 이벤트는 시스템 간의 결합도를 줄이는 데 초점을 맞춘다. 외부 이벤트는 발행처가 구독자의 행위에 관심을 두지 않도록 하여, 새로운 의존 관계가 형성되는 것을 방지해야 한다. 구독자가 필요로 하는 데이터가 페이로드에 포함될 경우, 외부 시스템의 비즈니스 변화에 의존하게 되어 결국 시스템 간의 결합도를 높이게 된다. 따라서 외부 시스템과의 의존 관계를 피하기 위해서는 이벤트를 일반화해야 한다.

 

이벤트 일반화

외부 시스템이 수행하고자 하는 행위는 다양하지만, 이벤트 인식 과정은 비교적 단순하게 일반화할 수 있다. 즉, “언제, 어떤 회원이(식별자) 무엇을 하여(행위) 어떤 변화(변화 속성)가 발생했는가”라는 형식으로 나타낼 수 있다. 나름의 육하원칙을 기반으로 한다.

식별자행위, 속성, 이벤트 시간이 포함된다면 어떤 시스템에서도 해당 이벤트를 인지할 수 있을 것이다. 이를 페이로드로 구현하면, 이벤트를 수신하는 측에서 필요한 이벤트를 분류하고 각 시스템에서 필요한 작업을 수행할 수 있다.

public class ExternalEvent {
    private final String memberNumber; // 식별자
    private final MemberEventType eventType; // 행위
    private final List<MemberEventAttributeType> attributeTypes;  // 속성
    private final LocalDateTime eventDateTime; // 이벤트 시간
}

외부 시스템들은 정해진 이벤트 형식 내에서 필요한 작업을 수행하면 되므로, 이벤트를 발행하는 시스템은 외부 시스템의 변화에 영향을 받지 않을 수 있다.

TIP: SNS 속성을 활용하여 구독자가 원하는 이벤트만 필터링할 수 있다. AWS SNS의 속성을 이용하면 각 구독자는 필요로 하는 이벤트 형식이나 속성을 필터로 정의하여, 해당 애플리케이션에 필요한 이벤트만 유입되도록 할 수 있다. 필터링 기능을 통해 애플리케이션이 이벤트를 직접 분류하는 데 소모되는 리소스를 절약할 수 있다.

ZERO-PAYLOAD 방식

ZERO-PAYLOAD 방식은 이벤트 기반 아키텍처에서 외부 이벤트의 부가 정보를 전달하는 방법 중 하나로, 주요 특징은 페이로드(이벤트에 포함되는 데이터)를 최소화하거나 아예 비워두는 것이다. 이 방식을 사용하는 이유는 아래 네 가지로 정리된다.

  1. 느슨한 결합: 이벤트에 부가 정보를 포함하지 않음으로써 외부 시스템과의 의존성을 줄인다. 이는 시스템 간의 결합을 느슨하게 하여 서로의 변경에 대한 영향을 최소화한다.
  2. 일반화된 이벤트: 이벤트는 일반화된 형식을 가지며, 특정 외부 시스템에 맞춰져 있지 않다. 이러한 일반화는 다양한 시스템에서 이벤트를 처리할 수 있게 하며, 이벤트를 수신하는 시스템이 필요한 정보는 API 호출 등을 통해 별도로 가져올 수 있다.
  3. 유연성: 외부 시스템은 이벤트를 수신한 후 필요할 때 API를 통해 최신 상태의 데이터를 가져올 수 있다. 이로 인해 이벤트를 발행하는 시스템의 변경 사항이 외부 시스템에 미치는 영향을 최소화할 수 있다.
  4. 순서 보장 문제 해결: 이벤트 순서 보장 문제를 해소하는 데 도움을 준다. 페이로드가 없거나 최소화되어 있기 때문에, 이벤트 처리 시 발생할 수 있는 복잡한 의존성을 줄일 수 있다.

결론적으로, ZERO-PAYLOAD 방식은 외부 시스템과의 연결을 최소화하고, 시스템 간의 변경에 대한 영향을 줄이며, 데이터의 동적 처리를 가능하게 하는 유연한 접근 방식이다. 이를 통해 더욱 효율적이고 확장 가능한 이벤트 기반 아키텍처를 구축할 수 있다.

배달의 민족, zero-payload 방식 우아콘 발표자료 (출처 : techblog.woowahan.com/7835/)

배달의 민족에서는 외부 이벤트의 부가 데이터를 전달하는 방법으로 ZERO-PAYLOAD 방식을 선택하였다. 이 접근 방식은 이벤트의 순서 보장 문제를 해결하는 데 주로 사용되지만, 페이로드에서 외부 시스템에 대한 의존성을 제거하여 느슨한 결합을 생성할 수 있는 장점이 있다. 외부 시스템은 일반화된 이벤트를 필터링하여 필요한 이벤트만 구독하고, 추가적인 정보는 API를 통해 최신 데이터를 확보할 수 있다.

 

이벤트 트랜잭션 제어는 애플리케이션 이벤트를 통해 수행되며, 내부 이벤트를 통해 시스템 내의 비관심사를 효율적으로 분리할 수 있다. 외부 이벤트는 외부 시스템과의 의존성 없이 발생하게 된다.

결과적으로, 회원 시스템에 이벤트 기반 아키텍처가 성공적으로 구축되었다. 이 시스템은 외부와의 연계성을 줄이면서도 필요한 정보를 동적으로 관리할 수 있는 유연성을 제공한다.

댓글