2026년 10월 27일 · @martin
모든 서비스의 실패 응답을 RFC 9457 Problem Details 하나로 통일하고, 그 규약을 Claude Code 커스텀 스킬 api-error-contract로 고정했습니다. 에러 코드는 <SERVICE>.<RESOURCE>.<REASON> 형식, 응답 조립은 CI4 예외 핸들러 한 곳에서만 합니다.
왜 에러 규약이 첫 번째 스킬이었나
AIvance의 제품은 모두 같은 CI4 기반(ci4-board)을 공유합니다. JWT 인증, 소셜 로그인, API 버전 관리까지 같은 코드에서 출발합니다. 그런데 에러 응답은 따로 정해 두지 않으면 제품마다 조금씩 달라지기 쉽습니다.
규약이 없으면 한 서비스는 { success: false, message } 같은 자체 응답 래퍼를, 다른 서비스는 CI4 ResponseTrait의 fail() 결과를 그대로 내보내게 됩니다. 프론트엔드와 다른 서비스는 그때마다 분기 코드를 새로 써야 합니다. 제품이 늘수록 이걸 되돌리는 비용이 가장 커집니다.
여기에 AI 문제가 더해집니다. Claude Code에게 엔드포인트를 만들어 달라고 하면, 실패 응답을 그때그때 그럴듯하게 새로 지어냅니다. 사람 리뷰어가 없는 1인 개발 환경에서는 이걸 매번 잡기 어렵습니다.
그래서 Claude Code 커스텀 스킬 후보 여섯 개(ci4-conventions, ci4-api-endpoint, ci4-testing 등) 가운데 api-error-contract를 가장 먼저 만들었습니다. 규약이 먼저 서야 나머지 스킬도 그 위에서 일관되게 동작하기 때문입니다.
스킬은 세 파일로 나눴다
에러 규약은 깁니다. 응답 형식, 코드 이름 규칙, 상태 코드 선택 기준, CI4 구현, 코드 목록까지 들어갑니다. 이걸 CLAUDE.md에 다 넣으면 에러와 상관없는 세션에서도 매번 컨텍스트를 차지합니다. 스킬은 관련 작업에서만 본문을 불러오니 평소 컨텍스트가 가볍습니다.
api-error-contract/
├── SKILL.md # 원칙, 응답 형식, 코드 명명, 상태 선택, 금지 사항
└── references/
├── ci4-implementation.md # ApiException → 핸들러 → traceId → Validation → 테스트
└── error-codes.md # COMMON/AUTH 시드 카탈로그와 추가 절차
SKILL.md는 짧게 두고, 긴 구현 코드와 카탈로그는 references/로 뺐습니다. 본문에는 "CI4 구현은 references/ci4-implementation.md를 읽는다"처럼 위치만 적습니다.
description이 발동을 결정한다
세션에는 스킬 이름과 description만 올라가 있고, 요청이 description과 맞을 때 본문을 불러옵니다. "PHP 관련" 같은 넓은 문구로는 잘 걸리지 않습니다. 그래서 실제로 입력할 표현을 그대로 넣었습니다.
---
name: api-error-contract
description: AIvance API 공통 에러 응답 규약. API 엔드포인트나 컨트롤러를
새로 만들거나 수정할 때, 예외/에러 처리를 추가할 때, 유효성 검사(validation)
실패 응답을 다룰 때, HTTP 상태 코드를 고를 때, 에러 코드를 정의하거나
이름 지을 때 반드시 이 스킬을 사용한다. "에러 처리", "예외", "응답 포맷",
"validation", "error code", "4xx/5xx"를 언급하거나, 명시적으로 요청하지
않았더라도 API 응답에 실패 경로가 포함되는 모든 작업에서 적용한다.
CodeIgniter 4 / PHP 백엔드 기준.
---
마지막 문장이 중요합니다. "라이선스 활성화 엔드포인트 추가해줘"처럼 에러를 언급하지 않은 요청도 실패 경로는 있습니다. 그 경우에도 발동하도록 명시했습니다.
응답 형식: RFC 9457 위에 세 필드를 얹다
응답 래퍼를 직접 설계하지 않고 표준인 RFC 9457(Problem Details for HTTP APIs)을 골랐습니다. 게이트웨이, 모니터링, SDK 생성기가 이미 이 형식을 압니다. Content-Type은 application/problem+json입니다.
{
"type": "https://errors.aivance.kr/auth/token-expired",
"title": "Access token has expired",
"status": 401,
"code": "AUTH.TOKEN.EXPIRED",
"detail": "The access token expired at 2026-08-05T09:12:44Z. Refresh it and retry.",
"instance": "/v1/licenses/1234",
"traceId": "01K2N8YQ4B7C3D9E"
}
표준 필드 다섯 개(type, title, status, detail, instance)에 확장 필드 세 개를 더했습니다.
|
필드 |
필수 |
역할 |
|---|---|---|
|
|
O |
기계 판독용 코드. |
|
|
O |
요청 단위 상관관계 ID. 응답 본문, |
|
|
△ |
필드 단위 오류. validation 실패에서만, 항상 배열 |
type은 실제로 열리는 문서가 없으면 about:blank로 둡니다. title은 같은 code면 항상 같은 문자열이고, 최종 사용자용이 아니라 개발자용 문구입니다.
원칙 세 가지
스킬 본문 맨 앞에는 이 세 줄만 두었습니다. 나머지 규칙은 모두 여기서 따라 나옵니다.
- 에러 코드는 영구 계약이다. 한 번 배포된 코드 문자열은 바꾸지 않습니다. 소비자가 그 문자열로 분기하기 때문입니다. 메시지는 바꿔도 코드는 못 바꿉니다.
- HTTP 상태는 분류,
code는 원인이다. 같은 401이라도 토큰 만료와 서명 불일치는 클라이언트가 다르게 처리해야 합니다. - 5xx는 내부를 드러내지 않는다. 스택 트레이스, 쿼리, 파일 경로는 로그로만 보내고 응답에는
traceId만 남깁니다.
성공 응답에는 응답 래퍼가 없다
이 형식은 실패 전용입니다. 성공 응답은 리소스를 그대로 반환하고, success: true 같은 플래그를 붙이지 않습니다. HTTP 상태가 이미 그 역할을 하기 때문입니다.
필드 단위 오류
{
"type": "about:blank",
"title": "Request validation failed",
"status": 422,
"code": "COMMON.VALIDATION.FAILED",
"instance": "/v1/users",
"traceId": "01K2N8YQ4B7C3D9E",
"errors": [
{ "field": "email", "code": "VALIDATION.INVALID", "message": "Must be a valid email address." }
]
}
errors는 오류가 하나여도 배열입니다. 객체와 배열을 오가면 클라이언트 파싱이 깨집니다. errors[].code는 기본값을 VALIDATION.INVALID 하나로 두었습니다. 규칙별 세분화(VALIDATION.REQUIRED, VALIDATION.TOO_SHORT)는 프론트엔드가 필드별로 다른 UI를 보여줄 때만 값어치가 있어서, 그 매핑 비용을 기본에서 뺐습니다.

성공은 컨트롤러에서 바로 나가고, 실패는 어떤 예외든 ApiExceptionHandler 한 곳을 거쳐 같은 형식으로 나갑니다. 아래 섹션들은 이 그림의 각 상자를 하나씩 설명합니다.
에러 코드 네이밍: <SERVICE>.<RESOURCE>.<REASON>
코드는 전부 대문자, 구분자는 점, 단어 안은 언더스코어입니다. 읽기만 해도 어느 서비스의 어떤 리소스에서 왜 실패했는지 알 수 있어야 합니다.
AUTH.TOKEN.EXPIRED
AUTH.TOKEN.SIGNATURE_INVALID
LICENSE.SEAT.LIMIT_EXCEEDED
BILLING.INVOICE.ALREADY_SETTLED
COMMON.VALIDATION.FAILED
COMMON.RATE_LIMIT.EXCEEDED
규칙은 세 가지입니다.
- REASON에는 결과가 아니라 원인을 쓴다.
LICENSE.SEAT.DENIED가 아니라LICENSE.SEAT.LIMIT_EXCEEDED입니다. 클라이언트가 "좌석 추가 구매" 같은 다음 행동을 고를 수 있어야 합니다. - 여러 서비스가 공유하는 의미는
COMMON.*을 쓴다. 같은 의미의 코드가 서비스마다 다르게 생기는 것이 카탈로그가 막으려는 문제입니다. - RESOURCE는 API 경로의 리소스명과 맞춘다.
/v1/licenses를 다루면LICENSE입니다.
E1024 같은 순번 코드는 금지했습니다. 읽어서 의미를 알 수 없고, 서비스가 늘면 번호 충돌 관리 비용만 생깁니다.
시드 카탈로그
references/error-codes.md에는 처음부터 공용 코드를 넣어 두었습니다. AI가 새 코드를 지어내기 전에 여기부터 확인하게 하려는 장치입니다.
|
코드 |
상태 |
의미 |
|---|---|---|
|
|
400 |
본문 파싱 실패, 필수 파라미터 누락 |
|
|
422 |
유효성 규칙 위반, |
|
|
429 |
레이트 리밋 초과, |
|
|
500 |
처리되지 않은 서버 오류 |
|
|
503 |
하위 의존 서비스 장애·점검 |
|
|
401 |
Authorization 헤더 없음 |
|
|
401 |
토큰 만료, 갱신 후 재시도 가능 |
|
|
401 |
서명 검증 실패, 갱신해도 안 됨 |
|
|
401 |
폐기된 토큰 |
|
|
403 |
인증됨, 권한 부족 |
|
|
403 |
토큰 스코프 부족 |
401을 네 개로 나눈 이유는 클라이언트의 다음 행동이 다르기 때문입니다. EXPIRED는 자동 갱신, SIGNATURE_INVALID와 REVOKED는 재로그인입니다.
새 코드 추가 절차
- 기존 코드로 표현할 수 있는지 먼저 검토한다.
<SERVICE>.<RESOURCE>.<REASON>형식으로 이름을 짓는다.- 카탈로그의 해당 섹션에 상태 코드와 의미를 함께 적는다.
- 그 코드를 반환하는 테스트를 추가한다.
배포된 코드는 카탈로그에서 지우지 않습니다. 쓰지 않게 된 코드는 deprecated 표시만 남깁니다.
HTTP 상태는 갈리는 지점만 정해 두면 된다
AI가 가장 자주 헷갈리는 것은 비슷한 상태 코드 사이의 선택이었습니다. 표만 주면 400과 422를 오가고, 401과 403을 섞습니다. 그래서 스킬에는 표와 함께 판단 기준을 문장으로 넣었습니다.
|
상태 |
쓸 때 |
대표 코드 |
|---|---|---|
|
400 |
요청 자체를 해석할 수 없음 |
|
|
401 |
인증 안 됨, 자격증명 무효 |
|
|
403 |
인증됐으나 권한 없음 |
|
|
404 |
리소스 없음, 또는 존재를 숨길 때 |
|
|
409 |
현재 상태와 충돌 |
|
|
422 |
형식은 맞지만 값이 규칙 위반 |
|
|
429 |
레이트 리밋 |
|
|
500 |
처리되지 않은 서버 오류 |
|
|
503 |
의존 서비스 장애·점검 |
|
판단이 갈리는 네 지점은 이렇게 정했습니다.
- 400 vs 422 — 요청을 해석할 수 없으면 400, 해석은 됐는데 값이 틀렸으면 422입니다. 폼 유효성 검사는 거의 항상 422입니다.
- 401 vs 403 — "누군지 모르겠다"는 401, "누군지 알지만 안 된다"는 403입니다. 401은 재인증하면 풀리고 403은 풀리지 않습니다.
- 403 vs 404 — 다른 테넌트의 리소스처럼 존재 자체가 정보라면 403 대신 404로 존재를 숨깁니다.
- 409 vs 422 — 다른 값을 보내면 되는 건 422, 값은 맞지만 지금 상태에서 불가능한 건 409입니다.
그리고 HTTP 200에 success: false를 담는 방식은 금지했습니다. 게이트웨이, 캐시, 모니터링이 전부 성공으로 집계하기 때문입니다.
CI4 구현: 응답을 조립하는 곳은 한 군데
구현 목표는 하나입니다. 에러 응답을 조립하는 코드가 프로젝트에 딱 한 곳만 있게 만드는 것입니다. 기준은 CI4 4.4 이상, PHP 8.2 이상입니다.
왜 ResponseTrait의 fail()을 쓰지 않나
CI4 ResponseTrait의 fail()은 대략 이런 모양을 만듭니다.
{ "status": 422, "error": 422, "messages": { "email": "..." } }
code도 traceId도 없고, messages가 상황에 따라 모양이 바뀝니다. 그래서 ResponseTrait는 계속 쓰되 에러 경로만 우리 형식으로 대체했습니다. 성공 응답에는 respond(), respondCreated(), respondNoContent()를 그대로 씁니다.
1. ApiException: 계약 정보를 예외가 들고 있다
컨트롤러가 응답을 조립하지 않으려면, 실패를 예외로 던지고 상태·코드·제목을 예외가 들고 있어야 합니다.
<?php
namespace App\Exceptions;
use RuntimeException;
class ApiException extends RuntimeException
{
/** @param list<array{field:string,code:string,message:string}> $errors */
public function __construct(
protected int $statusCode,
protected string $errorCode,
protected string $title,
protected string $detailMessage = '',
protected array $errors = [],
) {
parent::__construct($title);
}
public function getStatusCode(): int { return $this->statusCode; }
public function getErrorCode(): string { return $this->errorCode; }
public function getTitle(): string { return $this->title; }
public function getDetailMessage(): string { return $this->detailMessage; }
public function getErrors(): array { return $this->errors; }
}
2. 도메인별 하위 예외와 이름 있는 생성자
서비스마다 ApiException을 상속한 예외를 두고, 자주 쓰는 실패는 정적 생성자로 만듭니다. 호출부에서 상태 코드를 매번 고르지 않아도 되고, 코드 문자열 오타도 막힙니다.
final class LicenseException extends ApiException
{
public static function seatLimitExceeded(int $limit): self
{
return new self(
409,
'LICENSE.SEAT.LIMIT_EXCEEDED',
'Seat limit reached for this license',
"This license allows {$limit} seats. Release a seat or upgrade the plan.",
);
}
public static function notFound(): self
{
return new self(404, 'LICENSE.NOT_FOUND', 'License not found');
}
}
계층은 단순합니다. RuntimeException 아래 ApiException, 그 아래 ValidationException, LicenseException, BillingException처럼 서비스별 예외가 붙습니다.
3. ApiExceptionHandler: 유일한 조립 지점
BaseExceptionHandler를 확장하면 CLI·HTML 경로의 기본 동작은 두고 JSON 경로만 가로챌 수 있습니다.
<?php
namespace App\Exceptions;
use CodeIgniter\Debug\BaseExceptionHandler;
use CodeIgniter\Debug\ExceptionHandlerInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
use Throwable;
class ApiExceptionHandler extends BaseExceptionHandler implements ExceptionHandlerInterface
{
public function handle(
Throwable $exception,
RequestInterface $request,
ResponseInterface $response,
int $statusCode,
int $exitCode,
): void {
$traceId = service('traceContext')->get();
if ($exception instanceof ApiException) {
$status = $exception->getStatusCode();
$body = [
'type' => 'about:blank',
'title' => $exception->getTitle(),
'status' => $status,
'code' => $exception->getErrorCode(),
'detail' => $exception->getDetailMessage(),
'errors' => $exception->getErrors(),
];
} else {
// 예상하지 못한 예외. 내부 정보는 로그로만 보낸다.
$status = ($statusCode >= 400 && $statusCode < 600) ? $statusCode : 500;
log_message('critical', "[{traceId}] {message}\n{trace}", [
'traceId' => $traceId,
'message' => $exception->getMessage(),
'trace' => $exception->getTraceAsString(),
]);
$body = [
'type' => 'about:blank',
'title' => 'Internal server error',
'status' => $status,
'code' => 'COMMON.INTERNAL.ERROR',
'detail' => 'The request could not be completed. Contact support with the traceId.',
];
}
$body['instance'] = '/' . ltrim($request->getUri()->getPath(), '/');
$body['traceId'] = $traceId;
// 빈 detail, 빈 errors는 키째 뺀다. 소비자가 빈 문자열을 그대로 표시하는 사고를 막는다.
$body = array_filter($body, static fn ($v) => $v !== '' && $v !== []);
$response
->setStatusCode($status)
->setContentType('application/problem+json')
->setBody(json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES))
->send();
exit($exitCode);
}
}
4. 핸들러 등록
app/Config/Exceptions.php에서 API 요청일 때만 이 핸들러를 돌려줍니다. HTML 요청은 CI4 기본 에러 페이지를 그대로 씁니다. 판별 로직은 이 한 곳에만 둡니다.
public function handler(int $statusCode, Throwable $exception): ExceptionHandlerInterface
{
$request = service('request');
$isApi = $request instanceof \CodeIgniter\HTTP\IncomingRequest
&& (str_starts_with($request->getPath(), 'v1/')
|| str_contains($request->getHeaderLine('Accept'), 'json'));
return $isApi
? new \App\Exceptions\ApiExceptionHandler($this)
: parent::handler($statusCode, $exception);
}
5. traceId 필터
요청마다 ID를 하나 발급해 응답 본문, 응답 헤더, 모든 로그에 같은 값을 남깁니다. 상위 서비스가 X-Trace-Id를 넘기면 이어받습니다. MSA에서 호출 체인을 하나로 잇는 값입니다.
class TraceId implements FilterInterface
{
public function before(RequestInterface $request, $arguments = null)
{
$incoming = $request->getHeaderLine('X-Trace-Id');
service('traceContext')->set($incoming !== '' ? $incoming : bin2hex(random_bytes(8)));
}
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{
$response->setHeader('X-Trace-Id', service('traceContext')->get());
}
}
traceContext는 Config\Services에 공유 인스턴스로 등록한 작은 값 객체입니다. 서비스 컨테이너에 두든 요청 객체에 담든, 전 프로젝트가 같은 방식을 쓰는 것이 핵심입니다. 필터는 app/Config/Filters.php의 API 그룹에 겁니다.
6. Validation 매핑
CI4 Validation 결과를 errors 배열로 옮기는 함수를 하나 두고, 모든 컨트롤러가 이걸 거칩니다.
final class ValidationException extends ApiException
{
public static function fromValidation(ValidationInterface $validation): self
{
$errors = [];
foreach ($validation->getErrors() as $field => $message) {
$errors[] = ['field' => $field, 'code' => 'VALIDATION.INVALID', 'message' => $message];
}
return new self(422, 'COMMON.VALIDATION.FAILED', 'Request validation failed', '', $errors);
}
}
7. 컨트롤러에는 성공 경로만 남는다
public function activate(string $id): ResponseInterface
{
if (! $this->validate($this->rules)) {
throw ValidationException::fromValidation($this->validator);
}
$license = $this->licenses->find($id) ?? throw LicenseException::notFound();
$this->activation->activate($license, $this->request->getJsonVar('device_id'));
return $this->respondCreated(['activated_at' => Time::now()->toIso8601String()]);
}
if (실패) { return $this->fail(...) }가 사라지니 컨트롤러가 읽기 쉬워집니다. 좌석 초과는 ActivationService가 LicenseException::seatLimitExceeded()를 던집니다.
서비스 계층이 HTTP 상태를 아는 게 어색할 수 있습니다. 하지만 대안은 서비스가 결과 객체를 돌려주고 모든 컨트롤러가 같은 매핑을 복제하는 것입니다. 서비스가 HTTP, CLI, 큐 워커처럼 여러 전송 계층에서 쓰이게 되면 그때 도메인 예외와 HTTP 매핑을 분리하면 됩니다.
계약은 테스트가 없으면 조용히 깨진다
에러 규약은 리팩터링 중에 아무 소리 없이 깨집니다. 그래서 스킬의 구현 순서 마지막 단계를 "실패 경로마다 상태 코드와 code를 함께 검증하는 테스트를 쓴다"로 정했습니다. 스킬을 잊은 세션이 있어도 형식이 어긋나면 테스트가 실패합니다.
final class LicenseActivationTest extends CIUnitTestCase
{
use FeatureTestTrait;
use DatabaseTestTrait;
public function testReturnsSeatLimitCodeWhenNoSeatsRemain(): void
{
$this->seedLicenseWithFullSeats();
$result = $this->withHeaders(['Accept' => 'application/json'])
->call('post', '/v1/licenses/LIC-1/activate', ['device_id' => 'dev-9']);
$result->assertStatus(409);
$body = json_decode($result->getJSON(), true);
$this->assertSame('LICENSE.SEAT.LIMIT_EXCEEDED', $body['code']);
$this->assertNotEmpty($body['traceId']);
}
public function testInternalErrorDoesNotLeakExceptionMessage(): void
{
// 의존성을 강제로 실패시킨 뒤
$result = $this->call('post', '/v1/licenses/LIC-1/activate', ['device_id' => 'dev-9']);
$result->assertStatus(500);
$this->assertStringNotContainsString('SQLSTATE', $result->getJSON());
$this->assertStringNotContainsString(APPPATH, $result->getJSON());
}
}
첫 번째 테스트는 실패 경로마다 하나씩 씁니다. 두 번째 테스트는 서비스마다 한 번이면 충분합니다. 내부 정보 노출은 한 번 뚫리면 전부 뚫리는 종류의 문제라, 회귀 방지 가치가 큽니다.
설치와 발동 시점
스킬 디렉터리 위치가 곧 명령어 이름이 됩니다.
# 개인용 — 모든 프로젝트에서
unzip api-error-contract.skill -d ~/.claude/skills/
# 프로젝트용 — 이 저장소에서만, 커밋해서 공유
unzip api-error-contract.skill -d .claude/skills/
여러 제품이 같은 규약을 쓴다면 개인용이 편합니다. 다만 저장소에 커밋해 두면 협업자나 CI 세션도 같은 규칙을 따릅니다. 이름이 겹치면 개인용이 프로젝트용을 덮어씁니다.
발동 경로는 두 가지입니다.
- 자동 — 요청이 description과 맞으면 본문을 불러옵니다. "라이선스 활성화 엔드포인트 추가해줘", "이 컨트롤러 예외 처리 정리해줘", "422로 내려야 하나 400으로 내려야 하나?" 같은 요청에서 걸립니다.
- 수동 —
/api-error-contract를 입력하면 바로 불러옵니다. 기존 코드를 규약에 맞춰 감사할 때 씁니다.
운영하면서 알게 된 것
paths로 범위를 좁힐 수 있다.app/Controllers/**, app/Services/**, app/Exceptions/**, app/Filters/**처럼 지정하면 마이그레이션이나 뷰를 고칠 때는 끼어들지 않습니다. 단, 디렉터리 구조가 표준 CI4와 다르면 발동을 막아 버리니 주의합니다.disable-model-invocation은 쓰지 않는다. 배포·커밋처럼 부작용 있는 스킬용 옵션입니다. 규약 스킬은 자동으로 걸려야 의미가 있습니다.- 긴 세션 후반에는 한 번 더 부른다. 자동 압축이 일어나면 스킬 내용이 앞부분만 남을 수 있습니다. 규약이 흐려진 느낌이 들면
/api-error-contract를 다시 입력합니다. - 수정은 다음 세션부터 반영된다. 한 번 불러온 스킬은 세션 끝까지 컨텍스트에 남고 파일을 다시 읽지 않습니다.
마무리: 하지 말아야 할 것 다섯 가지
스킬 맨 끝에는 금지 사항을 모았습니다. AI가 실제로 자주 만드는 실수들입니다.
- HTTP 200에
success: false를 담아 에러를 반환한다. - 예외 메시지를 그대로
detail에 넣는다.SQLSTATE[23000]...이 나가면 스키마가 노출됩니다. - 같은 의미에 서비스마다 다른 코드를 쓴다.
- 에러 코드에 순번(
E1024)을 쓴다. - 성공 응답이나 validation이 아닌 에러에
errors배열을 채운다.
돌아보면 이 스킬이 한 일은 새로운 규칙을 만든 것이 아닙니다. RFC 9457이라는 이미 있는 표준을 고르고, 판단이 갈리는 지점을 문장으로 고정하고, 조립 지점을 한 곳으로 모았을 뿐입니다. 그 결과 Claude Code가 어느 제품에서 엔드포인트를 만들든 실패 응답이 같은 모양으로 나옵니다.
여러 서비스를 운영하고 있다면 첫 커스텀 스킬로 에러 규약을 권합니다. 규약은 길고, 자주 안 쓰이고, 한 번 어긋나면 되돌리기 비싸다는 세 조건을 모두 갖췄습니다. 스킬로 빼기에 이보다 좋은 대상은 드뭅니다.

댓글 0개