외주 작업을 진행하면서 항상 가장 중요시 여겼던 원칙중 하나는 생산성과 개발 속도 였습니다. 이 방법이 틀렸다고 생각하진 않습니다. 빠르게 개발하고 사용자 반응을 살피고 추후 필요한 기능을 덧붙이는 방법은 ROI 를 높인다 생각합니다. 하지만, 빠르게 시스템 규모가 커지고 정합성과 일관성을 고려해야 하는 상황은 점차 다가올거고 확장성을 고려한 시스템 설계도 어느정도 필요하다 생각했습니다.
그래서 이번 외주 프로젝트를 진행할때는 이벤트 기반 아키텍처의 뼈대를 만들고 확장성 있는 구조로 설계해봤고, 그 과정에서 배운 내용과 적용한 사례들에 대해 정리해볼까 합니다. (오버 엔지니어링이긴 한데.. 학습 목적으로 설계 해봤습니다)
이벤트 기반 아키텍처란 ?
이벤트 기반 아키텍처를 설계하기 앞서 개념에 대해 간단히 알아보면 다음과 같습니다.
이벤트 기반 시스템에서는 이벤트 제작자가 이벤트를 생성하고 이벤트 라우터(또는 브로커)에서 이벤트를 필터링한 다음 적절한 이벤트 소비자(또는 싱크)로 팬아웃합니다. 이벤트는 하나 이상의 일치하는 등록(Eventarc Advanced 사용 시) 또는 하나 이상의 일치하는 트리거(Eventarc Standard 사용 시)에 의해 정의된 구독을 기반으로 소비자에게 전달됩니다. 이러한 3가지 구성요소(이벤트 제작자, 이벤트 라우터, 이벤트 소비자)는 분리되어 독립적으로 배포, 업데이트, 확장 가능합니다. - 출처
그럼 이제, 이벤트 기반 아키텍처를 왜 도입해야 하는지 알아야 합니다.
MSA 환경에서 서비스 간에 느슨한 결합을 할 수 있다.
발행 또는 구독하는 시점을 지정할 수 있다.
비동기 이벤트 기반으로 복원력이 존재한다.
즉, 다양한 행위를 이벤트로 발행하고 각각의 트리거 시점을 지정해 도메인간 느슨한 결합을 유지 하는 것이 이벤트 기반 아키텍처의 핵심이라 이해했습니다. 또한, 각 도메인별로 응답을 기다리지 않고 비동기적으로 활용될 수 있기에 타 도메인에 대해 영향을 주지 않는다는 장점이 있습니다.
EDA 설계
어떤 메시지 브로커를 사용할지?
이벤트 기반 아키텍처를 적용하기 전에, 어떤 메시지 브로커를 사용할지 정해야 했고 AWS SNS/SQS 를 선택했습니다. 그 이유는 다음과 같습니다.
- 서비스 규모: 자동 확장으로 초기 소규모 트래픽부터 향후 대규모 트래픽까지 대응 가능
- 금전적 비용: 무료 티어로 초기 비용 $0, 트래픽 증가 시에만 비례적으로 과금
- 개발 공수: 설정 및 학습 시간 최소화, 운영 부담 거의 없어 제품 개발에 집중 가능
MVP 단계에서 개발자는 비즈니스 로직과 기능 개발에 집중해야 하며, 인프라 관리에 시간을 쓰는 것은 기회비용이 큽니다. 때문에 Kafka 와 RabbitMQ 보다 설정이 비교적 간단하고 고성능 처리가 굳이 필요 없는 SNS/SQS 를 통해 메시지를 발행하려 합니다.
이벤트 기반 아키텍처

결제 기능과 같이 정합성과 일관성이 중요한 기능에 대해서만 외부 메시지 브로커(SNS/SQS) 를 사용할 예정이고, 도메인 행위에 대해 이벤트를 발행하고 처리할때는 Spring Framework 에서 제공하는 Application Event 를 사용하려 합니다.
현재 프로젝트는 모놀리식 구조이기 때문에 모든 이벤트 메시지를 SNS 로 보낼 필요는 없다 생각했습니다. 오히로 불필요한 네트워크 접근이 필요하기 때문에 합리적이지 않죠. 물론 추후 MSA 와 같은 구조로 확장성을 고려한다면 도입하는게 맞지만 너무 많은 개발 공수와 오버 엔지니어링이라 판단했습니다.
해당 프로젝트에서 SNS 로 이벤트를 발행하는 기준을 세웠습니다.
1. 데이터 정합성과 일관성이 중요한가?
2. 재시도 로직이 반드시 필요한가?
이 기준에 부합하는 기능은 인앱 결제 기능과 사용자 포인트와 관련된 기능 입니다. 다른 비즈니스 로직에 비해 금전적인 성과와 직결되는 데이터가 포함되는 기능이니 정합성이 중요하고 재시도 로직이 필요하다 생각했습니다. 그러면 SNS/SQS 로 메시지를 발행하는 행위만으로 정합성이 보장될까?
아닙니다. SNS/SQS 메시지는 발행된 메시지가 반드시 소비 되어야 하는 기준이 있지만 해당 요청이 중복으로 처리 됐는지, 이미 처리된 메시지인지 확인하는 과정이 필요하고 이때 사용되는 패턴이 Outbox 패턴 입니다.
Outbox 패턴으로 정합성 보장하기
위 아키텍처에서 OutboxEvent 를 DB 에 저장하는 과정이 있습니다. 이는 이벤트에 대한 정보를 같은 트랜잭션에서 저장한 뒤 메시지 처리 여부를 확인하는 과정입니다.
이번 프로젝트에서 결제 기능에 아웃박스 패턴을 적용했고 아래 포스팅에서 자세히 다루고 있습니다.
https://comumu.tistory.com/195
Outbox 패턴을 이용한 결제 원자성 보장
Outbox 패턴이란?DB 트랜잭션과 이벤트 발행(메시지 전송)을 하나의 일관된 흐름으로 보장하기 위한 시스템 설계 패턴 입니다. 메시징 시스템에서 DB update 와 메시지 발행은 일반적으로 원자성을
comumu.tistory.com
Spring Event 와 느슨한 결합
경기 신청 비즈니스 로직
@Transactional
public void applyGame(final Long userId, final GameApplyRequest request){
validateGameApplyTime(game);
validateAlreadyApply(userId, game);
User user = OptionalUtil.getOrElseThrow(userRepository.findById(userId), NOT_EXIST_USER_ID);
user.validateCouponAmount();
user.decreaseCoupon();
Game game = OptionalUtil.getOrElseThrow(gameRepository.findById(request.getGameId()), NOT_EXIST_GAME_ID);
gameHistoryRepository.save(request.toGameHistory(user.getId(), game.getDistance()));
game.increaseParticipatedCount();
}
기존에 사용중이던 경기 신청 메서드 입니다. 사용자와 경기를 조회하고 다양한 상태 변경이 발생하고 있어 세개의 도메인(User, Game, GameHistory)이 강하게 결합되어 있습니다. 모놀리식 구조에서는 문제없이 동작하지만, 물리적으로 강하게 결합되어 있어 유지 보수와 확장성에 불리합니다.
엥? 외부 메시지를 사용하지 않는다면 굳이 Application Event 를 사용해야 했을까? 그냥 하나의 메서드 트랜잭션안에 모든 비즈니스 로직을 넣어 구현할 수 있지않을까? 라고 생각했었지만 다음과 같은 이유로 Application Event 사용을 결정했습니다.
1. 확장성: 경기 관련 부가 기능이 계속 추가될 것으로 예상 (알림, 통계, 쿠폰, 추천인 등)
2. 성능: 비동기 처리로 빠른 응답 속도 제공
3. 유지보수성: 각 기능이 독립적인 리스너로 관리되어 코드 변경 최소화
4. 에러 격리: 부가 기능 실패가 핵심 기능에 영향 없음
확장성이 뛰어난 구조는 각 도메인간 느슨하게 결합되어 있는 구조 라고 생각합니다. 즉 특정 도메인에 대해 강하게 결합되어 있으면 이는 결코 확정성이 좋은 구조가 아닙니다. 때문에 외부 메시지 브로커로 전송하지 않더라도 도메인간 결합을 느슨하게 풀어내는 걸 목표로 했습니다.
Spring Event 적용 후 코드
@Transactional
public void applyGame(final Long userId, final GameApplyRequest request) {
Game game = OptionalUtil.getOrElseThrow(gameRepository.findByGameConditions(request.getStartAt(), request.getDistance(), request.getType(), request.getActivityType()), NOT_EXIST_GAME_ID);
game.applyGame(userId, request.getAverageBpm(), request.getTargetCadence());
}
//Game 엔티티 내부 메서드
public void applyGame(Long userId, Integer averageBpm, Integer targetCadence) {
increaseParticipatedCount();
registerEvent(GameApplyEvent.from(userId, this.getId(), this.getDistance(), averageBpm, targetCadence, this.getStartAt(), this.getEndAt()));
}
Spring Event 를 적용한 코드 입니다. 즉 경기를 신청할때 Game 엔티티 내부 메서드에서 GameApplyEvent 를 발행합니다. 이후 각각 Game, User 도메인에 대한 각각의 리스너에서 비즈니스 로직을 처리하죠. 즉, 메시지를 발행함과 동시에 User 도메인과 물리적 의존관계가 사라짐을 확인할 수 있습니다.
또한 단순히, 메시징 시스템으로 보낸 메시지가 대상 도메인에게 기대하는 목적을 담는다면 이것은 이벤트라고 부르지 않는다고 합니다. 그저 메시징 시스템을 이용한 비동기 요청일 뿐이죠.
때문에 위 코드에서, 경기 신청(GameApplyEvent) 라는 이벤트만 발행하고 이벤트를 처리하는 리스너를 각 도메인별로 확장시키면서 코드를 유지보수 할 수 있게 됐습니다.
Fan-Out 패턴
하나의 이벤트를 여러개의 독립적인 소비자로 동시에 전파하는 구조 입니다. 즉, 1개의 이벤트가 N개의 소비자가 각자 독립적으로 처리하는 구조를 의미 합니다. 그렇다면 팬아웃 패턴은 왜 필요할까? 핵심은 바로 하나의 비즈니스 이벤트가 발생했을때 여러 후속 작업을 서로 독립적으로 처리하고 싶을때 사용하는 것 입니다.
예를들어, 게임이 종료됐을때 리워드 지급, 알림 전송, 경기 상태 변경 과 같은 비즈니스 로직이 있을때 각각의 행위에 대한 컨슈머(소비자) 를 만들 수 있습니다.

각 비즈니스 로직간 결합도가 감소하고, 유지보수와 확장성에 유리합니다. 또한 가장 핵심은 바로 장애 격리 입니다. 즉, 서로 독립적으로 실행되다 보니 하나의 비즈니스 로직이 실패했을때 다른 컨슈머에서 동작하는 로직은 정상적으로 실행됩니다.
이러한 특성때문에, 일관성과 정합성이 중요한 기능에서는 Fan-Out 패턴 사용을 조심해야 합니다. 만약, 게임이 종료되고 리워드가 지급됐는데 게임 상태가 변경되지 않으면 정합성이 깨져 크리티컬한 장애가 발생할 수 있습니다. 때문에 정합성이 중요한 비즈니스 로직들은 하나의 트랜잭션으로 묶고, 알림 전송과 같은 기능들을 분리하는 용도로 사용해야 할 것 같습니다.
'Devops' 카테고리의 다른 글
| [AWS] ELB 정리 (0) | 2026.01.19 |
|---|---|
| [AWS] ALB 와 NAT Instance 를 이용한 AWS 아키텍처 설계 (0) | 2026.01.19 |
| ElasticSearch 를 EC2 에서 실행하기 (0) | 2025.04.27 |
| [Devops] GitHub Container Registry 를 사용한 협업 방법 (2) | 2025.03.16 |
| [Devops] Docker Compose 를 활용한 Redis Ec2 배포 (0) | 2025.01.18 |