인메모리 버스 설계와 트랜잭션 훅 연동
Channel Talk
안녕하세요. 채널톡 백엔드 엔지니어 후입니다.
이 글에서는 채널톡 앱스토어를 개발하면서 마주쳤던 확장성 문제를 인메모리 이벤트 버스를 통해 해결했던 과정을 공유하려 합니다. 왜 인메모리 이벤트 버스를 개발했으며, 어떤 부분을 고민 했는지, 그 결과는 어땠는지를 공유드릴게요.
채널톡 앱스토어는 채널톡과 연동하여 다양한 부가 기능을 제공하는 앱들을 모아놓은 플랫폼입니다. 앱스토어의 각 앱들은 커맨드(”/”)와 Native Function을 통해 다양한 기능을 제공할 수 있고 이를 통해 사용자에게 편리함을 제공합니다. 예를 들어 채널톡에서 제공하는 Cafe24 허브나 Shopify 허브 같은 앱을 제공해 사용자가 편리하게 주문 조회, 주문 취소 등을 할 수 있게 하죠.
앱스토어는 사용자와 개발자에게 편리한 기능을 제공하기 위해서 복잡하게 동작하고 있습니다. 예를 들어, 앱을 삭제할 때 단순히 앱만 지워지는 것이 아니라 앱과 관련된 여러 도메인에 걸쳐 삭제 요청이 필요할 수 있습니다. 외부로 발행하는 Hook을 사용한다면 더 많은 도메인과 연결됩니다.
앱스토어가 채널톡의 점점 더 많은 확장 포인트를 제공하는 플랫폼이 될 것이라는 것을 생각해 보았을 때, 여러 도메인에 걸쳐 발생하는 요청들을 동기 방식으로만 처리하는 것은 분명한 한계가 존재했습니다. 이는 높은 결합도, 낮은 확장성, 코드 수정의 어려움, 그리고 증가하는 복잡성으로 이어졌습니다. (실제로 이런 비효율이 감지되고 있었습니다.)
이 문제를 해결하기 위해서 다양한 솔루션을 고려했습니다.
메시지 큐 사용
Saga 등 분산 트랜잭션 패턴
CDC + Outbox
이벤트 기반(Pub/Sub)
이벤트 기반을 제외한 위 예시들 모두 도메인 경계를 보존 할 수 있기 때문에 결합이 낮아지는 기대할 수 있는 있었지만, 각각 멀티 캐스트가 불가능 하거나 동기 호출만 지원하거나, DB 트랜잭션을 연동이 어렵거나, 확장시에 다른 코드에 영향을 많이 주는 등 크고 작은 제약이 있었습니다.
하지만 이벤트 기반 설계는 이러한 문제들을 효과적으로 해결할 수 있었습니다. 멀티 캐스트가 가능하고, 동기/비동기 등 실행 방식을 유연하게 결정할 수 있고, DB 트랜잭션을 사용할 수 있으며, 시스템 확장시 다른 컴포넌트에 미치는 영향을 최소화 할 수 있다는 장점이 있었습니다. 이러한 이유로 앱스토어는 이벤트 기반 아키텍처를 통해 기존의 문제들을 해결하고 더 나은 확장성을 확보하기로 결정했습니다.
이벤트 중심 아키텍처(EDA)는 시스템을 설계하는 패러다임 중 하나로, 이벤트의 발생, 감지, 소비를 중심으로 시스템을 구성합니다. 여기서 이벤트는 시스템 내에서 발생한 중요한 상태 변화를 나타내는 메시지로, 예를 들어, “사용자가 삭제되었다”, “주문이 생성되었다”와 같은 것들이 모두 이벤트가 될 수 있습니다.
EDA의 핵심은 서비스 간의 느슨한 결합입니다. 이벤트를 발생시키는 **생산자(publisher)**는 어떤 소비자가 이 이벤트를 처리하는지 알 필요가 없습니다. 마찬가지로 이벤트 **소비자(consumer)**도 어떤 생산자가 이벤트를 발생시켰는지 알 필요가 없습니다. 그저 특정 이벤트에 관심이 있으면 그 이벤트를 구독하고 처리할 뿐입니다.
이런 느슨한 결합으로 인해 다음과 같은 여러 장점이 있습니다.
확장성(Scalability): 새로운 이벤트 소비자가 추가되더라도 기존 서비스에 영향을 주지 않아 시스템 확장이 용이합니다. 예를 들어, 앱 삭제 시 새로운 작업을 추가해야 할 경우, 단순히 새로운 이벤트 핸들러를 추가하면 됩니다.
유지보수성(Maintainability): 각 서비스가 자신의 책임에만 집중하게 되어 코드의 응집도를 높이고, 가독성을 향상시켜 유지보수가 용이해집니다.
복원력(Resilience): 특정 서비스에 문제가 발생하더라도 다른 서비스에 영향을 덜 미칩니다. 이벤트가 비동기적으로 처리되므로, 한 서비스가 일시적으로 작동하지 않더라도 이벤트는 대기하고 있다가 서비스가 복구되면 처리될 수 있습니다.
물론 이러한 장점에 대한 단점들도 존재합니다.
복잡성 증가: 시스템의 분산화로 인해 이벤트 흐름을 추적하고 디버깅하는 것이 어려워질 수 있습니다. 이벤트가 여러 서비스를 거쳐 처리될 때 전체적인 데이터 흐름을 파악하기가 어려워집니다.
멱등성(Idempotency) 관리: 이벤트가 중복으로 발행되거나 재처리될 수 있는 상황에 대비하여, 이벤트 소비자는 동일한 이벤트를 여러 번 처리하더라도 결과가 동일하게 유지되도록 멱등성을 고려해야 합니다.
이벤트 정합성 보장: 여러 서비스가 동일한 이벤트에 반응하여 데이터를 변경할 때, 데이터의 일관성을 어떻게 보장할지 설계 단계부터 신중하게 고려해야 합니다.
EDA에서 이벤트 버스(Event Bus)는 이벤트 생산자와 소비자 사이에서 이벤트 전달을 중개하는 핵심적인 역할을 합니다. 이벤트 버스는 생산자가 발행한 이벤트를 받아 관심 있는 모든 소비자에게 효율적으로 전달하는 메커니즘을 제공합니다.
이벤트 버스는 다음과 같은 기능을 수행합니다.
이벤트 라우팅: 발행된 이벤트를 올바른 소비자로 전달합니다.
구독 관리: 어떤 소비자가 어떤 이벤트에 관심 있는지(구독하고 있는지) 관리합니다.
확장성 제공: 생산자와 소비자가 서로 직접 통신하지 않으므로, 양쪽을 독립적으로 확장할 수 있습니다.
이벤트 버스는 크게 두 가지 형태로 구현될 수 있습니다. 하나는 Kafka, RabbitMQ와 같은 분산 메시지 브로커를 활용하는 방식이고, 다른 하나는 단일 애플리케이션 내에서 작동하는 인메모리 이벤트 버스 방식입니다.
앱스토어의 경우, 초기 시스템 설계 시 분산 메시지 브로커 도입에 따른 운영 복잡성 및 추가적인 인프라 비용을 고려해야 했습니다. 반면, 인메모리 이벤트 버스는 분산 환경의 오버헤드를 피하면서도 모놀리식 애플리케이션 내부에서 컴포넌트 간의 결합도를 낮추고 유연성을 확보할 수 있다는 장점이 있었습니다.
따라서, 앱스토어에서는 시스템의 초기 복잡성을 최소화하면서도 EDA의 핵심 이점인 느슨한 결합과 확장성을 확보하기 위해 인메모리 이벤트 버스를 직접 구현하는 길을 택했습니다. 이 접근 방식은 추후 시스템 규모가 커지거나 분산이 필요해질 때, 외부에 메시지 큐 시스템을 도입하는 전환 비용을 최소화할 수 있는 유연성을 제공합니다.
앱스토어의 확장성 문제를 해결하기 위해 이벤트 기반 아키텍처를 도입하기로 결정한 후, 인메모리 이벤트 버스의 요구사항을 설정하는데 먼저 많은 노력을 기울였습니다. 기존의 문제점을 분석하고, 미래 확장성을 동시에 고려하여 다음과 같은 핵심 요구사항을 도출할 수 있었습니다.
간단한 사용성: 개발자가 복잡한 설정 없이 쉽고 직관적으로 이벤트를 발행하고 구독할 수 있어야 합니다.
동기/비동기 처리 지원: 특정 이벤트는 즉각적인 응답이 필요한 동기 방식으로, 또 다른 이벤트는 백그라운드에서 처리되어도 되는 비동기 방식으로 유연하게 실행될 수 있어야 합니다.
멀티 캐스트 지원: 하나의 이벤트가 발생했을 때, 여러 개의 관련 컴포넌트(구독자)가 동시에 이 이벤트를 받아 처리할 수 있어야 합니다.
트랜잭션 연동: 데이터베이스 트랜잭션과 연동되어, 트랜잭션이 성공적으로 커밋되었을 때만 이벤트가 발행되도록 선택적으로 보장해야 합니다.
예외 처리 및 로깅: 이벤트 처리 중 발생하는 예외를 적절히 처리하고, 문제 발생 시 원인을 파악할 수 있도록 상세한 로깅 기능을 제공해야 합니다.
위 요구 사항을 바탕으로 인메모리 이벤트 버스를 구성하는 주요 컴포넌트들을 다음과 같이 구성했습니다.
Publisher(발행자)
: 시스템에서 이벤트를 발행하는 컴포넌트입니다. 비즈니스 로직에서 이벤트를 발행하면 Multicaster
에 이벤트를 전달합니다.
Mutlicaster(분배기)
: Publisher
가 발행한 이벤트를 받아 등록된 이벤트 구독자에게 이벤트를 분배하는 역할을 합니다.
Handler(subscriber, 구독자)
: Multicaster에 등록되어 실제로 이벤트를 받아 처리하는 역할을 합니다. 종류는 다음과 같습니다.
EventHandler
: 동기 방식으로 이벤트를 처리합니다.
TransactionalEventHandler
: 동기/트랜잭션으로 이벤트를 처리합니다.
AsyncEventHandler
: 비동기로 이벤트를 처리합니다.
TransactionalAsyncHandler
: 비동기/트랜잭션으로 이벤트를 처리합니다.
실행 흐름은 다음과 같습니다.
시스템 실행 시점에 Multicaster
에 이벤트를 처리하는 Handdler
의 함수와 구독할 이벤트를 함께 등록합니다.
시스템에 요청이 들어왔을 때 비즈니스 로직을 수행하고, 이벤트를 발행하여 처리합니다.
그럼 인터페이스를 살펴보며 이어나가겠습니다.
Publisher
는 이벤트를 발행하는 발행자입니다. Publisher
의 Publish(ctx context.Context, event any)
함수에서 인자로 받는 이벤트의 타입을 any
로 받고 있습니다. 이 덕분에 어떤 이벤트든 모두 발행할 수 있다는 장점이 있습니다.
Mutlicaster
는 이벤트를 분배하는 역할을 합니다. Publisher
가 발행한 이벤트를 Multicaster
가 넘겨 받아 해당 이벤트를 구독하고 있는 Handler
의 등록된 함수를 호출합니다. 여기서 고려해야할 사항이 세 가지가 있습니다.
트랜잭셔널한 핸들러를 어떻게 실행할 것인가
비동기 핸들러를 어떻게 실행할 것인가
비동기 핸들러의 에러는 어떻게 처리할 것인가
먼저, 트랜잭셔널한 핸들러는 실행 phase를 명시한 핸들러를 말합니다. 실행 phase에는 Before Commit
, After Commit
, After RollBack
, After Completion
이 있습니다. 각각 트랜잭션 커밋 직전, 커밋 이후, 롤백 이후, 모든 트랜잭션 동작이 완료된 후를 의미하며, 명시된 phase에 따라 핸들러가 실행됩니다. 어떻게 하면 이런 핸들러를 원하는 시점에 실행할 수 있을까요?
이는 트랜잭션이 적용된 코드가 어떻게 실행되는지를 이해하면 알 수 있습니다. Java에서 트랜잭션은 AOP와 다이내믹 프록시를 사용하여 호출을 가로채어 실행됩니다. Go는 Java와 같은 런타임 AOP나 다이내믹 프록시를 직접 지원하지 않아 명시적인 호출이 필요하지만, 트랜잭션 경계를 특정 로직으로 감싸는 동작 원리는 유사합니다. 코드로 한번 살펴보겠습니다.
이렇게 트랜잭션 매니저는 실행할 함수 혹은 메서드를 인자로 받아서 트랜잭션을 시작하고, 트랜잭션을 커밋하거나 롤백할 수 있습니다. 만약 여기서 예외가 발생하면, 트랜잭션은 롤백됩니다.
이벤트 핸들러도 이와 비슷한 매커니즘을 이용하면 원하는 시점에 실행할 수 있습니다.
우리는 이미 코드에서 커밋 직전, 커밋 직후 등의 시점을 명확하게 알 수 있습니다. 그렇기 떄문에 그 시점에 Hook 실행을 지원한다면 원하는 시점에 실행이 가능할 것이라고 판단했습니다.
이렇게 AddHook
메서드를 호출해 원하는 실행 시점에 Hook을 등록하게 하고, TransactionManager
가 Hook을 호출하게 하면 핸들러도 원하는 시점에 실행이 가능해집니다.
다음으로, 비동기 핸들러를 어떻게 실행시킬 것인지에 대한 문제가 있었습니다. 앱스토어는 Go 언어로 구현되어 있기 때문에 비동기 처리에 대한 고민이 크지는 않았습니다. Go 루틴은 초기 스택 크기가 2KB 정도로 매우 작고, 컨텍스트 스위칭 작업을 유저 공간(user space)에서 처리하여 오버헤드가 훨씬 작기 때문에 비동기 작업의 경우 새로운 Go 루틴을 생성해서 실행시키면 되기 때문이죠.
그럼에도 불구하고 많은 Go 루틴 생성은 메모리나 CPU 자원을 소비할 수 있기 때문에 Go 루틴 풀을 구성했습니다. multicaster
가 이벤트를 전달 받고 핸들러를 호출할 때, 해당 핸들러가 비동기 핸들러라면 Go 루틴 풀에서 해당 핸들러의 이벤트 처리 함수를 실행 시키도록 했습니다.
마지막으로, 비동기 핸들러의 에러처리에 대한 문제가 있었습니다. 동기 핸들러의 경우 에러가 발생하면 트랜잭션이 롤백 되거나 혹은 요청에 대해 에러를 반환해 요청자에게 에러를 응답하지만 비동기 핸들러의 경우 백그라운드에서 동작하기 때문에 에러 상황에 대한 인지가 쉽지 않았습니다. 또한 모든 비동기 핸들러가 중요한 핸들러는 아니기에 단순 로깅만을 필요로 하는 경우도 있었습니다.
그래서 이를 보완하기 위해서 비동기 핸들러의 경우 ErrorHandler
를 등록할 수 있게 했습니다.
ErrorHandler
인터페이스를 제공함으로써 개발자는 로깅 뿐만 아니라 다양한 방식으로 에러를 핸들링 할 수 있습니다.
앱스토어에서는 이벤트 버스를 적용한 후 여러가지 변화들이 있었습니다.
위 코드는 이벤트 버스를 도입하기 전에 사용했던 코드입니다.
기존에는 특정 함수 호출에 트랜잭션 안에서 동작해야하는 함수와 트랜잭션 바깥에서 동작해야하는 함수를 명시적으로 구분해서 호출해야만 했습니다.
또한 LifecycleListener
의 인터페이스를 구현하는 모든 리스너의 존재를 알고있어야해서 강한 결합이 있었고, 확장성도 떨어졌습니다.
하지만 이벤트 버스 적용 이후 위 코드는 다음과 같이 바뀌었습니다.
기존 코드 보다 AppSvc
는 앱 생성이라는 핵심 비즈니스 로직에만 집중할 수 있기에 관심사가 확실하게 분리 되었고, 느슨한 결합을 통해 이벤트 처리로직에 대한 직접적인 의존성을 가지지 않게 되었습니다. 또한 간단히 handler
추가를 통해 쉽게 확장할 수 있고, 유연하게 트랜잭션 phase 관리도 할 수 있게 되었습니다.
앱스토어는 사용자와 개발자 모두에게 확장성을 제공하기 위해 진화하고 있으며, 이런 과정에 인메모리 이벤트 버스를 성공적으로 도입했습니다. 이 결정을 통해 여러 도메인에 걸쳐있던 기능들의 복잡한 동기 호출을 효과적으로 제거하고, 느슨한결합이라는 EDA의 핵심 이점을 내부 시스템에 적용할 수 있었습니다.
인메모리 이벤트 버스는 개발 단계에서 유연성과 확장성을 확보하는 데 큰 역할을 했습니다. 각 컴포넌트는 이제 직접적인 의존성 없이 이벤트를 통해 소통하며, 새로운 기능 추가나 기존 로직 변경 시 다른 코드에 미치는 영향을 최소화할 수 있게 되었습니다. 이는 실제로 코드 수정의 유연성을 높이고 응집도를 개선하는 결과로 이어졌습니다.
물론, 추가적인 확장 가능성도 여전히 존재합니다. 내부에서만 처리하는 이벤트를 메시지 큐와 같은 외부 분산 이벤트 시스템을 도입해 진정한 의미의 수평적 확장성을 확보할 수 있고, 이를 통해 영속성을 보장하고 더욱 안정적인 이벤트 전달이 가능할 것입니다.
이번 인메모리 이벤트 버스 개발을 통해, 추상적인 아키텍처 원칙이 실제 코드에 어떻게 적용되어 구체적인 문제 해결에 기여하는지 깊이 있게 이해할 수 있었습니다.
We Make a Future Classic Product