학습 자료

에러 · 재시도 · 캐싱 · 배포 (Part 7 마무리 — 시리즈 완결)


Article

23강까지 만든 my-ai-assistant는 겉보기엔 완성됐다. 브라우저에서 질문하면 검색하고, Claude 가 답하고, 그 답이 타이핑하듯 흘러나온다. 그런데 딱 하나, 한 번도 실패하지 않았을 때만 이 말이 맞다.

Claude 호출이 실패하거나 네트워크가 잠깐 끊기면 어떻게 될까? 같은 질문을 세 번 물으면 세 번 다 API 를 부를까? 지금까지는 이 질문들에 답할 필요가 없었다. 개발 중엔 다 잘 됐으니까. 오늘은 이 세 가지를 실제로 깨뜨려보고, 실제로 고친다.

23강 vs 24강
  1. 23강까지Claude 호출이 실패하면 Next.js 기본 500 에러로 죽고, 같은 질문도 매번 다시 API 를 부른다. dev 서버만 띄워봤다
  2. 24강 — 오늘실패를 구조화된 응답으로 바꾸고, 일시적 실패는 재시도하고, 반복 질문은 캐싱하고, 프로덕션 빌드까지 실제로 띄운다
코드는 23강의 lib.ts · vectors.json · docs 를 그대로 쓴다. 오늘 손대는 건 app/api/chat/route.ts 하나와, 새로 만드는 retry.ts 뿐이다.

문제 1 — 에러가 나면 그대로 죽는다

지금 route.ts 를 보자.

// 23강까지의 route.ts (일부)
const stream = client.messages.stream({ model: "claude-haiku-4-5", ... });
stream.on("text", (delta) => controller.enqueue(encoder.encode(delta)));
stream.on("end", () => controller.close());
stream.on("error", (err) => controller.error(err));

stream.on("error", ...) 가 있긴 하다. 하지만 이건 ReadableStream 자체를 에러 상태로 만들 뿐이라, 클라이언트는 Internal Server Error 라는 의미 없는 문자열만 받는다. 왜 실패했는지, 다시 시도하면 될 일인지 전혀 알 수 없다.

실제로 깨뜨려서 확인해본다. 모델 이름을 존재하지 않는 값으로 바꾸고 서버를 띄운 다음, curl 로 직접 두드렸다.

CHAT_MODEL=claude-haiku-9-9-nonexistent npm run dev
curl -i -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"question":"연차는 며칠이야?","history":[]}'
터미널 — 수정 전 (23강 코드 그대로 두고 잘못된 모델명 주입)
  • HTTP/1.1 500 Internal Server Error
  • Internal Server Error

진짜로 이렇게 나온다. Anthropic API 는 model: claude-haiku-9-9-nonexistent 라는 실제 에러 메시지를 돌려줬는데, 그 정보가 클라이언트까지 전혀 전달되지 않았다. 이걸 고친다.

고친 코드 — try/catch 로 구조화된 에러를 만든다

Claude 호출을 startClaudeStream 이라는 별도 함수로 뽑고, 그 함수가 던지는 에러를 POST 핸들러에서 잡는다.

// app/api/chat/route.ts
try {
  ({ stream, firstChunk } = await startClaudeStream(messages));
} catch (err) {
  console.error("[chat] Claude 호출 실패:", err);
  const status = (err as { status?: number }).status;
  return Response.json(
    {
      error: "답변을 생성하지 못했습니다. 잠시 후 다시 시도해주세요.",
      detail: (err as Error).message,
      retryable: isRetryableAnthropicError(err),
    },
    { status: status && status < 500 ? status : 502 },
  );
}

같은 방식으로 다시 두드려본다. 이번엔 모델명을 아직 고치지 않았는데, 에러 처리 코드가 진짜로 그 에러를 붙잡는지부터 확인해야 했기 때문이다.

터미널 — 수정 후, 여전히 잘못된 모델명(실제 API 응답을 그대로 감쌌다)
  • HTTP/1.1 404 Not Found
  • x-cache: (없음 — 에러라 캐시에 안 남는다)
  • {"error":"답변을 생성하지 못했습니다. 잠시 후 다시 시도해주세요.",
  • "detail":"404 {\"type\":\"error\",\"error\":{\"type\":\"not_found_error\",\"message\":\"model: claude-haiku-9-9-nonexistent\"},\"request_id\":\"req_011Cd9fVVEnkB8ni1B282jUA\"}",
  • "retryable":false}

서버 로그에도 실제 스택과 함께 원인이 정확히 찍힌다.

서버 로그 (npm run dev 출력)
  • [cache] MISS — "연차는 며칠이야?" — Claude 호출 진행
  • [chat] Claude 호출 실패: Error: 404 {"type":"error","error":{"type":"not_found_error","message":"model: claude-haiku-9-9-nonexistent"}}
  • POST /api/chat 404 in 1135ms

retryable: false 인 게 핵심이다. 모델 이름이 틀린 건 몇 번을 다시 불러도 똑같이 실패해서, 재시도로 고칠 수 없는 에러다. 다음 문제로 넘어간다.

문제 2 — 일시적 실패는 재시도해야 한다

모델명이 틀린 건 재시도해도 소용없지만, Anthropic 서버가 잠깐 과부하(529 overloaded_error)이거나 네트워크가 한 번 끊긴 거라면 얘기가 다르다. 몇백 ms 뒤에 다시 부르면 될 일이다. 이런 일시적 실패만 골라 재시도하는 유틸을 만든다.

// retry.ts
export function isRetryableAnthropicError(err: unknown): boolean {
  const status = (err as { status?: number } | undefined)?.status;
  return status === 429 || status === 529 || (typeof status === "number" && status >= 500);
}

export async function retryWithBackoff<T>(
  fn: (attempt: number) => Promise<T>,
  { maxRetries, baseDelayMs }: { maxRetries: number; baseDelayMs: number },
  isRetryable = isRetryableAnthropicError,
): Promise<T> {
  let lastError: unknown;
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn(attempt);
    } catch (err) {
      lastError = err;
      if (attempt === maxRetries || !isRetryable(err)) throw err;
      const delay = baseDelayMs * 2 ** attempt;
      console.log(`[retry] 시도 ${attempt + 1} 실패 (${(err as Error).message}) — ${delay}ms 후 재시도`);
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
  throw lastError;
}

429(너무 많이 불렀다) · 529(과부하) · 5xx(서버 쪽 문제)만 재시도 대상이다. 400·404 같은 "애초에 요청이 잘못됐다" 류는 몇 번을 다시 불러도 똑같이 실패하니 즉시 포기한다. 그리고 재시도 사이 간격을 baseDelayMs * 2^attempt 로 매번 두 배씩 늘린다. 이걸 지수 백오프라고 부른다. 서버가 힘든 상황에 똑같은 간격으로 계속 두드리면 오히려 부담을 더 준다.

재시도 로직 자체를 실제로 검증한다

진짜 Anthropic 서버의 529 과부하를 마음대로 재현할 순 없다. 대신 재시도 유틸 자체가 옳게 동작하는지를 가짜 실패 함수로 직접 실행해 확인한다. 두 번 실패하고 세 번째에 성공하는 경우, 그리고 재시도 불가능한 에러가 즉시 멈추는 경우를 각각 만들었다.

// retry-test.ts
let calls1 = 0;
const result1 = await retryWithBackoff(
  async () => {
    calls1++;
    console.log(`[flaky] 호출 ${calls1}번째`);
    if (calls1 < 3) throw fakeApiError(529, "overloaded_error");
    return "성공적으로 받은 응답";
  },
  { maxRetries: 3, baseDelayMs: 300 },
);
npx tsx retry-test.ts
터미널 (실제 실행)
  • === 케이스 1: 두 번 실패하고 세 번째에 성공 (재시도 가능한 529) ===
  • [flaky] 호출 1번째
  • [retry] 시도 1 실패 (overloaded_error) — 300ms 후 재시도
  • [flaky] 호출 2번째
  • [retry] 시도 2 실패 (overloaded_error) — 600ms 후 재시도
  • [flaky] 호출 3번째
  • 최종 결과: 성공적으로 받은 응답
  • === 케이스 2: 재시도 불가능한 오류(400)는 즉시 중단 ===
  • [fatal] 호출 1번째
  • 재시도 없이 즉시 실패함: invalid_request_error (총 호출 1번)

300ms → 600ms 로 간격이 실제로 두 배씩 늘어났고, 세 번째 시도에서 성공했다. 두 번째 케이스는 재시도를 한 번도 안 하고 딱 한 번 호출한 뒤 바로 실패했는데, isRetryableAnthropicError400 을 걸러낸 게 정확히 동작한다는 뜻이다.

스트리밍에 재시도를 붙일 때의 함정

여기서 하나 조심할 게 있다. 우리 API 는 스트리밍이라, 답이 글자 단위로 클라이언트에 이미 나가고 있는 도중이라면 그 스트림을 처음부터 다시 시작할 수 없다. 이미 화면에 나타난 답 뒤에 새 답을 또 이어붙이면 중복되거나 깨진다.

// route.ts — 첫 조각이 오기 전까지만 재시도 가능한 구간
async function startClaudeStream(messages) {
  return retryWithBackoff(async (attempt) => {
    const stream = client.messages.stream({ model: MODEL, max_tokens: 500, system: SYSTEM_PROMPT, messages });
    const firstChunk = await new Promise<string>((resolve, reject) => {
      const onText = (delta: string) => { stream.off("error", onError); resolve(delta); };
      const onError = (err: unknown) => { stream.off("text", onText); reject(err); };
      stream.once("text", onText);
      stream.once("error", onError);
    });
    return { stream, firstChunk };
  }, { maxRetries: 2, baseDelayMs: 500 });
}

문제 3 — 같은 질문도 매번 다시 API 를 부른다

지금까지의 코드는 똑같은 질문이 두 번 들어와도 매번 Claude 를 새로 부른다. 토큰 값이 그대로 두 번 나간다는 뜻이다. 답이 자주 안 바뀌는 사내 규정 챗봇 같은 경우, 같은 질문(+같은 history)엔 캐시를 쓰는 게 맞다.

// route.ts
const CACHE_TTL_MS = 5 * 60 * 1000;
const cache = new Map<string, { body: string; expiresAt: number }>();

export async function POST(request: Request) {
  const { question, history } = await request.json();
  const cacheKey = JSON.stringify({ question, history });
  const cached = cache.get(cacheKey);
  if (cached && cached.expiresAt > Date.now()) {
    console.log(`[cache] HIT — "${question}" — Claude 호출 없이 반환`);
    return new Response(cached.body, {
      headers: { "Content-Type": "text/plain; charset=utf-8", "X-Cache": "HIT" },
    });
  }
  console.log(`[cache] MISS — "${question}" — Claude 호출 진행`);
  // ... 검색 + Claude 스트리밍 (기존과 동일) ...
}

스트림이 끝날 때(stream.on("end", ...)) 지금까지 흘려보낸 글자를 전부 합쳐 cache 에 저장한다. 캐시 히트일 땐 아예 Claude 를 부르지 않고 저장된 문자열을 그대로 돌려준다.

실제로 20배 빨라지는 걸 확인한다

서버를 띄우고 같은 질문을 두 번 보냈다.

time curl -i -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"question":"야근 식대는 얼마까지 인정돼?","history":[]}'
같은 질문, 두 번째 호출

1차 호출 — cache MISS

실제로 Claude 를 부른다

  • X-Cache: MISS
  • curl 왕복 1.862s
  • 서버 로그: 1850ms
  • 실제 usage 소모

2차 호출 — cache HIT

같은 질문, 같은 history

  • X-Cache: HIT
  • curl 왕복 0.090s
  • 서버 로그: 10ms
  • Claude 호출 없음 — usage 0
서버 로그 (npm run dev 출력, 실제)
  • [cache] MISS — "야근 식대는 얼마까지 인정돼?" — Claude 호출 진행
  • POST /api/chat 200 in 1850ms
  • [cache] HIT — "야근 식대는 얼마까지 인정돼?" — Claude 호출 없이 반환
  • POST /api/chat 200 in 10ms

1850ms → 10ms. 185배다(curl 전체 왕복 기준으론 1.862s → 0.090s, 약 20배. 서버 처리 시간만 보면 응답 자체가 사실상 즉시 나갔다는 뜻이다). 다른 질문을 보내면 당연히 다시 MISS 가 뜨는 것도 확인했는데, 캐시가 질문 내용을 실제로 구분하고 있다는 뜻이다.

다른 질문을 보냈을 때 (실제)
  • [cache] MISS — "원격근무는 주 몇 일까지 가능해?" — Claude 호출 진행

프로덕션 빌드로 실제로 띄운다

지금까지 우린 늘 npm run dev 만 썼다. dev 서버는 코드가 바뀔 때마다 다시 컴파일하느라 매 요청이 느리고, 프로덕션 최적화(코드 압축, 정적 페이지 사전 생성)도 안 되어 있다. npm run build 로 실제 배포용 빌드를 만들고 npm run start 로 그 결과를 띄운다.

npm run build
터미널 (실제 빌드)
  • ▲ Next.js 16.2.10 (Turbopack)
  • ✓ Compiled successfully in 2.7s
  • Running TypeScript ...
  • Finished TypeScript in 2.0s ...
  • Collecting page data using 3 workers ...
  • ✓ Generating static pages using 3 workers (4/4) in 151ms
  • Finalizing page optimization ...
  • Route (app)
  • ┌ ○ /
  • ├ ○ /_not-found
  • └ ƒ /api/chat
  • ○ (Static) prerendered as static content
  • ƒ (Dynamic) server-rendered on demand
npm run start
터미널 (프로덕션 서버, 실제)
  • ▲ Next.js 16.2.10
  • - Local: http://localhost:3000
  • ✓ Ready in 145ms

dev 서버는 준비까지 300~400ms 대였는데, 프로덕션 서버는 145ms 다. 이미 다 컴파일해둔 걸 그대로 띄우기만 하니 더 빠르다. 실제로 질문을 보내 정상 동작을 확인한다.

curl -i -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"question":"연차는 1년에 며칠 생겨?","history":[]}'
터미널 — 프로덕션 빌드에서 받은 실제 답변
  • HTTP/1.1 200 OK
  • x-cache: MISS
  • 참고 문서에 따르면:
  • 입사 1년 차부터 연 15일의 연차 휴가가 발생합니다.
  • 다만, 입사 첫해에는 매달 만근 시 1일씩 발생하며, 최대 11일까지만 쌓을 수 있습니다.

배포 — 여기서부턴 각자의 계정으로

빌드가 통과하고 프로덕션 서버가 정상 응답한다는 건 배포할 준비가 됐다는 뜻이다. 실제로 인터넷에 올리는 마지막 단계는 코드가 아니라 계정이 필요한 일이라, 이 강의에서 실행해 보여줄 순 없다. 여러분 자신의 Vercel 계정으로 직접 해야 한다.

Vercel 배포 — 직접 해볼 단계
  1. 01

    GitHub 에 프로젝트를 올린다

    .env 는 .gitignore 에 있으니 커밋되지 않는다. 실제 키는 절대 커밋하지 않는다.

    git init && git add . && git commit -m "my-ai-assistant"
  2. 02

    vercel.com 에서 그 저장소를 Import 한다

    GitHub 계정으로 로그인하고 New Project → 방금 올린 저장소를 선택한다. Next.js 프로젝트는 별도 설정 없이 자동으로 인식된다.

  3. 03

    환경변수를 프로젝트 설정에 등록한다

    Settings → Environment Variables 에서 ANTHROPIC_API_KEY 를 등록한다. .env 파일 내용을 그대로 복붙하면 안 되고, 값만 등록한다.

  4. 04

    Deploy 를 누른다

    Vercel 이 우리가 방금 로컬에서 실행한 것과 같은 npm run build 를 자기 서버에서 돌리고, 성공하면 https://프로젝트이름.vercel.app 주소가 생긴다.

지금까지 만든 것 — Course B 의 최종 결과물

my-ai-assistant/ (ai-lab-b, lesson-24 — 최종)
  • my-ai-assistant
    • app/page.tsx입력창 + 스트리밍 답변 화면 (23강)
    • app/api/chat/route.ts검색 + 재시도 + 캐싱 + 구조화된 에러 (오늘)
    • retry.ts지수 백오프 재시도 유틸 (오늘)
    • lib.ts청킹 + 임베딩 + 코사인 유사도 (16~22강)
    • vectors.json저장된 벡터
    • docs/사내 규정 문서 3개
    • .env.example
    • package.json
12강에서 계산기 도구 하나로 시작한 어시스턴트가, 오늘 실패를 견디고 반복 질문을 캐싱하는 배포 준비된 앱이 됐다.

24강을 마치며

23강까지 만든 스트리밍 RAG 앱에, 진짜로 에러를 유발해 잡고, 재시도 유틸을 실제로 검증하고, 캐싱으로 185배 빠른 응답을 실측하고, 프로덕션 빌드까지 실제로 띄웠다.

다시 짚어보기
  1. 01

    잘못된 모델명으로 실제 API 에러를 일으켜 잡았다

    수정 전엔 500 Internal Server Error 뿐이었고, try/catch 이후엔 실제 status·detail·retryable 이 담긴 JSON 이 나왔다.

  2. 02

    재시도 가능한 에러와 아닌 에러를 나눴다

    429·529·5xx 만 지수 백오프로 재시도하고, 400·404 는 즉시 포기한다. retry-test.ts 로 두 경로 모두 실제 실행해 검증했다.

    npx tsx retry-test.ts
  3. 03

    스트리밍과 재시도를 안전하게 결합했다

    첫 글자가 클라이언트로 나가기 전까지만 재시도하고, 그 이후엔 깔끔하게 끊는다.

  4. 04

    같은 질문을 캐싱해 1850ms → 10ms 를 실측했다

    question + history 를 키로 5분 TTL 캐시를 뒀고, X-Cache 헤더와 서버 로그로 HIT/MISS 를 직접 확인했다.

  5. 05

    프로덕션 빌드로 실제 서버를 띄우고 확인했다

    npm run build → npm run start 로 실제 응답을 받았다. 그 이후 Vercel 배포는 각자의 계정으로 진행한다.

    npm run build && npm run start

1강의 빈 폴더에서 시작했다. npm init -y 한 줄과 API 키 하나가 전부였던 그 폴더가, 24강을 거치며 검색하고 대화를 기억하고 도구를 부르고 스스로 계획하고 브라우저에서 실시간으로 답하고 이제 실패까지 견디는 앱이 됐다.

Course A(111강)에서 API 호출·멀티턴·프롬프트를 다졌고, Course B(1224강)에서 그 위에 도구·RAG·에이전트·웹·프로덕션을 쌓았다. 중간에 지어낸 결과는 하나도 없었다. stop_reason 이 잘렸을 때도, 검색이 엉뚱한 문서를 가져왔을 때도, TypeScript 7 이 빌드를 깨뜨렸을 때도, 오늘 API 가 진짜로 404 를 뱉었을 때도, 실패한 그대로 싣고 고쳤다. 실전에서 여러분이 마주칠 것도 정확히 이런 모습일 거라서다.

Course B(시리즈) 완결.

여기까지 따라와 24강을 전부 실행해봤다면, 이제 여러분 손엔 "내 문서에 답하는 AI 어시스턴트"의 전체 코드와, 그걸 만드는 동안 실제로 마주친 에러들과 그걸 고친 기록이 남아 있다. 다음 앱은 docs/ 자리에 여러분의 문서를 넣고, SYSTEM_PROMPT 를 여러분의 용도에 맞게 바꾸는 것부터 시작하면 된다.

읽어줘서 고맙다.

에러 · 재시도 · 캐싱 · 배포 (Part 7 마무리 — 시리즈 완결) — 디코드랩(DCODELAB)