웹 애플리케이션은 보통 다음과 같은 요청-응답 구조에서 시작합니다.

Client
   │
   │ Request
   ▼
Server
   │
   ├─ DB 조회
   ├─ 비즈니스 로직
   ├─ 외부 API 호출
   ├─ LLM 호출
   └─ 결과 생성
   │
   ▼
Response

단순하고 이해하기 쉬운 구조입니다.

문제는 처리 시간이 길어지고 동시에 많은 사용자가 몰리기 시작할 때 발생합니다.
서버가 요청을 받은 뒤 결과가 만들어질 때까지 클라이언트와 연결을 유지해야 하기 때문입니다.

특히 LLM, AI Agent, 파일 분석, 이미지 처리, 대규모 배치, 외부 API 연동처럼 처리 시간이 일정하지 않은 작업에서는 이런 문제가 더 크게 나타납니다.

이번 글에서는 이러한 문제를 해결하기 위한 비동기 이벤트 기반 아키텍처를 살펴봅니다.

핵심 구성요소는 다음 네 가지입니다.

Client
Event Broker
Message Queue
Worker

여기에 시스템이 커지면 Event Log라는 개념까지 등장합니다.


1. 왜 비동기가 필요한가?

트래픽이 증가할 때 서버의 병목은 단순히 "CPU가 느려서"만 발생하는 것이 아닙니다.

강의에서는 크게 세 가지 대기를 설명합니다.

첫 번째는 요청 접수 대기, 두 번째는 요청 처리 대기, 세 번째는 클라이언트의 응답 대기입니다.
요청 처리가 끝나지 않으면 서버가 연결을 계속 유지하게 되고, 이런 연결이 누적되면 새로운 요청을 받을 수 있는 여유도 줄어듭니다.

은행에 비유하면 이해하기 쉽습니다.

은행 입구에서 기다림
= 요청 접수 병목

창구 직원이 대출 심사를 하느라 오래 붙잡힘
= 처리 병목

업무가 끝날 때까지 고객이 창구 앞을 계속 차지함
= 응답 대기 병목

서버에서도 비슷합니다.

Client
   │
   ▼
Server ───────── 작업 중 ─────────┐
   │                              │
   │       LLM 20초               │
   │       외부 API 5초           │
   │       DB 처리                │
   │                              │
   └──────────────────────────────┘
                  │
                  ▼
               Response

한두 명이면 별 문제가 없습니다.

하지만 요청이 수백, 수천 개씩 들어오면 서버는 작업뿐 아니라 대기하고 있는 연결 자체도 관리해야 합니다.

그래서 사고방식을 바꿉니다.

요청이 들어왔다고 그 자리에서 작업까지 끝낼 필요가 있을까?

요청은 빨리 접수하고 실제 처리는 다른 곳에서 하면 됩니다.

Client
   │ Request
   ▼
Server
   │
   ├── Queue에 작업 등록
   │
   └── 접수 완료
          │
          ▼
       Worker
          │
       실제 처리

핵심은 간단합니다.

요청 접수와 실제 작업 처리를 분리합니다.


2. "요청하고 기다리지 않는다"

비동기 시스템에서는 요청자가 작업이 끝날 때까지 기다리지 않는 구조를 만들 수 있습니다.

강의에서는 고객센터의 콜백을 예로 듭니다.

"고객님, 지금 상담원이 모두 통화 중입니다."

기존 방식
→ 전화기를 들고 계속 기다린다.

비동기 방식
→ 요청만 남기고 전화를 끊는다.
→ 상담원이 준비되면 다시 전화한다.

서버에서도 똑같습니다.

Client
   │
   │ 작업 요청
   ▼
Server
   │
   │ 접수
   ▼
연결 종료


나중에...


Server
   │
   │ 처리 결과 이벤트
   ▼
Client

강의에서는 이러한 흐름을 CPS 방식이라고 설명합니다.
요청 직후 응답 완료를 기다리는 것이 아니라 요청을 넘기고, 결과가 만들어졌을 때
다시 클라이언트 쪽으로 전달하는 방식입니다.
이를 위해 양방향 통신과 채널 기반 이벤트 전달을 사용합니다.

여기서 중요한 것은 단순히 async 키워드를 사용하는 것이 아닙니다.

동기

Request
   ↓
처리
   ↓
처리
   ↓
처리
   ↓
Response

에서

비동기 이벤트

Request
   ↓
Event
   ↓
Queue
   ↓
Worker

...

Result Event
   ↓
Broker
   ↓
Client

으로 시스템 구조 자체가 달라지는 것입니다.


3. 전체 아키텍처의 네 참가자

이벤트 기반 구조를 구성하는 핵심 참가자는 다음과 같습니다.

구성요소역할
Client이벤트를 보내고 결과 이벤트를 수신
Event Broker클라이언트와 이벤트 시스템 사이를 중계
PGMQ처리할 작업을 신뢰성 있게 보관
Worker실제 비즈니스 로직 수행

전체적인 구조부터 보면 다음과 같습니다.

┌─────────────┐
│   Client    │
└──────┬──────┘
       │
       │ WebSocket
       ▼
┌─────────────┐
│Event Broker │
└──────┬──────┘
       │
       │ write
       ▼
┌─────────────┐
│    PGMQ     │
│    Queue    │
└──────┬──────┘
       │
       │ read
       ▼
┌─────────────┐
│   Worker    │
└──────┬──────┘
       │
       ├─ DB
       ├─ LLM
       ├─ RAG
       ├─ 외부 API
       └─ Tool 실행

각 컴포넌트가 자기 역할만 담당한다는 것이 중요합니다.


4. PGMQ는 왜 필요한가?

PGMQ는 PostgreSQL을 기반으로 메시지 큐를 구현하는 방식입니다.

강의에서는 PGMQ의 핵심 연산을 다음과 같이 설명합니다.

write
read
delete
set_vt
archive

단순히 메시지를 넣고 빼는 정도의 기능만 담당합니다.

그런데 PGMQ를 사용하는 이유는 기능이 많아서가 아니라 PostgreSQL 안에서 큐와 일반 데이터 처리를 연결할 수 있다는 점에 있습니다.

강의에서는 메시지 큐와 영속화 테이블이 같은 PostgreSQL 안에 있기 때문에 큐 처리와 데이터 저장을 하나의 트랜잭션 범위에서 다룰 수 있다는 점을 중요한 장점으로 설명합니다.

예를 들어 외부 MQ라면 다음처럼 됩니다.

Application
   │
   ├──────────────▶ MQ Server
   │
   └──────────────▶ PostgreSQL

두 시스템이 별도로 존재합니다.

그러면 이런 상황을 생각해야 합니다.

1. MQ 메시지 처리 성공
2. DB 저장 직전 서버 장애
3. DB 저장 실패

또는 반대 상황도 가능합니다.

PGMQ처럼 PostgreSQL 내부에서 큐를 사용할 경우에는 다음과 같은 형태로 구성할 수 있습니다.

PostgreSQL
│
├─ Application Table
│
└─ PGMQ Queue

그래서 관련 작업을 하나의 DB 트랜잭션 안에서 관리할 수 있는 여지가 생깁니다.

물론 이 아키텍처 자체가 반드시 PGMQ에 종속되는 것은 아닙니다.
강의에서도 Redis, SQS, Kafka 등 다른 메시징 시스템으로 교체할 수 있으며
핵심은 MQ라는 역할 자체라고 설명합니다.


5. Event Broker는 일을 하지 않는다

처음 보면 Event Broker를 백엔드 서버라고 생각하기 쉽습니다.

하지만 이 구조에서 브로커의 역할은 조금 다릅니다.

이벤트를 전달할 뿐 비즈니스 로직을 처리하지 않습니다.

클라이언트가 직접 PGMQ에 접근하는 것이 아니라 Event Broker에게 이벤트를 보냅니다.

Client
   │
   │ Event
   ▼
Event Broker
   │
   │ write
   ▼
PGMQ

반대로 결과 이벤트도 Broker가 클라이언트에게 전달합니다.

PGMQ / Event Log
       │
       ▼
Event Broker
       │
       ▼
Client

강의에서도 Event Broker를 클라이언트가 직접 접속하는 소켓 서버로 설명하며,
PGMQ의 메시지를 쓰거나 전달할 뿐 메시지를 직접 처리하지 않는다고 강조합니다.

즉 역할은 교통정리입니다.

Event 수신
     ↓
Queue 전달
     ↓
결과 Event 조회
     ↓
구독 Client에게 전달

그래서 Broker 자체는 상대적으로 가볍게 유지할 수 있습니다.


6. Event Broker는 수평 확장이 쉽다

브로커 하나에 모든 클라이언트를 연결할 필요도 없습니다.

                   PGMQ
                    ▲
          ┌─────────┼─────────┐
          │         │         │
      Broker A  Broker B  Broker C
          ▲         ▲         ▲
          │         │         │
      Client들   Client들   Client들

사용자가 늘어나면 Broker를 늘립니다.

Broker 1
Client 1 ~ 1000

Broker 2
Client 1001 ~ 2000

Broker 3
Client 2001 ~ 3000

클라이언트 수가 증가해도 PostgreSQL 입장에서는 직접 수천 개의 클라이언트를 상대하는 것이
아니라 제한된 수의 Broker와 통신하게 됩니다.
강의에서는 이 구조를 일종의 프록시처럼 설명하며,
Broker를 추가하는 방식으로 접속 처리량을 확장할 수 있다고 설명합니다.

트래픽이 더 커지면 MQ 자체도 분리할 수 있습니다.

                Event Broker
                /          \
               /            \
           PGMQ #1        PGMQ #2
           AI Event       File Event

예를 들어 Event Type을 기준으로 다음처럼 라우팅할 수 있습니다.

CHAT_REQUEST
LLM_REQUEST
        ↓
PGMQ #1


FILE_ANALYSIS
IMAGE_PROCESS
        ↓
PGMQ #2

강의에서도 이벤트 타입에 따라 서로 다른 PGMQ 서버를 바라보도록 구성하면 MQ 부하 역시 분산할 수 있다고 설명합니다.

다만 "작은 인스턴스 하나가 정확히 몇천 연결을 처리한다" 같은 수치는 환경에 따라
크게 달라질 수 있으므로 아키텍처의 원리와 특정 테스트 결과는 구분해서 보는 편이 좋습니다.


7. 채널 기반으로 통신하는 이유

Broker가 개별 사용자를 지나치게 많이 알아야 한다면 문제가 생깁니다.

예를 들어 이런 구조입니다.

Server

User A → Connection A
User B → Connection B
User C → Connection C
...

사용자와 연결을 하나하나 강하게 결합하면 서버가 수많은 세션 정보를 직접 관리해야 합니다.
그래서 이벤트 시스템에서는 채널 또는 이벤트 타입 기반 구독을 사용할 수 있습니다.

Client A
구독:
- session:123
- llm.stream

Client B
구독:
- session:456
- tool.result

이벤트가 들어옵니다.

{
  "type": "llm.stream",
  "sessionId": "123",
  "payload": "안녕하세요."
}

Broker는 비즈니스 의미를 이해할 필요가 없습니다.

llm.stream 구독자 누구지?
           ↓
해당 클라이언트에게 전달

끝입니다.

강의에서도 서버가 특정 클라이언트를 직접 식별하여 1:1로 관리하기보다는,
어떤 채널을 구독하고 있는지를 중심으로 메시지를 전달하는 방식을 설명합니다.


8. Worker가 실제 일을 담당한다

이 아키텍처에서 실질적인 애플리케이션 로직은 Worker에 들어갑니다.

PGMQ
 │
 │ read
 ▼
Worker
 │
 ├─ DB 조회
 ├─ LLM 호출
 ├─ RAG 검색
 ├─ 파일 처리
 ├─ 외부 API
 └─ Tool 실행

작업이 완료되면 끝이 아닙니다.

Worker는 결과를 새로운 이벤트로 생성합니다.

REQUEST EVENT
      │
      ▼
    Worker
      │
      │ processing
      ▼
RESULT EVENT

예를 들어:

{
  "type": "SUMMARY_REQUESTED",
  "consultationId": 100
}

를 처리한 Worker가 다음 이벤트를 생성합니다.

{
  "type": "SUMMARY_COMPLETED",
  "consultationId": 100,
  "summary": "..."
}

그러면 결과 이벤트를 Broker가 가져가서 클라이언트에게 전달합니다.

강의에서는 Worker가 자신이 관심 있는 이벤트만 가져와 처리하고,
완료 후 새로운 이벤트를 다시 등록하는 구조를 설명합니다.
또한 이벤트 종류별로 Worker를 별도로 운영할 수 있기 때문에
새로운 기능을 추가할 때 기존 Broker나 MQ를 수정하지 않고
새로운 Worker만 추가하는 방식으로 확장할 수 있다고 설명합니다.

예를 들어:

PGMQ
 │
 ├─ SUMMARY_REQUEST ─────▶ Summary Worker
 │
 ├─ RAG_REQUEST ─────────▶ RAG Worker
 │
 ├─ TOOL_REQUEST ────────▶ Tool Worker
 │
 └─ FILE_ANALYSIS ───────▶ File Worker

기능이 늘어났다고 기존 Worker를 거대한 애플리케이션으로 만들 필요가 없습니다.


9. Client와 Worker만 애플리케이션을 이해한다

이 구조에서 상당히 중요한 특징이 하나 있습니다.

Event Broker와 MQ는 이벤트의 비즈니스 의미를 몰라도 됩니다.

예를 들어 다음 이벤트가 있다고 해보겠습니다.

{
  "type": "GENERATE_CODE",
  "payload": {
    "sessionId": "abc",
    "prompt": "회원 API를 만들어줘"
  }
}

PGMQ 입장에서는 그냥 데이터입니다.

Event Broker 입장에서도 그냥 데이터입니다.

MQ
"저장하라고 했으니 저장"

Broker
"전달하라고 했으니 전달"

반면 다음 둘은 이벤트의 의미를 알아야 합니다.

Client ← Event Protocol → Worker

즉 애플리케이션은 사실상 Client와 Worker 사이의 Event Protocol로 정의됩니다.

강의에서도 Client와 Worker 사이에 특정 Event Type에 대한 협약만 존재하고
Broker와 MQ는 이벤트 내용에 관심이 없기 때문에,
한 번 만들어둔 Broker와 MQ 인프라를 여러 애플리케이션에서 재사용할 수 있다고 설명합니다.

이것이 꽤 강력합니다.

공통 인프라

Event Broker
      +
     MQ

는 그대로 두고

서비스 A
Client A + Worker A

서비스 B
Client B + Worker B

서비스 C
Client C + Worker C

처럼 새로운 애플리케이션을 추가할 수 있기 때문입니다.


10. MQ에서 메시지를 읽자마자 삭제하면 안 되는 이유

이제 가장 중요한 신뢰성 이야기입니다.

Queue에 다음 작업이 있다고 해보겠습니다.

Message #100

"상담 123번을 요약하라"

Worker A가 메시지를 가져갑니다.

만약 read하는 순간 메시지를 Queue에서 삭제해버리면 어떻게 될까요?

PGMQ
 │
 │ read + delete
 ▼
Worker A
 │
 │ 작업 중...
 💥 서버 장애

메시지도 없고 작업도 완료되지 않았습니다.

작업을 완전히 잃어버립니다.

그래서 메시지를 읽는 것과 삭제하는 것을 분리합니다.

read
 ↓
set_vt
 ↓
처리
 ↓
delete

11. Visibility Timeout

Worker가 메시지를 가져가면 일정 시간 동안 해당 메시지를 다른 Worker에게 보이지 않도록 합니다.

이 시간이 Visibility Timeout입니다.

Message #100

       read
        │
        ▼

┌──────────────────────┐
│ Visibility Timeout   │
│                      │
│ 다른 Worker에게 안 보임 │
└──────────────────────┘
        │
        │ 처리
        ▼
      delete

정상적으로 처리되면:

0초
Worker A read

1초
Visibility Timeout 시작

20초
작업 완료

21초
delete

메시지 제거

문제는 Worker가 죽었을 때입니다.

0초
Worker A read

10초
Worker A 장애 💥

30초
Visibility Timeout 종료

Message #100 다시 visible

Worker B
   ↓
read
   ↓
재처리

그래서 Worker 하나가 죽더라도 작업 자체는 사라지지 않습니다.

강의에서도 Worker가 메시지를 읽을 때 set_vt로 다른 Worker가 해
당 메시지를 가져가지 못하도록 하고, Worker가 죽으면 timeout 이후
메시지가 다시 읽을 수 있는 상태로 돌아가며,
정상적으로 완료했을 때만 delete하는 방식으로 신뢰성을 확보한다고 설명합니다.


12. 하지만 Exactly Once라고 생각하면 위험하다

여기에는 실무적으로 중요한 함정이 있습니다.

다음 상황을 생각해보겠습니다.

Worker A
 │
 ├─ 결제 API 호출 성공
 │
 ├─ DB 저장 성공
 │
 ├─ 거의 모든 작업 완료
 │
 💥 delete 직전 서버 장애

Queue 입장에서는 delete가 호출되지 않았습니다.

Visibility Timeout이 끝나면:

Worker B
 │
 └─ 같은 Message 다시 처리

가 가능합니다.

즉 Visibility Timeout은 메시지 유실 방지에는 강하지만 자동으로 Exactly Once Processing을 만들어주는 것은 아닙니다.

실무에서는 보통 다음을 전제로 설계하는 편이 안전합니다.

At-Least-Once Delivery

        +

Idempotency

즉 같은 이벤트가 두 번 들어와도 결과가 망가지지 않도록 만들어야 합니다.

예를 들어 모든 Event에 고유 ID를 부여합니다.

{
  "eventId": "dcdb224a-...",
  "type": "PAYMENT_REQUEST",
  "paymentId": 100
}

그리고 이미 처리된 이벤트인지 확인합니다.

SELECT 1
FROM processed_event
WHERE event_id = :eventId;

처음 보는 이벤트라면

업무 처리
   ↓
processed_event 기록

이미 처리했다면

중복 Event
   ↓
SKIP

이런 설계가 바로 멱등성 입니다.


13. Queue와 Log는 무엇이 다른가?

여기까지 이해하면 한 가지 의문이 생깁니다.

Worker에게 일을 분배할 때는 Queue가 잘 맞습니다.

그런데 Broker가 여러 대라면 어떨까요?

Broker A
Broker B
Broker C

결과 Event 하나를 모든 Broker가 알아야 할 수도 있습니다.

그런데 Queue를 그대로 사용하면 문제가 생깁니다.


14. Queue는 경쟁 소비 방식이다

Queue에 다음 Event가 있다고 해보겠습니다.

EVENT #100
"상담 123 요약 완료"

세 Consumer가 동시에 보고 있습니다.

            Queue
              │
      ┌───────┼───────┐
      │       │       │
      ▼       ▼       ▼
      A       B       C

A가 먼저 읽었습니다.

A → Message 획득

B → 안 보임
C → 안 보임

Visibility Timeout이 적용되기 때문입니다.

이것을 Competing Consumer, 즉 경쟁 소비라고 볼 수 있습니다.

Worker에게는 굉장히 좋은 방식입니다.

Job 1 → Worker A
Job 2 → Worker B
Job 3 → Worker C
Job 4 → Worker A
Job 5 → Worker B

왜냐하면 하나의 작업을 여러 Worker가 처리할 필요가 없기 때문입니다.

Queue의 질문

"누가 이 일을 할 것인가?"

한 명만 고르면 됩니다.


15. 하지만 이벤트 전파에는 Queue가 불편하다

다음과 같은 상황을 생각해보겠습니다.

Client 1 → Broker A
Client 2 → Broker B
Client 3 → Broker C

그리고 결과 Event가 발생했습니다.

SUMMARY_COMPLETED

관련 클라이언트가 여러 Broker에 연결되어 있다면 각각의 Broker가 Event를 볼 수 있어야 합니다.

그런데 Queue라면:

             Queue
               │
          EVENT #100
               │
         Broker A read
               │
             끝

Broker B → 못 봄
Broker C → 못 봄

이 문제가 생깁니다.

그래서 작업 분배 Queue와 이벤트 전파 Log의 성격을 구분하는 것이 중요합니다.


16. Event Log는 메시지를 뺏어가지 않는다

Event Log는 Queue와 생각 자체가 다릅니다.

event_store

position | event
---------|------------------
101      | EVENT A
102      | EVENT B
103      | EVENT C
104      | EVENT D
105      | EVENT E

Event는 이미 발생한 사실이므로 변경하지 않습니다.

EVENT #101 발생
EVENT #102 발생
EVENT #103 발생

그리고 각 Broker는 자신이 어디까지 읽었는지만 기억합니다.

Broker A
cursor = 103

Broker B
cursor = 101

Broker C
cursor = 104

Broker A는

SELECT *
FROM event_store
WHERE position > 103
ORDER BY position;

를 실행합니다.

104
105

를 받습니다.

Broker B는

102
103
104
105

를 받습니다.

Broker C는

105

를 받습니다.

누가 Event를 읽었다고 다른 Broker에게 숨기지 않습니다.

Event는 이미 발생한 일이기 때문에 각 Consumer가
자신이 마지막으로 읽은 위치만
기억하면 되고, 서로 읽기를 동기화하거나 경쟁할 필요가 없다는 것입니다.
이를 fan-out tail 방식으로 설명합니다.


학습하고 정리하면서 느낀 점

이번 내용을 정리하면서 비동기는 단순히 async, Thread Pool, 논블로킹 처리만을 의미하는 것이 아니라는 점을 다시 이해했습니다.
핵심은 요청 접수, 실제 작업 처리, 결과 전달을 서로 분리해서 시스템의 결합도를 낮추는 것이었습니다.

특히 Queue → Worker → Result Event 흐름과 Visibility Timeout, Retry, Idempotency를 함께 보면서 메시지 큐가 단순한 작업 대기열이 아니라 실패와 재처리까지 고려한 구조라는 점이 인상적이었습니다.

또한 Queue와 Event Log의 차이도 명확해졌습니다.

Queue     = 누가 이 일을 처리할 것인가?
Event Log = 무슨 일이 발생했는가?

앞으로 AI Agent나 LLM 기반 서비스를 만들 때도
단순히 Controller → Service → LLM API 구조로 끝내기보다,
오래 걸리는 작업이라면 Queue와 Worker로 처리를 분리하고 진행 상태와
결과를 Event로 전달하는 구조
까지 고려해야겠다는 생각이 들었습니다.

결국 이번 학습에서 가장 크게 얻은 것은 특정 기술을 외우는 것보다
왜 Queue, Worker, Broker, Event Log가 필요한지를 하나의 흐름으로 이해하게 된 것입니다.

profile
레거시를 이해하면서도 새로운 기술을 현실적으로 적용할 수 있는 백엔드 개발자가 되는 것이 목표입니다.

0개의 댓글