실사용자가 이용하는 개발자 커뮤니티 홈페이지 프로젝트에서 구현한 기능과 개발 과정을 정리한 포스트입니다.

이 프로젝트에서는 LLM 호출과 크롤링 같은 외부 API 작업이 핵심 기능에 포함되어 있다.
이런 작업들은 n초 이상 소요되는 I/O 바운드 작업이다.
결국, 서버가 먹통이 되고 다운까지 이어질 수 있는 상황이 된다.
이런 문제를 해결하기 위해 외부 API 작업을 별도 워커로 분리하고, 메시지 큐를 통해 비동기로 처리하는 구조를 도입하기로 했다. API 서버는 큐에 메시지만 넣고 즉시 응답하며, 실제 작업은 워커가 큐에서 메시지를 꺼내 독립적으로 처리하는 방식이다.
Kafka는 홈페이지 수준의 트래픽과 소규모 팀 운영 부담이 크다. Redis Queue는 DLQ나 메시징 기능을 직접 구현해야 하는 점이 부담이었다. 따라서 메시지 영구 보존이 필요 없고, DLQ/라우팅 등을 지원해주는 RabbitMQ가 가장 적합하다고 판단했다.
public enum QueueType {
CRAWL_QUEUE("crawl_queue"),
RESUME_QUEUE("resume_queue"),
CS_QUEUE("cs_queue"),
CRAWL_DEAD_LETTER_QUEUE("crawl_deadletter_queue"),
RESUME_DEAD_LETTER_QUEUE("resume_deadletter_queue"),
CS_DEAD_LETTER_QUEUE("cs_deadletter_queue");
private final String name;
// ...
}
/** 큐에 메시지 전송 */
public void sendToQueue(
QueueType queueType, Map<String, String> data, String taskId, String type) {
try {
rabbitTemplate.convertAndSend(
queueType.getName(),
data,
message -> {
message.getMessageProperties().setMessageId(taskId);
message.getMessageProperties().setType(type);
message.getMessageProperties().setContentType("application/json");
return message;
});
// ...
}
// 재시도 시 같은 큐로 다시 넣기
public void retryToQueue(QueueType queueType, Map<String, String> data, int nextRetryCount) {
rabbitTemplate.convertAndSend(queueType.getName(), data, message -> {
message.getMessageProperties().setContentType("application/json");
return message;
});
}
// 실패 메시지 → DLQ
public void sendToDeadLetterQueue(QueueType queueType, Map<String, String> data) {
rabbitTemplate.convertAndSend(queueType.getName(), data, ...);
}
아래는 AI가 이력서를 파싱하는 시간을 Jaeger 트레이스로 확인한 결과다.
총 4.18s가 소요되는 작업을 비동기로 처리하여 클라이언트는 큐에 메시지를 넣는 시간(ms)만 기다리면 된다.
