디커플링 제대로 이해하기: 대기열과 발행/구독으로 버티는 시스템
동기 호출의 문제와 SQS 대기열, SNS 발행/구독, EventBridge로 서비스를 분리하는 방법을 살펴봅니다.
왜 디커플링이 필요할까요
디커플링은 한 부품이 멈춰도 나머지가 계속 일하도록 부품 사이에 완충 지대를 두는 설계입니다. 핵심은 "서로를 직접 부르지 않고, 메시지를 맡겨 두는 것"입니다.
쇼핑몰 주문 서버를 떠올려 보겠습니다. 주문이 들어오면 결제 서버를 호출하고, 결제가 끝나면 이메일 서버를 호출하고, 이어서 재고 서버를 호출합니다. 모두 직접 호출로 묶여 있는 상태를 강한 결합이라고 부릅니다.
이 구조에서는 이메일 서버 하나가 느려지기만 해도 주문 서버의 요청이 줄줄이 대기합니다. 대기하는 요청이 스레드와 커넥션을 붙잡고, 결국 주문 서버까지 응답을 못 하게 됩니다. 작은 고장이 도미노처럼 번지는 이 현상을 연쇄 장애라고 합니다. 디커플링은 바로 이 도미노 사이에 틈을 만드는 일입니다.
전화와 문자: 동기와 비동기
동기 호출은 전화입니다. 상대가 받을 때까지 기다려야 하고, 상대가 바쁘면 나도 아무 일을 못 합니다. 비동기 호출은 문자입니다. 보내 두면 나는 바로 다음 일을 하고, 상대는 여유가 생길 때 확인합니다.
그렇다고 모든 걸 비동기로 바꾸면 안 됩니다. 기준은 간단합니다. 사용자가 그 결과를 지금 보고 있어야 하는가를 물어보면 됩니다.
| 작업 | 방식 | 이유 |
|---|---|---|
| 로그인, 장바구니 조회 | 동기 | 사용자가 화면 앞에서 결과를 기다림 |
| 주문 접수 확인 | 동기 | "주문이 접수되었습니다"까지는 즉시 알려야 함 |
| 확인 이메일, 알림 발송 | 비동기 | 몇 초 늦어도 아무도 모름 |
| 이미지 변환, 리포트 생성 | 비동기 | 오래 걸리고 결과는 나중에 봐도 됨 |
| 통계 집계, 로그 적재 | 비동기 | 본 업무와 무관한 부수 작업 |
즉, 사용자 응답에 꼭 필요한 최소한만 동기로 남기고, 나머지는 "문자를 보내는" 쪽으로 돌리는 것이 요령입니다.
대기열: Amazon SQS
대기열은 보내는 쪽과 받는 쪽 사이에 놓인 우편함입니다. 생산자는 메시지를 넣고 바로 돌아가며, 소비자는 자기 속도에 맞춰 꺼냅니다. SQS가 주는 이점은 네 가지입니다.
- 버퍼링: 주문이 순간적으로 몰려도 대기열이 받아 두고, 소비자는 천천히 처리합니다.
- 내구성: 메시지는 여러 가용 영역에 복제되어 저장되고, 기본 4일(최대 14일) 동안 보관됩니다. 소비자가 잠시 죽어도 일감은 사라지지 않습니다.
- 독립적인 확장: 대기열에 쌓인 메시지 수(대기열 깊이)를 보고 소비자 서버 수만 따로 늘리거나 줄일 수 있습니다.
- 풀(pull) 방식: 소비자가 직접 메시지를 가져갑니다. 감당할 수 있는 만큼만 가져가므로 소비자가 넘치지 않습니다.
메시지를 꺼낸다고 바로 지워지지는 않습니다. 꺼낸 메시지는 가시성 제한 시간 동안 다른 소비자에게 숨겨지고, 처리를 끝낸 소비자가 직접 삭제해야 사라집니다. 처리 도중 서버가 죽으면 시간이 지나 메시지가 다시 보이고, 다른 소비자가 이어받습니다. 그래서 가시성 제한 시간은 평소 처리 시간보다 넉넉하게 잡아야 합니다.
이 방식은 메시지를 잃지 않는 대신, 같은 메시지가 두 번 전달될 수 있습니다. 이를 최소 1회 전달이라고 합니다. 반대로 무언가 잘못돼서 계속 실패하는 메시지는 정해둔 횟수만큼 재시도한 뒤 DLQ(Dead Letter Queue)로 옮겨 둡니다. 독이 된 메시지 하나가 대기열 전체를 막지 않게 하고, 나중에 사람이 원인을 살펴볼 수 있게 해 줍니다.
SQS에는 두 종류가 있습니다.
| 구분 | 표준 대기열 | FIFO 대기열 |
|---|---|---|
| 순서 | 대체로 지켜지지만 보장하지 않음 | 메시지 그룹 안에서 엄격히 보장 |
| 중복 | 드물게 두 번 전달될 수 있음 | 중복 제거 ID로 중복 입력을 걸러줌 |
| 처리량 | 사실상 무제한 | 제한이 있음(배치와 고처리량 모드로 늘릴 수 있음) |
| 어울리는 곳 | 알림, 이미지 처리처럼 순서가 상관없는 작업 | 계좌 입출금, 주문 상태 변경처럼 순서가 중요한 작업 |
순서가 정말 필요할 때만 FIFO를 고르고, 그 외에는 표준 대기열로 충분합니다.
발행/구독: Amazon SNS
대기열이 "한 통의 편지를 한 명이 처리"하는 구조라면, 발행/구독은 "한 번 방송하면 구독자 모두가 받는" 구조입니다. SNS에서는 발행자가 토픽에 메시지를 한 번 보내면, 그 토픽을 구독한 SQS, Lambda, HTTP 엔드포인트, 이메일 등에 복사본이 밀어 넣어집니다(push).
발행자는 누가 구독하는지 전혀 모릅니다. 새 기능이 주문 이벤트를 필요로 하면 구독자만 하나 더 붙이면 되고, 주문 서버 코드는 그대로입니다. 구독자마다 필터 정책을 달면 원하는 메시지만 받을 수도 있습니다. 예를 들어 해외 배송 서비스는 country가 KR이 아닌 주문만 받도록 걸러 낼 수 있습니다.
다만 SNS는 메시지를 저장해 두는 곳이 아닙니다. 받는 쪽이 길게 멈춰 있으면 재시도 끝에 메시지가 사라질 수 있고, 소비자가 자기 속도를 조절할 수도 없습니다. 그래서 실무에서는 각 구독자 앞에 SQS 대기열을 하나씩 두는 SNS + SQS 팬아웃을 자주 씁니다. SNS는 복사를, SQS는 보관과 속도 조절을 맡는 역할 분담입니다.
이벤트 기반 아키텍처와 EventBridge
여기서 한 걸음 더 나가면 이벤트 기반 아키텍처가 됩니다. 서비스는 "결제 서버야, 이것 해 줘"라고 명령하지 않고, "주문이 생겨났습니다"라는 사실만 알립니다. 무엇을 할지는 듣는 쪽이 정합니다.
EventBridge는 이런 이벤트를 모으는 이벤트 버스입니다. 이벤트가 버스에 들어오면 규칙이 내용을 보고 알맞은 대상(Lambda, SQS, Step Functions 등)으로 보냅니다. SNS 필터보다 이벤트 본문을 더 세밀하게 골라낼 수 있고, AWS 서비스와 SaaS에서 나오는 이벤트도 그대로 받을 수 있습니다.
| 서비스 | 한 줄 요약 | 전달 방식 | 저장 | 언제 쓰나요 |
|---|---|---|---|---|
| SQS | 한 메시지를 한 소비자가 처리 | 풀 | 최대 14일 | 작업을 쌓아 두고 천천히 처리할 때 |
| SNS | 한 메시지를 모든 구독자에게 복사 | 푸시 | 없음 | 같은 소식을 여러 곳에 동시에 알릴 때 |
| EventBridge | 규칙에 따라 이벤트를 골라 배달 | 푸시 | 없음(보관소로 재생 가능) | 많은 서비스가 다양한 이벤트를 주고받을 때 |
비동기의 대가: 멱등성, 재시도, 백프레셔
비동기는 공짜가 아닙니다. 메시지를 잃지 않으려고 다시 보내다 보면, 같은 메시지가 두 번 올 수밖에 없습니다. 그래서 소비자는 멱등성을 갖춰야 합니다. 같은 메시지를 여러 번 처리해도 결과가 한 번 처리한 것과 같아야 한다는 뜻입니다.
가장 흔한 방법은 주문 ID 같은 고유 키로 처리 기록을 남기는 것입니다. 결제 서비스가 order-1042를 받으면 먼저 처리 기록 테이블을 확인하고, 이미 있으면 결제하지 않고 메시지만 지웁니다. DynamoDB의 조건부 쓰기(키가 없을 때만 저장)를 쓰면 "확인"과 "기록"을 한 번에 할 수 있습니다. "재고를 1 빼라"보다 "주문 1042의 재고를 차감 완료로 표시하라"처럼 상태를 지정하는 설계도 도움이 됩니다.
실패하면 다시 시도하되, 바로 다시 두드리면 안 됩니다. 능력이 모자라서 느려진 서비스에 재시도가 몰리면 더 느려지기 때문입니다. 그래서 1초, 2초, 4초, 8초처럼 간격을 두 배씩 늘리는 지수 백오프를 쓰고, 여기에 무작위 간격(지터)을 섞어 모두가 동시에 다시 몰려들지 않게 합니다. 정해둔 횟수를 넘기면 DLQ로 보냅니다.
백프레셔는 "받는 쪽이 받을 수 있는 만큼만 흘려보내는" 원리입니다. 대기열은 그 자체로 백프레셔 장치입니다. 소비자가 느리면 메시지가 쌓일 뿐 서비스가 무너지지 않습니다. 대신 쌓이는 속도(가장 오래된 메시지의 나이)를 지켜보다가 소비자를 늘리거나, Lambda라면 동시 실행 수를 제한해 뒤에 있는 데이터베이스를 보호하면 됩니다.
예제: 주문 하나를 세 곳에 나눠 보내기
앞의 쇼핑몰을 다시 설계해 보겠습니다. 주문 서비스는 주문을 저장하고 "주문이 생겼다"는 이벤트를 SNS 토픽에 한 번 발행한 뒤, 곧바로 사용자에게 "주문이 접수되었습니다"라고 응답합니다. 나머지는 각 서비스가 알아서 처리합니다.
토픽은 메시지를 세 대기열에 하나씩 복사하고, 결제·이메일·재고 서비스는 각자의 대기열에서 메시지를 가져갑니다. 이제 이메일 서버가 한 시간 동안 멈춰도 주문과 결제는 평소처럼 돌아갑니다. 그동안 이메일 대기열에 메시지가 쌓일 뿐이고, 서버가 살아나면 밀린 메일을 차례로 보냅니다.
세일 기간에 주문이 열 배로 늘면 어떨까요? 대기열 깊이를 보고 결제 서비스만 늘리면 됩니다. 나중에 "주문 분석" 서비스가 생기면 토픽에 대기열 하나만 더 구독시키면 되고, 주문 서비스 코드는 한 줄도 바뀌지 않습니다. 결제 서비스는 같은 주문이 두 번 와도 주문 ID로 걸러 내므로 결제가 두 번 일어나지 않습니다.
설계 원칙 정리
디커플링은 "어느 부품이 멈춰도 나머지는 버틴다"는 목표를 위한 수단입니다. 설계할 때 아래 다섯 가지를 떠올리면 됩니다.
- 사용자가 기다리는 일만 동기로 두고, 나머지는 메시지로 넘깁니다.
- 한 사람에게 맡길 일은 대기열(SQS), 여러 곳에 알릴 소식은 발행/구독(SNS, EventBridge)으로 보냅니다.
- 푸시형 배달 뒤에는 대기열을 받쳐 메시지를 보관하고 속도를 조절합니다.
- 메시지는 두 번 올 수 있다고 가정하고, 고유 키로 멱등하게 처리합니다.
- 실패는 지수 백오프로 재시도하고, 끝내 안 되는 메시지는 DLQ에 모아 사람이 확인합니다.
처음에는 구성 요소가 늘어나 복잡해 보일 수 있습니다. 하지만 장애가 나는 날, 주문은 계속 받고 이메일만 조금 늦게 나가는 시스템을 보면 그 값어치를 실감하게 됩니다.