비동기 작업 처리 #작업 큐, 메시지 브로커, 이벤트 기반 아키텍처

비동기 작업 처리란?

비동기 작업 처리(Asynchronous Processing)는 요청을 받은 시점에 모든 작업을 완료할 때까지 기다리지 않고, 후속 작업을 별도의 실행 흐름으로 분리하여 처리하는 방식이다.

일반적인 동기 처리에서는 요청을 받은 서버가 모든 작업을 순차적으로 수행한 뒤 응답한다.

예를 들어 게임 서버에서 유저가 아이템을 구매하면 다음 작업이 발생할 수 있다.

  • 구매 요청 검증
  • 재화 차감
  • 아이템 지급
  • 구매 기록 저장
  • 로그 기록
  • 업적 진행도 갱신
  • 알림 발송

이 모든 작업을 요청 처리 과정에서 실행하면 응답 시간이 길어지고, 부가 기능의 장애가 구매 API까지 지연시킬 수 있다.

비동기 처리를 적용하면 핵심 작업과 후속 작업을 분리할 수 있다.

클라이언트
    |
    v
게임 서버
    |
    +-- 구매 검증
    +-- 재화 차감
    +-- 아이템 지급
    |
    +-- 작업 큐에 후속 작업 등록
              |
              v
          작업 워커
              |
              +-- 구매 로그 기록
              +-- 업적 갱신
              +-- 알림 발송

핵심은 모든 작업을 무조건 비동기로 실행하는 것이 아니다. 사용자 응답에 반드시 필요한 작업과 나중에 처리해도 되는 작업을 구분하는 것이다.

게임 구매처럼 재화와 아이템의 정합성이 중요한 작업은 핵심 트랜잭션에서 처리하고, 통계 집계나 알림처럼 지연 처리가 가능한 작업은 별도의 비동기 흐름으로 분리할 수 있다.

작업 큐와 메시지 브로커

작업 큐(Job Queue)

작업 큐는 나중에 처리해야 할 작업을 저장하고, 워커가 이를 가져가 실행하도록 하는 구조다.

Producer
   |
   | 작업 등록
   v
Job Queue
   |
   | 작업 가져오기
   v
Worker
   |
   v
작업 실행 및 결과 기록

각 구성 요소의 역할은 다음과 같다.

  • Producer: 작업을 생성하고 큐에 등록한다.
  • Queue: 처리 대기 중인 작업을 보관한다.
  • Worker: 큐에서 작업을 가져와 실행한다.
  • Result Store: 작업의 처리 결과와 상태를 저장한다.

예를 들어 게임 서버가 유저의 전투 종료 이벤트를 수신했다면, 랭킹 점수 집계 작업을 큐에 등록할 수 있다. 별도의 워커는 해당 작업을 가져와 점수를 계산하고 랭킹 데이터를 갱신한다.

작업 큐의 주요 목적은 다음과 같다.

  • 요청 처리와 후속 작업의 분리
  • 작업 처리량의 제어
  • 순간적인 요청 증가에 대한 완충
  • 워커의 수평 확장
  • 실패한 작업의 재시도
  • 작업 처리 상태의 추적

다만 큐는 처리 능력 자체를 무한히 증가시키지 않는다. 작업이 들어오는 속도가 워커의 처리 속도보다 지속적으로 빠르면 대기열이 계속 증가한다.

메시지 브로커(Message Broker)

메시지 브로커는 서비스나 프로세스 사이에서 메시지를 전달하는 중개 시스템이다.

대표적인 기술은 다음과 같다.

  • RabbitMQ
  • Apache Kafka
  • Redis Streams
  • AWS SQS
  • NATS

각 기술은 메시지를 전달하고 소비하는 기능을 제공하지만, 내부 동작과 보장 수준은 서로 다르다.

기술주요 특징대표적인 활용
RabbitMQ큐, 라우팅, 확인 응답작업 분배, 서비스 간 메시징
Kafka분산 로그, 파티션, 오프셋이벤트 스트리밍, 대량 이벤트 처리
Redis StreamsRedis 기반 메시지 스트림, 소비자 그룹경량 비동기 처리, 이벤트 전달
AWS SQS관리형 메시지 큐클라우드 작업 분배

작업 큐와 메시지 브로커는 같은 개념이 아니다.

작업 큐는 작업을 대기시키고 처리하는 패턴 또는 구조이며, 메시지 브로커는 그 구조를 구현하는 데 사용할 수 있는 인프라 기술이다.

또한 메시지 브로커가 존재한다고 해서 메시지가 반드시 유실되지 않거나 중복 없이 처리되는 것은 아니다. 내구성, 확인 응답, 재전달, 보존 기간, 소비자 상태 관리 등의 설정에 따라 동작이 달라진다.

작업 큐 설계 시 고려 사항

작업의 우선순위

긴급한 결제 검증과 중요도가 낮은 통계 집계를 동일한 큐에서 처리하면 대량의 통계 작업이 중요한 작업을 지연시킬 수 있다. 필요하다면 우선순위별로 큐를 분리한다.

워커의 동시 처리량

워커를 무제한으로 늘리면 데이터베이스 연결 수가 급증하거나 외부 API의 요청 제한을 초과할 수 있다. 시스템의 처리 능력에 맞춰 동시 실행 수를 제한해야 한다.

재시도 정책

일시적인 네트워크 오류는 재시도할 수 있지만, 잘못된 입력값이나 권한 오류는 반복해서 실행해도 성공하지 않는다.

오류 유형에 따라 재시도 여부를 구분하고, 지수 백오프(Exponential Backoff)와 최대 재시도 횟수를 설정하는 것이 일반적이다.

실패 작업의 격리

최대 재시도 횟수를 초과한 작업은 Dead Letter Queue(DLQ)와 같은 별도 저장소로 이동시켜 원인을 분석하고 복구할 수 있도록 한다.

이벤트 기반 아키텍처

이벤트 기반 아키텍처란?

이벤트 기반 아키텍처(Event-Driven Architecture, EDA)는 시스템에서 발생한 사건을 이벤트로 표현하고, 이를 구독하는 구성 요소가 해당 이벤트를 바탕으로 동작하는 구조다.

예를 들어 게임에서 PlayerLevelUp 이벤트가 발생했다고 가정하자.

게임 서버
    |
    | PlayerLevelUp 이벤트 발행
    v
이벤트 브로커
    |
    +--> 업적 서비스
    |
    +--> 알림 서비스
    |
    +--> 통계 서비스

게임 서버는 레벨업이라는 사건을 발행한다. 업적 서비스는 업적을 갱신하고, 알림 서비스는 메시지를 생성하며, 통계 서비스는 관련 데이터를 집계한다.

게임 서버는 각 서비스의 내부 구현을 직접 호출할 필요가 없다.

이 구조에서는 이벤트 발행자와 소비자가 서로의 구현에 강하게 의존하지 않도록 설계할 수 있다.

명령(Command)과 이벤트(Event)의 차이

명령과 이벤트는 목적이 다르다.

  • 명령: 어떤 작업을 수행하라고 요청한다.
  • 이벤트: 어떤 사건이 이미 발생했음을 알린다.

예를 들어 다음과 같다.

Command: GrantReward
Event:   RewardGranted

GrantReward는 보상을 지급하라는 요청이다. 반면 RewardGranted는 보상이 실제로 지급되었다는 사실을 나타낸다.

이 차이는 시스템의 정합성에 직접적인 영향을 준다.

보상 지급 요청을 받았다는 이유만으로 지급 완료 이벤트를 발행하면 안 된다. 지급 트랜잭션이 성공한 뒤에 해당 사실을 이벤트로 전달해야 한다.

이벤트 기반 구조의 장점

결합도 감소

이벤트 발행자는 구독자의 세부 구현을 알 필요가 없다. 새로운 구독자를 추가해도 기존 발행자의 코드를 변경하지 않을 수 있다.

기능 확장 용이성

게임 출시 이후 분석, 추천, 알림, 부정행위 탐지 기능을 추가할 때 기존 핵심 로직에 대한 변경을 줄일 수 있다.

독립적인 확장

이벤트 처리량이 많은 서비스만 별도로 확장할 수 있다.

장애 격리

일부 후속 서비스가 일시적으로 중단되더라도 이벤트를 보존하고 재처리할 수 있다. 단, 이 효과는 브로커의 내구성과 이벤트 발행 방식이 적절하게 구성되어 있을 때 성립한다.

이벤트 기반 구조의 한계

이벤트 기반 아키텍처는 복잡성을 제거하는 것이 아니라 분산된 처리 흐름으로 이동시킨다.

대표적인 문제는 다음과 같다.

  • 이벤트가 실제로 처리되었는지 추적하기 어렵다.
  • 이벤트가 중복 전달될 수 있다.
  • 서비스마다 데이터 반영 시점이 달라질 수 있다.
  • 이벤트 처리 순서가 보장되지 않을 수 있다.
  • 장애 발생 시 여러 서비스의 상태를 함께 복구해야 할 수 있다.

따라서 이벤트 ID, 상관관계 ID(Correlation ID), 처리 상태, 재시도 정책, 중복 처리 방지 로직 등을 함께 설계해야 한다.

비동기 작업의 분리

무엇을 비동기로 분리할 것인가?

비동기 처리의 목적은 모든 코드를 별도의 워커로 옮기는 것이 아니다. 응답 지연, 장애 전파, 자원 사용량을 분석해 적절한 경계를 찾는 것이다.

작업은 크게 다음과 같이 구분할 수 있다.

작업 유형예시처리 방식
핵심 동기 작업재화 차감, 아이템 지급, 권한 검증요청 과정에서 검증하고 확정
후속 비동기 작업알림 발송, 통계 집계, 분석 로그큐 또는 이벤트 브로커로 분리
장시간 비동기 작업대량 데이터 내보내기, 리포트 생성작업 ID를 발급하고 별도 워커에서 처리

게임 아이템 구매를 예로 들면, 클라이언트는 구매 요청을 보내고 서버는 구매 조건을 검증한다. 재화 차감과 아이템 지급은 원자적으로 처리되어야 한다.

이후 구매 완료 이벤트를 기반으로 통계나 알림을 처리할 수 있다.

다만 핵심 트랜잭션이 커밋된 직후 프로세스가 종료되면, 데이터베이스에는 구매 기록이 남았지만 이벤트는 발행되지 않는 문제가 발생할 수 있다.

이를 해결하는 대표적인 방법이 Transactional Outbox 패턴이다.

Transactional Outbox 패턴

Transactional Outbox는 비즈니스 데이터 변경과 이벤트 발행에 필요한 기록을 동일한 데이터베이스 트랜잭션으로 저장하는 방식이다.

BEGIN TRANSACTION
    |
    +-- 재화 차감
    +-- 아이템 지급
    +-- 구매 기록 저장
    +-- Outbox 이벤트 저장
    |
COMMIT
    |
    v
Outbox Publisher
    |
    v
메시지 브로커
    |
    v
이벤트 소비자

구매 데이터와 Outbox 이벤트가 같은 트랜잭션에서 커밋되므로, 구매는 저장됐지만 이벤트 발행 기록이 아예 남지 않는 문제를 줄일 수 있다.

별도의 발행 프로세스가 Outbox 테이블을 읽어 메시지 브로커로 이벤트를 전달한다.

단, Outbox를 사용해도 브로커 전송이 중복될 수 있다. 발행 성공 후 데이터베이스의 발행 완료 상태를 기록하기 전에 프로세스가 종료되면 같은 이벤트가 다시 전송될 수 있기 때문이다.

따라서 소비자 측에서도 중복 처리를 방지해야 한다.

작업 분리 시 주의 사항

데이터 정합성

비동기 작업이 완료되기 전에는 시스템의 일부 데이터가 최신 상태가 아닐 수 있다. 사용자에게 즉시 보여야 하는 정보와 지연 갱신이 허용되는 정보를 구분해야 한다.

작업의 멱등성

같은 작업이 여러 번 실행되어도 결과가 중복 반영되지 않도록 설계해야 한다.

실패 복구

작업이 실패했을 때 재시도할지, 취소할지, 운영자 개입이 필요한지 정의해야 한다.

작업 간 의존성

후속 작업이 선행 작업의 성공에 의존한다면, 선행 작업의 완료 여부를 확인하기 전에 실행해서는 안 된다.

관측 가능성

작업이 어디에서 지연되거나 실패했는지 확인할 수 있도록 로그, 메트릭, 추적 정보를 남겨야 한다.

비동기 작업 상태 관리

작업 상태가 필요한 이유

비동기 작업은 요청을 받은 순간 완료되지 않을 수 있다. 따라서 클라이언트와 서버가 작업의 진행 상황을 확인할 수 있는 상태 관리 체계가 필요하다.

예를 들어 유저가 대량의 게임 전적 데이터를 내보내도록 요청했다면, 서버는 즉시 파일을 생성하는 대신 작업 ID를 발급할 수 있다.

클라이언트
    |
    | POST /exports
    v
API 서버
    |
    +-- 작업 등록
    +-- 작업 ID 반환
             |
             v
          작업 큐
             |
             v
          작업 워커
             |
             +-- 데이터 조회
             +-- 파일 생성
             +-- 결과 저장

클라이언트는 작업 ID를 이용해 처리 상태를 조회할 수 있다.

작업 상태 모델

일반적인 작업 상태는 다음과 같이 구성할 수 있다.

상태의미
PENDING작업 등록 완료, 처리 대기
RUNNING워커가 작업 처리 중
SUCCEEDED작업 성공
RETRY_WAIT재시도 대기
FAILED작업 실패
CANCELLED작업 취소

실제 시스템에서는 RETRY_WAIT를 별도 상태로 두지 않고 재시도 횟수와 다음 실행 시각으로 관리할 수도 있다.

상태 전이는 허용된 경로로 제한해야 한다.

PENDING
   |
   v
RUNNING ---------> SUCCEEDED
   |
   +-------------> RETRY_WAIT
   |                    |
   |                    v
   +<--------------- 재실행
   |
   +-------------> FAILED

PENDING ---------> CANCELLED

실행 중인 작업의 취소는 단순히 상태를 변경하는 것만으로 충분하지 않을 수 있다. 이미 실행된 외부 요청이나 데이터 변경을 되돌릴 수 있는지 별도로 판단해야 한다.

작업 상태 저장소

작업 상태는 작업의 성격과 운영 요구 사항에 따라 저장한다.

  • Redis: 빠른 상태 조회, 짧은 수명의 작업 정보, 진행률 캐싱
  • RDBMS: 영속적인 작업 이력, 상태 전이 기록, 조회 및 감사
  • 메시지 브로커: 처리 대기 메시지와 전달 상태 관리
  • 오브젝트 스토리지: 생성된 파일이나 대용량 결과물 저장

메시지 브로커가 제공하는 메시지 상태와 비즈니스 관점의 작업 상태는 동일하지 않다.

예를 들어 메시지가 브로커에서 소비되었다고 해서 파일 생성이 성공한 것은 아니다. 따라서 실제 비즈니스 작업의 완료 여부를 별도로 기록해야 할 수 있다.

Node.js 작업 상태 관리 예시

다음은 Express와 작업 큐 라이브러리인 BullMQ를 사용해 작업을 등록하고 상태를 조회하는 간단한 예시다.

import express from "express";
import { Queue, Worker } from "bullmq";

const app = express();
app.use(express.json());

const connection = {
  host: process.env.REDIS_HOST ?? "127.0.0.1",
  port: Number(process.env.REDIS_PORT ?? 6379),
};

const exportQueue = new Queue("export-jobs", {
  connection,
});

// 작업 등록
app.post("/exports", async (req, res, next) => {
  try {
    const job = await exportQueue.add(
      "create-export",
      {
        userId: req.body.userId,
      },
      {
        attempts: 3,
        backoff: {
          type: "exponential",
          delay: 1000,
        },
        removeOnComplete: false,
        removeOnFail: false,
      },
    );

    res.status(202).json({
      jobId: job.id,
      status: "PENDING",
    });
  } catch (error) {
    next(error);
  }
});

// 작업 상태 조회
app.get("/exports/:jobId", async (req, res, next) => {
  try {
    const job = await exportQueue.getJob(req.params.jobId);

    if (!job) {
      return res.status(404).json({
        error: "JOB_NOT_FOUND",
      });
    }

    const state = await job.getState();

    res.json({
      jobId: job.id,
      state,
      progress: job.progress,
      result: state === "completed" ? job.returnvalue : undefined,
      error: state === "failed" ? job.failedReason : undefined,
    });
  } catch (error) {
    next(error);
  }
});

// 별도 워커 프로세스에서 실행하는 것을 권장
const worker = new Worker(
  "export-jobs",
  async (job) => {
    const { userId } = job.data;

    await job.updateProgress(10);

    // 실제 데이터 조회 및 파일 생성 로직
    const result = await createExport(userId);

    await job.updateProgress(100);

    return result;
  },
  {
    connection,
    concurrency: 5,
  },
);

// 예시용 함수
async function createExport(userId) {
  return {
    userId,
    fileUrl: "/exports/example.csv",
  };
}

app.listen(3000);

이 예시는 작업 등록, 재시도, 상태 조회의 기본적인 흐름을 보여준다. 실제 서비스에서는 다음 사항을 추가로 고려해야 한다.

  • 작업 ID를 추측해 다른 사용자의 작업 결과를 조회하지 못하도록 소유권을 검증한다.
  • 실제 파일을 생성하기 전후에 권한과 입력값을 검증한다.
  • Redis 연결, 워커 오류, 서버 종료에 대한 처리를 구현한다.
  • 작업 결과와 실패 사유에 민감한 정보가 포함되지 않도록 관리한다.
  • 작업 완료 후 결과 데이터의 보존 기간과 삭제 정책을 설정한다.
  • 작업 등록과 상태 저장이 장애 상황에서도 적절하게 복구되도록 구성한다.

BullMQ의 상태 이름은 waiting, active, completed, failed, delayed 등으로 표현될 수 있다. 위 예시의 PENDING은 API가 클라이언트에 반환하는 초기 상태를 의미하며, 라이브러리의 내부 상태 이름과 반드시 일치하는 것은 아니다.

비동기 작업에서 발생하는 주요 문제

중복 처리

메시지 브로커는 장애 복구와 재전달을 위해 같은 메시지를 다시 전달할 수 있다.

따라서 메시지를 한 번만 전달하는 것에 의존하기보다, 동일한 작업이 반복 실행되어도 비즈니스 결과가 중복 반영되지 않도록 설계해야 한다.

예를 들어 보상 지급 작업은 이벤트 ID나 보상 지급 ID에 유니크 제약을 적용해 중복 지급을 방지할 수 있다.

작업 유실

작업을 메모리에만 저장하거나, 데이터베이스 커밋과 메시지 발행을 별개로 처리하면 프로세스 장애 시 작업이 유실될 수 있다.

중요한 작업이라면 내구성 있는 큐, 영속화 설정, 확인 응답, Outbox 패턴 등을 검토해야 한다.

작업 대기열 증가

작업 유입량이 처리량보다 크면 큐에 쌓인 작업 수가 계속 증가한다.

이때 단순히 워커를 추가하는 것만으로는 해결되지 않을 수 있다. 데이터베이스가 병목이라면 워커 증가가 오히려 부하를 악화시킬 수 있다.

주요 모니터링 지표는 다음과 같다.

  • 대기 중인 작업 수
  • 가장 오래 대기 중인 작업의 시간
  • 초당 작업 유입량과 완료량
  • 작업별 평균 및 최대 처리 시간
  • 재시도 횟수와 실패율
  • 워커의 CPU, 메모리, 연결 풀 사용량

순서 보장

동일한 사용자의 이벤트가 순서대로 발생하더라도 여러 워커가 병렬 처리하면 완료 순서가 달라질 수 있다.

예를 들어 레벨 10 달성 이벤트보다 레벨 11 달성 이벤트가 먼저 반영되면 업적이나 통계 데이터가 잘못 갱신될 수 있다.

필요하다면 사용자 ID와 같은 키를 기준으로 파티셔닝하거나, 작업을 직렬 처리하거나, 버전 번호를 이용해 오래된 이벤트의 적용을 차단해야 한다.

재시도와 부작용

재시도는 모든 실패를 해결하는 만능 수단이 아니다.

외부 결제 요청이 타임아웃되었다고 가정하자. 요청이 실제로 실패했는지, 결제는 성공했지만 응답만 유실되었는지 확인할 수 없을 수 있다.

이때 같은 요청을 무조건 재전송하면 중복 결제가 발생할 수 있다.

외부 시스템의 멱등성 키, 요청 결과 조회, 보상 처리 등 부작용을 통제할 방법이 필요하다.

실시간 게임 서버에서의 활용

비동기 작업 처리는 게임 서버의 여러 영역에 활용할 수 있다.

기능비동기 처리 활용
매칭 시스템매칭 요청을 큐에 넣고 별도 프로세스에서 그룹 구성
랭킹 시스템전투 결과 이벤트를 소비해 랭킹 점수 집계
게임 로그플레이 이벤트를 수집해 분석 시스템으로 전달
보상 지급보상 지급 요청을 큐로 처리하되 중복 지급 방지
알림 시스템업적 달성, 친구 접속 등의 알림 생성
데이터 내보내기전적과 통계 데이터를 파일로 생성
부정행위 탐지플레이 로그를 비동기 분석해 의심 패턴 탐지

단, 실시간 전투 시뮬레이션이나 프레임 단위의 게임 상태 동기화까지 일반적인 작업 큐에 맡기는 것은 적절하지 않을 수 있다. 이러한 처리는 지연 시간과 순서 보장 요구 사항이 높기 때문이다.

실시간 게임 로직은 서버의 게임 루프나 해당 세션의 실행 모델에서 처리하고, 로그 집계와 분석 등 지연을 허용할 수 있는 후속 작업을 비동기로 분리하는 편이 일반적이다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다