
회원가입 버튼을 눌렀는데 3초나 걸린다고? (Message Queue 도입기)
사용자가 회원가입 버튼을 누르면 이메일을 보낼 때까지 로딩 바가 돌아가고 있었습니다. 이메일 서버가 죽으면 회원가입도 실패했죠. 이 강결합(Tightly Coupled) 문제를 해결하기 위해 메시지 큐(Redis/BullMQ)를 도입하고, '식당 주문표' 비유로 비동기 처리를 이해한 과정을 공유합니다.

사용자가 회원가입 버튼을 누르면 이메일을 보낼 때까지 로딩 바가 돌아가고 있었습니다. 이메일 서버가 죽으면 회원가입도 실패했죠. 이 강결합(Tightly Coupled) 문제를 해결하기 위해 메시지 큐(Redis/BullMQ)를 도입하고, '식당 주문표' 비유로 비동기 처리를 이해한 과정을 공유합니다.
결제 API 연동이 끝이 아니었다. 중복 결제 방지, 환불 처리, 멱등성까지 고려하니 결제 시스템이 왜 어려운지 뼈저리게 느꼈다.

왜 넷플릭스는 멀쩡한 서버를 랜덤하게 꺼버릴까요? 시스템의 약점을 찾기 위해 고의로 장애를 주입하는 카오스 엔지니어링의 철학과 실천 방법(GameDay)을 소개합니다.

로그인과 권한 체크를 혼동해서 발생했던 실제 보안 사고 사례와, 이를 '공항 보안 검색'과 '호텔 카드키'에 비유하여 명쾌하게 정리했습니다. JWT 구조부터 OAuth 2.0, 그리고 MSA 환경에서의 인증 전략까지.

캐시를 어디에, 어떻게 배치해야 할까? Cache-Aside, Read-Through, Write-Back 등 5가지 핵심 패턴과 캐시 스탬피드(Thundering Herd) 현상을 막는 방법을 상세히 다뤄봤습니다.

서비스 초기, 사용자들로부터 불만이 접수되었습니다. "가입 버튼을 누르면 앱이 멈춘 것 같아요. 너무 느려요." 사용자가 입력해야 할 정보는 아이디와 비밀번호뿐인데, 처리하는 데 3~5초가 걸리고 있었습니다. 한국인에게 3초는 영겁의 시간입니다. 참다못한 사용자는 뒤로 가기를 누르거나 창을 닫아버렸죠.
확인해 보니 회원가입 로직이 동기(Synchronous) 방식으로 짜여 있었습니다.
/* 나쁜 예: 동기 처리 (순차 실행) */
app.post('/signup', async (req, res) => {
// 1. DB에 회원 정보 저장 (0.1초 - 빠름)
const user = await db.saveUser(req.body);
// 2. 환영 이메일 발송 (SMTP Server 연동) (3초...???) 🚨
await emailService.sendWelcome(user.email);
// 3. 슬랙 채널에 "신규 가입 알림" 전송 (1초) 🚨
await slackService.sendNotification(`New User: ${user.email}`);
// 4. 응답 (총 4.1초 소요)
res.send("가입 완료!");
});
문제는 외부 서비스(External Service)였습니다. 이메일 서버(Google SMTP)와 슬랙 API가 느릴 때마다, 제 소중한 회원가입 API도 덩달아 느려졌습니다. 더 최악인 건, 이메일 서버가 터지면 회원가입도 같이 실패(500 Error)한다는 거였습니다. "이메일 못 받았다고 가입을 막는 게 말이 돼?"
이건 아키텍처 관점에서 강한 결합(Tight Coupling)이 원인이었습니다.
이 문제를 해결하기 위해 메시지 큐(Message Queue)를 도입했습니다. 개념은 맛집 식당과 똑같습니다.
이제 코드는 이렇게 바뀝니다. 웨이터(웹 서버)는 더 이상 요리(이메일 발송)를 기다리지 않습니다.
/* 좋은 예: 비동기 처리 (Fire and Forget) */
app.post('/signup', async (req, res) => {
// 1. DB 저장 (필수 작업)
const user = await db.saveUser(req.body);
// 2. 큐에 "이메일 보내줘" 메시지 던짐 (0.001초 - 순식간)
await messageQueue.add('send-email', { email: user.email });
// 3. 큐에 "슬랙 알림 보내줘" 메시지 던짐 (0.001초)
await messageQueue.add('send-slack', { email: user.email });
// 4. 즉시 응답 (총 0.102초 소요)
res.send("가입 완료!");
});
사용자는 이제 0.1초 만에 "가입 완료" 메시지를 봅니다. 쾌적하죠. 이메일은 나중에(1초 뒤든 10분 뒤든) 백그라운드 워커가 알아서 보낼 겁니다.
메시지 큐를 도입하려고 보니 선택지가 너무 많았습니다. 무엇을 써야 할까요?
저 같은 초기 스타트업이나 소규모 프로젝트에는 Redis가 압도적으로 유리합니다.
| 특징 | Kafka | RabbitMQ | Redis (BullMQ) | AWS SQS |
|---|---|---|---|---|
| 주 용도 | 대용량 로그 스트리밍 | 복잡한 라우팅 | 간단한 작업 큐 | 서버리스 큐 |
| 속도 | 매우 빠름 | 빠름 | 매우 빠름 (In-Memory) | 보통 |
| 메시지 보존 | 디스크 (영구) | 메모리/디스크 | 메모리 (휘발성) | 디스크 (최대 14일) |
| 운영 난이도 | 헬(Hell) | 상 | 하 (Redis만 있으면 됨) | 최하 (관리 필요 없음) |
| 추천 대상 | 대기업, 데이터 등대 | MSA, 금융권 | 스타트업, 사이드 프로젝트 | AWS 올인한 팀 |
Node.js 환경에서 BullMQ 라이브러리를 사용하니 구현이 정말 간단했습니다.
사용자의 요청을 받아 큐에 넣는 역할입니다.
import { Queue } from 'bullmq';
// Redis 연결 설정
const connection = { host: 'localhost', port: 6379 };
const emailQueue = new Queue('email-queue', { connection });
async function signUp(user) {
// DB 저장 로직...
// 큐에 작업(Job) 추가. (재시도 옵션 포함)
await emailQueue.add('welcome-email',
{ email: user.email },
{
attempts: 3, // 실패하면 3번까지 재시도해라!
backoff: 5000 // 재시도 간격은 5초
}
);
return "가입 완료";
}
큐에서 작업을 꺼내 실제로 처리하는 역할입니다. 웹 서버와는 별도의 프로세스로 띄우는 게 좋습니다.
import { Worker } from 'bullmq';
const worker = new Worker('email-queue', async job => {
console.log(`[Job ${job.id}] 이메일 발송 시작: ${job.data.email}`);
try {
// 3초 걸리는 작업
await emailService.sendWelcome(job.data.email);
console.log("발송 성공!");
} catch (err) {
console.error("발송 실패... 재시도합니다.");
throw err; // 에러를 던지면 BullMQ가 알아서 재시도 처리함
}
}, { connection });
이제 이메일 서버가 죽어도 괜찮습니다. 작업은 큐에 쌓여있다가, 서버가 살아나면(또는 재시도 시점에) Worker가 다시 처리하니까요. 심지어 Worker 서버를 10대로 늘리면 이메일 발송 속도도 10배 빨라집니다. 이게 바로 확장성(Scalability)과 결합도 낮추기(Decoupling)의 힘입니다.
"그럼 모든 걸 큐에 넣으면 되나요?" 아닙니다. 주의할 점이 있습니다.
동기(Synchronous) 처리는 쉽고 직관적입니다. 코드가 위에서 아래로 흐르니까요. 하지만 사용자의 시간을 뺏습니다. 비동기(Asynchronous) 처리는 시스템 구조가 조금 복잡해지지만, 사용자 경험(UX)을 극대화하고 시스템 안정성을 높입니다.
체크리스트:여러분의 API가 느리다면, 혹시 웨이터가 주방에서 요리까지 하고 있는 건 아닌지 확인해 보세요. 주문표(Message)만 던지고 나오세요.