1. "회원가입이 왜 이렇게 느려요?"
서비스 초기, 사용자들로부터 불만이 접수되었습니다. "가입 버튼을 누르면 앱이 멈춘 것 같아요. 너무 느려요." 사용자가 입력해야 할 정보는 아이디와 비밀번호뿐인데, 처리하는 데 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)이 원인이었습니다.
2. 해결책 - "주방에 주문표만 던지고 나오세요"
이 문제를 해결하기 위해 메시지 큐(Message Queue)를 도입했습니다. 개념은 맛집 식당과 똑같습니다.
- Before (동기 처리): 웨이터가 주문을 받고, 주방에 가서 요리가 다 될 때까지(30분) 서서 기다렸다가 서빙하고, 그제야 다음 손님을 받음. (비효율의 극치)
- After (비동기 처리): 웨이터가 주문을 받아 주문표(Message)를 주방 레일 (Queue)에 꽂아두고, "주문 접수됐습니다"라고 말한 뒤 바로 다음 손님을 받음. 요리사(Worker)는 주문표를 순서대로 가져가서 요리함.
이제 코드는 이렇게 바뀝니다. 웨이터(웹 서버)는 더 이상 요리(이메일 발송)를 기다리지 않습니다.
/* 좋은 예: 비동기 처리 (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분 뒤든) 백그라운드 워커가 알아서 보낼 겁니다.
3. 도구 선택: Kafka vs RabbitMQ vs Redis?
메시지 큐를 도입하려고 보니 선택지가 너무 많았습니다. 무엇을 써야 할까요?
1. Apache Kafka
- 특징: 링크드인에서 만든 괴물. 대용량 로그 처리에 특화됨.
- 장점: 엄청난 처리량(Throughput), 데이터 영속성(파일 저장), 메시지 리플레이 가능.
- 단점: 너무 무거움. Zookeeper도 설치해야 하고(요즘은 없어도 되지만), 운영 난이도가 높음.
- 결론: 우리 서비스는 넷플릭스가 아니다. 오버엔지니어링.
2. RabbitMQ
- 특징: 가장 전통적인 메시지 브로커. AMQP 프로토콜 사용.
- 장점: 복잡한 라우팅(Routing) 기능, 안정성 높음.
- 단점: 역시 운영하려면 별도 서버가 필요함. 설정이 은근 복잡.
- 결론: 나쁘지 않지만 좀 더 가벼운 게 없을까?
3. Redis (BullMQ)
- 특징: 인메모리 DB인 Redis를 큐로 활용하는 방식.
- 장점: 이미 캐시용으로 Redis를 쓰고 있음. 추가 인프라 구축 필요 없음. Node.js 라이브러리(Bull)가 너무 잘 되어 있음.
- 단점: Redis가 꺼지면 메모리에 있던 메시지가 날아갈 수 있음 (설정으로 디스크 저장 가능하지만 Kafka보단 불안).
- 결론: "이메일 하나 놓친다고 회사가 망하진 않는다. 지금은 속도와 개발 생산성이 중요하다." -> Redis 선택!
저 같은 초기 스타트업이나 소규모 프로젝트에는 Redis가 압도적으로 유리합니다.
한 눈에 보는 비교표
| 특징 | Kafka | RabbitMQ | Redis (BullMQ) | AWS SQS |
|---|---|---|---|---|
| 주 용도 | 대용량 로그 스트리밍 | 복잡한 라우팅 | 간단한 작업 큐 | 서버리스 큐 |
| 속도 | 매우 빠름 | 빠름 | 매우 빠름 (In-Memory) | 보통 |
| 메시지 보존 | 디스크 (영구) | 메모리/디스크 | 메모리 (휘발성) | 디스크 (최대 14일) |
| 운영 난이도 | 헬(Hell) | 상 | 하 (Redis만 있으면 됨) | 최하 (관리 필요 없음) |
| 추천 대상 | 대기업, 데이터 등대 | MSA, 금융권 | 스타트업, 사이드 프로젝트 | AWS 올인한 팀 |
4. 구현 - BullMQ로 10분 만에 끝내기
Node.js 환경에서 BullMQ 라이브러리를 사용하니 구현이 정말 간단했습니다.
Producer (웹 서버)
사용자의 요청을 받아 큐에 넣는 역할입니다.
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 "가입 완료";
}
Worker (백그라운드 프로세스)
큐에서 작업을 꺼내 실제로 처리하는 역할입니다. 웹 서버와는 별도의 프로세스로 띄우는 게 좋습니다.
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)의 힘입니다.
5. 주의사항 - 큐가 만능은 아니다
"그럼 모든 걸 큐에 넣으면 되나요?" 아닙니다. 주의할 점이 있습니다.
- 순서 보장이 어렵다: 큐에 넣은 순서대로 처리된다는 보장이 항상 있진 않습니다. (특히 병렬 처리 시). 순서가 중요한 작업(주식 거래 등)은 조심해야 합니다.
- 디버깅이 힘들다: "분명 넣었는데 왜 안 나가지?" 로그가 분산되어 있어서 추적하기 가끔 힘듭니다. (BullBoard 같은 대시보드를 꼭 붙이세요).
- 메시지 유실: Redis가 터지면 큐에 있던 "가입 환영 이메일" 요청이 날아갈 수 있습니다. 절대 잃어버리면 안 되는 데이터(결제 정보 등)는 Kafka나 RDBMS를 큐처럼 써야 합니다.
6. 마무리 - 사용자를 기다리게 하지 마라
동기(Synchronous) 처리는 쉽고 직관적입니다. 코드가 위에서 아래로 흐르니까요. 하지만 사용자의 시간을 뺏습니다. 비동기(Asynchronous) 처리는 시스템 구조가 조금 복잡해지지만, 사용자 경험(UX)을 극대화하고 시스템 안정성을 높입니다.
체크리스트:
- 사용자에게 즉각적인 결과(성공/실패)를 알려줘야 하는가? (로그인, 결제 승인, 비밀번호 변경) -> 동기 처리
- 결과를 나중에 알아도 되거나, 실패해도 재시도하면 되는가? (이메일, 카톡 알림, 통계 집계, 이미지 리사이징) -> 무조건 큐에 넣으세요.
여러분의 API가 느리다면, 혹시 웨이터가 주방에서 요리까지 하고 있는 건 아닌지 확인해 보세요. 주문표(Message)만 던지고 나오세요.