4주차 — 이벤트 소싱·CPS·멱등성으로 만드는 엔터프라이즈 온프레미스 AI 에이전트 서버
개발자 PC에 설치되는 로컬 에이전트와 달리, 전 직원의 부하와 며칠짜리 장기 세션을 견뎌야 하는 기업용 에이전트 서버는 HTTP 요청-응답을 버리고 이벤트 소싱 + PGMQ + CPS + 멱등성 + 워커 스레드 풀로 재설계해야 한다. 이 문서는 그 아키텍처를 왜·어떻게의 순서로 정리한다.
0. 사전 필수 용어 (선행지식)
본문을 읽기 전에 다음 용어를 먼저 잡아 두면 3장 이후가 훨씬 쉽게 읽힌다. 깊게 파고들 필요는 없고, "무엇을 위한 것인지"만 감을 잡으면 된다.
1. 이벤트 소싱 (Event Sourcing) 상태를 직접 저장하는 대신, 상태를 바꾼 사건(이벤트)들을 순서대로 기록하고 그 로그를 되짚어 현재 상태를 만들어 내는 방식이다. 은행 잔고를 숫자로 저장하는 대신 "입금 1만·출금 3천" 거래 내역을 쌓아 두고 필요할 때 합산해 잔고를 구하는 것과 같다. 본문 3·6·9장의 뼈대다. 개념이 없다면 이벤트 소싱 입문 자료를 먼저 보고 오면 좋다.
2. 메시지 큐 (Message Queue, MQ) 작업을 줄 세워 두는 서버다. 하는 일은 "넣기·꺼내기·점유 표시"뿐이며, 선입선출과 가벼운 동기화만 보장한다. 식당 대기 명단처럼 순서를 지켜 주되 요리는 직접 하지 않는다. 본문의 PGMQ가 이 역할을 한다(3장).
3. 멱등성 (Idempotency) 같은 요청을 몇 번 실행해도 결과가 같은 성질이다. 엘리베이터 버튼을 세 번 눌러도 한 번 부른 것과 같은 것과 같다. 네트워크에선 재시도가 불가피하므로 필수다(6장에서 집중 설명).
4. CPS (Continuation-Passing Style) "결과가 준비되면 이걸 실행해 줘"라는 후속 작업(continuation)을 넘겨 주고 즉시 돌아오는 프로그래밍 스타일이다. 자바스크립트의 콜백과 본질이 같다. 음식점에서 진동벨을 받고 자리로 돌아가 기다리는 것과 같다. 본문 4장의 핵심.
5. 직렬화 (Serialization) 메모리 안의 객체를 저장·전송할 수 있는 문자열/바이트로 바꾸는 일이다. 이벤트가 DB·큐·네트워크를 넘나들려면 모든 메시지가 완전히 직렬화돼야 한다(이미지는 Base64 등). 3장에서 제약으로 등장한다.
📚 참고: 이 다섯 용어는 서로 물려 있다. "이벤트를 직렬화해 MQ에 쌓고, CPS로 논블로킹 처리하며, 재시도에 대비해 멱등하게 설계한다"가 이 강의 한 줄 요약이다.
1. 주제 정의
이 강의는 기업(엔터프라이즈)용 온프레미스 AI 코딩 에이전트 서버를 어떻게 구현하는지를 다룬다. 서버 자체는 이미 한 바퀴 구현해 두었고, 이번 시간에는 "왜 이런 구조여야 하는가"라는 아키텍처 원리와 이벤트 스펙을 설명한다.
핵심을 한 문장으로 요약하면 다음과 같다.
에이전트의 턴 루프를 인메모리 함수 호출로 돌리지 말고, 모든 단계 전이를 이벤트로 분해해 PGMQ에 쌓고, 워커 스레드 풀이 CPS 방식으로 논블로킹 처리하며, 재시도에 대비해 키 기반 멱등으로 설계한다.
로컬 설치형 에이전트와의 대비가 출발점이다.
- 로컬 설치형 (코덱스·클로드코드 등): 에이전트 본체가 사용자 PC에 다 설치된다. 컴퓨터의 모든 기능이 그 안에 있고, 한 대만 돌리면 된다.
- 엔터프라이즈 온프레미스 서버 (이 강의): 보안 때문에 클라이언트 설치형을 쓰지 않는다. 클라이언트는 철저히 클라이언트 역할만 하고, 실제 운영은 온프레미스에 설치된 에이전트 서버가 처리한다.
이 차이가 모든 설계 결정을 만든다. 서버는 한 사람이 아니라 전 직원의 부하를 받아야 하기 때문이다.
💡 실무 노하우: "장난감 에이전트"와 "산업용(인더스트리얼) 에이전트"를 구분하라. AI에게 "코딩 에이전트 만들어 줘"라고 하면 가장 쉬운 기본형이 나온다. 그러나 실제 서비스로 부하를 견디고 유지보수·성능·차별화가 필요한 제품은 복잡하고 어려운 스펙을 모두 갖춰야 한다. 이 강의가 어려운 이유가 그것이다.
2. 풀려는 문제
왜 평범한 HTTP 서버로는 안 되는가? 문제는 층층이 쌓여 있다.
2-1. HTTP 요청-응답 전략이 통하지 않는다
네이버가 수천만 명을 받아낼 수 있는 이유는 "요청이 오면 빨리 응답하고 잊어버리기" 때문이다. 그러나 바이브 코딩 에이전트는 다르다. 사용자는 이틀이고 삼일이고 같은 세션을 열어 두고 채팅한다. 서버가 클라이언트를 끊어낼 수 없고 연결이 계속 유지된다. 이 지옥에서 벗어나려면 부하를 확 줄이는 이벤트 기반 프로토콜을 쓸 수밖에 없다.
2-2. 인메모리 턴 루프의 한계
요청 → 이터레이션 반복 → 도구 호출 → 최종 응답으로 가는 턴 루프(펀 루프)를 함수 호출로 구현하면, 중간 컨텍스트가 전부 인메모리에 쌓인다. 그러면:
- 프로세스가 죽으면 진행 중인 턴이 통째로 날아간다. 컨텍스트를 보존하는 주체가 없다.
- 도구 실행이 길어지면 루프가 블록된다. 인터럽트도, 스트리밍도 안 되고 서버 자체가 막힌다.
- 같은 세션에 여러 클라이언트가 동시에 붙을 수 없다. 턴 루프를 특정 연결의 인메모리 프로세스로 진행하기 때문이다. (모바일과 PC에서 같은 채팅방을 동시에 보는 일이 불가능해진다.)
2-3. RDB 테이블 기반 작업 큐의 문제
테이블(RDB)에 할 일 목록을 넣고 워커가 빼내 처리하면:
- 레코드에 락이 걸린다. 그것도 읽기 락이 아니라 insert/delete 락이라 동시성 처리가 매우 어렵다.
- 락을 쥐고 태스크를 가져간 워커가 죽으면 영원히 멈춘다. 바이브 코딩은 작업 중단·중간 끊기가 잦아 이런 구멍이 여기저기 생긴다.
- 이벤트가 무지막지하게 생성되는데 이를 폴링하는 것도 부하가 크다.
2-4. 별도 브로커(Kafka·Kinesis)의 부담
- Kafka는 좋지만 Kafka를 또 관리해야 한다.
- Kinesis는 좋지만 비싸다.
- 무엇보다 이런 시스템은 DB와 별개로 존재해서, 어드민에서 설정한 보안·프롬프트 리라이팅·가드레일 데이터와 따로 놀아 동기화를 해 줘야 한다.
⚠️ 주의: 위 네 문제는 독립적이지 않다. "장기 세션 → 인메모리 붕괴 → 큐 필요 → RDB 락 지옥 → 별도 브로커 동기화 부담"으로 연쇄한다. 하나만 해결해선 안 되고 아키텍처 전체를 바꿔야 한다.
3. 핵심 개념·구조
3-1. PostgreSQL 확장으로 문제를 한 DB 안에서 해결
위 문제들을 한 번에 풀기 위해, 이미 쓰고 있는 PostgreSQL에 익스텐션을 깐다.
- PGMQ: PostgreSQL에 설치하면 아마존 SQS(Simple Queue Service)와 같은 메시지 큐를 DB가 대신 운영해 준다. MQ가 하는 일은 세 가지뿐이다 — ① 큐에서 꺼내기 ② 지우기 ③ "꺼내지도 지우지도 말고 내가 점유 중"이라 마킹하기. 선입선출과 가벼운 동기화(싱크로나이즈)만 보장한다.
- PGVector: 임베딩 DB. 에이전트가 자기가 벌여 놓은 세션들을 정기적으로 점검하며 자기 성찰·자기 루프 개선을 하려면 히스토리를 임베딩 검색해야 한다. 그 용도로 벡터 확장을 깐다.
PG 확장의 장점은 전부 SQL문으로도 된다는 점이다. DB 두 개를 원격으로 운영하며 동기화할 필요가 없다. 기존 SQLite에 있던 어드민 데이터(User·Session·Model Endpoint·Model 설정)를 db-initializer로 PostgreSQL로 이관하고, 어드민과 에이전트가 동일한 PostgreSQL DB를 엔드포인트로 참조한다. (에이전트 쪽에 별도 PostgreSQL 컨테이너를 띄우지 않고 어드민 DB를 공유한다.)
💡 실무 노하우: MQ 시스템은 대부분 말도 안 되게 빠르다. Kafka도 RabbitMQ 기반이고, "MQ가 제일 가벼운 서버"다 — 하는 일이 큐 관리뿐이라 넣고 빼는 게 아주 빠르다. 게다가 인퍼런스 자체가 워낙 느려서 큐 입출력 부하는 눈에 띄지도 않는다. 워커들이 필요한 만큼만 태스크를 집어 일하고 나머지는 방치하므로 인메모리 폭주도 없다.
3-2. 이벤트 소싱으로의 전환
세션을 세션 ID로만 식별되는 끝없는 메시지 스트림으로 본다. 턴 생성, 이터레이션 시작, 도구 호출, 도구 결과 등 모든 행위가 처리될 때마다 이벤트로 등록된다.
- 루프의 각 단계를 전이하는 이벤트를 기록하면, 컨텍스트는 지금껏 쌓인 이벤트를 묶어 투영(projection)해 만들어 낸다.
- 이 투영기(포법)가 있어야 컴팩션(compaction) 시 "컴팩션 밑으로만 투영"하는 일도 가능하다.
- 재시작·재생·감사·컴팩트가 모두 이벤트 로그에서 함수적 연산으로 컨텍스트를 끄집어내는 것으로 성립한다.
3-3. 이벤트 소싱의 제약
- 완전 직렬화: 인메모리 기반이 아니므로 모든 메시지가 완전히 직렬화돼야 한다. SQS에 저장하든 이력 검토를 하든 무조건 직렬화가 전제다.
- 대용량 데이터: 이미지는 Base64로 바뀌어 용량이 커진다. 인메모리 업로드는 바이너리면 되지만 이벤트로 옮겨 다닐 땐 직렬화 때문에 커진다. 10MB PDF를 Base64로 직렬화하려면 버겁고 서버가 버벅인다.
- 멱등 설계: 큐는 기본적으로 중복해서 쓰고 읽는다. 그래서 큐 기반 이벤트 서버 자체를 멱등하게 설계해야 안전하다. 안정성을 위해 아웃박스(outbox)도 구현해야 한다.
3-4. 이벤트 = API, 그리고 엠벨롭 스펙
이 구조에서는 이벤트 자체가 API다. 요청-응답 프로토콜 정의가 필요 없다. "이벤트가 무엇이고, 누가 만들었고, 수신하면 무슨 일을 하는가"만 있으면 된다. 서버·워커·클라이언트 사이의 유일한 계약은 "이벤트를 누가 발행하고 수신했느냐"뿐이다.
모든 이벤트는 래퍼인 엠벨롭(envelope)에 감싸진다. 진짜 내용은 엠벨롭 안의 페이로드(payload)다.
| 엠벨롭 필드 | 역할 |
|---|---|
eventKey |
이벤트를 식별하는 고유 PK. 보통 UUIDv7로 발급 |
transactionKey |
멱등성용 키 (6장에서 상세) |
parentEventKey 등 |
인과 그래프 복원용 연결성 키 |
kind |
역할: request·response·progressive·notify·heartbeat 등 |
state |
queued·in-progress·completed·failed·cancelled·timeout 등 |
scope |
session·turn·iteration·step·tool 등 계층 |
sequence |
스트림 정렬용 단조 증가 번호 |
- 이벤트는 self-description이어야 한다. 이벤트 자체 메시지만으로 완전해야 하고 다른 것에 의존하면 안 된다.
- sequence가 필요한 이유: 모델의 최종 응답이나 도구 콜 결과가 스트림으로 온다. 응답 키는 동일하게 유지한 채 토큰마다 번호를 붙여 "첫 번째 토큰·세 번째 토큰"을 구분해 보낸다. 모델 응답을 한 번에 보낼 수 있다고 생각하면 안 된다.
3-5. 계층적 컨텍스트
이벤트는 계층 컨텍스트를 이룬다: session 키 → turn 키 → iteration 키 → step 키 → tool call 키. 이터레이션이 여러 스텝으로 이뤄지고, 툴 호출도 개별 툴 콜마다 키가 필요하다. 이것이 지금 널리 쓰이는 에이전틱 AI의 일반적 형태이며, 업체들이 만들어 낸 익숙한 구조를 그대로 쓴다.
3-6. kind로 본 이벤트의 역할 분류
엠벨롭의 kind는 이벤트가 대화에서 맡은 역할을 나타낸다.
| kind | 의미 |
|---|---|
request |
클라이언트→서버 요청 |
response |
처리 결과 응답 |
progressive |
진행 중 중간 산출(스트리밍 조각) |
notify |
알림 |
heartbeat |
살아 있는지 핑(ping)을 때리는 신호 |
state는 이벤트의 생애 단계를 나타낸다 — 큐에 들어왔다(queued), 워커가 가져갔다·실행 중이니 대기해라(in-progress), 완료(completed)·실패(failed)·취소(cancelled)·타임아웃(timeout). 이 두 축(kind·state)과 scope·sequence가 결합해 "이벤트 자체 메시지만으로 완전한(self-description)" 상태를 만든다.
3-7. 이벤트 소싱 서버의 실제 동작 모양
강의에서 메모장으로 시뮬레이션한 서버의 동작은 이렇다.
- 클라이언트들이 이벤트를 큐에 던진다(발행). 응답을 기다리지 않는다(CPS).
- 서버(워커들)는 큐에서 이벤트를 꺼내 신속하게 멀티스레드로 분산 처리한다. 처리 결과는 다시 큐에 넣는다.
- 큐를 처리하는 워커가 "이건 누구 거네"(어느 채널 건인지) 판정해 해당 채널로 이벤트를 발행한다.
- 이 과정이 끝없이 반복된다. 너무 바빠 가져갈 워커가 없으면 이벤트가 쌓이고, 여유가 있으면 즉시 쳐낸다.
핵심은 서버가 이벤트의 내용에 관심이 없다는 것이다. 받아서 넘겨주기만 하면 되고, 워커가 가져가기만 하면 된다. 채널은 이벤트의 속성값일 뿐이며, 서버는 전체를 다 이벤트 큐에 넣고 워커를 쫙 돌린다. 서버가 생성한 이벤트도 마찬가지로 큐에 실어 CPS답게 날려 보낸다.
📚 참고: 이 강의에서는 소싱만 검증하는 이벤트 소싱 테스트 클라이언트를 따로 만들어 두었다(에이전트 본체가 아니라 이벤트 발행·수신만 시험하는 도구). 즉 실제 에이전트 로직과 이벤트 인프라를 분리해 각각 검증한다.
4. 실습·구현 가이드 (Step 1·2·3·4)
Step 1 — PostgreSQL 확장 설치 및 어드민 DB 이관 (⏱ 08:36)
목표: SQLite 기반 어드민 DB를 PostgreSQL로 이관하고, PGMQ·PGVector 확장을 설치한다.
- 에이전트 Dockerfile에서 Node 기반 이미지를 확인한다(강의 기준 Node.js LTS 계열, 이미지에 알파인 사용).
- 어드민의 SQLite 의존성을 PostgreSQL로 변경한다.
- PostgreSQL 이미지에
PGVector확장을 설치하고 추가 빌드를 수행한다. mq-extension(PGMQ)을create해 설치한다.db-initializer로 User·Session·Model Endpoint·Model 설정 등 기존 데이터를 마이그레이션한다.- 어드민과 에이전트가 같은 PostgreSQL DB를 참조하도록 엔드포인트를 연결한다.
⚠️ 주의: PG 확장 설치는 추가 빌드를 필요로 할 수 있다. 에이전트 컨테이너에 별도 PostgreSQL을 띄우지 말고 어드민 DB를 공유하도록 연결하라.
Step 2 — 이벤트 소싱 구조로 전환 및 이벤트 정의 (⏱ 13:00)
목표: 인메모리 루프를 버리고 이벤트 스트림으로 전환, 엠벨롭·패밀리 스펙을 정의한다.
- 세션을 세션 ID로 식별되는 끝없는 메시지 스트림으로 간주하고 모든 행위를 이벤트로 등록한다.
- 단계 전이를 이벤트로 기록해 컨텍스트를 투영으로 재구성하도록 설계한다.
- 모든 메시지가 완전 직렬화되도록 하고, 대용량 파일 처리 방안을 함께 고민한다.
- 엠벨롭 스펙(
eventKey·transactionKey·parentEventKey·kind·state·scope·sequence)을 먼저 정리한다. - 이벤트 패밀리(Session·Turn·Iteration·ToolCall·Process·Guardrail·Approve·Artifact 등)를 정의한다.
- 패밀리들을 union(합집합) 타입으로 묶어 단일 처리기가 모든 종류를 라우팅하게 한다.
type VibeEvent =
| SessionEvent | TurnEvent | IterationEvent
| ToolCallEvent | ProcessEvent | ... // sum 타입
패밀리 예시: - Session: create · get · delete · cancel · resume · session state - Turn: started · input append · stop · finalizing · cancel - Iteration: started · progress · merge - ToolCall: call · start · progress · complete - Approve/Artifact: granted · rejected · expired / file · diff · patch · log
⚠️ 주의: 개별 워커를 이벤트 타입마다 난립시키면 강타입 생태계가 약해진다. 합 타입 하나로 묶어 처리기가 라우팅하게 하라. 서버는 어떤 이벤트인지 관심 없이 "받아서 넘겨주기만" 하면 되고, 워커가 가져가기만 하면 된다.
Step 3 — CPS 기반 양방향 소켓 통신 구현 (⏱ 27:00)
목표: 요청-응답 블로킹을 끊고 비동기 양방향 통신을 도입한다. 이 부분이 진짜 중요하다.
원격 함수 호출을 생각해 보자. request → response, 함수로 치면 call → return. 여기서 문제는 call부터 return까지 전 구간이 블로킹되고, 그 길이는 함수가 얼마나 무거운 일을 하느냐에 달렸다는 것이다.
CPS는 이 연결을 잘라 버린다.
// ❌ 블로킹: 전 구간 대기
const res = await callRemote(req)
// ✅ CPS: 즉시 종결, 준비되면 continuation이 결과 발행
onRequest(evt, () => publish(resultEvent))
- 클라이언트는 요청 후 아무 응답도 즉시 받지 않는다. 요청은 여기서 종결되어 클라이언트 블로킹이 걸리지 않는다.
- 서버는 처리가 끝나면 그때 클라이언트에게 결과를 던진다. 응답을 던질 위치, 즉 나중에 결과가 갈 곳이 continuation이다.
- 함수형에서 continuation은 1급 함수(끝나면 보고할 함수)다. 자바에서는 이걸 콜백이라 부른다. "CPS가 대단해 보이지만 결국 콜백"이다. 다만 모든 것이 콜백으로만 연쇄된다.
- 따라서 클라이언트도 응답을 받으려면 자기도 서버처럼 결과를 받을 메소드를 준비해야 한다. 즉 클라이언트와 서버 둘 다 서버다.
이 방식은 HTTP로는 안 되고 소켓으로만 된다. SSE는 서버→클라 단방향만 되므로 부적합하다. 그래서 웹소켓 기반으로 소켓 포털을 열어 처리한다. 실제로는 다음 개념으로 수렴한다.
- 클라이언트·서버 모두 이벤트 브로커다.
- 둘의 계약은 채널로 성립한다. 클라이언트가 누구인지는 관심 없고 채널로 계약한다.
- 예: 클라이언트 A는 3번 채널을 구독하고, 할 말은 3번 채널에 남긴다. 서버도 3번 채널의 메시지를 처리하다가 A에게 할 말이 있으면 3번 채널에 이벤트를 발행한다.
💡 실무 노하우: 세션 하나가 개별 채널로 취급된다. 이 도메인의 특이점은 채널은 엄청 많은데 채널당 구독자는 한두 명(본인 + 모바일)뿐이라는 것이다. 그래도 Turn·Iteration·Session을 분리 관리하려면 채널은 반드시 분리한다.
Step 4 — 멱등성·워커 스레드 풀·Abort 구현 (⏱ 41:54 / 1:07:07)
목표: 재시도 안전성(멱등)과 동시 처리(워커 풀), 안전한 중단(Abort)을 구현한다.
- 모든 주요 이벤트에
transactionKey를 넣어 키 기반 멱등을 적용한다. - 들어오는 요청의
transactionKey와payload를 함께 검사한다. 같은 키인데 payload가 다르면 오류로 처리한다. - 이미 처리된 요청은 재실행하지 않고, 처리 중이면 기다렸다가 결과를 준다.
- PGMQ에서 이벤트는 결과 저장까지 끝난 뒤 삭제하고, 그 전에는 "마킹"만 한다.
node:worker_threads(강의 기준 Node LTS에서 네이티브 지원)로 실제 물리 스레드 풀을 만들고 postMessage로 통신한다.- 각 워커에
AbortController를 내장해 중단 신호에 반응한다.
// Abort에 반응하는 워커: 작은 단위로 쪼개 await
while (hasWork && !signal.aborted) {
await step()
}
⚠️ 주의: 워커 내부를 무거운 한 덩어리로 짜면 Abort 신호가 와도 반응할 수 없다. 반드시 작은 단위로 분할하고
await지점을 둬서 중간에 취소에 반응하게 하라. 이것이 워커 구현에서 가장 어려운 점이다.
워커 풀 구현 구성 요소
create-worker-pool: 풀을 만들고 사전에 워커 슬롯을 미리 확보한다.worker-pool:run메소드만 제공하는 단순한 인터페이스로 둔다.create-slot: 여기서 진짜로 워커를 만들어 낸다. 웹 워커(Web Worker)를 만드는 과정과 사실상 동일하다 — 메시지가 들어오면 처리하고, Abort에 반응하고, 에러면 에러를 보고한다.- 실제 워커는
node:worker_threads에서 가져온다. 이것으로 물리 스레드 기반 워커를 만들고postMessage로 통신한다. 각 워커의execute로직은 모든 메시지를 async로 처리한 뒤 parent 포털에 다시 메시지를 전송한다. 여기에 나중에 이벤트 타입별 처리기가 라우팅되어 붙는다.
💡 실무 노하우: 이 프로젝트는 멀티스레드임에도 불구하고 Rust가 아니라 TypeScript로 구현했다. 비슷한 오픈소스 프로젝트들이 Rust로 되어 있지만, Rust 코드는 읽기·설명하기가 너무 어렵다. 학습자가 코드를 분석하고 "대충 이렇게 생겼구나" 알아먹게 하려는 의도적 선택이다. 폴더 구조나 모양은 언어와 무관하게 비슷하다.
5. 사례·적용
5-1. 메타(Meta)의 세션 데이터 회수 사례 — "세션 이력이 곧 석유"
강의에서 든 실제 뉴스 사례. 메타가 개발자를 내보내며 노트북을 회수해 그들이 에이전트를 어떻게 썼는지 이력을 학습 데이터로 활용했다는 것이다. 여기서 큰 교훈이 나온다.
- 로컬 에이전트의 세션 파일(JSON), 롤아웃, 메모리는 개발자 PC에 다 쌓인다.
- 그 세션 이력이야말로 "석유·금화·토큰·코인"이었음을 깨닫게 됐다.
- 개발자가 퇴사하며 그 폴더를 지우면? 그 세션은 회사 자산(회사 라이브러리·문서·업무 보고서)으로 진행됐으니 회사 자산이라 볼 수 있다. 그러나 개인 PC에 있으면 통제가 안 된다.
그래서 "애당초 중앙에 직접 수집되는 에이전트 코딩 툴은 없나"라는 니즈가 생긴다. 이 강의의 온프레미스 서버는 모든 이력이 중앙 어드민 DB에 쌓이므로 통제를 따로 할 필요가 없는 솔루션이다.
📚 참고: 로컬 에이전트(코덱스·클로드코드 등)는 "너 혼자 쓸 것"을 가정한다. 그래서 세션 파일이 JSON으로 다 열려 있고, 샌드박스 설정에서 시크릿까지 다 들여다볼 수 있으며, 이력 통제가 없다. 개인용에는 편하지만 엔터프라이즈 보안·감사 요구에는 취약하다.
5-2. 감사·모니터링을 위한 키 발급 (⏱ 1:13:00)
- 모든 session·turn·iteration·tool call에 키를 발급하고 PostgreSQL에 기록한다.
- 트랜잭션 키(번호표)를 DB 테이블에 남기면, 키별로 로그를 쌓아 "김 대리가 이 키로 무슨 짓을 했는지" 감시할 수 있다.
- 보관 범위(세션 키·작업 키를 어디까지)는 기업 보안 요구사항에 따라 달라진다.
5-3. 도구 실행 스트리밍 (⏱ 1:00:00~)
모델 토큰뿐 아니라 도구 호출 과정도 스트림된다. 과거처럼 "도구 호출했습니다 → 결과 나왔습니다"로 끝내지 않고, 도구 실행 중간중간을 스트리밍한다. "지금 파일 탐색 중입니다 → 15건 관련 문서 발견 → 그중 3개가 관련 깊음"처럼 중간 출력을 계속 흘려보내다가 나중에 완료(complete)된다. 도구 호출은 사실 모델과 무관한 처리기지만, 그 처리 과정도 계속 구경할 수 있게 해 주는 것이다. 이 "모니터링 투명성"은 장난감이 아니라 산업용 스펙과 경쟁하려면 반드시 갖춰야 할 기능이다.
5-4. 외부 프로세스 도구와 취소 전파 (⏱ 1:01:00~)
도구를 내장이 아니라 외부 익스터널 프로세스로 분리해 실행한다. 그러면 외부 프로세스와 에이전트 사이에도 이벤트가 오간다 — 진행 중 아웃풋, 완료/실패, 타임아웃. 사용자가 인퍼런스를 중단하면 그 신호가 루프 중간 통신을 타고 도구까지 가서 캔슬레이션이 일어나야 한다(문서 500장 탐색 중이라도 멈춰야 한다).
6. 핵심 원리
6-1. 왜 리트라이는 불가피한가 — 멱등성의 근본 이유
멱등이란 "같은 요청이 두 번 도착해도 괜찮다"는 것이다. 이미 처리했으면 처리했다고 알려 주고, 안 했으면 처리해 주면 된다. 몇 번을 보내도 문제만 안 나면 된다. (한 번만 처리하든 매번 같게 처리하든 상관없다.)
중복은 왜 반드시 일어나는가? 네트워크의 본질 때문이다.
- 클라이언트가 요청했는데 응답을 못 받았다. 이때 "서버에서 실행은 됐는데 응답이 유실된 것"인지 "실행 자체가 실패한 것"인지 구분할 수 없다.
- 500 코드조차 서버가 보내 줘야 500이다. 못 받으면 없는 것이고, 구분이 안 된다.
- 구분이 안 되면 유일한 대안은 리트라이뿐이다. (지인에게 전화했는데 신호 두 번 가고 끊겼을 때, 상대가 씹은 건지 영화관 모드인지 알 수 없어 다시 걸 수밖에 없는 것과 같다.)
리트라이를 하니 멱등이 필요하다. 서버가 즉시 응답 안 하고 15초 후에 응답했을 뿐인데 타임아웃 5초로 걸었다면 이미 요청을 3번 보냈을 수 있다. 송금이라면 3배로 나간다. 그래서 "같은 요청은 몇 번 하든 한 번만 송금"하는 안전장치가 필요하다.
📚 참고: "정확히 한 번만 요청하고 응답받는다"는 개념은 네트워크에서 성립할 수 없다. 아직까지 소프트웨어 공학은 이 문제를 해결하는 다른 방법을 발견하지 못했다. 그래서 CPS는 요청과 응답을 분리해 주고받고, 멱등성으로 중복을 흡수한다.
6-2. 키 기반 멱등 — 꼼수
송금·입금·로그인마다 개별 멱등을 구현하면 너무 힘들고, 별도 메모리·연산 비용도 크다. 그래서 키 기반 멱등이라는 꼼수를 쓴다.
- 먼저 키를 발급받는다. (키 발급 자체는 요청마다 새 키가 나오므로 멱등이 아니다 — B 멱등.)
- 일단 키를 얻으면, 모든 요청에 그 키를 넣는다.
- "이 키로 일어나는 요청은 이미 처리했어? 안 했어?"로 멱등하게 판정한다.
이렇게 하면 어떤 요청이든 멱등으로 바꿀 수 있다. 요청 ID·세션 ID가 다 이 역할이다. 멱등은 키라는 인자로 결정된다.
6-3. 키 + 페이로드 검사
키만 검사하는 게 아니라 키와 payload를 함께 검사한다.
- 같은 키 + 같은 요청 내용 → 멱등 (괜찮음, 이미 처리했으면 결과 반환)
- 같은 키 + 다른 요청 내용 → 오류 (이미 쓴 키를 다르게 재사용 = 말이 안 됨)
이벤트+CPS 구조에서는 멱등이 더 절실하다. 요청하면 응답이 오는 것도 아니고, 또 다른 이벤트로 응답을 확인해야 하며, 도착 순서도 보장되지 않아 리트라이 가능성이 더 높다. 그래서 이벤트 소싱 기반은 카프카가 그렇듯 반드시 키를 발급한다.
6-4. PGMQ 삭제 시점 — 유실 방지
MQ는 금방금방 제거하므로 워커가 폴링으로 가져와 마킹하고 온다. 그럼 언제 지우는가?
- ❌ 워커가 가져가는 순간 삭제: 워커가 처리 도중 죽으면 이벤트가 큐에서도 사라져 아무도 모르게 유실된다. (워커는 스레드일 뿐이라 죽어도 다시 투입하면 되지만, 이벤트가 없어지는 건 안 된다.)
- ✅ 결과 저장까지 완료된 뒤 삭제, 그 전엔 "처리 중" 마킹. 이래야 사고가 나도 재처리가 된다.
⚠️ 주의: 이것이 "인메모리 큐 묘사"와 "실제 PGMQ 아키텍처"의 결정적 차이다. 인메모리 설명에선 워커가 가져갈 때 큐에서 내렸지만, 실제 MQ에선 절대 그러면 안 된다.
6-5. 순서는 보장되지 않는다
클라이언트가 요청 1·2·3을 순서대로 보내도 네트워크 지연으로 서버 도착 순서는 뒤죽박죽이다. 멀티스레드 이벤트 루프를 돌리면 어떤 게 먼저 처리될지 예측 불가다. 대신 그 대가로 고성능이다 — 블록킹 없이 멀티스레드로 분산돼 작업이 쫙 날아간다. 순서가 필요하면 sequence·parentEventKey로 나중에 재정렬·재구성한다.
7. 한계·트레이드오프
| 항목 | 이득 | 비용/한계 |
|---|---|---|
| 이벤트 소싱 | 재시작·재생·감사·컴팩션 가능, 장애에 강함 | 사고 전환이 어렵고, 아웃박스 등 구현할 게 많음 |
| 완전 직렬화 | 저장·전송·이력 검토 가능 | 이미지 Base64로 용량 급증, 대용량 PDF는 버벅임 |
| 멱등성 | 재시도 안전, 중복 실행 방지 | 별도 메모리·DB, 요청마다 추가 게이트(연산) 비용 |
| CPS + 소켓 | 블로킹 제거, 처리량 극대화 | HTTP 불가·소켓 필수, 발상 전환이 어려움 |
| 워커 스레드 풀 | 병렬 처리, 워커 죽어도 교체 가능 | 물리 스레드 생성 비용, 풀 관리 정책 필요 |
7-1. 대용량 데이터 직렬화
인메모리 업로드는 바이너리로 되지만, 이벤트로 옮길 땐 직렬화 때문에 Base64로 커진다. 큰 이미지가 들어오면 버벅인다. 대안(강의에서 명시되지 않은 추정): 대용량 원본 대신 스토리지 URL·파일 경로 같은 레퍼런스를 이벤트에 담는다.
7-2. 워커 페이로드 설계
무거운 작업을 워커에 통째로 주면 그 워커가 락처럼 묶여 멀티스레드 활용률이 떨어진다. 이벤트를 작게 분할한 페이로드로 만들어 빨리빨리 처리돼야 서버 가동률이 좋아진다.
⚠️ 주의: 이건 자바스크립트의 싱글스레드 이벤트 루프가 아니다. 워커 스레드 패턴은 진짜 물리 스레드 기반의 비동기 컨텍스트다. 현대 고성능 서버 아키텍처는 결국 다 이 형태로 수렴하며, 그 근본 이유가 멱등성이다.
7-3. 단일 서버 송신 한계 → 샤드
이벤트 수신은 PGMQ에서 고속으로 하지만, 송신은 전 직원 클라이언트에게 CPS로 쏴야 해서 단일 서버가 벅차다. 다만 CPS+워커스레드만으로도 웬만한 사용자 수는 안 뻗으므로, 엔터프라이즈 기준에서 감당이 안 될 때 샤드 분리로 스케일아웃한다(8-2 참조).
8. 다른 접근과 비교
8-1. HTTP 요청-응답 vs 이벤트 소싱 + CPS
| 축 | HTTP 요청-응답 | 이벤트 소싱 + CPS |
|---|---|---|
| 연결 수명 | 짧게 응답하고 잊음 | 장기 세션 유지 |
| 블로킹 | call~return 전 구간 블로킹 | 즉시 종결, continuation으로 후속 |
| 장애 복원 | 진행 중 턴 유실 | 이벤트 로그로 재구성·리플레이 |
| 동시 접속 | 같은 세션 다중 접속 불가 | 채널 구독으로 다중 접속 가능 |
| 통신 방식 | HTTP | 웹소켓(양방향), SSE 불가 |
8-2. 단일 서버 vs 샤드 스케일아웃 (⏱ 1:36:00~)
이벤트 발급은 어차피 PGMQ에서 한다. 클라이언트 접속 시 "너는 앞으로 샤드 1번과 얘기해"라고 안내하면, 그 샤드가 해당 채널의 이벤트 스트림만 계속 쏴 준다. 샤드는 아무 지식이 없어도 되고(데이터는 다 MQ·PG 안에), 특정 채널만 전송하면 된다. 이렇게 샤드를 여러 개 발급하면 서버를 스케일아웃할 수 있다.
8-3. 로컬 설치형 vs 온프레미스 서버형
| 축 | 로컬 (코덱스·클로드코드) | 온프레미스 서버 (이 강의) |
|---|---|---|
| 설치 위치 | 개발자 PC 전체 | 서버, 클라이언트는 껍데기만 |
| 부하 | 한 대만 | 전 직원 |
| 보안·감사 | JSON 열림, 이력 통제 없음 | 중앙 DB에 키·로그 축적, 감사 가능 |
| 데이터 소유 | 개인 PC(회수 시에만 확보) | 애초에 중앙 수집 |
💡 실무 노하우(최신 모범사례): "AI가 이슈 제기하고 AI가 해소한다" 할 정도로 도구들이 일주일에 두세 번씩 업데이트되지만, 코드를 까 보면 코어 아키텍처는 여전히 사람 손을 탄 커밋이다. 유지보수·픽스·일부 기능은 AI가 하지만, 제품의 차별화를 만드는 코어는 좋은 아키텍처 지식을 가진 개발자가 만든다. 그래서 개발 공부를 깊게 해 "남이 짠 바이브 코딩보다 뛰어난 품질"을 만드는 것이 개발자가 갈 길이다.
9. 메타인지 자기평가
아래 질문에 막힘없이 답할 수 있으면 이 강의를 소화한 것이다.
개념 이해 점검 - [ ] 왜 엔터프라이즈 에이전트는 로컬 설치형이 아니라 클라이언트-서버 모델을 쓰는지 보안·부하 관점에서 설명할 수 있는가? - [ ] HTTP 요청-응답이 장기 세션에 부적합한 이유를 "빨리 응답하고 잊는다"는 전략과 연결해 설명할 수 있는가? - [ ] 이벤트 소싱에서 컨텍스트가 투영(projection)으로 만들어진다는 것의 의미를 아는가?
구조 설계 점검 - [ ] 엠벨롭 7개 필드(eventKey·transactionKey·parentEventKey·kind·state·scope·sequence)의 역할을 각각 말할 수 있는가? - [ ] PGMQ의 세 동작과 "완료 후 삭제·처리 중 마킹" 규칙, 그리고 그 이유(유실 방지)를 설명할 수 있는가? - [ ] 이벤트 패밀리를 union(sum) 타입으로 묶는 이유를 강타입·단일 처리기 관점에서 설명할 수 있는가?
원리 심화 점검 - [ ] "응답 유실 vs 실행 실패를 구분할 수 없다"에서 시작해 리트라이 → 멱등 필요성까지의 논리 사슬을 재구성할 수 있는가? - [ ] 키 기반 멱등에서 왜 키뿐 아니라 payload도 함께 검사하는지 설명할 수 있는가? - [ ] CPS가 블로킹을 제거해 처리량을 높이는 원리를, continuation(콜백)과 연결해 설명할 수 있는가?
적용·확장 점검 - [ ] 워커에 무거운 작업을 통째로 주면 안 되는 이유와 Abort 반응형 루프의 형태를 코드로 그릴 수 있는가? - [ ] 세션-채널 매핑에서 "채널은 많지만 구독자는 한두 명"이라는 특이점을 설명할 수 있는가? - [ ] 언제 샤드 분리로 스케일아웃해야 하는지 판단 기준을 말할 수 있는가?
✅ 확인: 다음 단계는 이 개념 위에서 실제 간단한 바이브 코딩 에이전트를 클라이언트까지 포함해 구동해 보는 것이다. 또한 이벤트 소싱·멱등성·CPS의 심화 학습으로 엔터프라이즈 아키텍처 설계 역량을 강화하라. 테스트 결과는 항상 스크린샷으로 날짜별 증거를 남겨 두면 구동이 안 되는 상황에서도 무엇을 검증했는지 보여줄 수 있다.
클릭하거나 Space를 눌러 뒤집기