RabbitMQ로 작업 큐 만들기#
서버 개발 사전 과제를 받았었는데, 주제가 송금 시스템이었다.
자고로 은행 시스템은 과할 정도로 데이터 정합성과 트랜잭션을 요구하는 영역이라고 생각했다.
그리고 단순히 과제를 가지고 있는 지식 내에서 해결하기보다,
최대한 해보고 싶은 것들을 유기적으로 붙여보자는 생각을 하면서 시스템을 설계했다.
아키텍처#

- 서버의 요청을 rabbitMQ를 통해 직렬화하여 동시 요청 환경에서도 요청 간 정합성을 보장하려 노력했다.
- 한도 조회 같은 상태성 로직은 DB 계산 비용을 줄이기 위해 Redis에서 상태를 관리하고,
실패한 경우에만 DB로 fallback을 보내는 Cache-Aside 구조를 설계했다. - 인증 또한 은행 도메인의 중요한 요소지만,
해당 기능에 힘을 실을 필요는 없다고 판단해 Keycloak 기반 SSO로 분리했다.
왜 요청을 큐로 보냈는가#
데이터 정합성과 트랜잭션을 우선적으로 고민하면서,
동시에 들어오는 요청을 어떻게 다룰 것인가? 에 대해 많은 고민을 했었다.
예를 들어, 동일한 계좌에서 여러 출금 요청이 동시에 들어오는 경우,
각 요청이 동일한 시점의 잔액을 기준으로 판단되면
의도하지 않은 결과가 발생할 수 있다.
단순히 락을 통해 문제를 해결하는 시도를 생각할 수 있지만,
확장성을 고려한다면 DB나 서비스 수준에 제한된 락 설계보다
요청 흐름 자체를 제어할 수 있는 구조가 더 적합하다고 보았다.
그래서 요청을 즉시 처리하는 대신,
RabbitMQ를 통해 요청을 이벤트로 직렬화하고
한 계좌에 대한 요청은 항상 순서대로 해석되도록 설계했다.
요청 처리 흐름#

이 시퀀스 다이어그램은, 송금 요청을 즉시 처리하는 것이 아니라 이벤트로 전환하여,
요청 응답과 실제 상태 변경을 분리한 처리 흐름을 나타낸다.
1. 요청과 응답#
클라이언트의 이체 요청은 API 서버에서,
계좌 소유자, 유효성, 한도 조건을 1차 검증한 뒤
즉시 이벤트를 발행하고, 202 Accepted 응답을 eventId와 함께 반환한다.
이 구조에서는
이벤트 발행 후 전달과 클라이언트 응답의 순서는 보장되지 않는다.
API는 요청을 기다리고 응답을 주기보다,
응답이 처리가능한지 확인하고, 이벤트 발행을 확인해주는 역할에 집중한다.
2. 캐시 전략#
한도와 같은 상태성 정보를 Redis에서 관리하면서,
매 요청마다 DB 집계를 수행하지 않도록 했고,
캐시 미스가 발생한 경우에만 DB 조회를 통해 상태를 복구하는
Cache-Aside 전략을 사용했다.
물론 Caffeine Cache같은 로컬 캐시를 사용할 수 있지만,
해당 시스템은 수평 확장을 전제로 한 분산 서버 환경을 가정했기 때문에
상태 공유가 가능한 외부 캐시를 사용하는 것이 더 적합하다고 판단했다.
3. Worker#
Worker는 RabbitMQ로 전달된 이벤트를 순차적으로 소비하며,
실질적인 상태 변경을 담당한다.
여기서도 1차 검증을 통해 걸러낸 요청을 추가적으로 검증해
동시에 들어온 요청 간 충돌 문제를 구조적으로 해소한다.
이를 통해 상태 변경은 항상 단일 책임 지점에서
순서가 보장된 방식으로 수행되도록 설계했다.
RabbitMQ 내부 구성#
앞서 요청을 이벤트로 전환해 처리한다고 설명했지만,
그 다음 고민은 **“RabbitMQ를 어떻게 구성할 것인가”**였다.
RabbitMQ는 단순히 큐에 메시지를 넣고 빼는 도구가 아니라,
Exchange / Queue / Binding이라는 개념을 통해
메시지 라우팅 방식을 명확하게 설계할 수 있다.
이 시스템에서는 다음 기준으로 RabbitMQ를 구성했다.
1. Queue vs Topic#
이번 구조에서는 Topic Exchange를 사용했다.
- 입금 / 출금 / 이체는 모두 “계좌 이벤트”라는 공통점을 가지지만
- 처리 로직과 후속 동작은 서로 다르다
이를 하나의 큐로 몰아넣기보다,
account.deposit
account.withdraw
account.transfer와 같은 routing key를 기준으로
의미적으로 분리된 이벤트 스트림을 만들고 싶었다.
Topic Exchange를 사용하면
- 이벤트 타입을 routing key로 표현할 수 있고
- 필요하다면 account.* 같은 형태로 확장도 가능하다
즉, 지금은 단일 Consumer지만,
구조적으로는 이벤트 기반 확장이 가능한 형태를 유지했다.
2. Producer (API 서버)의 역할#
API 서버는 다음 역할만 수행한다.
- 요청을 검증한다
- 이벤트를 직렬화한다
- RabbitMQ에 publish 한다
여기서 중요한 점은,
API 서버는 실제 상태 변경을 절대 수행하지 않는다는 것이다.
즉, Producer는:
- “이 요청은 처리해도 된다”는 사실만 보장하고
- “언제, 어떻게 처리될지”는 Queue 이후의 책임으로 넘긴다
이로 인해 API 서버는:
- 빠르게 응답할 수 있고
- 동시 요청 상황에서도 상태 경쟁을 피할 수 있다
3. Consumer (Worker)의 역할#
Worker는 RabbitMQ로부터 이벤트를 하나씩 소비하며,
다음 작업을 수행한다.
- 이벤트 수신
- 현재 상태 재검증 (한도, 잔액 등)
- 상태 변경 (DB 반영)
- 캐시 갱신
여기서 중요한 설계 포인트는 Consumer의 처리 단위였다.
- 한 계좌에 대한 이벤트는 순서가 중요하다
- 따라서 동일 계좌의 이벤트는 항상 같은 흐름에서 처리되어야 한다
이를 위해, 큐를 계좌 단위로 쪼개지는 않았지만
이벤트 자체를 순차적으로 소비하도록 설계했다
즉, 정합성의 책임은 Consumer 단에서 일관되게 관리된다.
4. Listener 설정과 순차 처리#
Spring Boot에서는 @RabbitListener를 사용해 메시지를 수신한다.
이때 Listener의 동시성 설정을 낮게 유지함으로써:
- 하나의 Worker 인스턴스 내에서는 이벤트가 순차적으로 처리되고
- 여러 Worker 인스턴스로 확장할 경우에도, Queue 단위의 순서 보장을 활용할 수 있다
이는 “락을 걸지 않고도 순서를 보장하는 방식”으로,
이번 시스템에서 가장 중요한 설계 포인트 중 하나였다.
인증과 Keycloak#
송금 시스템에서 인증과 인가는 분명 중요한 요소다.
하지만 이번 과제에서는 인증 그 자체를 구현하는 것이 목적은 아니라고 판단했다.
그래서 Keycloak을 통해 위임하는 방식을 택했고,
이를 통해 과제에서는 비즈니스 로직에 집중할 수 있었다.
Keycloak 설정 방법 등은
향후에 RBAC 등을 포함해 다시 한 번 적용해보고,
추가적으로 포스팅할 생각이다.
결론#
재밌었다.
내부적으로 헥사고날 아키텍처를 기반으로 설계하고,
Keycloak을 통해 인증을 분리하고,
RabbitMQ를 이용해 메시지 기반으로 요청을 처리하면서
과제보다는 시스템 설계 관점에서 많이 고민해볼 수 있었다.
안 써봤던 기술들을 억지로 끼워 넣기보다는,
왜 필요한지, 어떤 문제를 해결하려는지부터 생각해보는 과정 자체가
이번 과제에서 가장 큰 수확이었다.