티스토리 뷰

반응형

백엔드에서 비동기 작업과 큐는 언제 도입해야 할까? FastAPI · Spring Boot · Node.js로 보는 실전 기준

한 줄 요약

비동기 작업은 사용자가 기다릴 필요 없는 일을 요청-응답 흐름 밖으로 빼는 방식이고, 큐는 그 일을 안전하게 쌓고, 나중에, 다시, 다른 워커가 처리하게 만드는 구조입니다. 메일 발송, 이미지 리사이즈, AI 요약, 웹훅 후처리처럼 느리거나 실패 가능성이 있는 작업은 큐로 빼는 게 보통 더 안정적입니다. (FastAPI)

이 글에서 다루는 내용

  • 비동기 작업이 왜 필요한지
  • BackgroundTasks, @Async, setTimeout 수준으로 끝나는 일과 큐가 필요한 일을 어떻게 구분하는지
  • FastAPI, Spring Boot, Node.js에서 바로 시작 가능한 최소 구현
  • 재시도, 멱등성, DLQ를 어떤 감각으로 봐야 하는지
  • 검색에 잘 걸리도록 질문형 제목, 초반 정답 요약, 정의 문장, 실행 코드, FAQ 중심 구조를 반영한 글 구성 기준

비동기 작업이란?

비동기 작업은 HTTP 응답을 먼저 돌려준 뒤, 사용자가 기다릴 필요가 없는 후속 작업을 나중에 처리하는 방식입니다. FastAPI는 BackgroundTasks를 “응답을 반환한 뒤 실행할 작업”에 쓰는 기능으로 설명하고, Spring은 비동기 메서드 실행을 위한 @Async와 task executor 추상화를 제공합니다. BullMQ는 Redis 위에서 동작하는 Node.js 큐 시스템으로, 작업을 큐에 넣고 워커가 처리하는 구조를 제공합니다. (FastAPI)

조금 더 현실적으로 말하면 이렇습니다.

  • 주문 생성은 바로 끝나야 한다
  • 그런데 주문 확인 메일은 조금 늦어도 된다
  • 업로드 응답은 바로 주고, 썸네일 생성은 나중에 해도 된다
  • 챗봇 응답은 바로 주고, 대화 로그 분석은 뒤에서 해도 된다

이때 비동기 처리를 안 하면, 사용자는 굳이 기다릴 필요 없는 작업 때문에 응답이 느려집니다.


왜 요청-응답 안에서 다 처리하면 버거워질까

이건 진짜 초반에 많이 겪습니다.

예를 들어 회원가입 API 하나가 있다고 해볼게요.

  1. 사용자 저장
  2. 환영 메일 발송
  3. 슬랙 알림 전송
  4. 추천 코드 생성
  5. CRM 동기화

이걸 전부 한 요청 안에서 처리하면, 가장 느린 외부 API 하나 때문에 전체 응답이 늦어집니다. 그리고 중간에 하나라도 실패하면 “회원가입은 됐는데 메일은 안 감” 같은 애매한 상태가 생기죠.

그래서 비동기 처리는 보통 이런 상황에서 도입합니다.

  • 사용자에게 바로 응답을 줘야 할 때
  • 외부 API 호출이 느리거나 불안정할 때
  • 이미지/문서/AI 처리처럼 시간이 걸릴 때
  • 재시도가 필요한 후처리가 있을 때

Spring의 task execution 문서는 비동기 실행용 TaskExecutor와 스케줄링용 TaskScheduler를 분리해서 설명하고, Celery는 태스크 큐 자체를 “실시간 처리와 스케줄링을 지원하는 시스템”으로 소개합니다. (Home)


그냥 비동기 함수면 충분할까, 아니면 큐가 필요할까

여기서 제일 많이 헷갈립니다.

저는 이걸 이렇게 나눕니다.

응답만 빨리 돌려주면 되는 가벼운 작업

  • 로그 파일 쓰기
  • 간단한 메일 발송
  • 내부 후처리 한두 개
  • 실패해도 치명적이지 않은 작업

이럴 땐 FastAPI BackgroundTasks, Spring @Async, Node.js의 간단한 비동기 처리로 시작할 수 있습니다. FastAPI는 background task를 응답 반환 뒤 실행할 수 있다고 설명하고, Spring은 @Async로 비동기 메서드 실행을 지원합니다. (FastAPI)

실패해도 다시 해야 하는 중요한 작업

  • 결제 후 영수증 발송
  • 썸네일/미디어 변환
  • AI 임베딩 생성
  • 웹훅 후처리
  • 대량 메일/문자 발송
  • 멀티 서버 환경에서 처리해야 하는 일

이럴 땐 큐가 필요합니다. BullMQ는 Redis 기반 큐로 작업을 쌓고 워커가 처리하게 하며, 실패한 작업의 재시도와 백오프도 공식 지원합니다. Celery도 태스크 재시도와 브로커 재연결을 공식적으로 지원합니다. (docs.bullmq.io)

핵심만 말하면,

“한 번 놓치면 안 되는 일”은 큐로 가는 경우가 많습니다.


큐란 무엇인가?

큐는 나중에 처리할 작업을 안전하게 쌓아두는 저장소이자 전달 구조입니다.

BullMQ 문서는 큐를 “처리 대기 중인 작업 목록”으로 설명합니다. 작업은 작은 메시지일 수도 있고, 더 오래 걸리는 작업일 수도 있습니다. Celery는 태스크를 메시지 브로커를 통해 워커에게 전달하는 구조를 사용합니다. (docs.bullmq.io)

즉 큐 구조는 보통 이렇게 생깁니다.

  1. API 서버가 작업 생성
  2. 큐에 job 추가
  3. 워커가 큐에서 job 꺼냄
  4. 실제 처리
  5. 성공/실패 상태 기록
  6. 실패하면 재시도 또는 DLQ 이동

큐를 도입하면 뭐가 좋아질까

제가 느끼는 장점은 꽤 분명합니다.

  • HTTP 응답이 빨라집니다
  • 느린 외부 API가 사용자 응답 시간을 직접 잡아먹지 않습니다
  • 실패한 작업을 재시도할 수 있습니다
  • 워커 수를 늘려 처리량을 올릴 수 있습니다
  • API 서버와 후처리 서버 역할을 나눌 수 있습니다

BullMQ는 워커, 재시도, 백오프, 우선순위, 작업 상태 개념을 제공하고, Celery는 retry 시 같은 task-id로 같은 큐에 다시 메시지를 보내는 메커니즘을 문서화하고 있습니다. (docs.bullmq.io)


그렇다고 큐가 무조건 정답은 아니다

이건 꼭 말하고 싶어요.

큐는 좋지만 구조가 하나 더 생깁니다.

  • Redis나 RabbitMQ 같은 브로커
  • 워커 프로세스
  • 작업 상태 추적
  • 중복 처리 방지
  • 실패 작업 정리

즉 “메일 한 번 보내는 기능” 때문에 처음부터 큐를 넣는 건 과할 수 있어요.

그래서 저는 이렇게 추천합니다.

  • 가벼운 후처리: 프레임워크 기본 비동기부터
  • 중요한 후처리: 큐로 승격
  • 멀티 서버/고트래픽/재시도 필요: 큐 거의 필수

FastAPI에서는 어디까지가 기본 비동기고, 어디부터가 큐일까

FastAPI 공식 문서는 BackgroundTasks를 응답 이후 실행할 작업에 쓰는 기능으로 설명합니다. 예시도 로그 파일 쓰기, 알림 전송 같은 가벼운 작업입니다. (FastAPI)

즉 FastAPI에서 이런 건 BackgroundTasks로 시작해도 괜찮습니다.

  • 간단한 메일 발송
  • 로그 남기기
  • 내부 통계 카운트
  • 중요하지 않은 후처리

반면 이런 건 큐를 생각해야 합니다.

  • 영상/음성 처리
  • AI 문서 임베딩 대량 생성
  • 재시도 필요한 외부 연동
  • 멀티 인스턴스 환경에서 보장돼야 하는 작업

Celery는 Python 진영에서 가장 대표적인 큐 중 하나고, retry와 브로커 재연결, 워커 운영 기능이 잘 정리돼 있습니다. (docs.celeryq.dev)


Spring Boot에서는 어디까지가 @Async고, 어디부터가 메시지 큐일까

Spring은 이쪽이 되게 잘 나뉘어 있습니다.

  • @Async: 같은 애플리케이션 안에서 비동기 메서드 실행
  • TaskExecutor: 스레드풀 기반 실행 정책
  • Spring AMQP 같은 메시징: 큐/브로커 기반 비동기 처리

Spring Framework는 @Async와 TaskExecutor를 공식 제공하고, Spring Boot는 별도 Executor 빈이 없으면 AsyncTaskExecutor를 자동 구성한다고 설명합니다. Spring AMQP는 비동기 consumer와 prefetch 조정 같은 운영 포인트도 제공합니다. (Home)

즉 Spring에서 이런 건 @Async로 시작하기 좋습니다.

  • 메일 발송
  • 내부 알림
  • 후처리 저장
  • 가벼운 외부 연동

이런 건 큐가 더 어울립니다.

  • 오래 걸리는 처리
  • 워커 독립 배포가 필요한 처리
  • 대량 재시도
  • 순차 처리나 버퍼링이 필요한 작업

Node.js에서는 어디까지가 비동기고, 어디부터가 BullMQ일까

반응형

Node.js는 원래 비동기 언어라서 더 헷갈립니다.

“어차피 async/await인데, 왜 큐가 필요하지?”
이 질문 많이 나와요.

근데 async/await는 현재 프로세스 안에서 비동기 흐름을 다루는 문법일 뿐이고,
큐는 작업을 안전하게 저장하고 다른 워커가 나중에 처리하게 하는 구조입니다.

BullMQ는 Redis 기반 큐 시스템이고, Queue에 job을 넣고 Worker가 처리합니다. 실패한 작업은 재시도할 수 있고, 백오프 전략도 지원합니다. Redis 연결이 필요하다는 점도 공식 문서에 나옵니다. (docs.bullmq.io)

즉 Node.js에서 이런 건 그냥 비동기로 충분할 수 있어요.

  • 내부 함수 조합
  • 짧은 외부 API 호출
  • 즉시 끝나는 후처리

반면 이런 건 BullMQ가 잘 맞습니다.

  • 이미지 리사이즈
  • 이메일 대량 발송
  • PDF 생성
  • 웹훅 재처리
  • 재시도 필요한 연동 작업

1) FastAPI 예제: BackgroundTasks로 시작하는 가벼운 비동기

FastAPI 공식 문서 기준으로 BackgroundTasks는 응답을 반환한 뒤 실행할 작업에 적합합니다. 의존성 주입 시스템 안에서도 함께 동작합니다. (FastAPI)

설치 방법

pip install fastapi uvicorn

예제 코드

# main.py
from pathlib import Path
from fastapi import BackgroundTasks, FastAPI
from pydantic import BaseModel, EmailStr

app = FastAPI()

LOG_FILE = Path("sent_emails.log")


class SendWelcomeEmailRequest(BaseModel):
    email: EmailStr
    nickname: str


def write_welcome_email_log(email: str, nickname: str) -> None:
    LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
    with LOG_FILE.open("a", encoding="utf-8") as f:
        f.write(f"welcome_email sent to={email} nickname={nickname}\n")


@app.post("/api/users/welcome-email")
async def send_welcome_email(
    request: SendWelcomeEmailRequest,
    background_tasks: BackgroundTasks,
):
    # 여기서는 실제 메일 전송 대신 로그 기록으로 대체
    background_tasks.add_task(
        write_welcome_email_log,
        request.email,
        request.nickname,
    )
    return {
        "message": "요청은 정상적으로 접수되었습니다.",
        "status": "queued_in_process",
    }

실행 방법

uvicorn main:app --reload

예상 결과

POST /api/users/welcome-email
-> 응답은 바로 오고
-> sent_emails.log 파일에 나중에 기록이 남는다

이 방식이 잘 맞는 상황

  • 실패해도 큰일 나지 않는 작업
  • 단일 서버
  • 짧은 작업
  • 응답만 빨리 주면 되는 상황

이 방식이 아쉬운 상황

  • 서버 프로세스가 죽으면 작업 보장이 약함
  • 멀티 서버에서 중앙 큐처럼 다루기 어려움
  • 재시도/상태 추적이 빈약함

그래서 중요한 작업은 Celery 같은 큐로 넘어가는 게 맞습니다. Celery는 retry와 브로커 재연결을 지원하고, task queue 자체를 위해 설계된 시스템입니다. (docs.celeryq.dev)


2) FastAPI 예제: Celery로 큐 분리하기

Celery는 Python 작업 큐의 대표적인 선택지입니다. 태스크 재시도는 retry()로 지원되고, 브로커 연결 실패 시 자동 재연결도 지원합니다. (docs.celeryq.dev)

설치 방법

pip install fastapi uvicorn celery redis

예제 코드

# celery_app.py
from celery import Celery

celery_app = Celery(
    "tasks",
    broker="redis://localhost:6379/0",
    backend="redis://localhost:6379/1",
)
# tasks.py
from celery_app import celery_app

@celery_app.task(bind=True, max_retries=3)
def send_welcome_email_task(self, email: str, nickname: str) -> dict:
    try:
        # 실제 서비스라면 여기서 외부 메일 API 호출
        print(f"[worker] send welcome email to={email}, nickname={nickname}")
        return {"status": "sent"}
    except Exception as exc:
        raise self.retry(exc=exc, countdown=5)
# main.py
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
from tasks import send_welcome_email_task

app = FastAPI()

class SendWelcomeEmailRequest(BaseModel):
    email: EmailStr
    nickname: str

@app.post("/api/users/welcome-email")
async def send_welcome_email(request: SendWelcomeEmailRequest):
    task = send_welcome_email_task.delay(request.email, request.nickname)
    return {
        "message": "작업이 큐에 등록되었습니다.",
        "task_id": task.id,
        "status": "queued",
    }

실행 방법

uvicorn main:app --reload
celery -A tasks worker --loglevel=info

예상 결과

POST /api/users/welcome-email
-> 응답에서 task_id 반환
-> Celery worker가 Redis 큐에서 작업을 꺼내 처리

3) Spring Boot 예제: @Async로 시작하는 가벼운 비동기

Spring Framework는 @Async를 통해 비동기 메서드 실행을 지원합니다. Spring Boot는 별도 Executor가 없으면 AsyncTaskExecutor를 자동 구성합니다. (Home)

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.0.3'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

예제 코드

// BackendApplication.java
package com.example.backend;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@EnableAsync
@SpringBootApplication
public class BackendApplication {
    public static void main(String[] args) {
        SpringApplication.run(BackendApplication.class, args);
    }
}
// WelcomeEmailService.java
package com.example.backend.service;

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Service
public class WelcomeEmailService {

    @Async
    public void sendWelcomeEmail(String email, String nickname) {
        System.out.println("[async] send welcome email to=" + email + ", nickname=" + nickname);
    }
}
// WelcomeEmailController.java
package com.example.backend.controller;

import com.example.backend.service.WelcomeEmailService;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/users")
public class WelcomeEmailController {

    private final WelcomeEmailService welcomeEmailService;

    public WelcomeEmailController(WelcomeEmailService welcomeEmailService) {
        this.welcomeEmailService = welcomeEmailService;
    }

    public record SendWelcomeEmailRequest(String email, String nickname) {}

    @PostMapping("/welcome-email")
    public Object sendWelcomeEmail(@RequestBody SendWelcomeEmailRequest request) {
        welcomeEmailService.sendWelcomeEmail(request.email(), request.nickname());
        return java.util.Map.of(
                "message", "요청은 정상적으로 접수되었습니다.",
                "status", "queued_in_process"
        );
    }
}

실행 방법

./gradlew bootRun

이 방식이 잘 맞는 상황

  • 애플리케이션 내부 비동기
  • 가벼운 후처리
  • 스레드풀로 충분한 처리량
  • 별도 브로커를 아직 두고 싶지 않은 경우

주의할 점

  • @Async는 큐가 아닙니다
  • 워커 독립 배포, 강한 재시도, DLQ 같은 기능은 별도 메시징이 더 적합합니다

4) Spring Boot 예제: 메시지 큐로 넘기는 구조

Spring AMQP는 비동기 메시지 소비와 consumer 설정, prefetch 같은 운영 포인트를 제공합니다. 큰 메시지나 느린 소비자는 prefetch 값을 낮게 잡는 게 나을 수 있다고 공식 문서가 설명합니다. (Home)

여기서는 구조 감각만 보여주는 최소 예제로 갑니다.

build.gradle 추가 의존성

implementation 'org.springframework.boot:spring-boot-starter-amqp'

예제 코드

// MailQueueProducer.java
package com.example.backend.messaging;

import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;

@Component
public class MailQueueProducer {

    private final RabbitTemplate rabbitTemplate;

    public MailQueueProducer(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }

    public void publishWelcomeEmail(String email, String nickname) {
        rabbitTemplate.convertAndSend(
                "mail.exchange",
                "mail.welcome",
                email + "," + nickname
        );
    }
}
// MailQueueConsumer.java
package com.example.backend.messaging;

import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

@Component
public class MailQueueConsumer {

    @RabbitListener(queues = "mail.welcome.queue")
    public void consume(String payload) {
        String[] parts = payload.split(",", 2);
        String email = parts[0];
        String nickname = parts[1];
        System.out.println("[worker] send welcome email to=" + email + ", nickname=" + nickname);
    }
}
// WelcomeEmailController.java
package com.example.backend.controller;

import com.example.backend.messaging.MailQueueProducer;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/users")
public class WelcomeEmailController {

    private final MailQueueProducer mailQueueProducer;

    public WelcomeEmailController(MailQueueProducer mailQueueProducer) {
        this.mailQueueProducer = mailQueueProducer;
    }

    public record SendWelcomeEmailRequest(String email, String nickname) {}

    @PostMapping("/welcome-email")
    public Object sendWelcomeEmail(@RequestBody SendWelcomeEmailRequest request) {
        mailQueueProducer.publishWelcomeEmail(request.email(), request.nickname());
        return java.util.Map.of(
                "message", "작업이 큐에 등록되었습니다.",
                "status", "queued"
        );
    }
}

5) Node.js 예제: BullMQ로 큐 분리하기

BullMQ는 Redis 위에서 동작하는 Node.js 큐 시스템입니다. Queue가 작업을 추가하고, Worker가 작업을 처리합니다. Redis 연결이 필요하고, 실패한 작업은 재시도할 수 있으며 backoff도 설정할 수 있습니다. (docs.bullmq.io)

설치 방법

npm install express bullmq ioredis

예제 코드

// queue.js
import { Queue } from "bullmq";

export const welcomeEmailQueue = new Queue("welcome-email", {
  connection: {
    host: "127.0.0.1",
    port: 6379,
  },
});
// worker.js
import { Worker } from "bullmq";

const worker = new Worker(
  "welcome-email",
  async (job) => {
    const { email, nickname } = job.data;
    console.log(`[worker] send welcome email to=${email}, nickname=${nickname}`);
    return { status: "sent" };
  },
  {
    connection: {
      host: "127.0.0.1",
      port: 6379,
    },
  }
);

worker.on("completed", (job) => {
  console.log(`job completed: ${job.id}`);
});

worker.on("failed", (job, err) => {
  console.error(`job failed: ${job?.id}`, err);
});
// server.js
import express from "express";
import { welcomeEmailQueue } from "./queue.js";

const app = express();
const PORT = 3000;

app.use(express.json());

app.post("/api/users/welcome-email", async (req, res) => {
  const { email, nickname } = req.body;

  if (!email || !nickname) {
    return res.status(400).json({ message: "필수값이 누락되었습니다." });
  }

  const job = await welcomeEmailQueue.add(
    "send-welcome-email",
    { email, nickname },
    {
      attempts: 3,
      backoff: {
        type: "fixed",
        delay: 3000,
      },
      removeOnComplete: true,
    }
  );

  return res.json({
    message: "작업이 큐에 등록되었습니다.",
    jobId: job.id,
    status: "queued",
  });
});

app.listen(PORT, () => {
  console.log(`server running on http://localhost:${PORT}`);
});

실행 방법

node server.js
node worker.js

예상 결과

POST /api/users/welcome-email
-> jobId 반환
-> worker가 Redis 큐에서 작업 처리
-> 실패 시 최대 3번 재시도

BullMQ는 실패 작업 재시도 시 backoff를 지원하고, unrecoverable error로 재시도 중단도 할 수 있습니다. (docs.bullmq.io)


언제 큐를 넣어야 하는지 정말 현실적으로 정리하면

저는 보통 이렇게 봅니다.

아직 큐가 없어도 되는 경우

  • 단일 서버
  • 작업이 짧다
  • 실패해도 다시 버튼 누르면 된다
  • 운영상 반드시 보장할 필요는 없다

큐가 거의 필요한 경우

  • 실패해도 다시 처리해야 한다
  • 외부 API 불안정성이 있다
  • 처리 시간이 길다
  • 요청 수가 많다
  • 워커를 따로 늘리고 싶다
  • 작업 상태를 추적해야 한다

BullMQ는 “작업 대기열”을, Celery는 “태스크 큐”를, Spring AMQP는 “비동기 메시지 소비”를 위한 도구로 각각 설명하고 있습니다. 결국 셋 다 같은 문제를 풉니다. (docs.bullmq.io)


재시도는 어떻게 생각해야 할까

여기서 진짜 중요한 감각이 하나 있습니다.

재시도는 좋은 기능이지만, 모든 실패를 무조건 다시 해서는 안 됩니다.

예를 들면 이런 식으로 나눠야 해요.

  • 일시적 네트워크 오류: 재시도 가능
  • 외부 API 503: 재시도 가능
  • 잘못된 이메일 주소: 재시도 불가
  • 검증 오류: 재시도 불가

Celery는 retry()를 공식 지원하고, BullMQ는 attempts와 backoff를 지원합니다. 반대로 BullMQ는 UnrecoverableError를 써서 재시도를 끊을 수도 있습니다. (docs.celeryq.dev)

즉 큐를 넣을 때는 “실패했으니 다시”가 아니라
“이 실패는 다시 하면 성공할 가능성이 있나?”
이 질문을 같이 넣어야 합니다.


DLQ는 뭐고, 왜 필요할까

DLQ는 Dead Letter Queue,
즉 여러 번 재시도해도 실패한 작업을 따로 모아두는 장소라고 이해하면 됩니다.

오늘 예제 코드엔 따로 넣지 않았지만, 운영으로 가면 굉장히 중요합니다.

왜냐하면 “실패한 줄도 모르고 묻혀버린 작업”이 제일 위험하거든요.

  • 결제 후 영수증 메일 누락
  • AI 임베딩 생성 실패
  • 웹훅 후처리 누락
  • 이미지 썸네일 생성 누락

이런 걸 운영 중에 나중에 발견하면 꽤 아픕니다.
그래서 큐 시스템을 도입하면 실패 작업 모니터링과 재처리 전략도 같이 봐야 합니다. BullMQ도 failed jobs와 manual retry 패턴을 공식 문서에 두고 있습니다. (docs.bullmq.io)


멱등성(idempotency)은 큐에서도 중요하다

이건 제가 꼭 같이 묶어서 보는 포인트예요.

워커가 재시도하면 같은 작업이 두 번 실행될 수 있습니다.
그러면 이런 일이 생길 수 있어요.

  • 같은 메일 두 번 발송
  • 같은 포인트 두 번 적립
  • 같은 주문 후처리 두 번 실행

그래서 큐 작업도 보통 이런 식으로 보호해야 합니다.

  • 작업 키를 고유하게 생성
  • 이미 처리한 작업인지 체크
  • 상태 전이 기반으로 한 번만 처리
  • 외부 API가 idempotency key를 지원하면 같이 사용

즉 큐는 재시도를 쉽게 해주지만,
그만큼 중복 처리 방지를 더 신경 써야 합니다.


자주 발생하는 오류

오류 1. 비동기 처리했는데 여전히 응답이 느립니다

원인:

  • 실제 느린 작업이 여전히 요청-응답 안에 있음
  • 큐에 넣기 전에 무거운 전처리를 하고 있음
  • 외부 API 호출을 큐가 아니라 서비스에서 먼저 해버림

해결 방법:

  • HTTP 응답 전에 꼭 필요한 일만 남기기
  • 나머지는 job payload로 넘기기
  • “큐 등록” 자체만 요청 안에서 끝내기

오류 2. 작업이 실패했는데 아무도 모릅니다

원인:

  • 워커 로그만 보고 있음
  • 실패 이벤트/모니터링 없음
  • DLQ나 failed jobs 확인 체계 없음

해결 방법:

  • failed job 수집
  • 알림 연결
  • 재처리 화면 또는 운영 스크립트 준비

BullMQ는 completed/failed 이벤트를 다룰 수 있고, failed jobs 재시도 관련 가이드를 제공합니다. (docs.bullmq.io)


오류 3. 큐는 넣었는데 중복 실행됩니다

원인:

  • 재시도 또는 중복 enqueue
  • 워커가 중간 실패 후 다시 처리
  • 멱등성 고려 없음

해결 방법:

  • job id 또는 비즈니스 키 기반 중복 방지
  • DB 상태 기반 멱등 처리
  • 외부 API idempotency key 활용

실무에서 주의할 점

1. “가벼운 비동기”와 “신뢰성 있는 큐”를 구분하세요

FastAPI BackgroundTasks, Spring @Async, Node async 함수는 큐가 아닙니다. 편하지만 강한 보장은 약합니다. (FastAPI)

2. 워커는 앱 서버와 다르게 생각하세요

워커는 CPU, 메모리, 동시성, 재시도 정책이 다를 수 있습니다. BullMQ production guide도 운영 시 고려사항을 따로 설명합니다. (docs.bullmq.io)

3. 재시도는 정책이어야 합니다

무한 재시도는 사고를 키웁니다. 최대 횟수, backoff, unrecoverable error 기준이 필요합니다. (docs.bullmq.io)

4. 작업 payload는 너무 크게 만들지 마세요

큐에는 보통 “처리에 필요한 최소 정보”만 넣는 편이 좋습니다.
예: 파일 바이트 전체 대신 object key, user id, task type

5. 사용자가 꼭 기다려야 하는 일은 큐로 빼지 마세요

결제 승인 결과처럼 즉시 알아야 하는 일까지 전부 큐로 빼면 UX가 나빠질 수 있습니다.


FAQ

Q. FastAPI에서는 BackgroundTasks만 써도 충분한가요?

작고 가벼운 후처리라면 충분할 수 있습니다. 다만 중요한 작업 보장, 재시도, 멀티 서버 환경이 필요하면 Celery 같은 큐가 더 적합합니다. FastAPI 공식 문서도 background task는 응답 후 실행할 작업에 쓰는 기능으로 설명하고, Celery는 태스크 큐와 재시도 기능을 제공합니다. (FastAPI)

Q. Spring Boot에서 @Async와 메시지 큐의 차이는 뭔가요?

@Async는 같은 애플리케이션 내부 스레드풀에서 비동기 실행하는 방식이고, 메시지 큐는 브로커를 통해 작업을 저장하고 별도 소비자가 처리하는 구조입니다. 신뢰성과 재시도, 워커 분리가 중요하면 큐가 더 맞습니다. (Home)

Q. Node.js는 원래 비동기인데 왜 BullMQ가 필요한가요?

언어의 비동기와 작업 큐는 다른 문제입니다. async/await는 현재 프로세스 안의 비동기 흐름이고, BullMQ는 작업을 저장하고 다른 워커가 나중에 처리하게 만드는 구조입니다. (docs.bullmq.io)

Q. 재시도는 몇 번이 적당한가요?

정답은 없지만, 보통은 3회 안팎 + backoff부터 많이 시작합니다. 중요한 건 횟수보다 “재시도 가능한 실패인지”를 구분하는 겁니다. BullMQ와 Celery 모두 재시도 기능을 제공하지만, unrecoverable failure 처리도 같이 고려해야 합니다. (docs.bullmq.io)

Q. 큐를 넣으면 무조건 더 좋은가요?

아닙니다. 운영 요소가 하나 더 생깁니다. 작은 프로젝트나 가벼운 후처리라면 프레임워크 기본 비동기로 시작하는 편이 더 현실적일 수 있습니다. 큐는 신뢰성과 재시도, 분산 처리가 필요할 때 강해집니다. (FastAPI)


핵심 요약

  • 비동기 작업은 사용자가 기다릴 필요 없는 일을 응답 밖으로 빼는 구조입니다. (FastAPI)
  • 큐는 작업을 안전하게 쌓고 워커가 나중에 처리하게 만드는 구조입니다. (docs.bullmq.io)
  • FastAPI BackgroundTasks, Spring @Async, Node async 함수는 시작점으로 좋지만 큐와는 다릅니다. (FastAPI)
  • 재시도와 backoff는 중요하지만, 멱등성 없이 무턱대고 넣으면 위험합니다. (docs.bullmq.io)
  • 검색에 잘 걸리는 개발 글은 질문형 제목, 초반 정답 요약, 정의 문장, 실행 코드, FAQ, 출처 구조가 강합니다.

출처

  • FastAPI 공식 문서 — Background Tasks. (FastAPI)
  • Spring Framework 공식 문서 — Task Execution and Scheduling, @Async. (Home)
  • Spring Boot 공식 문서 — AsyncTaskExecutor auto-configuration. (Home)
  • Spring AMQP 공식 문서 — Asynchronous Consumer / prefetch. (Home)
  • BullMQ 공식 문서 — What is BullMQ, Queues, Workers, Connections, Retrying failing jobs. (docs.bullmq.io)
  • Celery 공식 문서 — Introduction, Tasks retry, Workers reconnect, Calling tasks retry. (docs.celeryq.dev)
  • 글 구성 참고: 업로드하신 “AI 검색에 잘 걸리는 글” 가이드.

백엔드개발, FastAPI, SpringBoot, Nodejs, Express, 비동기처리, 작업큐, Celery, BullMQ, 백엔드시리즈

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/06   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함
반응형