예외의 종류 원칙 9가 ErrorCode를 업무 도메인까지 세분화하기로 하면서, 마지막 문단에 대가를 이렇게 적어뒀다.

대신 같은 기술 예외라도 도메인마다 코드가 늘어나고, 중앙 Advice·Security 핸들러·공용 외부 연동 클라이언트가 현재 도메인을 어떻게 알아낼지 별도 설계가 필요하다. 이미 공개한 COMMON 코드를 도메인 코드로 바꾸는 마이그레이션도 코드 수명주기 규칙이 정해지기 전에는 자동으로 처리하지 않는다.

이 한 문단이 장부에 항목 넷을 만들었다. 3번(수명주기)·4번(진단 정보 경계)·5번(도메인 판별)·6번(공용 클라이언트 소유권)이다. 이 글은 그 넷을 갚는다.

넷을 한 글에 묶은 이유는 전부 같은 전제에서 나오기 때문이다.

ErrorCode 의 숫자는 클라이언트가 분기하는 공개 계약이다
    │
    ├─ 계약이니 누가 발급하는지 정해야 한다      → 도메인 판별 · 공용 클라이언트 소유권
    ├─ 계약이니 숫자가 바뀌면 안 된다            → 폐기와 소유권 이전
    └─ 계약 밖의 것은 흘리면 안 된다             → 진단 정보 경계

먼저: 이미 정해져 있어 다시 고르지 않은 것

ErrorCode는 9자리다. 상태 3 + DomainCode 3 + 일련번호 3. common/error에 enum으로 두고 실패의 단일 출처로 쓴다.

번역 자리는 이미 갈려 있다. 예외의 종류가 영속성 예외는 Repository 구현체에서, 외부 연동 실패는 연동 클라이언트에서 번역하기로 했다. RestClientException 같은 기술 타입이 Service로 새지 않게 하는 것이 그 규칙의 목적이다.

로그는 경계 한 곳에서만 남긴다. 4xx는 warn(스택 없음), 5xx는 error(스택 포함). Service·Domain·Mapper에서는 안 남긴다.

응답에 내부 정보를 싣지 않는다. 예외 처리 표준 원칙 5가 e.getMessage() 대신 defaultMessage만 쓰기로 했다. 이 글은 그 결정을 뒤집지 않는다 — 아래 원칙 8은 그 선을 유지한 채 로그로 가는 길만 놓는다.

경로는 규칙화돼 있다. URL 라우팅 네이밍 표준이 리소스를 복수 명사로 고정하고 도메인 이름을 경로에 안 넣기로 했다. 이게 아래 원칙 1을 가능하게 한 전제다.


원칙 1. 중앙 Advice는 URI의 리소스 세그먼트로 도메인을 판별한다

Advice가 HttpMessageNotReadableException을 잡았다고 하자. ORDER_MALFORMED_REQUEST_BODY를 줄지 USER_MALFORMED_REQUEST_BODY를 줄지 정하려면 지금 어느 도메인의 요청인지 알아야 한다.

안 A — HandlerMethod의 패키지를 읽는다. Advice 메서드가 HandlerMethod를 주입받아 컨트롤러 클래스가 order.api에 있다는 걸 본다. 얻는 것은 매핑 테이블이 필요 없다는 점이다. 패키지 구조가 이미 도메인이라 손으로 유지할 것이 없다.

버린 이유는 원칙 9가 이미 적어둔 코드를 만들 수 없다는 것이다. 그 enum에 이 둘이 나란히 있다.

NO_HANDLER(404, DomainCode.COMMON, 1, ...)               // 경로를 못 찾음
ORDER_METHOD_NOT_ALLOWED(405, DomainCode.ORDER, 1, ...)  // 405 인데 ORDER 다

405는 핸들러 매핑 단계에서 터진다. HttpRequestMethodNotSupportedException이 나는 시점에는 핸들러가 아직 정해지지 않아 HandlerMethod가 없다. 415도 같다. 안 A로 가면 이 둘은 영영 COMMON이 되고, 원칙 9가 적어둔 상수가 만들 수 없는 값이 된다.

안 B — 컨트롤러에 @DomainOwner(DomainCode.ORDER)를 붙인다. 명시적이고 패키지 구조에 안 묶인다. 버린 이유는 안 A와 같다 — 애노테이션도 결국 핸들러가 정해져야 읽는다. 게다가 패키지가 이미 도메인인데 같은 사실을 한 번 더 적는 것이라, 둘이 어긋날 자리가 생긴다.

골랐다 — 안 C, URI의 리소스 세그먼트를 본다. 안 A와 안 B가 못 지켜주는 것은 핸들러 이전 실패다. URI는 언제나 있다.

// common/error/DomainResolver.java
DomainCode resolve(String uri) {
    for (String segment : uri.split("/")) {
        DomainCode found = DomainCode.byResource(segment);
        if (found != null) return found;
    }
    return DomainCode.COMMON;
}

세그먼트를 앞에서부터 훑어 처음 맞는 것을 쓴다. /admin/ordersadmin이 안 걸리고 orders에서 ORDER가 된다 — 라우팅 표준이 관리자 접두사를 리소스 에 붙이기로 했기 때문에 이 순회가 성립한다.

대가는 URI와 도메인을 잇는 표가 어딘가에 있어야 한다는 것이다. 안 A는 그게 필요 없었다. 그 표를 어디에 둘지가 다음 원칙이다.


원칙 2. 리소스 이름과 도메인의 매핑은 DomainCode가 소유한다

표가 필요해졌으니 자리를 정해야 한다.

안 A — DomainResolver 안에 Map으로 둔다. 판별 로직과 데이터가 한 파일에 모인다. 버린 이유는 DomainCode에 상수를 추가하는 사람과 표를 고치는 사람이 다른 파일을 본다는 것이다. 도메인을 새로 만들면서 표를 빼먹으면 그 도메인의 모든 프레임워크 실패가 조용히 COMMON이 된다. 예외도 경고도 없다.

안 B — 설정 파일(application.yml)에 둔다. 코드를 안 고치고 바꾼다. 버린 이유는 DomainCode가 enum이라 컴파일 타임 상수인데 짝이 되는 표만 런타임 문자열이 되는 것이다. 오타가 나도 뜨기 전까지 모른다.

골랐다 — 안 C, DomainCode가 자기 리소스 이름을 든다.

// common/error/DomainCode.java
public enum DomainCode {
    COMMON(0),
    ORDER(1, "orders", "order-items"),
    USER(2, "users"),
    PRODUCT(3, "products");

    private final int code;
    private final Set<String> resources;
}

안 A와 안 B가 못 지켜주는 것은 한 곳만 고치면 되는 것이다. 도메인을 새로 만들 때 상수 한 줄에 리소스 이름을 같이 적으므로, 표를 따로 찾아갈 일이 없다.

대가는 common이 리소스 이름까지 알게 되는 것이다. 패키지 구조 표준이 “common에는 도메인이 없는 것만 둔다”고 했는데, DomainCode는 이미 그 원칙의 인정하고 가는 타협으로 등록돼 있다. 도메인 이름을 아는 것이 허용된 자리에 리소스 이름이 하나 더 붙는 것이라, 새 예외를 여는 것이 아니다.


원칙 3. 리소스를 못 찾으면 COMMON으로 내보내고 그대로 둔다

순회해도 안 걸리는 요청이 있다. /health, 오타 난 경로, 정적 리소스 같은 것들이다.

여기서 틀린 추측을 하지 않는 것이 중요하다. 억지로 가장 비슷한 도메인을 고르거나 첫 세그먼트를 무조건 쓰면, 로그에 없는 사실이 기록된다. 원칙 9가 NO_HANDLERCOMMON으로 둔 것이 이미 그 뜻이었다.

GET /health           매칭 없음  COMMON
GET /odrers           오타      COMMON  (ORDER  추측하지 않는다)
GET /admin/orders     orders    ORDER

COMMON은 실패가 아니라 정직한 답이다. “도메인을 식별할 수 없었다”가 사실이고, 그 사실이 그대로 코드에 남는다.

대가는 도메인을 등록하는 걸 빼먹었을 때도 똑같이 COMMON이 나온다는 것이다. 진짜로 식별 불가한 것과 등록을 빠뜨린 것이 로그에서 같아 보인다. 기계가 가를 방법을 지금은 모르고, 안다고 지어내지 않는다.


원칙 4. 공용 연동 클라이언트는 번역하지 않고, 도메인 어댑터가 번역한다

예외의 종류 원칙 3의 예시에서 출발한다.

// 지금 표준에 실려 있는 코드
public class PaymentClient {
    public PaymentResult pay(PaymentCommand command) {
        try { ... } catch (ResourceAccessException e) {
            throw ErrorCodeException.of(ErrorCode.ORDER_PAYMENT_TIMEOUT, e);
            //                                    ^^^^^ ORDER 가 박혀 있다
        }
    }
}

주문만 쓸 때는 이게 맞다. 문제는 정산이 같은 결제사 API를 부르기 시작하는 순간이다 — 정산이 실패했는데 로그에는 ORDER_PAYMENT_TIMEOUT이 찍힌다. 장애 대응이 엉뚱한 곳을 판다.

PaymentClient는 자기를 누가 불렀는지 모른다. 그런데 원칙 9는 실패한 업무 도메인을 코드에 남기라고 한다. 둘 중 하나는 양보해야 한다.

안 A — 호출자가 인자로 알려준다. paymentClient.pay(command, DomainCode.ORDER). 가장 적게 고친다. 버린 이유는 어댑터 시그니처에 호출자 정보가 박히는 것이고, 더 나쁘게는 잘못 넘겨도 아무도 안 막는다는 것이다. 정산 코드에서 복사해 붙이며 ORDER를 그대로 두면 원래 버그가 그대로 재현되는데, 이번엔 컴파일도 통과한다.

안 B — 연동 전용 DomainCode를 만든다. PAYMENT_TIMEOUT처럼 결제 자체를 도메인으로 본다. 래퍼가 안 생겨 가장 쉽다. 버린 이유는 원칙 9가 안 A를 버린 이유와 정확히 같은 실패라는 것이다 — 로그만 보고 주문이 망했는지 정산이 망했는지 못 가른다. 업무 맥락을 지우자고 도메인 칸을 만든 게 아니다.

안 C — 결제를 도메인으로 승격한다. payment/를 만들고 PaymentProvider로 다른 도메인이 쓴다. 기존 「도메인 간 창구」 규칙을 그대로 탄다. 버린 이유는 코드가 결국 PAYMENT_가 되어 안 B와 같은 곳에 도착한다는 것이다. 구조만 커지고 문제는 안 풀린다.

골랐다 — 안 D, 도메인마다 얇은 어댑터를 둔다.

// common/client/PaymentClient.java — 기술 예외를 그대로 던진다. ErrorCode 를 모른다
public PaymentResult pay(PaymentCommand command) { ... }

// order/infra/OrderPaymentAdapter.java — 자기 도메인 코드로 번역한다
try {
    return paymentClient.pay(command);
} catch (ResourceAccessException e) {
    throw ErrorCodeException.of(ErrorCode.ORDER_PAYMENT_TIMEOUT, e);
}

안 A·B·C가 못 지켜주는 것은 코드의 도메인 칸이 사실과 맞는 것이다. 어댑터는 자기가 어느 도메인에 사는지 컴파일 타임에 알고, 잘못 넘길 인자가 없다.

「번역은 연동 클라이언트에서」라는 원칙 3을 어기는 것이 아니다. 그 규칙의 목적은 RestClientException이 Service로 새지 않게 하는 것인데, 어댑터도 infra라서 기술 예외는 여전히 infra에서 죽는다. 번역 자리가 한 칸 옮겨간 것이지 계층을 넘은 게 아니다.

대가는 도메인마다 어댑터 클래스가 하나씩 느는 것이다. 주문과 정산이 각각 OrderPaymentAdapter·SettlementPaymentAdapter를 갖고, 둘의 try/catch 모양이 거의 같다. 그 중복은 받아들인다 — 지우려면 다시 안 A나 안 B가 된다.


원칙 5. 공용으로 올리는 시점은 두 번째 도메인이 쓸 때다

원칙 4는 common/client라는 새 자리를 만들었다. 그러면 모든 외부 연동을 처음부터 거기 둬야 하는가?

아니다. 패키지 구조 표준의 기준이 그대로 답한다 — “이 클래스가 특정 도메인이 없어져도 남아야 하는가.”

주문만 결제사를 부른다        → order/infra/PaymentClient      도메인이 죽으면 같이 죽는다
정산도 부르기 시작한다        → common/client/PaymentClient    한쪽이 죽어도 남는다
                              + 도메인마다 어댑터

처음부터 common에 두지 않는 이유는 공유가 실제로 생기기 전에는 그게 공용인지 알 수 없기 때문이다. 미리 올려두면 어댑터도 미리 만들게 되고, 끝까지 한 도메인만 쓰는 연동에 래퍼가 한 겹 얹힌 채 남는다.

대가는 두 번째 사용처가 생길 때 옮기는 작업이 필요하다는 것이다. 클라이언트를 common/client로 옮기고, 원래 도메인에 어댑터를 만들어 하드코딩돼 있던 ErrorCode를 거기로 내린다. 한 번에 끝나는 이사이고, 미리 하는 추측보다 싸다.


원칙 6. 폐기한 코드는 지우지 않고 @Deprecated로 남긴다

여기서부터는 시간 축이다. 9자리 숫자는 클라이언트가 분기하는 공개 계약이라, 한 번 나간 숫자는 우리 마음대로 못 다룬다.

안 A — enum에서 지우고 번호도 재사용한다. 번호가 촘촘해진다. 버린 이유는 옛 코드로 분기하던 클라이언트가 새 의미의 실패에 옛 분기를 태우는 것이다. 409001002가 “이미 확정된 주문”이었다가 “쿠폰 중복 적용”이 되면, 업데이트 안 한 앱은 쿠폰 오류에 “주문 확정” 화면을 띄운다. 예외도 안 나고 아무 신호도 없다.

안 B — 지우고 번호는 별도 문서에 예약해둔다. enum이 깔끔하게 유지된다. 버린 이유는 예약 목록을 사람이 보고 피해야 한다는 것이다. 새 상수를 추가할 때 그 문서를 안 열면 그대로 재사용 사고가 난다. 이 저장소가 안을 버릴 때 반복해서 쓰는 근거가 그 모양이다 — 사람이 계속 기억해야만 지켜지는 규칙.

골랐다 — 안 C, 상수를 남기고 @Deprecated를 붙인다.

@Deprecated  // 2026-08-10 폐기. 쿠폰 정책 개편으로 발생 경로가 사라짐
ORDER_COUPON_EXPIRED(409, DomainCode.ORDER, 3, "만료된 쿠폰입니다."),

안 A와 안 B가 못 지켜주는 것은 번호가 구조적으로 예약되는 것이다. 상수가 그 자리에 있으므로 같은 일련번호를 또 쓸 수가 없고, 사람이 목록을 확인할 필요도 없다. 덤으로 IDE가 새 사용처에 경고를 띄운다.

대가는 enum이 계속 커지는 것이다. 죽은 상수가 쌓여 파일이 길어진다. 그래도 택한 이유는 줄어드는 쪽의 실패가 조용하기 때문이다 — 파일이 긴 건 보이지만, 재사용된 번호는 안 보인다.


원칙 7. 소유 도메인이 바뀌면 새 코드를 발급하고 옛 코드를 폐기한다

주문에 있던 실패가 정산으로 옮겨가는 일이 생긴다. 그러면 코드도 따라가야 할 것 같은데, 9자리 구조가 그걸 막는다.

409  001  002        409  004  001
     ^^^ ORDER            ^^^ SETTLEMENT

도메인이 숫자 안에 인코딩돼 있다. 그래서 “소유를 옮긴다”는 곧 “숫자가 바뀐다”이고, 숫자는 계약이다. 「숫자는 두고 상수 이름만 바꾼다」는 애초에 불가능하다 — 숫자가 도메인에서 나오기 때문이다.

안 A — 옛 코드를 그대로 쓴다. 바꿀 게 없어 가장 싸다. 버린 이유는 6개월 뒤에 ORDER_ 코드가 정산 로그에서 나오는 걸 보고 버그로 오해한다는 것이다. 원칙 4에서 공용 클라이언트가 거짓말하는 걸 고쳐놓고 여기서 다시 거짓말을 허용하는 셈이다.

골랐다 — 안 B, 새 코드를 발급하고 옛 것은 원칙 6대로 폐기한다. 전환 기간에는 둘이 함께 산다.

@Deprecated  // 2026-08-10 SETTLEMENT_PAYOUT_FAILED(504004001) 로 이관
ORDER_PAYOUT_FAILED(504, DomainCode.ORDER, 7, "정산 지급에 실패했습니다."),

SETTLEMENT_PAYOUT_FAILED(504, DomainCode.SETTLEMENT, 1, "정산 지급에 실패했습니다."),

계약을 안 깨면서 현실을 따라가는 유일한 길이다. 옛 숫자는 계속 유효하고, 새 숫자가 옆에 선다.

대가는 한동안 같은 실패에 코드가 둘이라는 것이다. 클라이언트가 새 코드로 옮길 때까지 서버가 둘 다 낼 수 있고, 그 기간 동안 로그에 두 값이 섞인다. @Deprecated 주석에 이관처를 적어 그 혼란을 줄인다.


원칙 8. 진단 정보는 응답 본문이 아니라 X-Trace-Id 헤더로 잇는다

계약 이야기의 마지막은 계약 밖의 것을 어떻게 다루느냐다.

예외 처리 표준 원칙 5가 응답에 내부 정보를 안 싣기로 했고, 그건 유지한다. 문제는 개발자가 원인을 찾는 길이 없다는 것이다. 500을 받으면 서버 로그를 봐야 하는데, 로그에는 다른 요청이 뒤섞여 있어 어느 줄이 그 요청인지 모른다.

안 A — 개발 프로파일에서만 응답에 원인과 스택을 담는다. 프론트 개발자가 바로 본다. 버린 이유는 응답 스키마가 환경마다 달라지는 것이다. Swagger 문서와 어긋나고, 실수로 운영에 켜지면 SQL 조각과 파일 경로가 그대로 나간다. 한 번의 설정 실수가 곧 유출이라 되돌릴 수 없는 종류의 실패다.

안 B — 공통 봉투에 traceId 필드를 넣는다. 클라이언트가 바로 쓰고 화면에도 띄운다. 버린 이유는 HttpApiResponse가 성공·실패 공용 클래스라는 것이다. 실패에만 채우면 성공 응답마다 "traceId": null이 붙는데, 경계 DTO의 null 표기 표준이 “Response의 공개 필드는 null이어도 JSON 키를 생략하지 않는다”고 정해뒀다. 항상 채우면 그건 그것대로 성공 응답 상태 표준의 봉투와 예시를 전부 고쳐야 한다.

골랐다 — 안 C, X-Trace-Id 응답 헤더로 내보낸다.

HTTP/1.1 500 Internal Server Error
X-Trace-Id: a3f9c1e2

{ "code": 500000001, "message": "서버 내부 오류가 발생했습니다." }

안 A가 못 지켜주는 것은 응답 모양이 환경과 무관하게 하나인 것이고, 안 B가 못 지켜주는 것은 기존 봉투를 안 건드리는 것이다. 그리고 추적 키는 응답 내용이 아니라 요청의 메타데이터라 헤더가 의미상으로도 맞다.

성공 응답에도 나간다. 실패에만 붙이면 “느린데 성공한 요청”을 추적할 수 없고, 붙이는 자리가 조건 분기 없이 한 곳이라 빠뜨릴 자리도 없다.

대가는 클라이언트가 사용자에게 보여주려면 헤더를 읽어 화면까지 나르는 한 단계가 는다는 것이다. 본문에 있으면 공짜였을 일이다.


원칙 9. traceId는 Filter에서 만들어 MDC에 넣는다

헤더로 내보내기로 했으니 값을 어디서 만드는지가 남는다. 조건은 두 개다 — Advice보다 앞서야 하고, 핸들러 이전 실패에서도 있어야 한다.

안 A — Advice에서 만든다. 실패 응답에만 필요하니 거기서 만들면 된다. 버린 이유는 로그와 못 잇는다는 것이다. 예외가 터지기 전에 이미 찍힌 로그 줄에는 그 값이 없고, 성공 요청에는 아예 없다. 잇자고 만든 키인데 잇지 못한다.

안 B — Interceptor에서 만든다. 요청 시작에 가깝다. 버린 이유는 HandlerInterceptor가 핸들러 매핑 이후에 돈다는 것이다. 원칙 1에서 405·415가 그 앞에서 터지는 걸 확인했는데, 같은 실패에서 traceId도 없어진다.

골랐다 — 안 C, Servlet Filter에서 만든다.

// common/trace/TraceIdFilter.java
String traceId = UUID.randomUUID().toString().substring(0, 8);
MDC.put("traceId", traceId);
response.setHeader("X-Trace-Id", traceId);
try {
    chain.doFilter(request, response);
} finally {
    MDC.clear();   // 스레드 풀이 재사용하므로 반드시 지운다
}

안 A와 안 B가 못 지켜주는 것은 모든 요청에 값이 있는 것이다. Filter는 DispatcherServlet보다 앞이라 핸들러를 못 찾은 요청에도 값이 붙는다.

MDC에 넣으면 로그 패턴이 알아서 찍는다. 로그를 남기는 쪽 코드는 안 바뀐다 — 원칙 4의 “경계 한 곳에서만 남긴다”가 그대로 유지된다.

%d{HH:mm:ss} [%X{traceId}] %-5level %logger{36} - %msg%n

finallyMDC.clear()가 규칙의 핵심이다. 톰캣이 스레드를 재사용하므로 안 지우면 다음 요청이 앞 요청의 traceId를 물고 간다. 값이 없는 것보다 나쁘다 — 틀린 키로 엉뚱한 요청의 로그를 읽게 된다.

대가는 비동기로 넘어가면 MDC가 따라가지 않는다는 것이다. @Async나 별도 스레드 풀에서는 값이 비고, 그때 어떻게 나를지는 정하지 않았다. 지금 이 저장소에 비동기 경로가 없어 미룬다.


판단 기준 정리

질문
Advice가 도메인을 어떻게 아나 URI 리소스 세그먼트 405·415는 HandlerMethod가 없다
세그먼트가 여러 개면 앞에서부터 처음 맞는 것 관리자 접두사가 리소스 앞에 온다
리소스↔도메인 표는 어디에 DomainCode enum 안 상수 추가와 같은 자리라 빼먹지 않는다
못 찾으면 COMMON, 추측하지 않는다 없는 사실을 로그에 남기지 않는다
공용 연동 클라이언트는 번역하지 않는다 자기를 누가 불렀는지 모른다
그럼 누가 번역하나 도메인 infra의 얇은 어댑터 컴파일 타임에 자기 도메인을 안다
언제 common으로 올리나 두 번째 도메인이 쓸 때 공유는 생기기 전엔 알 수 없다
안 쓰는 코드는 @Deprecated로 남긴다 번호가 구조적으로 예약된다
번호 재사용 하지 않는다 옛 분기가 새 실패에 조용히 걸린다
소유 도메인이 바뀌면 새 코드 발급 + 옛 것 폐기 도메인이 숫자에 박혀 있어 이동 = 계약 파기
개발 환경 응답은 운영과 같다 스키마가 갈리면 유출 경로가 생긴다
그럼 원인은 어떻게 찾나 X-Trace-Id 헤더 봉투를 안 건드리고 로그와 이어진다
traceId를 어디서 만드나 Servlet Filter 핸들러 이전 실패에도 값이 있어야 한다

이 표준을 정하기까지

출발점이 이미 적혀 있었다. 원칙 9의 마지막 문단이 “별도 설계가 필요하다”고 미뤄둔 것 셋과, “수명주기 규칙이 정해지기 전에는”이라고 조건을 건 것 하나. 장부 네 항목이 그 한 문단에서 나왔다는 걸 확인하고 나서 한 글로 묶기로 했다. 따로 쓰면 네 글이 서로를 참조하게 된다.

첫 갈림길에서 원칙 9가 스스로를 반박했다. 도메인 판별을 HandlerMethod로 하려다, 그 enum에 이미 ORDER_METHOD_NOT_ALLOWED(405, ...)가 적혀 있는 걸 봤다. 405 시점에는 HandlerMethod가 없다. 원칙 9가 예시로 적어둔 상수를 원칙 9의 방식으로는 만들 수 없었던 것이고, 그래서 URI로 갔다.

그리고 어제 쓴 라우팅 표준이 여기서 값을 했다. URI에서 도메인을 뽑는 건 경로가 제멋대로면 못 하는 일이다. 리소스가 복수 명사로 고정되고 관리자 접두사가 앞에 오기로 정해져 있어서 “앞에서부터 처음 맞는 세그먼트”라는 단순한 순회가 성립했다. 하루 전 결정이 오늘 전제가 됐다.

공용 클라이언트 쪽은 기존 예시의 전제가 안 적혀 있던 것이 문제였다. 원칙 3에 실린 PaymentClientErrorCode.ORDER_PAYMENT_TIMEOUT이 하드코딩돼 있다. 그 코드는 틀리지 않았다 — 주문 하나만 쓰는 동안은 정확히 맞는 모양이고, 아래 원칙 5가 그걸 그대로 인정한다. 문제는 “도메인 하나가 쓴다”는 전제가 글에 없었다는 것이다. 전제가 없으니 공유가 생겨도 아무도 이 코드를 다시 볼 이유가 없고, 그 순간부터 조용히 거짓말을 시작한다. 이 글에서 그 예시에 전제와 갈림길을 함께 적었다.

넷 중 셋을 버린 근거가 같았다. 인자로 넘기기·연동 전용 도메인·결제 도메인 승격 — 전부 원칙 9가 안 A를 버릴 때 쓴 이유(“로그만 보고 어느 업무가 망했는지 못 가른다”)에 그대로 걸렸다. 같은 판단 기준이 세 번 연속 같은 답을 냈고, 그때 이게 취향이 아니라 규칙이라는 게 분명해졌다.

수명주기 쪽은 9자리 구조가 답을 거의 정해놨다. “소유가 바뀌면 코드를 옮긴다”를 검토하다 도메인이 숫자 안에 인코딩돼 있다는 걸 다시 봤다. 옮기는 순간 숫자가 바뀌고, 숫자는 계약이다. 「이름만 바꾼다」가 불가능하다는 걸 확인하고 나서야 새 코드 발급이 유일한 길이라는 결론이 남았다.

마지막에 봉투가 답을 갈랐다. traceId를 응답 본문에 넣으려다, HttpApiResponse가 성공·실패 공용이라 실패에만 채우면 성공 응답에 "traceId": null이 붙는다는 걸 봤다. 경계 DTO 표준이 키를 생략하지 못하게 해뒀기 때문이다. 다른 표준 둘이 동시에 걸리자 봉투 밖(헤더)이 남았고, 그게 의미상으로도 더 맞았다 — 추적 키는 응답 내용이 아니라 요청의 메타데이터다.


정리

  • 중앙 Advice는 URI의 리소스 세그먼트로 도메인을 판별한다. HandlerMethod는 405·415에 없다
  • 리소스↔도메인 표는 DomainCode가 든다. 상수 추가와 같은 자리여서 빼먹을 곳이 없다
  • 못 찾으면 COMMON이고 추측하지 않는다. 없는 사실을 로그에 남기지 않는다
  • 공용 연동 클라이언트는 번역하지 않고, 도메인 infra의 얇은 어댑터가 번역한다. 어댑터는 자기 도메인을 컴파일 타임에 안다
  • common/client로 올리는 건 두 번째 도메인이 쓸 때다. 공유는 생기기 전에 알 수 없다
  • 폐기는 @Deprecated로 남기는 것이고 번호는 재사용하지 않는다. 상수가 남아야 번호가 구조적으로 예약된다
  • 소유 도메인이 바뀌면 새 코드를 발급한다. 도메인이 숫자에 박혀 있어 이동은 곧 계약 파기다
  • 진단 정보는 응답에 안 싣고 X-Trace-Id 헤더로 잇는다. 환경마다 스키마가 갈리면 그게 유출 경로다
  • traceId는 Filter에서 만들어 MDC에 넣고 finally에서 지운다. 안 지우면 다음 요청이 물고 간다

AI 코드 어시스턴트에 바로 적용하기

Claude Code — .claude/skills/errorcode-contract-standard/SKILL.md

---
name: errorcode-contract-standard
description: ErrorCode의 발급 주체·도메인 판별·폐기·추적 키 규칙. ErrorCode를 추가하거나 폐기할 때, 중앙 Advice나 Security 핸들러를 만질 때, 외부 연동 클라이언트를 만들거나 공유할 때 반드시 적용한다.
---

# ErrorCode 계약 표준

`ErrorCode`의 9자리 숫자는 클라이언트가 분기하는 공개 계약이다. 발급·폐기·추적을 그 전제 위에서 다룬다.

## 도메인 판별

- 중앙 Advice는 **요청 URI의 리소스 세그먼트**`DomainCode`를 판별한다. `HandlerMethod`나 컨트롤러 애노테이션으로 판별하지 않는다 — 405·415는 핸들러가 정해지기 전에 터진다.
- 세그먼트는 **앞에서부터 훑어 처음 맞는 것**을 쓴다. `/admin/orders``ORDER`다.
- 리소스 이름과 도메인의 매핑은 **`DomainCode` enum이 상수마다 들고 있다.** 별도 `Map`이나 설정 파일에 두지 않는다.
- 어느 세그먼트도 안 맞으면 `COMMON`이다. **가장 비슷한 도메인으로 추측하지 않는다.**

## 외부 연동 클라이언트

- **여러 도메인이 공유하는 클라이언트는 `ErrorCode`로 번역하지 않는다.** 기술 예외를 그대로 던진다.
- 번역은 **각 도메인 `infra`의 얇은 어댑터**(`OrderPaymentAdapter` 등)가 하고, 자기 도메인의 `ErrorCode`를 쓴다.
- 호출자가 `DomainCode`를 인자로 넘기지 않는다. 연동 전용 `DomainCode`(`PAYMENT` 등)를 만들지 않는다.
- 한 도메인만 쓰는 동안은 그 도메인의 `infra`에 둔다. **두 번째 도메인이 쓸 때 `common/client`로 올리고 어댑터를 만든다.**

## 폐기와 번호

- 안 쓰는 `ErrorCode` 상수를 **지우지 않는다.** `@Deprecated`를 붙이고 폐기 날짜와 이유를 주석에 적는다.
- **일련번호를 재사용하지 않는다.** 폐기한 상수가 그 자리에 남아 번호를 예약한다.
- 실패의 소유 도메인이 바뀌면 **새 `ErrorCode`를 발급하고 옛 것을 폐기한다.** 숫자에 도메인이 인코딩돼 있어 옮기면 계약이 깨진다.
- 폐기 주석에 이관처 상수와 숫자를 적는다.

## 진단 정보와 추적

- 오류 응답 본문에 **원인·스택트레이스·내부 클래스명을 담지 않는다.** 환경(프로파일)에 따라 응답 스키마를 바꾸지 않는다.
- 대신 **`X-Trace-Id` 응답 헤더**를 성공·실패 모두에 내보낸다. 공통 봉투에 `traceId` 필드를 추가하지 않는다.
- `traceId`**Servlet Filter**에서 만들어 MDC에 넣는다. Interceptor나 Advice에서 만들지 않는다.
- `finally`에서 **`MDC.clear()`를 반드시 호출한다.** 스레드 풀 재사용으로 다음 요청이 앞 요청의 값을 물고 간다.
- 로그 패턴에 `%X{traceId}`를 넣는다. 로그를 남기는 코드는 바꾸지 않는다.

GitHub Copilot — .github/instructions/errorcode-contract-standard.instructions.md

---
description: ErrorCode의 발급 주체·도메인 판별·폐기·추적 키 규칙
applyTo: "**/*.java"
---

# ErrorCode 계약

- 중앙 Advice는 요청 URI의 리소스 세그먼트로 `DomainCode`를 판별한다. `HandlerMethod`나 컨트롤러 애노테이션으로 판별하지 않는다.
- 세그먼트는 앞에서부터 훑어 처음 맞는 것을 쓴다. `/admin/orders``ORDER`다.
- 리소스 이름과 도메인의 매핑은 `DomainCode` enum이 상수마다 들고 있다. 별도 `Map`이나 설정 파일에 두지 않는다.
- 어느 세그먼트도 안 맞으면 `COMMON`이다. 비슷한 도메인으로 추측하지 않는다.
- 여러 도메인이 공유하는 외부 연동 클라이언트는 `ErrorCode`로 번역하지 않고 기술 예외를 그대로 던진다.
- 번역은 각 도메인 `infra`의 얇은 어댑터가 하고 자기 도메인의 `ErrorCode`를 쓴다.
- 호출자가 `DomainCode`를 인자로 넘기지 않는다. 연동 전용 `DomainCode`를 만들지 않는다.
- 한 도메인만 쓰는 클라이언트는 그 도메인 `infra`에 둔다. 두 번째 도메인이 쓸 때 `common/client`로 올린다.
- 안 쓰는 `ErrorCode` 상수를 지우지 않는다. `@Deprecated`와 폐기 날짜·이유를 붙인다.
- 일련번호를 재사용하지 않는다.
- 소유 도메인이 바뀌면 새 `ErrorCode`를 발급하고 옛 것을 폐기한다. 폐기 주석에 이관처를 적는다.
- 오류 응답 본문에 원인·스택트레이스·내부 클래스명을 담지 않는다. 프로파일에 따라 응답 스키마를 바꾸지 않는다.
- `X-Trace-Id` 응답 헤더를 성공·실패 모두에 내보낸다. 공통 봉투에 `traceId` 필드를 추가하지 않는다.
- `traceId`는 Servlet Filter에서 만들어 MDC에 넣고, `finally`에서 `MDC.clear()`를 호출한다.
- 로그 패턴에 `%X{traceId}`를 넣는다.

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기