대규모 웹 애플리케이션을 개발할 때 속도는 가장 중요한 요소 중 하나입니다. 사용자들은 더 이상 응답을 오래 기다리지 않으며, 그럴 필요도 없습니다. 하지만 일부 프로세스는 시간이 오래 걸리고, 더 빠르게 만들거나 아예 제거할 수 없는 경우도 있습니다.
메시지 큐(Message Queue)는 기존의 요청-응답 흐름에 별도의 분기를 추가함으로써 이 문제를 해결합니다. 덕분에 사용자에게는 즉각적인 응답을 제공하고, 시간이 오래 걸리는 작업은 백그라운드에서 처리할 수 있습니다. 모두가 만족하는 결과죠.
이 글에서는 메시지 큐가 무엇인지 설명하고, 간단한 애플리케이션을 직접 만들어 보며 시작하는 방법을 다룹니다. Node.js의 기본기를 갖추고 있어야 하며, 로컬 또는 클라우드 인스턴스에 Redis가 설치되어 있어야 합니다.
큐(Queue)란 무엇인가?
큐는 데이터를 순서대로 저장할 수 있는 자료구조로, FIFO(First-In-First-Out, 선입선출) 원칙을 따릅니다.
컴퓨터 과학에서의 큐 개념은 일상생활의 줄 서기와 동일합니다. 사람들이 어떤 것을 받기 위해 줄을 서듯, 큐에는 뒤에서 들어와 자기 차례가 올 때까지 기다린 후, 처리가 끝나면 앞으로 나갑니다.
컴퓨터 과학 관점에서 보면, API 요청 같은 프로세스가 실행되는 도중 특정 작업(예: 이메일 전송)을 현재 흐름에서 분리하고 싶을 때 해당 작업을 큐에 넣고 원래 프로세스를 계속 진행하면 됩니다.
아래 다이어그램은 큐의 생명주기를 보여줍니다.
Queue Lifecycle | https://optimalbits.github.io/bull/
잡(Job)이란 무엇인가?
잡은 큐에서 처리되는 데이터 단위로, 주로 JSON 형태의 객체입니다.
공항 줄에 선 각 승객에 비유할 수 있습니다. 각 승객은 여권이나 필요한 서류 등 구체적인 데이터와 지침이 담긴 서류가방을 들고 있으며, 자기 차례가 되면 이를 활용해 절차를 진행합니다.
새로 온 사람은 줄 맨 뒤에 붙고, 맨 앞사람부터 차례로 처리됩니다. 잡도 마찬가지입니다. 각 잡은 처리에 필요한 데이터를 포함하며, 새 잡은 뒤에서 추가되고 앞에서 꺼내져 처리됩니다.
잡 프로듀서(Job Producer)란?
잡 프로듀서는 큐에 잡을 추가하는 코드를 의미합니다. 실제 생활에 비유하면, 공항에서 목적에 따라 어느 줄에 서야 하는지 안내하는 안내 요원과 같습니다.
잡 프로듀서는 잡 컨슈머와 독립적으로 존재할 수 있습니다. 즉, 마이크로서비스 환경에서 특정 서비스는 잡을 큐에 추가하는 역할만 담당하고, 이후 처리 방식에는 관여하지 않을 수 있습니다.
워커(Worker, 잡 컨슈머)란?
워커 또는 잡 컨슈머는 잡을 실행할 수 있는 프로세스 또는 함수입니다. 은행 창구원에 비유할 수 있습니다. 첫 번째 고객이 오면 줄에 서고, 창구원이 호출하면 줄이 비게 됩니다.
창구원은 거래 처리에 필요한 정보를 고객에게 요청합니다. 한 고객을 처리하는 동안 네 명의 새 고객이 줄을 섰다면, 그들은 창구원이 첫 번째 고객을 끝낸 후 다음 고객을 부를 때까지 기다려야 합니다. 큐의 워커도 동일하게 동작합니다. 큐의 첫 번째 잡을 꺼내 처리하는 것이죠.
실패한 잡(Failed Jobs)이란?
때때로 잡이 처리 중 실패할 수 있습니다. 잡이 실패하는 주요 원인은 다음과 같습니다.
- 유효하지 않거나 누락된 입력 데이터: 잡 처리에 필요한 데이터가 없으면 실패합니다. 예를 들어 수신자 이메일 주소가 없으면 이메일 전송 잡은 실패합니다.
- 타임아웃(Timeout): 잡이 평소보다 오래 걸리면 큐 메커니즘이 실패로 처리할 수 있습니다. 의존성 문제 등이 원인일 수 있지만, 하나의 잡이 영원히 실행되는 상황은 피해야 합니다.
- 네트워크 또는 인프라 문제: 통제하기 어렵지만 실제로 발생하는 문제입니다. 예를 들어 데이터베이스 연결 오류는 잡을 실패하게 만듭니다.
- 의존성 문제: 잡이 외부 리소스에 의존하는 경우, 해당 리소스를 사용할 수 없거나 처리에 실패하면 잡도 실패합니다.
잡이 실패했을 때는 재시도하도록 큐 메커니즘을 설정할 수 있습니다. 즉시 재시도하거나 일정 시간 후에 재시도할 수 있으며, 최대 시도 횟수를 설정하는 것이 좋습니다. 그렇지 않으면 영원히 실패하는 잡을 무한히 실행하게 됩니다.
큐는 마이크로서비스 간 견고한 통신 채널을 구축하는 데 유용합니다. 여러 서비스가 같은 큐를 사용할 수 있고, 서비스마다 다른 역할을 담당할 수 있습니다. 한 서비스가 자신의 작업을 마치면 다른 서비스의 워커가 기다리는 큐에 잡을 추가하고, 그 서비스가 잡을 받아 필요한 처리를 수행합니다.
또한 큐는 프로세스에서 무거운 작업을 분리하는 데 유용합니다. 이 글에서 살펴보겠지만, 이메일 전송처럼 시간이 오래 걸리는 작업을 큐에 넣으면 응답 시간 저하를 방지할 수 있습니다.
마지막으로, 큐는 단일 장애점(Single Point of Failure)을 방지하는 데 도움이 됩니다. 실패 가능성이 있고 재시도가 필요한 작업은 큐로 처리하여 일정 시간 후 다시 시도하는 것이 바람직합니다.
큐를 활용하는 간단한 애플리케이션 만들기
이제 Node.js와 Redis를 사용해 간단한 프로젝트를 만들어 보겠습니다. 큐 시스템 구축의 복잡성을 크게 줄여주는 Bull 라이브러리를 사용하며, 프로젝트는 이메일을 전송하는 단일 엔드포인트를 갖습니다.
새 Node.js 프로젝트 생성
mkdir nodejs-queue-project
cd nodejs-queue-project
npm init -y
위 명령어는 nodejs-queue-project 폴더와 그 안에 package.json 파일을 생성합니다. 생성된 파일은 다음과 같습니다.
{
"name": "nodejs-queue-project",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
필요한 의존성 설치
npm i express @types/express @types/node body-parser ts-node ts-lint typescript nodemon nodemailer @types/nodemailer
위 명령어는 프로젝트에 필요한 패키지와 의존성들을 설치합니다.
설치 후 package.json의 scripts 섹션에 dev 명령어를 추가하세요. 전체 package.json은 다음과 같아야 합니다.
{
"name": "nodejs-queue-project",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "nodemon src/app.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@types/express": "^4.17.17",
"@types/node": "^20.3.3",
"@types/nodemailer": "^6.4.8",
"body-parser": "^1.20.2",
"express": "^4.18.2",
"nodemailer": "^6.9.3",
"nodemon": "^2.0.22",
"ts-lint": "^4.5.1",
"ts-node": "^10.9.1",
"typescript": "^5.1.6"
}
}
이 파일에는 설치된 모든 의존성이 표시됩니다. dev 스크립트를 사용하면 npm run dev 명령어가 실행됩니다.
엔드포인트 구축하기
먼저 src라는 이름의 새 폴더를 생성합니다. 이 폴더에는 모든 코드 파일이 들어가며, 첫 번째 파일은 애플리케이션의 루트 파일인 app.ts입니다(package.json에 정의된 대로).
app.ts 파일에서 필요한 패키지를 가져오고, 아래와 같이 이메일을 전송하는 단일 엔드포인트를 가진 간단한 서버를 만들겠습니다.
import express from "express";
import bodyParser from "body-parser";
import nodemailer from "nodemailer";
const app = express();
app.use(bodyParser.json());
app.post("/send-email", async (req, res) => {
const { from, to, subject, text } = req.body;
// 튜토리얼이므로 테스트 계정을 사용합니다
const testAccount = await nodemailer.createTestAccount();
const transporter = nodemailer.createTransport({
host: "smtp.ethereal.email",
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
tls: {
rejectUnauthorized: false,
},
});
console.log("Sending mail to %s", to);
let info = await transporter.sendMail({
from,
to,
subject,
text,
html: `<strong>${text}</strong>`,
});
console.log("Message sent: %s", info.messageId);
console.log("Preview URL: %s", nodemailer.getTestMessageUrl(info));
res.json({
message: "Email Sent",
});
});
app.listen(4300, () => {
console.log("Server started at https://localhost:4300");
});
이제 터미널에서 npm run dev를 실행하면 서버가 시작됩니다. 터미널에 Server started at https://localhost:4300 메시지가 표시됩니다.
Postman 같은 도구로 엔드포인트를 테스트해 볼 수 있습니다.
스크린샷에서 볼 수 있듯이 요청 처리에 약 4초가 걸렸습니다. 엔드포인트 입장에서는 매우 느린 속도입니다. 터미널을 확인하면 전송된 이메일을 미리 볼 수 있는 URL도 확인할 수 있습니다.
링크를 열면 이메일이 어떻게 보이는지 확인할 수 있습니다.
큐 생성하기
프로세스를 더 빠르게 만들려면 이메일을 큐에 넣어 나중에 전송하고, 사용자에게는 즉시 응답을 보내면 됩니다.
이를 위해 큐를 생성하는 데 사용할 bull 라이브러리와 타입 정의 패키지를 설치합니다.
npm i bull @types/bull
bull로 새 큐를 만드는 것은 큐 이름을 지정해 새 Bull 객체를 인스턴스화하는 것만큼 간단합니다.
// 파일 상단에 추가
import Bull from 'bull';
const emailQueue = new Bull("email");
큐 이름만으로 큐를 생성하면 기본 Redis 연결 URL인 localhost:6379를 사용하려고 시도합니다. 다른 URL을 사용하려면 Bull 클래스에 두 번째 인자로 옵션 객체를 전달하면 됩니다.
const emailQueue = new Bull("email", {
redis: "localhost:6379",
});
이제 요청이 들어올 때마다 잡을 큐에 추가하는 간단한 함수, 즉 잡 프로듀서를 만들 수 있습니다.
type EmailType = {
from: string;
to: string;
subject: string;
text: string;
};
const sendNewEmail = async (email: EmailType) => {
emailQueue.add({ ...email });
};
새로 만든 sendNewEmail 함수는 EmailType 타입의 객체를 받습니다. 발신자 주소(from), 수신자 주소(to), 이메일 subject, 본문(text)을 포함하며, 새 잡을 큐에 추가합니다.
요청 처리 중에 이메일을 직접 보내는 대신 이 함수를 사용하도록 엔드포인트를 수정합니다.
app.post("/send-email", async (req, res) => {
const { from, to, subject, text } = req.body;
await sendNewEmail({ from, to, subject, text });
console.log("Added to queue");
res.json({
message: "Email Sent",
});
});
이제 코드는 더 간결해졌고 프로세스도 빨라졌습니다. 요청 처리 시간이 약 40밀리초로 단축되어, 이전보다 약 100배 빨라졌습니다.
이 시점에서 이메일은 큐에 추가된 상태이며, 처리될 때까지 대기합니다. 잡은 동일한 애플리케이션이나 다른 서비스(마이크로서비스 환경의 경우)가 처리할 수 있습니다.
잡 처리하기
메일이 큐에서 벗어나지 못한다면 이 사이클은 불완전하고 무의미합니다. 잡을 처리하고 큐를 비우는 잡 컨슈머를 만들어야 합니다.
Job 객체를 받아 이메일을 전송하는 함수의 로직을 작성하면 됩니다.
const processEmailQueue = async (job: Job) => {
// 튜토리얼이므로 테스트 계정을 사용합니다
const testAccount = await nodemailer.createTestAccount();
const transporter = nodemailer.createTransport({
host: "smtp.ethereal.email",
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
tls: {
rejectUnauthorized: false,
},
});
const { from, to, subject, text } = job.data;
console.log("Sending mail to %s", to);
let info = await transporter.sendMail({
from,
to,
subject,
text,
html: `<strong>${text}</strong>`,
});
console.log("Message sent: %s", info.messageId);
console.log("Preview URL: %s", nodemailer.getTestMessageUrl(info));
return nodemailer.getTestMessageUrl(info);
};
위 함수는 Job 객체를 받습니다. 이 객체에는 잡의 상태와 데이터를 보여주는 유용한 속성들이 있으며, 여기서는 data 속성을 사용했습니다.
현재는 함수만 있을 뿐, 어떤 큐에서 작업해야 하는지 알지 못하기 때문에 잡을 자동으로 가져오지 않습니다.
워커를 큐에 연결하기 전에, 몇 개의 요청을 보내 큐에 잡을 추가해 보세요. redis-cli에서 다음 명령어를 실행하면 현재 대기 중인 이메일 잡을 확인할 수 있습니다.
LRANGE bull:email:wait 0 -1
이 명령은 이메일 대기열을 조회하고 대기 중인 잡들의 id를 반환합니다.
워커가 실제로 어떻게 동작하는지 보여드리기 위해 몇 개의 잡을 미리 만들어 두었습니다.
이제 다음 코드를 추가해 워커를 큐에 연결합니다.
emailQueue.process(processEmailQueue);
완성된 app.ts 파일은 다음과 같습니다.
import express from "express";
import bodyParser from "body-parser";
import nodemailer from "nodemailer";
import Bull, { Job } from "bull";
const app = express();
app.use(bodyParser.json());
const emailQueue = new Bull("email", {
redis: "localhost:6379",
});
type EmailType = {
from: string;
to: string;
subject: string;
text: string;
};
const sendNewEmail = async (email: EmailType) => {
emailQueue.add({ ...email });
};
const processEmailQueue = async (job: Job) => {
// 튜토리얼이므로 테스트 계정을 사용합니다
const testAccount = await nodemailer.createTestAccount();
const transporter = nodemailer.createTransport({
host: "smtp.ethereal.email",
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
tls: {
rejectUnauthorized: false,
},
});
const { from, to, subject, text } = job.data;
console.log("Sending mail to %s", to);
let info = await transporter.sendMail({
from,
to,
subject,
text,
html: `<strong>${text}</strong>`,
});
console.log("Message sent: %s", info.messageId);
console.log("Preview URL: %s", nodemailer.getTestMessageUrl(info));
};
emailQueue.process(processEmailQueue);
app.post("/send-email", async (req, res) => {
const { from, to, subject, text } = req.body;
await sendNewEmail({ from, to, subject, text });
console.log("Added to queue");
res.json({
message: "Email Sent",
});
});
app.listen(4300, () => {
console.log("Server started at https://localhost:4300");
});
파일을 저장하면 서버가 재시작되면서 즉시 메일 전송을 시작하는 것을 확인할 수 있습니다. 워커가 큐를 감지하고 즉시 처리를 시작하기 때문입니다.
이제 프로듀서와 워커가 모두 활성화되었습니다. 모든 새 API 요청은 큐에 추가되고, 워커는 대기 중인 잡이 없다면 즉시 처리합니다.
마무리
이 글이 메시지 큐의 개념, 잡 추가 및 처리 프로세스 생성 방법, 그리고 이를 활용해 더 나은 웹 애플리케이션을 구축하는 방법을 이해하는 데 도움이 되었기를 바랍니다. 이 글에서 사용한 코드 파일은 GitHub에서 확인할 수 있습니다.
질문이나 의견이 있다면 언제든지 연락해 주세요. LinkedIn, Twitter, GitHub에서 필자의 다른 글과 작업을 팔로우할 수도 있습니다.