라우터에는 왜 실패 처리 코드가 없을까

제가 만든 행사 대기열 프로젝트(Cloudflare Workers + Hono + D1)의 코드를 처음부터 다시 뜯어보는 스터디를 시작했습니다. 첫날 목표는 “지형 파악” — 요청 하나가 어디로 들어와서 어디로 나가는지였는데, 따라가다 보니 계속 같은 질문이 나왔습니다.

성공하면 ok()로 응답하는 건 보이는데… fail()은 대체 어디서 부르는 거지?

이 글은 그 질문의 답을 따라가며 배운 것들의 기록입니다.

요청은 항상 같은 방향으로 흐른다

이 프로젝트의 worker 내부는 4개 층으로 나뉩니다.

route      → HTTP 파싱, 응답 포장만. 판단 없음
service    → 업무 규칙. "닫힌 부스에는 등록 못 한다" 같은 판단
repository → SQL만. 판단 없음
D1         → 데이터

식당에 비유하면 route는 홀 직원, service는 주방장, repository는 창고 담당입니다. 홀 직원은 주문만 전달하고, 창고 담당은 시킨 재료만 꺼내옵니다. 판단은 전부 주방장 몫이에요.

실제로 라우트 핸들러는 이렇게 3줄로 끝납니다.

publicRoutes.get("/queue/:queueEntryId/status", async (c) => {
  const { queueService } = createContainer(c.env.DB);
  const status = await queueService.getStatus(c.req.param("queueEntryId"));
  return ok(c, status);   // 성공 봉투 { ok: true, data } 포장
});

판단이 하나도 없죠. 그런데 이상합니다. 대기열 항목이 없으면? 부스가 닫혀 있으면? 실패 처리 코드가 이 파일 어디에도 없습니다.

층을 나누면 좋은 점을 먼저 짚어두면 — 버그의 주소가 정해집니다. “대기 인원 계산이 이상하다”면 service만 보면 되고, “쿼리가 느리다”면 repository만 보면 됩니다. 그리고 service를 테스트할 때 진짜 DB 대신 가짜 repository를 꽂을 수 있어서 단위 테스트가 가능해집니다.

실패는 return이 아니라 throw로 표현한다

답은 service에 있었습니다. service는 규칙 위반을 발견하면 에러를 return하지 않고 throw합니다.

// queue.service.ts
if (booth.status !== "open") {
  throw new AppError(ERROR_CODES.BOOTH_NOT_OPEN, "Booth is not open", 409);
}

throw가 return과 결정적으로 다른 점: 던져진 예외는 중간 함수들을 자동으로 관통해 위로 올라갑니다. 비상벨과 같아요. service 깊은 곳에서 벨을 울리면, 라우트 핸들러는 아무것도 안 해도 즉시 중단되고, 벨은 누군가 catch할 때까지 계속 올라갑니다. (await도 실패한 Promise를 만나면 그 자리에서 벨을 다시 울려줍니다.)

그 벨을 받는 곳이 앱 전체에 딱 하나 있습니다.

// index.ts
app.onError(onError);
 
// middleware/error.ts — fail()이 사용되는 유일한 곳
export function onError(err: Error, c: Context) {
  if (err instanceof AppError)  return fail(c, err.code, err.message, err.status);
  if (err instanceof ZodError)  return fail(c, "VALIDATION_ERROR", "Validation failed", 400);
  console.error("Unhandled error:", err);
  return fail(c, "INTERNAL_ERROR", "Internal server error", 500);
}

fail()은 안 쓰이는 게 아니라, 이 한 곳에서만 쓰이고 있었습니다.

이 구조가 사는 이유

이 방식이 없다면 라우트 10개가 전부 이렇게 생겨야 합니다.

try {
  const entry = await queueService.register(...);
  return ok(c, entry, 201);
} catch (e) {
  if (e instanceof AppError) return fail(c, e.code, ...);
  if (e instanceof ZodError) return fail(c, "VALIDATION_ERROR", ...);
  return fail(c, "INTERNAL_ERROR", ..., 500);
}

이 catch 블록이 모든 라우트에 복붙되고, 언젠가 한 곳은 포맷이 어긋나거나 처리가 빠집니다. 출구를 onError 하나로 모으면 어떤 에러든 — 예상 못 한 버그조차 — 반드시 같은 봉투 {ok:false, error:{code, message}}로 나갑니다. 계약을 어기고 싶어도 어길 수가 없는 구조예요.

정리하면 성공과 실패가 이런 대칭을 이룹니다.

성공: service가 값을 return → route가 ok()로 포장
실패: service가 AppError를 throw → onError가 fail()로 포장

한 장으로 그리면

flowchart TD
    W["🌐 web (프론트)"] -- "HTTP 요청" --> M["관문: CORS 미들웨어"]
    M -- "next()" --> R["route — 파싱·포장만"]
    R -- "호출" --> S["service — 업무 규칙·판단"]
    S -- "데이터 요청" --> P["repository — SQL만"]
    P --- D[("D1")]

    S -- "✅ 성공: return 값" --> OK["route가 ok() 포장<br/>{ ok: true, data }"]
    S -. "⚡ 실패: throw AppError<br/>(핸들러를 관통해 위로)" .-> OE["onError — 유일한 에러 출구"]
    OE --> FAIL["fail() 포장<br/>{ ok: false, error: { code, message } }"]

    OK --> W2["🌐 web — ok 필드 하나로 분기"]
    FAIL --> W2

    style S fill:#fff3cd,stroke:#b58900,color:#1f1f24
    style OE fill:#f8d7da,stroke:#c0392b,color:#1f1f24
    style OK fill:#d4edda,stroke:#27ae60,color:#1f1f24

실선이 정상 경로, 점선이 비상벨(throw) 경로입니다. 요청은 항상 위→아래 한 방향으로 흐르고, 응답으로 나가는 문은 딱 두 개 — 성공이면 route의 ok(), 실패면 onError의 fail()뿐입니다.

스스로 틀려보고 알게 된 것 두 가지

배운 걸 말로 재현해보다가 두 번 틀렸는데, 오히려 이게 제일 남는 부분이었습니다.

하나. “service가 ok를 내보낸다”고 설명했다가 교정받았습니다. service는 ok를 모릅니다. 값을 return하거나 throw할 뿐이고, HTTP 봉투 포장은 전부 웹 계층(route와 onError)의 일입니다. 이 분리 덕분에 service는 HTTP 없이 단위 테스트할 수 있습니다.

둘. “409라는 상태코드는 onError가 만든다”고 생각했는데, 절반만 맞았습니다. 응답 JSON을 조립하는 건 onError지만, “이건 규칙 위반이고 409다”라는 결정은 throw하는 순간 service가 이미 내린 것입니다. onError는 포장만 해요. 그래서 상태코드를 바꾸고 싶으면 고칠 곳은 onError가 아니라 service입니다. — 결정과 조립을 구분하기. 이번 스터디에서 얻은 가장 좋은 렌즈였습니다.

관통하는 원리: 규칙은 한 곳에만

돌아보니 이 코드베이스의 모든 설계가 같은 문장으로 수렴했습니다.

  • 프론트와 백엔드가 공유하는 검증 규칙(Zod 스키마) → packages/shared 한 곳
  • “부스가 없으면 404” 같은 도메인 규칙 → 그 도메인의 service 한 곳
  • 에러를 응답으로 바꾸는 로직 → onError 한 곳
  • CORS·인증 검사 → 라우트마다가 아니라 미들웨어 관문 한 곳

같은 규칙이 두 곳에 살면 언젠가 서로 어긋나고, 그게 버그가 됩니다. 층을 나누고 예외를 중앙으로 모으는 것도 결국 이 원리의 다른 표현이었습니다.

다음 글에서는 요청 하나(참가자 상태 폴링)를 프론트의 React 훅부터 D1의 SQL까지 끝에서 끝까지 추적해볼 예정입니다.