Devin.KR

Node.js API 상태 코드와 오류 처리 - HttpError 로 404 405 500 한곳에서 (Node.js API 6단원)

개발자 조회 0

이 단원에서 배우는 것

5단원 서버는 동작하지만, 상태 코드를 정하는 코드가 여기저기 흩어져 있다. 기능이 늘수록 "이 경우는 404였나 400이었나", "오류 모양이 왜 주소마다 다르지?" 같은 문제가 생긴다. 이 단원에서는 이 책의 최종 구조를 잡는다. 이후 단원은 이 구조 위에 기능을 한 겹씩 더한다.

  • 상태 코드를 들고 다니는 HttpError를 만들고, 던지는 곳과 응답하는 곳을 분리한다.
  • 오류 응답 모양을 { "error": { "code", "message", "details" } } 하나로 통일한다.
  • 라우팅을 표로 바꾸고 PATCH(수정)·DELETE(삭제), 404와 405의 차이, 204 응답을 다룬다.
  • 예상하지 못한 오류는 500으로 답하되, 내부 정보는 서버 기록에만 남긴다.

문제 상황

프론트엔드 담당이 이렇게 말했다. "오류 응답이 주소마다 달라요. 어떤 건 error가 문자열이고 어떤 건 details가 있어요. 오류 종류로 분기하고 싶은데 한글 메시지를 비교할 수는 없잖아요." 운영 담당은 이렇게 말했다. "어제 옛날 데이터가 섞인 뒤로 검색하면 500이 나는데, 응답에 서버 파일 경로가 찍혀 나가던데요?"

이번 단원부터는 한 프로젝트 폴더 memo-api를 계속 키운다. 폴더 구조는 이렇다.

memo-api/
  server.mjs      포트를 열고 앱을 연결한다
  app.mjs         라우팅 표와 오류 처리
  call.mjs        시험용 클라이언트
  lib/
    http.mjs      HttpError, JSON 응답, 본문 읽기
    memos.mjs     메모 입력 규칙

완성 코드

lib/http.mjs

// lib/http.mjs — 상태 코드가 붙은 오류, JSON 응답, JSON 본문 읽기
export class HttpError extends Error {
  constructor(status, code, message, details, headers = {}) {
    super(message);
    this.status = status;     // 404, 422 같은 HTTP 상태 코드
    this.code = code;         // 프로그램이 분기할 때 쓰는 짧은 영어 이름
    this.details = details;   // 필드별 오류 목록 등 (없어도 된다)
    this.headers = headers;   // 405 의 allow 처럼 함께 보낼 응답 헤더
  }
}

export function sendJson(res, status, data, headers = {}) {
  if (status === 204) {                 // 204 는 본문이 없어야 한다
    res.writeHead(204, headers);
    return res.end();
  }
  const body = JSON.stringify(data);
  res.writeHead(status, {
    'content-type': 'application/json; charset=utf-8',
    'content-length': Buffer.byteLength(body),
    ...headers,
  });
  res.end(body);
}

const MAX_BYTES = 10 * 1024;

export async function readJson(req) {
  const type = req.headers['content-type'] ?? '';
  if (!type.startsWith('application/json')) {
    throw new HttpError(415, 'UNSUPPORTED_MEDIA_TYPE', 'content-type 은 application/json 이어야 합니다');
  }
  const chunks = [];
  let size = 0;
  for await (const chunk of req) {
    size += chunk.length;
    if (size > MAX_BYTES) {
      throw new HttpError(413, 'BODY_TOO_LARGE', `본문은 ${MAX_BYTES}바이트까지입니다`);
    }
    chunks.push(chunk);
  }
  try {
    return JSON.parse(Buffer.concat(chunks).toString('utf8'));
  } catch {
    throw new HttpError(400, 'INVALID_JSON', 'JSON 형식이 아닙니다');
  }
}

lib/memos.mjs

// lib/memos.mjs — 메모 입력 규칙. HTTP 를 모르는 순수 함수로 둔다
import { HttpError } from './http.mjs';

const FIELDS = ['title', 'done'];

// partial=true 이면 PATCH 용: 보낸 필드만 검사하고, 아무 필드도 없으면 거절한다
export function validateMemo(input, { partial = false } = {}) {
  if (input === null || typeof input !== 'object' || Array.isArray(input)) {
    throw new HttpError(422, 'VALIDATION_FAILED', '입력값을 확인하세요', ['본문은 JSON 객체여야 합니다']);
  }
  const errors = [];
  const value = {};

  if (!partial || 'title' in input) {
    const title = typeof input.title === 'string' ? input.title.trim() : input.title;
    if (typeof title !== 'string') errors.push('title 은 문자열이어야 합니다');
    else if (title.length === 0) errors.push('title 은 비어 있을 수 없습니다');
    else if (title.length > 50) errors.push('title 은 50자 이하여야 합니다');
    else value.title = title;
  }
  if ('done' in input) {
    if (typeof input.done !== 'boolean') errors.push('done 은 true 또는 false 여야 합니다');
    else value.done = input.done;
  } else if (!partial) {
    value.done = false;
  }
  const unknown = Object.keys(input).filter((k) => !FIELDS.includes(k));
  if (unknown.length) errors.push(`모르는 필드: ${unknown.join(', ')}`);
  if (partial && Object.keys(input).length === 0) errors.push('바꿀 필드가 하나도 없습니다');

  if (errors.length) throw new HttpError(422, 'VALIDATION_FAILED', '입력값을 확인하세요', errors);
  return value;
}

export function parseId(text) {
  const id = Number(text);
  if (!Number.isSafeInteger(id) || id < 1) {
    throw new HttpError(400, 'INVALID_ID', 'id 는 1 이상의 정수여야 합니다');
  }
  return id;
}

app.mjs

// app.mjs — 라우팅 표와 한곳에 모은 오류 처리. 서버를 띄우는 코드는 server.mjs 에 있다
import { HttpError, sendJson, readJson } from './lib/http.mjs';
import { validateMemo, parseId } from './lib/memos.mjs';

export function createApp({ memos = [], onError = console.error } = {}) {
  let nextId = Math.max(0, ...memos.map((m) => m.id)) + 1;

  function find(id) {
    const memo = memos.find((m) => m.id === id);
    if (!memo) throw new HttpError(404, 'MEMO_NOT_FOUND', `${id}번 메모가 없습니다`);
    return memo;
  }

  const routes = [
    ['GET', '/health', () => ({ body: { ok: true } })],
    ['GET', '/memos', ({ url }) => {
      const q = url.searchParams.get('q');
      const items = q ? memos.filter((m) => m.title.includes(q)) : memos;
      return { body: { items, count: items.length } };
    }],
    ['POST', '/memos', async ({ req }) => {
      const memo = { id: nextId++, ...validateMemo(await readJson(req)) };
      memos.push(memo);
      return { status: 201, body: memo, headers: { location: `/memos/${memo.id}` } };
    }],
    ['GET', '/memos/:id', ({ params }) => ({ body: find(parseId(params.id)) })],
    ['PATCH', '/memos/:id', async ({ req, params }) => {
      const memo = find(parseId(params.id));
      Object.assign(memo, validateMemo(await readJson(req), { partial: true }));
      return { body: memo };
    }],
    ['DELETE', '/memos/:id', ({ params }) => {
      const memo = find(parseId(params.id));
      memos.splice(memos.indexOf(memo), 1);
      return { status: 204 };
    }],
  ];

  // '/memos/:id' 같은 모양과 실제 경로를 비교해 { id: '3' } 을 돌려준다. 안 맞으면 null
  function match(pattern, path) {
    const a = pattern.split('/');
    const b = path.split('/');
    if (a.length !== b.length) return null;
    const params = {};
    for (let i = 0; i < a.length; i++) {
      if (a[i].startsWith(':')) params[a[i].slice(1)] = decodeURIComponent(b[i]);
      else if (a[i] !== b[i]) return null;
    }
    return params;
  }

  async function dispatch(req, res) {
    const url = new URL(req.url, 'http://localhost');
    const allowed = [];
    for (const [method, pattern, handler] of routes) {
      const params = match(pattern, url.pathname);
      if (!params) continue;
      if (method !== req.method) {
        allowed.push(method);
        continue;
      }
      const { status = 200, body, headers } = await handler({ req, url, params });
      return sendJson(res, status, body, headers);
    }
    if (allowed.length) {
      throw new HttpError(405, 'METHOD_NOT_ALLOWED', `${req.method} 메서드는 이 주소에서 쓸 수 없습니다`,
        undefined, { allow: allowed.join(', ') });   // 쓸 수 있는 메서드를 알려 준다
    }
    throw new HttpError(404, 'NOT_FOUND', `${url.pathname} 는 없는 주소입니다`);
  }

  return async function app(req, res) {
    try {
      await dispatch(req, res);
    } catch (err) {
      if (res.headersSent) return res.destroy(err);     // 이미 보내기 시작했으면 끊는 수밖에 없다
      if (err instanceof HttpError) {
        const { status, code, message, details, headers } = err;
        return sendJson(res, status, { error: { code, message, details } }, headers);
      }
      onError(err, req);                                   // 예상 못 한 오류: 기록은 서버에만
      sendJson(res, 500, { error: { code: 'INTERNAL', message: '서버에서 문제가 생겼습니다' } });
    }
  };
}

server.mjs

메모 목록에는 일부러 title이 없는 옛날 데이터를 섞어 두었다. 문제 상황의 500을 재현하기 위해서다.

// server.mjs — 앱을 만들어 포트에 연결하는 일만 한다
import { createServer } from 'node:http';
import { createApp } from './app.mjs';

const memos = [
  { id: 1, title: 'Node 설치 확인', done: true },
  { id: 2, title: '모듈 복습', done: false },
  { id: 3, done: false },            // 예전 버전이 남긴, title 이 빠진 데이터
];

const app = createApp({
  memos,
  onError: (err, req) => console.error(`[500] ${req.method} ${req.url}\n${err.stack}`),
});

createServer(app).listen(3700, '127.0.0.1', () => {
  console.log('메모 API 대기 중: http://127.0.0.1:3700');
});

call.mjs

// call.mjs — "메서드 경로 [JSON본문]" 목록을 차례로 보내고 결과를 한 줄씩 출력한다
// 사용법: node call.mjs "GET /memos" "PATCH /memos/2 {\"done\":true}" ...
const BASE = process.env.API_BASE ?? 'http://127.0.0.1:3700';

for (const line of process.argv.slice(2)) {
  const [method, path, ...rest] = line.split(' ');
  const body = rest.join(' ') || undefined;
  const headers = body ? { 'content-type': 'application/json' } : {};
  const res = await fetch(BASE + path, { method, headers, body });
  const text = await res.text();
  const allow = res.headers.get('allow');
  console.log(`${method} ${path} -> ${res.status}${allow ? ` (allow: ${allow})` : ''}`);
  if (text) console.log(`  ${text}`);
}

줄별 해설

던지는 곳과 응답하는 곳을 나눈다

5단원에서는 문제를 발견한 곳에서 바로 sendJson(res, 422, ...)을 불렀다. 그러려면 검증 함수가 res를 알아야 한다. 이번에는 문제를 발견한 곳에서는 throw new HttpError(422, 'VALIDATION_FAILED', ...)무엇이 잘못됐는지만 던지고, 응답은 app 함수의 catch 한곳에서 보낸다. 덕분에 validateMemoparseId는 요청도 응답도 모르는 평범한 함수가 되어, 10단원에서 서버 없이 바로 테스트할 수 있다.

codeMEMO_NOT_FOUND, VALIDATION_FAILED처럼 프로그램이 비교하는 짧은 영어 이름이고, message는 사람이 읽는 문장이다. 클라이언트는 code로 분기하고 message는 화면에 보여 준다. 메시지 문구를 고쳐도 클라이언트 코드는 깨지지 않는다.

이 API의 오류 code
code상태언제
INVALID_JSON400본문이 JSON 문법에 맞지 않음
INVALID_ID400id가 1 이상 정수가 아님
MEMO_NOT_FOUND404id의 메모가 없음
NOT_FOUND404그런 주소가 없음
METHOD_NOT_ALLOWED405주소는 있고 메서드가 틀림
BODY_TOO_LARGE413본문이 10KB 초과
UNSUPPORTED_MEDIA_TYPE415JSON이 아닌 형식
VALIDATION_FAILED422값이 규칙에 어긋남
INTERNAL500예상하지 못한 오류(버그)

오류 처리는 한곳에서

  return async function app(req, res) {
    try {
      await dispatch(req, res);
    } catch (err) {
      if (res.headersSent) return res.destroy(err);     // 이미 보내기 시작했으면 끊는 수밖에 없다
      if (err instanceof HttpError) {
        const { status, code, message, details, headers } = err;
        return sendJson(res, status, { error: { code, message, details } }, headers);
      }
      onError(err, req);                                   // 예상 못 한 오류: 기록은 서버에만
      sendJson(res, 500, { error: { code: 'INTERNAL', message: '서버에서 문제가 생겼습니다' } });
    }
  };
}

처리 함수와 검증 함수는 오류를 던지기만 하고, app 함수의 catch 한곳이 응답을 정한다. HttpError는 들고 온 상태 코드로, 그 밖의 오류는 기록만 남기고 500으로 답한다.

그림 6-1. 오류를 던지는 곳과 응답하는 곳

모든 요청은 이 함수를 지난다. 오류는 세 갈래로 처리된다.

  • 이미 응답을 보내기 시작한 경우 — 상태 코드를 바꿀 방법이 없다. 연결을 끊어 클라이언트가 불완전한 응답을 알아채게 한다.
  • HttpError — 우리가 예상하고 던진 오류다. 들고 온 상태 코드와 code, message, details를 그대로 응답한다. detailsundefined이면 JSON.stringify가 그 필드를 빼 준다.
  • 그 밖의 모든 오류 — 코드의 버그다. 클라이언트에게는 "서버에서 문제가 생겼다"는 사실만 알리고, 오류 객체 전체(스택 포함)는 onError로 넘겨 서버 기록에만 남긴다. err.message를 응답에 넣지 않는 이유는 파일 경로, SQL 문, 라이브러리 버전 같은 내부 정보가 그대로 새어 나가기 때문이다.

onError를 인자로 받는 것도 이유가 있다. 앱은 오류를 "어디에 기록할지" 모른다. 지금은 server.mjsconsole.error로 찍고, 9단원에서는 로거로 바꾸고, 10단원 테스트에서는 아무것도 하지 않는 함수를 넘긴다. 앱 코드는 그대로다.

라우팅 표와 경로 변수

routes 배열의 각 줄은 [메서드, 경로 모양, 처리 함수]다. 4단원의 if 사슬과 하는 일은 같지만, API 전체가 표 하나에 보이고 새 주소를 한 줄로 더할 수 있다. 처리 함수는 응답을 직접 보내지 않고 { status, body, headers }돌려준다. 보내는 일은 dispatch가 한다. 응답 보내는 코드가 한 군데라서 4단원 연습 문제의 "end()를 빠뜨리는" 실수가 생길 수 없다.

  function match(pattern, path) {
    const a = pattern.split('/');
    const b = path.split('/');
    if (a.length !== b.length) return null;
    const params = {};
    for (let i = 0; i < a.length; i++) {
      if (a[i].startsWith(':')) params[a[i].slice(1)] = decodeURIComponent(b[i]);
      else if (a[i] !== b[i]) return null;
    }
    return params;
  }

/memos/:id를 빗금으로 자른 조각과 실제 경로 조각을 하나씩 비교한다. :로 시작하는 조각은 무엇이든 받아 params에 담는다. 퍼센트 인코딩된 값을 풀려고 decodeURIComponent를 불렀는데, 이 한 줄에 버그가 숨어 있다. 실행 결과에서 확인한다.

404와 405는 다르다

dispatch는 경로가 맞는데 메서드가 다른 줄을 만나면 그 메서드를 allowed에 모은다. 끝까지 맞는 줄이 없을 때, allowed가 비어 있으면 그런 주소 자체가 없으므로 404, 차 있으면 "주소는 있는데 그 메서드는 안 된다"이므로 405다. 405 응답에는 쓸 수 있는 메서드 목록을 allow 헤더로 알려 주는 것이 HTTP의 약속이다.

요청의 경로와 맞는 줄이 없으면 404, 경로는 맞는데 메서드가 다른 줄만 있으면 405와 allow 헤더로 쓸 수 있는 메서드를 알려 준다.

그림 6-2. 라우팅 표에서 404와 405가 갈리는 방법

수정과 삭제

PATCH는 보낸 필드만 바꾸는 부분 수정이다. 그래서 validateMemo(..., { partial: true })는 보낸 필드만 검사하고, 아무 필드도 없으면 거절한다. 전체를 갈아 끼우는 PUT은 이 API에서 지원하지 않기로 했다. 삭제에 성공하면 돌려줄 본문이 없으므로 204 No Content를 쓴다. 204는 본문이 없어야 하므로 sendJson이 따로 처리한다.

같은 메모를 두 번 지우면 두 번째는 404다. "이미 없으니 성공"으로 보고 204를 주는 API도 있다. 어느 쪽이든 문서에 적어 두면 된다.

parseId는 경로의 id가 1 이상의 정수인지 확인한다. /memos/abc는 "그런 메모가 없다"(404)가 아니라 "id 모양이 틀렸다"(400)로 답한다. Number.isSafeInteger1.5, NaN, 너무 큰 수를 한 번에 걸러 준다.

6단원 메모 API 명세
요청성공주요 실패
POST /memos201 + location400 · 413 · 415 · 422
GET /memos/:id200400 · 404
PATCH /memos/:id200 고친 메모400 · 404 · 422
DELETE /memos/:id204 본문 없음400 · 404
표에 없는 메서드-405 + allow

실제 실행 결과

한 터미널에서 memo-api 폴더의 node server.mjs를 띄우고, 다른 터미널에서 요청을 보냈다.

$ node call.mjs "GET /memos/2" "GET /memos/99" "GET /memos/abc" 'PATCH /memos/2 {"done":true}' 'PATCH /memos/2 {}' "PUT /memos/2" "DELETE /memos/1" "DELETE /memos/1"
GET /memos/2 -> 200
  {"id":2,"title":"모듈 복습","done":false}
GET /memos/99 -> 404
  {"error":{"code":"MEMO_NOT_FOUND","message":"99번 메모가 없습니다"}}
GET /memos/abc -> 400
  {"error":{"code":"INVALID_ID","message":"id 는 1 이상의 정수여야 합니다"}}
PATCH /memos/2 -> 200
  {"id":2,"title":"모듈 복습","done":true}
PATCH /memos/2 -> 422
  {"error":{"code":"VALIDATION_FAILED","message":"입력값을 확인하세요","details":["바꿀 필드가 하나도 없습니다"]}}
PUT /memos/2 -> 405 (allow: GET, PATCH, DELETE)
  {"error":{"code":"METHOD_NOT_ALLOWED","message":"PUT 메서드는 이 주소에서 쓸 수 없습니다"}}
DELETE /memos/1 -> 204
DELETE /memos/1 -> 404
  {"error":{"code":"MEMO_NOT_FOUND","message":"1번 메모가 없습니다"}}

404(MEMO_NOT_FOUND), 400(INVALID_ID), 422(VALIDATION_FAILED), 405(METHOD_NOT_ALLOWEDallow 헤더), 204(본문 없음)가 모두 같은 모양으로 나왔다. 이제 문제 상황의 두 요청을 보낸다.

$ node call.mjs "GET /memos?q=모듈" "GET /memos/%zz"
GET /memos?q=모듈 -> 500
  {"error":{"code":"INTERNAL","message":"서버에서 문제가 생겼습니다"}}
GET /memos/%zz -> 500
  {"error":{"code":"INTERNAL","message":"서버에서 문제가 생겼습니다"}}

둘 다 500이다. 응답에는 내부 정보가 하나도 없다. 원인은 서버 출력에 남았다.

메모 API 대기 중: http://127.0.0.1:3700
[500] GET /memos?q=%EB%AA%A8%EB%93%88
TypeError: Cannot read properties of undefined (reading 'includes')
    at file:///home/me/node-book/memo-api/app.mjs:18:53
    at Array.filter (<anonymous>)
    at async.req.req (file:///home/me/node-book/memo-api/app.mjs:18:31)
    at dispatch (file:///home/me/node-book/memo-api/app.mjs:62:53)
    at Server.app (file:///home/me/node-book/memo-api/app.mjs:74:13)
    at Server.emit (node:events:509:20)
    at parserOnIncoming (node:_http_server:1383:12)
    at HTTPParser.parserOnHeadersComplete (node:_http_common:125:17)
[500] GET /memos/%zz
URIError: URI malformed
    at decodeURIComponent (<anonymous>)
    at match (file:///home/me/node-book/memo-api/app.mjs:46:57)
    at dispatch (file:///home/me/node-book/memo-api/app.mjs:56:22)
    at Server.app (file:///home/me/node-book/memo-api/app.mjs:74:13)
    at Server.emit (node:events:509:20)
    at parserOnIncoming (node:_http_server:1383:12)
    at HTTPParser.parserOnHeadersComplete (node:_http_common:125:17)

첫 번째는 검색 필터가 m.title.includes(q)를 부르는데 3번 메모에 title이 없어서 난 TypeError다. 스택의 첫 줄 app.mjs:18이 정확한 위치다. 두 번째는 %zz가 올바른 퍼센트 인코딩이 아니라서 decodeURIComponentURIError를 던졌다. 클라이언트가 잘못 보낸 요청인데 서버 잘못(500)으로 답한 셈이다. 둘 다 다음 단원 코드에서 고친다(연습 문제 1번).

실무에서 자주 틀리는 것

1. 500 응답에 err.message나 스택을 담는다

개발 중에는 편하지만 운영에서는 정보 유출이다. 공격자는 일부러 이상한 요청을 보내 오류 메시지로 내부 구조를 알아낸다. 원인은 서버 로그에서 본다.

2. 오류를 return으로 흘려보낸다

검증 함수가 { errors }를 돌려주는 5단원 방식은, 호출하는 쪽이 매번 if (errors)를 확인해야 한다. 한 곳이라도 빠뜨리면 잘못된 값이 저장된다. 던지는 방식은 확인을 빠뜨려도 catch로 올라가므로 조용히 지나가지 않는다.

3. 모든 "없음"을 404로 답한다

주소는 있는데 메서드가 틀린 경우(405), id 모양이 틀린 경우(400)까지 404로 답하면, 클라이언트 개발자는 오타를 찾느라 시간을 쓴다. 상태 코드는 클라이언트가 "무엇을 고쳐야 하나"를 알 수 있는 만큼만 세분한다.

4. 버그를 catch로 덮는다

검색 500을 없애려고 필터에 try/catch를 두르고 빈 결과를 돌려주면 500은 사라지지만 데이터 문제는 계속 숨어 있다. 원인(깨진 데이터)을 고치고, 코드는 그런 데이터가 와도 안전하게 만든다.

연습 문제

  1. GET /memos/%zz가 500 대신 400을 받도록 match 함수를 고쳐라. 이 API의 경로 변수는 숫자 id뿐이라는 점을 이용해도 된다.
  2. 500 응답의 messageerr.message를 담으면 이번 실행에서 클라이언트는 무엇을 보게 되는가? 그것이 왜 문제인가?
  3. DELETE /memos(목록 주소)를 보내면 상태 코드와 allow 헤더는 무엇인가?

정답과 해설

  1. 방법은 두 가지다. decodeURIComponenttry/catch로 감싸 실패하면 HttpError(400, 'INVALID_PATH', ...)를 던지거나, 숫자 id만 쓰므로 아예 디코딩하지 않는 것이다. 다음 단원의 app.mjs는 두 번째 방법을 썼다. 그러면 %zz는 문자열 그대로 parseId로 가서 숫자가 아니므로 INVALID_ID 400이 된다(7단원 실행 결과의 마지막 요청). 필요 없는 변환은 하지 않는 것이 가장 확실한 방어다.
  2. Cannot read properties of undefined (reading 'includes')URI malformed가 그대로 나간다. 사용자에게는 쓸모없는 문장이고, 공격자에게는 "이 서버는 Node.js이고, 검색어로 includes를 부르며, 경로를 디코딩한다"는 단서가 된다. 데이터베이스를 쓰는 서버라면 SQL 문이나 테이블 이름이 새어 나갈 수도 있다. 응답은 일반적인 문장으로, 자세한 내용은 로그로 보낸다. 9단원에서 요청마다 번호를 붙이면, 사용자가 알려 준 번호로 로그를 찾아볼 수 있다.
  3. 405allow: GET, POST다. /memos 모양의 줄은 GET, POST 두 개뿐이기 때문이다. 10단원 통합 테스트가 이 결과를 확인한다.

지금 메모는 서버를 끄면 사라진다. 다음 단원에서 3단원의 파일 함수로 저장소를 만들고, 요청이 동시에 몰릴 때 데이터가 사라지는 문제를 직접 재현해서 막는다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.