실무에서 Kafka를 쓰고 있었다. 메시지를 produce하고, consume하고, 모니터링 대시보드를 확인하는 정도였다. 클러스터를 직접 구성하거나, topic 설계부터 consumer group 전략까지 처음부터 결정해본 적은 없었다.
Hexagonal Architecture도 비슷했다. 개념은 알고 있었고, 기존 코드에서 port/adapter 패턴을 따르고 있었지만, 빈 프로젝트에서 레이어를 나눠본 경험은 없었다.
직접 다뤄보고 싶었다. 그래서 채팅 시스템을 만들기로 했다.
왜 채팅인가
채팅은 Kafka의 pub/sub 모델과 자연스럽게 맞물리는 도메인이다. 메시지를 발행하고 구독자에게 전달하는 흐름이 채팅의 핵심 동작과 일치한다.
WebSocket 기반 실시간 통신, 이벤트 기반 아키텍처, 멀티 인스턴스 간 메시지 동기화. 이 세 가지를 하나의 프로젝트에서 다룰 수 있다고 판단했다.
기술 선택
Go + Java
채팅 서비스는 Go로 만들었다. 경량 goroutine 기반의 동시성 처리가 WebSocket 서버에 적합하다고 봤다. 사용자 인증 서비스는 Java(Spring WebFlux)로 만들었다. OAuth2 + JWT 인증은 Spring Security 생태계가 잘 갖추어져 있었고, 이미 익숙한 프레임워크였다.
API Gateway는 Kotlin으로 Spring Cloud Gateway를 사용했다. user-service와 같은 reactive 스택 위에서 동작하는 점, Java 생태계와의 연속성이 선택 이유였다.
MongoDB
채팅 메시지는 document 구조로 저장하는 것이 자연스러웠다. 방과 메시지가 비정형 데이터에 가까웠고, 스키마 변경이 잦을 것이라 예상했다.
처음에는 Redis를 사용했다. 빠르게 프로토타이핑하기에는 좋았지만, 메시지 영속성이 필요해지면서 MongoDB로 전환했다.
Kafka KRaft
Kafka는 KRaft 모드로 구성했다. ZooKeeper 의존성 없이 Kafka 자체적으로 메타데이터를 관리하는 방식이다. 별도 ZooKeeper 클러스터를 운영하지 않아도 되어 인프라 구성이 단순해졌다.
3-node 클러스터를 Docker Compose로 구성했고, 각 노드가 controller와 broker 역할을 겸임하도록 설정했다.
아키텍처 진화
프로젝트는 한 번에 설계한 것이 아니다. PR 단위로 점진적으로 바뀌어갔다.
시작
처음에는 user-service(Java)와 chat-service(Go) 두 개로 시작했다. chat-service가 WebSocket 핸들링, 방 관리, 메시지 저장, 브로드캐스팅을 전부 담당했다. 저장소는 Redis였다.
Redis → MongoDB
메시지를 영속적으로 저장해야 했다. Redis는 인메모리 특성상 적합하지 않다고 판단했고, MongoDB로 교체했다. 이 과정에서 repository 계층만 교체하면 되는 구조의 이점을 체감했다. Hexagonal Architecture를 적용해둔 덕분이었다.
Hexagonal Architecture 정리
user-service를 먼저 정리했다. 기존에 대략적으로 나눠져 있던 패키지를 domain/entity, port/driving, port/driven, adapter/driving, adapter/driven 구조로 재배치했다. 이후 chat-service에도 같은 구조를 적용했다.
Kafka 도입
Kafka producer를 먼저 구현하고, 이어서 consumer를 추가했다. 이때 동시성 문제를 마주했다.
채팅 방에 사용자가 join/leave하는 동안 메시지가 동시에 브로드캐스트되면 race condition이 발생했다. RoomManager에 2단계 lock 전략을 도입해서 해결했다. 방 목록 접근에는 RoomManager 레벨의 RWMutex를, 방 내부 참가자 접근에는 LiveRoom별 RWMutex를 사용해 병목을 줄였다.
서비스 분리
chat-service가 커지면서 messenger-service와 message-service를 분리했다. messenger-service는 Kafka producer/consumer와 WebSocket 핸들링을, message-service는 메시지 저장과 조회를 담당한다.
Fat Domain
초기에는 도메인 엔티티가 데이터만 들고 있었다. 도메인 로직을 엔티티로 옮기고, application 계층에 usecase 패턴을 도입했다. 각 usecase는 단일 Handle 메서드를 가지며, 하나의 비즈니스 동작만 책임진다.
Kafka를 채팅 브로커로
메시지 흐름은 다음과 같다.
sequenceDiagram
participant C as WebSocket Client
participant S as SendUseCase
participant DB as MongoDB
participant K as Kafka
participant B as MessageBroker
participant R as RoomManager
C->>S: 메시지 전송
S->>DB: 메시지 저장
S->>K: Kafka publish
K->>B: Consumer 수신
B->>S: OnReceive 콜백
S->>R: Broadcast
R->>C: WebSocket 전달
SendUseCase가 MessageSubscriber 인터페이스를 직접 구현하고, MessageBroker에 자기 자신을 등록한다. Observer 패턴이다. Consumer가 메시지를 수신하면 등록된 모든 subscriber의 OnReceive를 호출하고, subscriber는 RoomManager를 통해 해당 방의 모든 WebSocket 클라이언트에게 메시지를 전달한다.
이 구조의 이점은 수평 확장이다. 채팅 서비스 인스턴스가 여러 개 떠 있을 때, 한 인스턴스에서 발생한 메시지가 Kafka를 통해 다른 인스턴스에도 전달된다. 같은 방에 접속한 사용자가 서로 다른 인스턴스에 연결되어 있어도 메시지를 주고받을 수 있다.
회고
Kafka를 직접 다뤄보고 싶어서 시작한 프로젝트였다.
Hexagonal Architecture가 Go에서 자연스럽게 동작한다는 것을 확인했다. Go의 암묵적 인터페이스 덕분에 port를 정의하고 adapter를 구현하는 과정이 간결했다. DI 프레임워크 없이 main 함수에서 직접 의존성을 조립하는 방식도 오히려 명시적이고 추적하기 쉬웠다.
동시성 제어에서 가장 많이 배웠다. 처음에는 하나의 RWMutex로 전체 방 목록을 보호했는데, 병목이 생겼다. 방 목록과 방 내부 참가자를 분리해서 각각 lock을 거는 2단계 전략으로 바꾸니, 벤치마크에서 확연한 차이가 나타났다. 이론으로 이해하는 것과 직접 벤치마크를 돌려보며 체감하는 것은 다른 경험이었다.
아쉬운 점도 있다. 테스트 코드가 부족했다. Hexagonal Architecture의 핵심 이점 중 하나가 port를 mock으로 교체해서 테스트하기 쉽다는 것인데, 테스트를 충분히 작성하지 못했다.
gRPC도 설정만 해두고 서비스 간 통신에는 적용하지 못했다. 현재 서비스 간 통신은 전부 REST다. gRPC 적용은 다음 단계로 남겨두었다.
Kafka를 직접 다뤄보고 싶어서 시작했고, 그 이상을 얻었다. 아키텍처 설계, 동시성 제어, 서비스 분리. 하나의 시스템 안에서 함께 마주하는 것은 각각을 따로 공부하는 것과 다른 경험이었다.