1. 왜 ChatGPT App에 비동기 작업이 필요한가
세상이 완벽하다면 모든 MCP 툴은 몇백 밀리초 안에 끝났을 겁니다. 하지만 현실에서 흥미로운 일들은 대체로 오래 걸리고 무겁습니다:
- 사용자의 구매 이력이 들어 있는 큰 CSV 파싱;
- 여러 외부 API에서 데이터 집계(각각이 때론 잠들고, 때론 503으로 응답);
- 중간 단계가 많은 복잡한 추천 생성;
- 큰 보고서와 프레젠테이션 생성.
이를 하나의 동기 툴 호출(tool‑call)에 억지로 넣으면 세 가지 문제에 부딪힙니다.
첫째, 타임아웃. ChatGPT 세션, HTTP 인프라, MCP 클라이언트 — 이런 것들은 “5분 뒤에 응답”에 맞춰져 있지 않습니다. 연결을 너무 오래 쥐고 있는 서버는 ChatGPT에게도, 사용자에게도 “멈춘” 것처럼 보입니다.
둘째, 부하 관리. 100명의 사용자가 동시에 “연말 선물 초분석”을 실행한다면, MCP 서버가 HTTP 스레드에서 그 모든 긴 작업을 동기적으로 붙잡고 있게 하고 싶진 않을 겁니다. 급증을 흡수하고, 작업을 큐에 분산하며, 여러 워커로 처리할 수 있는 중간 계층이 필요합니다.
셋째, UX. 사용자가 GiftGenius 위젯에서 버튼을 누르고 40초 동안 하나의 스피너만 바라보게 한다면, 오래된 인터넷 뱅킹을 떠올리게 합니다. “빠른 응답 + 진행 상황 + 취소 가능” 모델이 훨씬 낫습니다.
이 문제들은 “시작 → 큐 → 백그라운드 → 이벤트”라는 공통 패턴으로 해결됩니다.
2. MCP 컨텍스트에서의 기본 async‑job 아키텍처
우리 GiftGenius를 예로 들어봅시다. 새로운 무거운 시나리오가 생겼다고 합시다: “친구의 구매 이력과 SNS를 기반으로 한 선호도 심층 분석”. 이런 작업은 몇 분 걸릴 수 있으므로:
- MCP 도구(tool)는 모델로부터 요청 파라미터를 받습니다.
- 즉시 계산하지 않고, 데이터베이스에 Job 레코드를 만듭니다.
- 작업을 큐에 넣습니다.
- 곧바로 ChatGPT에 응답합니다: “분석을 시작했습니다, 다음은 jobId입니다”.
- 백그라운드 워커가 큐에서 작업을 가져와 무거운 일을 처리하고, 진행 중에는 MCP 이벤트 job.progress와 job.partial을 보내며, 마지막에는 job.completed 또는 job.failed를 보냅니다.
아키텍처 관점에서 보면 대략 다음과 같습니다:
flowchart LR
subgraph ChatGPT
U[사용자] --> GPT[모델 + ChatGPT UI]
end
GPT -->|call_tool analyze_preferences| MCP[MCP 서버]
subgraph Backend
MCP -->|Job 생성| DB[(작업 DB)]
MCP -->|enqueue| Q[큐]
W[워커] -->|take job| Q
W -->|상태/진행 업데이트| DB
W -->|MCP 이벤트: job.progress/job.completed| MCP
end
MCP -->|SSE 이벤트| GPT
중요한 생각: MCP 서버는 반드시 모놀리식일 필요가 없습니다. 종종 내부 비동기 인프라 위에 선 프런트(facade) 역할을 합니다. 툴 호출을 받고, job을 만들고, 이벤트를 보내며, 무거운 작업은 별도의 워커 프로세스가 수행합니다.
3. 비동기 작업을 위한 데이터 모델
간단한 Job 모델부터 시작해 봅시다. TypeScript와 가상의 Node/MCP 서버를 사용해, 여러분의 스택에 어떻게 들어맞는지 바로 보이도록 하겠습니다.
메모리/DB에 둘 수 있는 가장 단순한 모델은 다음과 같습니다:
// openai/jobs/model.ts
export type JobStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'failed'
| 'canceled';
export interface GiftJob {
id: string; // jobId
type: 'deep_gift_analysis';
status: JobStatus;
payload: {
recipientProfile: string; // 프로필 텍스트/ID
budget: number;
};
result?: unknown; // 최종 추천
error?: string; // 오류 원인
attempts: number; // 실행을 시도한 횟수
createdAt: Date;
updatedAt: Date;
}
실제 프로젝트에서는 GiftJob을 Postgres, DynamoDB, Firestore 등 어딘가에 저장하겠지만, 이 강의에서 중요한 필드는 다음과 같습니다:
- status — 작업의 현재 상태로, 이벤트와 UX에 모두 반영됩니다;
- attempts — retry 카운터;
- error — 로그와 디버깅을 위한 정보;
- payload — 워커가 처리에 사용하는 입력 데이터.
4. async‑job을 생성하는 MCP 도구
start_deep_analysis 도구를 상상해 봅시다. 이전에는 동기적으로 모든 일을 했을 수 있지만, 이제는 작업을 큐에 넣고 jobId만 반환합니다.
// openai/tools/startDeepAnalysis.ts
import { v4 as uuid } from 'uuid';
import { createJobAndEnqueue } from '../jobs/queue';
// MCP SDK를 위한 의사 타입
type StartDeepAnalysisInput = {
recipientProfile: string;
budget: number;
};
type StartDeepAnalysisOutput = {
jobId: string;
message: string;
};
export async function startDeepAnalysisTool(
input: StartDeepAnalysisInput
): Promise<StartDeepAnalysisOutput> {
const jobId = uuid();
await createJobAndEnqueue({
id: jobId,
type: 'deep_gift_analysis',
status: 'pending',
payload: {
recipientProfile: input.recipientProfile,
budget: input.budget,
},
attempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
});
return {
jobId,
message: `심층 분석을 시작했습니다. 작업 ID: ${jobId}. 준비되는 대로 업데이트를 보내겠습니다.`,
};
}
여기서 중요한 점:
- MCP 도구는 빠르게 동작합니다: 최대 DB/큐에 대한 몇 번의 요청;
- jobId가 포함된 구조화된 응답을 반환합니다. ChatGPT는 이를 “사용자 설명”에 사용할 수 있고, GiftGenius 위젯은 자신의 widgetState에 저장할 수 있습니다.
이 도구의 JSON Schema는 jobId를 문자열로, message를 사람이 읽을 수 있는 텍스트로만 설명하면 됩니다 — 모델은 이것이 작업 식별자임을 이해하고, 이후 대화 단계에서 이를 참조할 수 있습니다.
5. 간단한 큐와 워커: 학습용 버전
지금은 Redis, RabbitMQ 등을 끌어오지 말고, 단순한 인‑메모리 큐로 시작해 봅시다. 실제 프로덕션에서는 물론 별도의 서비스(SQS/BullMQ/Cloud Tasks 등)를 쓰겠지만, 논리는 동일합니다.
먼저 큐의 골격입니다:
// openai/jobs/queue.ts
import type { GiftJob } from './model';
const jobs = new Map<string, GiftJob>(); // 메모리 내 "DB"
export const queue: string[] = []; // id 기반 단순 큐
export async function createJobAndEnqueue(job: GiftJob) {
jobs.set(job.id, job);
queue.push(job.id);
}
export function getJob(id: string): GiftJob | undefined {
return jobs.get(id);
}
export function updateJob(id: string, patch: Partial<GiftJob>) {
const job = jobs.get(id);
if (!job) return;
const updated: GiftJob = { ...job, ...patch, updatedAt: new Date() };
jobs.set(id, updated);
}
이제 큐를 주기적으로 확인하고 job을 가져와 처리하는 원시적인 워커입니다:
// openai/jobs/worker.ts
import { getJob, updateJob } from './queue';
import { emitJobEvent } from './events';
async function processJob(jobId: string) {
const job = getJob(jobId);
if (!job) return;
updateJob(jobId, { status: 'in_progress' });
await emitJobEvent(jobId, 'job.started', {});
try {
// 여기서 시간이 오래 걸리는 비즈니스 로직을 호출합니다
const result = await doDeepGiftAnalysis(job.id, job.payload);
updateJob(jobId, { status: 'completed', result });
await emitJobEvent(jobId, 'job.completed', { resultSummary: summarize(result) });
} catch (err) {
updateJob(jobId, {
status: 'failed',
error: (err as Error).message,
});
await emitJobEvent(jobId, 'job.failed', { error: 'Internal error' });
}
}
그리고 앱 시작 시 어딘가에서 실행할 수 있는 “루프형” 워커입니다:
// openai/jobs/workerLoop.ts
import { queue } from './queue';
import { processJob } from './worker';
export function startWorkerLoop() {
setInterval(async () => {
const jobId = queue.shift(); // 원칙적으로는 경쟁 상태를 막아야 합니다
if (!jobId) return;
await processJob(jobId);
}, 1000); // 1초마다 큐를 확인
}
이것은 학습용 예제입니다. 실제 환경에서는 setInterval 대신, 새 메시지가 들어오면 워커를 “깨우는” 정상적인 큐를 사용합니다. 하지만 핵심 아이디어는 분명합니다: 워커는 MCP 도구와 분리되어 백그라운드에서 동작하고, 이벤트를 통해 MCP 서버와 통신합니다.
6. 워커에서 MCP 이벤트 생성하기
이전 강의에서 MCP 이벤트의 형식을 이미 봤습니다: type, 고유한 event_id, timestamp, job_id, payload. 이제 워커가 helper emitJobEvent를 호출하고, 그 helper가 MCP 서버의 SSE 채널을 통해 ChatGPT로 이벤트를 전달하는 방법을 보여 드리겠습니다.
간단한 헬퍼 예시:
// openai/jobs/events.ts
import { randomUUID } from 'crypto';
import { sendMcpEvent } from '../mcp/eventBus';
export async function emitJobEvent(
jobId: string,
type: 'job.started' | 'job.progress' | 'job.completed' | 'job.failed',
payload: unknown
) {
const event = {
event_id: randomUUID(),
type,
job_id: jobId,
timestamp: new Date().toISOString(),
payload,
};
await sendMcpEvent(event);
}
그리고 MCP 서버 내부의 sendMcpEvent는 MCP SDK의 SSEServerTransport로 이 이벤트를 전달하는 구체적인 방법을 알고 있습니다. 예를 들어, 로컬 이벤트 버스나 Redis Pub/Sub를 통해 전달할 수 있으며, 이는 모듈 12에서 다뤘습니다.
핵심: 워커는 ChatGPT와 직접 통신하지 않습니다. MCP 서버와 통신하고, MCP 서버가 SSE 연결을 유지하며 클라이언트로 이벤트를 전달합니다.
7. 워커에서의 진행 상황(progress)과 부분 결과(partial results)
이제 가장 흥미로운 부분, 진행 상황과 부분 결과입니다. GiftGenius의 긴 분석은 다음과 같은 단계로 쪼갤 수 있습니다:
- 데이터 수집 및 정규화;
- 기본 세그먼트 구성;
- 초기 선물 아이디어 생성;
- 최종 랭킹과 설명 텍스트 생성.
각 단계에서 job.progress를 보내고, 때로는 job.partial도 보내 UI가 먼저 선물 목록을 일부 보여 줄 수 있습니다.
예시 워커:
async function doDeepGiftAnalysis(jobId: string, payload: GiftJob['payload']) {
await emitJobEvent(jobId, 'job.progress', { step: 1, totalSteps: 4 });
const normalized = await collectAndNormalizeData(payload);
await emitJobEvent(jobId, 'job.progress', { step: 2, totalSteps: 4 });
const roughGifts = await generateInitialGifts(normalized);
await emitJobEvent(jobId, 'job.partial', { gifts: roughGifts.slice(0, 3) });
await emitJobEvent(jobId, 'job.progress', { step: 3, totalSteps: 4 });
const finalGifts = await rerankAndBeautify(roughGifts);
await emitJobEvent(jobId, 'job.progress', { step: 4, totalSteps: 4 });
return finalGifts;
}
위젯은 이벤트를 들으면서 먼저 3개의 “초안” 선물을 “추가 정교화 중” 같은 표시와 함께 보여 줄 수 있고, job.completed 이후에는 목록을 갱신하고 로딩 인디케이터를 제거할 수 있습니다. 이 모든 것은 우리가 강의 3에서 이야기한 UX 패턴에 딱 맞습니다.
8. 워커를 위한 retry 로직
이제 가장 긴장되는 지점: 오류와 재시도입니다.
워커가 작업을 처리하는 동안 외부 상품 목록 API를 호출한다고 가정해 봅시다. 그 API가 때때로 500이나 429로 응답한다면, 첫 오류에서 바로 작업을 포기하는 것은 이상합니다. 그렇다고 무한 재시도도 불가합니다. 그러면 자기 자신이나 외부 서비스를 DDoS하게 됩니다.
필요한 것은 지수(Exponential) 지연이 있는 retry 전략과 시도 횟수 제한입니다.
먼저, 이 과정 전반에 걸쳐 필요한 오류 분류를 소개합니다:
- 일시적(transient) — 타임아웃, 500, 503, 429;
- 영구적(permanent) — 잘못된 입력, 존재하지 않는 리소스;
- 치명적(bug) — 코드 버그, TypeError, 예기치 않은 예외.
재시도는 일시적 오류에만 의미가 있습니다. 나머지는 솔직히 'failed'로 표시해야 합니다.
단순화해서 헬퍼를 만들어 봅시다:
// openai/jobs/retry.ts
export function shouldRetry(error: unknown): boolean {
if (!(error instanceof Error)) return false;
// 가정: HTTP 5xx 또는 429
return /5\d\d|429/.test(error.message);
}
export function getDelayMs(base: number, attempt: number): number {
const jitter = Math.random() * 100; // 작은 지터
return base * 2 ** attempt + jitter; // 지수 백오프
}
이제 GiftJob의 attempts를 고려하도록 워커를 업데이트합니다:
// openai/jobs/worker.ts
import { getJob, updateJob } from './queue';
import { emitJobEvent } from './events';
import { shouldRetry, getDelayMs } from './retry';
const MAX_ATTEMPTS = 5;
export async function processJob(jobId: string) {
const job = getJob(jobId);
if (!job) return;
updateJob(jobId, { status: 'in_progress' });
try {
const result = await doDeepGiftAnalysis(job.id, job.payload);
updateJob(jobId, { status: 'completed', result });
await emitJobEvent(jobId, 'job.completed', {
resultSummary: summarize(result),
});
} catch (err) {
const attempts = job.attempts + 1;
const error = err as Error;
if (attempts <= MAX_ATTEMPTS && shouldRetry(error)) {
const delay = getDelayMs(1000, attempts); // 1s,2s,4s...
updateJob(jobId, { attempts, status: 'pending', error: error.message });
setTimeout(() => {
// 실제 큐라면 지연을 두고 작업을 다시 enqueue 했을 것입니다
processJob(jobId);
}, delay);
await emitJobEvent(jobId, 'job.progress', {
retry: attempts,
nextAttemptInMs: delay,
});
} else {
updateJob(jobId, { status: 'failed', error: error.message });
await emitJobEvent(jobId, 'job.failed', {
error: '여러 차례 시도했지만 분석을 완료하지 못했습니다',
});
}
}
}
여기서 몇 가지가 중요합니다.
첫째, attempts는 작업 자체에 저장됩니다 — 로깅과 가시성에 모두 편리합니다 (그래프에서 재시도가 얼마나 발생하는지 보기 좋게 드러납니다).
둘째, 재시도할 때마다 job.progress를 보내며, 이것이 몇 번째 시도인지 명시합니다. 모델은 이 정보를 사용해 사용자에게 “선물 서버가 불안정하게 응답하고 있어요. 다시 시도 중입니다.”라고 설명할 수 있습니다.
셋째, 어떤 경우든 job.completed 또는 job.failed 중 하나는 반드시 전송되도록 보장합니다. “살아있는 것도 죽은 것도 아닌” 작업이 남지 않게 합니다.
취소('canceled')도 중요한 상태입니다. 학습 예제에서는 구현하지 않지만, 프로덕션에서는 보통 사용자(위젯의 “취소” 버튼) 또는 타임아웃에 의해 설정됩니다. 이 경우 워커는 다음에 큐에서 작업을 가져갔을 때 status가 'canceled'임을 보고 처리를 시작하지 않으며, MCP 서버는 최종 이벤트 job.canceled를 보냅니다.
9. 멱등성과 retry: 같은 실수를 두 번 밟지 않기
retry를 도입하면 “같은 일을 두 번 하는” 위험이 생깁니다. 커머스 모듈에서는 특히 치명적입니다(예: 돈이 두 번 빠짐). GiftGenius에서도 반복이 좋지 않은 시나리오가 있습니다: 친구에게 같은 이메일을 두 번 보내거나, 내부 분석에 중복 레코드를 남기는 등.
따라서 두 가지 원칙을 따르십시오.
첫째: job 핸들러는 멱등적이어야 합니다.
같은 jobId로 여러 번 호출하더라도(재시도 또는 실수로) 시스템이 망가지면 안 됩니다. 이를 위해:
- 모든 부작용(DB 기록, 이메일 전송, 주문 생성 등)은 jobId 또는 다른 자연스러운 식별자에 묶어, 이미 이 단계를 수행했는지 빠르게 확인할 수 있어야 합니다;
- job.status가 이미 'completed' 또는 'failed'라면, 재호출을 무시하거나 준비된 결과를 그대로 반환할 수 있습니다.
간단한 보호 예시:
export async function processJob(jobId: string) {
const job = getJob(jobId);
if (!job) return;
if (job.status === 'completed' || job.status === 'failed') {
// 작업이 이미 성공적으로 또는 최종적으로 종료됨
return;
}
// ... 나머지 코드
}
둘째: 이벤트도 멱등적이어야 합니다.
우리는 이미 event_id와, 클라이언트가 중복을 필터링할 수 있다는 점을 이야기했습니다. 하지만 서버 측에서도 주의해야 합니다. 워커 재시작이나 큐에서 복구될 때, 불필요하게 동일한 job.progress를 남발하지 마십시오.
10. 아키텍처에서 큐와 워커의 위치
그림은 멋지지만, 실제로 워커는 어디서 돌아갈까요? 전형적인 선택지가 몇 가지 있습니다.
통합 워커: MCP 서버와 워커가 같은 프로세스/배포입니다. 툴 호출도 받고, worker loop도 올립니다. 장점은 단순함: 서비스 수가 적고 배포가 쉽습니다. 단점은 확장성: 워커를 늘리려면 MCP 서버 전체를 스케일링해야 합니다.
분리 워커: MCP 서버는 하나의 서비스, 워커는 또 다른 서비스입니다. 그 사이에는 큐와(필요하다면) 이벤트를 위한 Pub/Sub가 있습니다. BullMQ/Redis와 MCP 이벤트 맥락에서 자주 언급되는 방식입니다: MCP 서버는 Redis 채널 'mcp:events'를 구독하고, 워커는 그곳에 이벤트를 발행합니다.
혼합형: MCP 서버 인스턴스 중 하나만 워커를 함께 돌리고, 나머지는 HTTP/SSE만 처리합니다. Vercel 같은 서버리스 플랫폼에서, 상시 백그라운드 프로세스가 까다로울 때 유용합니다.
우리의 학습용 GiftGenius에서는 일단 첫 번째 방식을 사용해도 됩니다: MCP 서버 + 프로세스 내 간단한 워커 하나. 프로덕션과 확장 모듈로 넘어가면, 워커를 별도 서비스로 이전할 수 있습니다.
11. 예시: GiftGenius의 전체 async 파이프라인
사용자가 채팅에서 다음과 같이 썼을 때 어떤 일이 벌어지는지 순서대로 살펴보겠습니다:
“우주 팬을 위한 복잡한 선물 추천이 필요해요. 그의 과거 구매 이력도 반영해 주세요.”
- 모델은 start_deep_analysis 도구를, 수신자 프로필과 예산 파라미터로 호출합니다.
- 도구는 상태가 'pending'인 GiftJob을 DB에 만들고, 큐에 넣은 뒤 jobId와 확인 메시지를 반환합니다.
- ChatGPT는 사용자에게 분석이 시작되었음을 설명하고, jobId를 GiftGenius 위젯에 전달할 수 있습니다.
- 위젯은 해당 jobId의 이벤트에 SSE로 구독하고, 진행 바와 “데이터 수집 및 분석 중” 같은 상태를 표시합니다.
- 워커는 큐에서 새 job을 보고 상태를 'in_progress'로 업데이트한 뒤 job.started를 보냅니다.
- 처리 과정에서 여러 번 job.progress(단계)와 job.partial(처음 2–3개의 선물)을 보냅니다.
- 중간에 외부 API가 실패하면, 워커는 지수 백오프로 재시도하고, attempts를 갱신하며 재시도 정보를 담은 이벤트를 보냅니다.
- 마지막에 job.completed를 요약과 최종 추천과 함께 보내거나, job.failed를 명확한 설명과 함께 보냅니다.
- 위젯은 이 이벤트를 바탕으로 UI를 업데이트하고, ChatGPT는 텍스트 요약을 생성한 뒤 후속 액션을 제안할 수 있습니다: “아이디어 더 보기”, “예산 좁히기”, “선물 유형 변경”.
사용자 관점에서는 통제 가능한 “살아 있는” 장기 프로세스입니다. 백엔드 관점에서는 큐, 워커, retry가 있는 정상적인 async 파이프라인입니다.
12. 작은 연습(자기 주도 학습)
내용을 굳히고 싶다면 GiftGenius에 대해 다음을 시도해 보세요:
- 실제 DB를 위한 jobs 테이블 스키마 설계: 어떤 인덱스가 필요한지, 어떤 필드가 필터링(사용자, 상태, 생성일)에 사용될지;
- HTTP 엔드포인트 /api/jobs/:id를 위한 TypeScript 타입을 초안으로 작성: SSE가 불가한 경우를 대비해, 위젯이 상태를 폴링할 수 있도록;
- retry 정책을 기술: 시도 횟수, 기본 지연, 그래도 실패한 작업을 어떻게 처리할지 (간단한 dead‑letter 테이블 또는 로깅 + 알림).
이 과제는 나중에 프로덕션과 관측성 모듈에서, “pending 상태로 N분 이상 걸린 작업 수” 같은 메트릭을 다룰 때 유용합니다.
13. 비동기 작업에서 흔히 하는 실수
오류 №1: 모든 것을 툴 호출에 동기적으로 처리하기.
가장 흔한 함정은 큐 없이 하나의 MCP 도구에 모든 무거운 작업을 밀어 넣는 것입니다. 요청이 적을 때는 그럭저럭 됩니다. 부하가 커지거나 외부 API가 느려지면 타임아웃, 채팅 멈춤, 극도로 불편한 UX를 맞이합니다. 수십 초 이상 걸릴 가능성이 있는 작업은 처음부터 jobId가 있는 async‑job으로 설계하는 편이 낫습니다.
오류 №2: 명확한 Job 모델 부재.
가끔 개발자들이 DB에 작업 상태를 저장하지 않고 “그냥 큐 메시지”로만 처리하려 합니다. 그러면 기본 질문에 답하기 어려워집니다: “작업 상태는?”, “몇 번 시도했나?”, “왜 실패했나?”. Job의 명확한 모델과 status, attempts, error, createdAt 같은 필드는 디버깅, 모니터링, UX의 기초입니다.
오류 №3: retry가 아예 없거나, 반대로 무한 재시도.
어떤 팀은 500 한 번에 바로 실패하고, 어떤 팀은 while (!success)로 시도 횟수를 제한하지 않습니다. 전자는 일시적 장애로 많은 작업을 잃고, 후자는 부하 폭풍을 만들고 외부 API를 차단할 위험이 있습니다. 합리적인 중간이 필요합니다: 제한된 시도 횟수 + 지수 지연 + 일시적/영구적 오류의 구분.
오류 №4: 멱등적이지 않은 핸들러.
각 시도 때마다 외부 시스템에 새 레코드를 생성하거나, 같은 결제를 수행하거나, 같은 이메일을 보내면 retry는 곧바로 문제가 됩니다. 핸들러는 이 jobId의 작업이 이미 성공적으로 끝났는지 판단하고, 위험한 부작용을 반복하지 않아야 합니다.
오류 №5: 오류 시 이벤트 부재.
워커가 예기치 않은 예외로 떨어지고 콘솔에만 로그를 남긴 뒤 끝나는 경우가 있습니다. 사용자는 영원히 job.completed를 기다리게 되죠. 오류로 종료된 모든 경로는 결국 job.failed로 이어지고, DB의 Job 상태가 업데이트되어야 합니다. 그렇지 않으면 MCP 스트림은 편도 “블랙박스”가 됩니다.
오류 №6: 지나치게 잦은 진행 이벤트.
“정직함”을 이유로 1%마다 job.progress를 보내면 네트워크, 클라이언트, MCP 서버가 과부하됩니다. 단계 변경 시점이나 큰 변화(예: 10% 단위)만 전송하고, 나머지는 내부 로그에만 남기는 것이 좋습니다.
오류 №7: 프로덕션에서 인‑메모리 큐 사용.
queue: string[]와 Map을 쓰는 학습 예제는 아키텍처 이해에는 좋지만, 실제 프로덕션에서는 프로세스 재시작이나 서버 장애에서 바로 무너집니다. 진지한 운영에는 외부 큐와 스토리지가 필요합니다: SQS, Pub/Sub, RabbitMQ, Redis Streams 등. 인‑메모리 방식은 로컬 개발과 간단한 데모에만 적합합니다.
GO TO FULL VERSION