본문으로 건너뛰기
김신건의 로그

[Koa] Error Handling

· 수정 · 📖 약 1분 · 467자/단어 #koa #error #exception #http-errors
Koa Error Handling, ctx.throw, ctx.assert, koa error middleware, koa app.on error, Koa 에러 처리, http-errors koa

정의

Koa Error Handling 은 미들웨어 chain 에서 발생한 예외를 catch 하여 응답을 정형화하는 패턴입니다. Koa 는 자동 error handler 를 제공하지만, 프로덕션은 대개 커스텀 error middleware + app.on('error') 로직을 조합합니다.

기본: try/catch middleware

가장 관용적 패턴. 모든 미들웨어의 최외곽.

app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.status = err.status || err.statusCode || 500;
    ctx.body = {
      error: err.message,
      ...(process.env.NODE_ENV !== 'production' && {stack: err.stack}),
    };
    ctx.app.emit('error', err, ctx);
  }
});

app.on('error') 는 로깅/외부 알림 (Sentry) 용:

app.on('error', (err, ctx) => {
  logger.error({err, url: ctx?.url});
  Sentry.captureException(err);
});

ctx.throw()

http-errors 기반 예외 발생. Status + message + properties:

ctx.throw(400);                              // 400 Bad Request
ctx.throw(400, 'Missing email');
ctx.throw(404, 'User not found');
ctx.throw(422, 'Validation failed', {
  field: 'email',
  code: 'INVALID_EMAIL',
});

Error 객체 자체:

const err = new Error('boom');
err.status = 400;
throw err;

ctx.assert()

Truthy 검사 + 실패 시 throw:

ctx.assert(user, 401, 'Login required');
ctx.assert(id, 400, 'ID required');
ctx.assert(admin, 403);

assert(user, 401) == if (!user) ctx.throw(401).

Async error

Async middleware 에서 throw 는 자동으로 upstream 으로. await 없이 던지면 위험:

// BAD
app.use(async (ctx, next) => {
  await next();
});

app.use(async (ctx) => {
  someAsyncFn().catch(err => { throw err; });   // upstream 안 감
});

await 을 반드시:

app.use(async (ctx) => {
  await someAsyncFn();   // 실패 시 자동 upstream
});

응답 형식 통일

app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    const status = err.status || err.statusCode || 500;
    const isProduction = process.env.NODE_ENV === 'production';

    ctx.status = status;
    ctx.body = {
      error: {
        code: err.code || (status === 500 ? 'INTERNAL' : 'ERROR'),
        message: (isProduction && status === 500) ? 'Internal Server Error' : err.message,
        ...(err.details && {details: err.details}),
        ...(!isProduction && {stack: err.stack}),
      },
      timestamp: new Date().toISOString(),
      path: ctx.path,
      requestId: ctx.state.requestId,
    };

    if (status >= 500) {
      ctx.app.emit('error', err, ctx);
    }
  }
});

Custom Error class

class DomainError extends Error {
  constructor(code, message, status = 400, details) {
    super(message);
    this.code = code;
    this.status = status;
    this.details = details;
    this.name = 'DomainError';
  }
}

class UserNotFoundError extends DomainError {
  constructor(id) {
    super('USER_NOT_FOUND', `User ${id} not found`, 404);
    this.userId = id;
  }
}

// 사용
router.get('/users/:id', async (ctx) => {
  const user = await User.findById(ctx.params.id);
  if (!user) throw new UserNotFoundError(ctx.params.id);
  ctx.body = user;
});

Error middleware 에서 특정 error 매핑:

if (err instanceof PrismaClientKnownRequestError) {
  if (err.code === 'P2002') {
    ctx.status = 409;
    ctx.body = {error: 'Duplicate entry'};
    return;
  }
}

Validation error (Zod)

const {ZodError} = require('zod');

app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    if (err instanceof ZodError) {
      ctx.status = 400;
      ctx.body = {
        error: 'Validation failed',
        details: err.errors,
      };
      return;
    }
    throw err;   // 다음 handler 로
  }
});

404 처리

Router 가 매칭 실패 시 default 로 404. 커스텀:

app.use(async (ctx, next) => {
  await next();
  if (ctx.status === 404 && !ctx.body) {
    ctx.body = {error: 'Not found', path: ctx.path};
  }
});

또는 router 뒤에 catch-all:

app.use(router.routes());
app.use(router.allowedMethods());
app.use(async (ctx) => {
  ctx.status = 404;
  ctx.body = {error: 'Not found'};
});

app.on('error')

Middleware chain 안 오류 + Koa 자체 오류 모두 emit.

app.on('error', (err, ctx) => {
  // ctx 는 있을 수도 없을 수도 (chain 밖 에러는 없음)
  const info = {
    err: {
      message: err.message,
      code: err.code,
      status: err.status,
      stack: err.stack,
    },
    request: ctx ? {
      method: ctx.method,
      url: ctx.url,
      headers: ctx.headers,
      ip: ctx.ip,
    } : null,
  };
  logger.error(info);
});

Unhandled Rejection / Uncaught Exception

Middleware 밖 에러 (setTimeout, background task):

process.on('unhandledRejection', (reason, promise) => {
  logger.error('Unhandled Rejection:', reason);
  Sentry.captureException(reason);
});

process.on('uncaughtException', (err) => {
  logger.error('Uncaught Exception:', err);
  Sentry.captureException(err);
  // 프로세스 재시작 권장 (일관 상태 보장 불가)
  process.exit(1);
});

Client 오류 vs Server 오류

  • 4xx (client): 로그 낮은 level. Alert X.
  • 5xx (server): 로그 error level. Alert O.
if (status >= 500) {
  logger.error({err});
  Sentry.captureException(err);
} else {
  logger.warn({err});
}

Timeout

const timeout = require('koa-timeout-v2');
app.use(timeout(30000, {status: 408}));

Long-running handler 는 별도 (queue 로 오프로드).

Error middleware 순서

최외곽에 (첫 미들웨어). 그래야 downstream 모든 에러 catch.

app.use(errorMiddleware);      // 1
app.use(logger);                // 2
app.use(bodyParser());          // 3
app.use(auth);                  // 4
app.use(router.routes());       // 5

Multiple error handlers

에러 종류별로:

// Layer 1: Domain errors
app.use(async (ctx, next) => {
  try { await next(); } catch (err) {
    if (err instanceof DomainError) {
      ctx.status = err.status;
      ctx.body = {code: err.code, message: err.message};
      return;
    }
    throw err;
  }
});

// Layer 2: Validation errors
app.use(async (ctx, next) => {
  try { await next(); } catch (err) {
    if (err instanceof ZodError) {
      ctx.status = 400;
      ctx.body = {errors: err.errors};
      return;
    }
    throw err;
  }
});

// Layer 3: Fallback (unknown)
app.use(async (ctx, next) => {
  try { await next(); } catch (err) {
    ctx.status = 500;
    ctx.body = {error: 'Internal Server Error'};
    ctx.app.emit('error', err, ctx);
  }
});

함정

WARNING

Error middleware 를 마지막에 등록하면 못 잡음. 반드시 첫 번째.

CAUTION

Async 안 throw 는 promise rejection. await 없이 next 하면 lost.

WARNING

Prod 에서 stack trace 노출 위험. Env 로 분기.

IMPORTANT

ctx.throw() vs custom Error. throw 는 http-errors 기반. Custom error 는 별도 인스턴스. Middleware 에서 두 유형 모두 처리.

CAUTION

ctx.body 를 error middleware 앞 에서 세팅 후 throw = ctx.body 유지 X. Downstream 이 override.

관련 위키

이 글의 용어 (8개)
[FastAPI] Middlewarefastapi
정의 FastAPI Middleware 는 모든 요청과 응답을 가로채 로깅, 인증, 압축, CORS 등 공통 처리를 하는 계층입니다. Starlette 의 ASGI middlew…
[Framework] Koa.jsframeworks
정의 Koa.js 는 Express 창시자 TJ Holowaychuk 이 2013년 발표한 Node.js 웹 프레임워크 입니다. Express 의 후계자 성격이며, 미들웨어를 a…
[Koa] Body Parsingframeworks
정의 Koa Body Parsing 은 HTTP 요청 body 를 파싱해 에 사용 가능한 형태로 만드는 미들웨어입니다. Koa 코어는 body parser 를 포함하지 않으므로 …
[Koa] Context (ctx)frameworks
정의 Koa Context ( ) 는 각 요청마다 생성되어 미들웨어와 route handler 에 전달되는 객체입니다. (Node 원본), (Koa 확장), (Koa 확장), (…
[Koa] Middleware (Onion Model)frameworks
정의 Koa Middleware 는 요청/응답 사이에 실행되는 async 함수입니다. 각 미들웨어는 (context) 와 (다음 미들웨어를 실행하는 함수) 를 받고, 로 down…
[Koa] Router (@koa/router)frameworks
정의 @koa/router 는 Koa 의 공식 라우터입니다. Koa 코어는 라우팅을 제공하지 않으므로 별도 설치가 필요합니다. Express 스타일 라우팅 (HTTP method…
[Koa] Testing (Supertest, Jest, Vitest)frameworks
정의 Koa Testing 은 (HTTP 호출) + / (assertions/runner) 조합이 표준입니다. 을 Supertest 에 전달하면 실제 HTTP 서버 없이 요청/응…
[NestJS] Exception Filtersframeworks
정의 NestJS Exception Filter 는 애플리케이션에서 발생한 예외를 catch 하여 클라이언트에게 응답 형식을 통일합니다. 데코레이터로 어떤 예외를 처리할지 지정합…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기