munjji 님의 블로그
[CS] 메시지 큐 본문
동기와 비동기 처리의 차이에 대해서 이전 포스트에 작성해두었지만, 간략하게 회원가입 예제로 정리해보고 시작해보자.
동기
public String signUpSync(String email, String name) {
// 모든 작업이 순서대로 끝나야 다음으로 넘어감
User user = db.save(email, name); // 1단계: DB 저장 (300ms)
emailService.sendWelcome(email); // 2단계: 이메일 발송 (3000ms)
imageService.setDefaultProfile(user); // 3단계: 프로필 설정 (1000ms)
crmService.register(user); // 4단계: CRM 등록 (1500ms)
return "가입 완료"; // 총 5800ms 후에 응답
}
사용자는 이메일이 발송되고, 프로필이 설정되고, CRM에 등록될 때까지 5.8초를 기다려야 한다.
이 중에서 DB 저장만이 가입에 꼭 필요한 작업이고, 나머지 세 가지 작업은 사실 사용자가 기다릴 이유는 없는 작업들이다.
비동기
public String signUpAsync(String email, String name) {
// 핵심 작업만 동기로 처리
User user = db.save(email, name); // DB 저장 (300ms) — 가입에 꼭 필요
// 나머지는 비동기로 백그라운드에 맡김
CompletableFuture.runAsync(() -> emailService.sendWelcome(email));
CompletableFuture.runAsync(() -> imageService.setDefaultProfile(user));
CompletableFuture.runAsync(() -> crmService.register(user));
return "가입 완료"; // DB 저장 직후 300ms 만에 응답!
}
사용자는 DB 저장이 끝나자마자 300ms 만에 응답을 받는다.
나머지 작업들은 백그라운드에서 알아서 처리된다.
사용자에게 꼭 필요한 작업만 기다리게 하고, 나머지는 뒤에서 처리한다.
실행 결과 비교
=== 동기 방식 ===
[0ms] 회원가입 요청
[300ms] DB 저장 완료
[3300ms] 이메일 발송 완료
[4300ms] 프로필 설정 완료
[5800ms] CRM 등록 완료
[5800ms] 응답: "가입 완료" ← 사용자가 받는 시점
=== 비동기 방식 ===
[0ms] 회원가입 요청
[300ms] DB 저장 완료
[300ms] 응답: "가입 완료" ← 사용자가 받는 시점 (19배 빠름!)
이후 백그라운드에서...
[1300ms] 이메일 발송 완료
[1300ms] 프로필 설정 완료
[1800ms] CRM 등록 완료
메시지 큐가 왜 필요한가?
회원가입 예제로 보는 메시징 큐
비동기 방식의 한계
앞서 CompletableFuture로 비동기 처리를 했는데, 문제가 하나 있다.
이메일 발송 도중 서버가 갑자기 죽으면?
CompletableFuture.runAsync(() -> emailService.sendWelcome(email)); // 작업 유실!
CompletableFuture.runAsync(() -> imageService.setDefaultProfile(user)); // 작업 유실!
CompletableFuture.runAsync(() -> crmService.register(user)); // 작업 유실!
[300ms] DB 저장 완료 → 응답 반환
백그라운드 작업 시작...
[1000ms] 💥 서버 다운!
[결과]
✅ DB에 유저는 저장됨
❌ 이메일 미발송 (기록 없음)
❌ 프로필 미설정 (기록 없음)
❌ CRM 미등록 (기록 없음)
메모리에만 있던 작업들이 흔적도 없이 사라진다. 메시징 큐는 이 문제를 해결할 수 있다.
메시지 큐를 끼우면 어떻게 달라지나?
[기존 비동기]
OrderService ──runAsync──▶ EmailService (메모리에서 직접 실행)
[메시징 큐 도입]
OrderService ──넣기──▶ [Queue] ──꺼내기──▶ EmailWorker (별도 프로세스)
(Producer) (보관) (Consumer)
Producer는 큐에 메시지를 넣고 끝이다. 실제 발송은 Consumer가 큐에서 꺼내 처리한다.
서버가 죽어도 메시지는 큐에 남아있다!
메시징 큐는 이 문제를 해결합니다.
작업을 메모리가 아닌 외부 저장소(큐)에 기록해두기 때문에, 서버가 죽었다 살아나도 이어서 처리할 수 있다.
그래서 메시지 큐가 필요하다.
메시지 큐란?
메시지 큐는 Producer(생산자)와 Consumer(소비자) 사이에서 메시지를 임시로 저장하고 전달하는 중간 저장소
[Producer] --> [Message Queue] --> [Consumer]
생산자 중간 저장소 소비자
메시지 큐의 핵심 특징
- 비동기 통신
Producer는 메시지를 큐에 넣고 바로 다음 작업을 수행합니다. Consumer가 언제 처리하든 상관없음 - 디커플링 (Decoupling)
Producer와 Consumer가 서로를 직접 알 필요가 없습니다. 서비스 간 의존성이 낮아짐 - 버퍼링
트래픽이 급증해도 큐가 메시지를 쌓아두고, Consumer가 자신의 처리 속도에 맞춰 소비함 - 내구성 (Durability)
메시지를 디스크에 저장하여 시스템 장애 시에도 유실을 방지함
메시지 큐를 적용해보자
메시지 구조 정의
// 큐에 담길 메시지 객체
public class NotificationMessage {
public enum Type { EMAIL, PUSH, CRM_REGISTER, PROFILE_SETUP }
private String messageId; // 고유 ID — 중복 처리 방지
private Type type; // 작업 종류
private String userId;
private String email;
private int retryCount; // 재시도 횟수
}
Producer — 회원가입 서비스
public class SignUpService {
private final BlockingQueue<NotificationMessage> queue;
private final UserRepository userRepository;
public String signUp(String email, String name) {
// 1. 핵심 작업만 동기로 처리
User user = userRepository.save(email, name); // DB 저장 (300ms)
// 2. 나머지는 큐에 메시지를 넣고 즉시 반환
queue.offer(new NotificationMessage(
UUID.randomUUID().toString(), Type.EMAIL, user.getId(), email
));
queue.offer(new NotificationMessage(
UUID.randomUUID().toString(), Type.PROFILE_SETUP, user.getId(), email
));
queue.offer(new NotificationMessage(
UUID.randomUUID().toString(), Type.CRM_REGISTER, user.getId(), email
));
// 3. 큐에 넣자마자 즉시 응답 (발송 완료 기다리지 않음)
return "가입 완료";
}
}
Consumer — 메시지를 꺼내 처리하는 Worker
public class NotificationWorker {
private final BlockingQueue<NotificationMessage> queue;
private final BlockingQueue<NotificationMessage> deadLetterQueue; // 최종 실패 보관
private static final int MAX_RETRY = 3;
public void start() {
// 서버 시작 시 루프 실행
while (true) {
// 큐에서 메시지 꺼냄 — 없으면 올 때까지 대기
NotificationMessage msg = queue.poll(1, TimeUnit.SECONDS);
if (msg == null) continue;
process(msg);
}
}
private void process(NotificationMessage msg) {
try {
switch (msg.getType()) {
case EMAIL -> sendEmail(msg.getEmail());
case PROFILE_SETUP -> setDefaultProfile(msg.getUserId());
case CRM_REGISTER -> registerCRM(msg.getUserId());
}
System.out.println("처리 완료: " + msg.getType());
} catch (Exception e) {
handleFailure(msg, e); // 실패 시 재시도 or DLQ
}
}
private void handleFailure(NotificationMessage msg, Exception e) {
if (msg.getRetryCount() < MAX_RETRY) {
// 재시도 — 횟수마다 대기 시간을 2배씩 늘림 (지수 백오프)
msg.setRetryCount(msg.getRetryCount() + 1);
long delay = (long) Math.pow(2, msg.getRetryCount()) * 1000; // 2초 → 4초 → 8초
scheduler.schedule(() -> queue.offer(msg), delay, TimeUnit.MILLISECONDS);
System.out.println(delay/1000 + "초 후 재시도: " + msg.getType()
+ " (" + msg.getRetryCount() + "/" + MAX_RETRY + ")");
} else {
// 최대 재시도 초과 → Dead Letter Queue 이동
deadLetterQueue.offer(msg);
System.out.println("❌ DLQ 이동: " + msg.getMessageId() + " — 관리자 확인 필요");
}
}
}
전체 실행 흐름
=== 정상 시나리오 ===
[0ms] 회원가입 요청
[300ms] DB 저장 완료
[300ms] Queue에 메시지 3개 등록
[300ms] 응답: "가입 완료" ← 사용자는 여기서 끝
백그라운드 Worker가 처리...
[800ms] ✅ 프로필 설정 완료
[1300ms] ✅ CRM 등록 완료
[3300ms] ✅ 이메일 발송 완료
=== 이메일 발송 실패 시나리오 ===
[3300ms] ❌ 이메일 발송 실패 (외부 서비스 일시 장애)
[5300ms] 🔄 2초 후 재시도 (1/3)
[5300ms] ❌ 또 실패
[9300ms] 🔄 4초 후 재시도 (2/3)
[9300ms] ✅ 성공!
=== 서버 재시작 시나리오 ===
[500ms] 💥 서버 다운! (이메일 아직 미발송)
Queue에 메시지가 남아있음
[재시작] Worker 재시작
[재시작] ✅ 이메일 발송 완료 — 유실 없음
세 가지 방식 최종 비교
// 1. 동기 — 순서대로 기다림
User user = db.save(email, name); // 300ms
emailService.send(email); // 3000ms ← 사용자가 기다림
imageService.setProfile(user); // 1000ms ← 사용자가 기다림
crmService.register(user); // 1500ms ← 사용자가 기다림
return "가입 완료"; // 5800ms 후 응답
// 2. 비동기 — 빠르지만 유실 위험
User user = db.save(email, name); // 300ms
CompletableFuture.runAsync(() -> emailService.send(email)); // 서버 죽으면 유실
CompletableFuture.runAsync(() -> imageService.setProfile(user));
CompletableFuture.runAsync(() -> crmService.register(user));
return "가입 완료"; // 300ms 후 응답, but 유실 위험
// 3. 메시징 큐 — 빠르고 안전
User user = db.save(email, name); // 300ms
queue.offer(new Message(EMAIL, email)); // 큐에 기록 (유실 없음)
queue.offer(new Message(PROFILE, userId));
queue.offer(new Message(CRM, userId));
return "가입 완료"; // 300ms 후 응답 + 안전
| 동기 | 비동기(Future) | 메시징 큐 | |
| 응답 속도 | 5800ms | 300ms | 300ms |
| 서버 재시작 시 | 안전 | 작업 유실 | 작업 보존 |
| 실패 재시도 | 없음 | 직접 구현 | 기본 제공 |
| 코드 복잡도 | 낮음 | 중간 | 높음 |
동기는 안전하지만 느리고,
비동기는 빠르지만 유실 위험이 있고,
메시징 큐는 빠르면서도 안전하다.
대신 구조가 복잡해지는 트레이드오프가 있다.
메시지 큐를 사용하는 이유
1. 작업 유실 방지 (내결함성) 🌟
[비동기만 사용]
서버 A ──runAsync──▶ 이메일 발송 (메모리에서 실행)
서버 다운 → 작업 사라짐
[메시지 큐 사용]
서버 A ──넣기──▶ [Queue에 기록] ──꺼내기──▶ Worker
서버 다운해도 서버 재시작하면
메시지 남아있음 이어서 처리
CompletableFuture는 메모리에서 실행되기 때문에 서버가 죽으면 진행 중이던 작업이 흔적도 없이 사라진다.
큐는 외부 저장소에 메시지를 기록해두기 때문에 서버가 재시작되어도 이어서 처리할 수 있다.
2. 부하 분산 (트래픽 급증 대응)
평소엔 초당 100건을 처리하다가 이벤트 기간에 갑자기 10,000건이 몰리는 상황
[큐 없이]
10,000건 요청 ──▶ 서버가 한 번에 처리 시도
서버 과부하 → 다운
[큐 있을 때]
10,000건 요청 ──▶ [Queue에 차곡차곡 쌓임]
↓
Worker가 처리 가능한 속도로 꺼내서 처리
서버는 안정적으로 유지
큐가 댐 역할을 한다.
갑자기 물이 많이 와도 댐이 막아주고, 아래로는 일정한 양만 흘려보낸다. 서버는 자신이 감당할 수 있는 속도로만 처리하면 된다.
3. 서비스 간 느슨한 결합
여러 서비스가 직접 연결되어 있으면 하나가 죽을 때 연쇄 장애가 발생
[직접 연결 — 강한 결합]
주문 서비스 ──HTTP 호출──▶ 이메일 서비스
──HTTP 호출──▶ SMS 서비스
──HTTP 호출──▶ CRM 서비스
이메일 서비스가 다운되면 주문 서비스도 실패
[메시지 큐 — 느슨한 결합]
주문 서비스 ──▶ [Queue]
├──▶ 이메일 Worker
├──▶ SMS Worker
└──▶ CRM Worker
이메일 Worker가 다운돼도 주문 서비스는 정상 동작
메시지는 큐에 쌓이다가 Worker 복구 후 처리
주문 서비스는 큐에 메시지만 넣으면 끝 ✌️
이메일 서비스가 살아있는지 신경 쓸 필요가 없다.
4. 재시도 (Retry)
외부 서비스는 일시적으로 실패하는 경우가 많다.
(네트워크 순간 장애, 외부 API 점검 등)
private void handleFailure(NotificationMessage msg, Exception e) {
if (msg.getRetryCount() < MAX_RETRY) {
msg.setRetryCount(msg.getRetryCount() + 1);
// 실패할수록 대기 시간을 2배씩 늘림 (지수 백오프)
// 1번 실패 → 2초 후 재시도
// 2번 실패 → 4초 후 재시도
// 3번 실패 → 8초 후 재시도
long delay = (long) Math.pow(2, msg.getRetryCount()) * 1000;
scheduler.schedule(() -> queue.offer(msg), delay, TimeUnit.MILLISECONDS);
} else {
// 3번 모두 실패 → Dead Letter Queue로 이동
deadLetterQueue.offer(msg);
}
}
큐가 없으면 재시도 로직을 모든 서비스마다 직접 구현해야 한다. 큐를 쓰면 이 로직이 Worker 한 곳에만 있으면 된다.
5. 확장성 (Worker 수평 확장)
처리량이 부족하면 Worker만 늘리면 된다.
[평소]
[Queue] ──▶ Worker 1개 (초당 100건 처리)
[트래픽 증가 시]
[Queue] ──▶ Worker 1
──▶ Worker 2 Worker만 추가하면 처리량이 바로 늘어남
──▶ Worker 3
Producer(주문 서비스) 코드는 전혀 건드릴 필요가 없다. 큐를 바라보는 Worker만 더 띄우면 해결할 수 있다.
출처
'CS' 카테고리의 다른 글
| [정보처리기사] 5과목. 정보시스템 구축 관리 (0) | 2026.03.13 |
|---|---|
| [정보처리기사] 3과목. 데이터베이스 구축 (0) | 2026.03.13 |
| [정보처리기사] 2과목. 소프트웨어 개발 (0) | 2026.03.12 |
| [정보처리기사] 1과목. 소프트웨어 설계 (0) | 2026.03.11 |
| [CS] 비동기 처리 (0) | 2026.03.05 |