예외 처리 표준HttpApiResponse에는 success(int status, T data)가 있었다. 하지만 언제 200, 201, 202를 넘길지는 정하지 않았다. 더 큰 문제는 본문의 status만 바뀌고 실제 HTTP 응답은 여전히 200일 수 있다는 점이었다.

code도 성공이면 항상 200000000이었다. 실패 코드는 앞 세 자리가 HTTP 상태인데 성공만 201 Created의 본문에 200000000이 들어가면 처음에 세운 코드 체계가 깨진다.

정해야 했던 건 이거였다. 처리 결과를 어떤 상태로 번역할지, 공통 봉투와 204 중 무엇을 지킬지, 실제 HTTP 상태와 본문의 중복을 어떻게 어긋나지 않게 만들지.


먼저: 업무 결과는 Service가 만들고 HTTP 결과는 Controller가 번역한다

Domain은 생성됐는지, 변경됐는지, 어떤 식별자가 생겼는지를 판단한다. Service는 유스케이스를 실행하고 orderId, operationId 같은 업무 결과를 반환한다. 그 결과가 201 Created인지 202 Accepted인지는 HTTP 경계인 Controller가 정한다.

따라서 Domain과 Service에는 HttpStatus, ResponseEntity, URI를 전달하지 않는다. 실패도 같은 경계다. Domain과 Service는 분류된 ErrorCodeException을 던지고 Advice가 ErrorCode를 실제 HTTP 응답으로 번역한다. 기존 ErrorCode가 상태 분류를 가진 비대칭은 유지하되 Spring 타입은 안쪽으로 보내지 않는다.


원칙 1. HTTP 메서드가 아니라 실제 처리 결과로 성공 상태를 정한다

안 A — 모든 성공을 200 OK로 반환한다. 클라이언트 처리는 단순하지만 새 리소스 생성과 비동기 접수가 동기 완료와 구분되지 않는다.

안 B — HTTP 메서드로 상태를 고정한다. POST는 201, DELETE는 204처럼 외우기 쉽다. 하지만 POST /orders/{id}/cancel은 새 리소스를 만들지 않는데도 201이 되어 메서드와 실제 결과가 어긋난다.

안 C — 클라이언트가 관찰하는 처리 결과로 정한다. 일반 조회·수정·동기 명령 완료는 200, 새 리소스 생성은 201, 추적 가능한 비동기 접수는 202다.

골랐다 — 안 C. 안 A가 잃는 결과 의미와 안 B가 만드는 잘못된 생성을 모두 피한다. 대신 같은 HTTP 메서드라도 유스케이스 결과에 따라 상태가 달라질 수 있어 Controller가 결과 의미를 알아야 한다.

처리 결과 상태
조회·수정·삭제·반환값 없는 동기 명령 완료 200 OK
새 리소스 생성 201 Created
추적 가능한 비동기 작업 접수 202 Accepted

원칙 2. 반환 데이터가 없어도 204 대신 200과 공통 봉투를 사용한다

안 A — 본문이 필요 없는 성공은 204 No Content로 반환한다. HTTP 의미는 정확하지만 HttpApiResponse가 사라진다. 클라이언트는 상태에 따라 JSON 파싱 여부부터 나눠야 한다.

안 B — 200 OKHttpApiResponse<Void>를 반환한다. 삭제, 상태 변경, 비밀번호 변경처럼 결과 데이터가 없어도 성공·실패가 같은 봉투를 유지한다. 대신 204의 더 구체적인 의미를 포기한다.

골랐다 — 안 B. 안 A가 깨는 공통 응답 계약을 지킨다. 데이터가 없다는 사실은 data: null로 표현하고 errors는 빈 배열로 내린다.

이 판단은 서버가 스스로 본문을 생략하는 경우에만 해당한다. 클라이언트가 조건부 요청으로 본문을 거절하는 304는 근거가 달라서 원칙 12에서 따로 갈랐다.

{
  "success": true,
  "status": 200,
  "code": 200000000,
  "message": "OK",
  "data": null,
  "errors": []
}

이 코드가 보여주는 것은 “삭제는 무조건 200”이 아니라 반환 데이터 없는 동기 처리가 완료됐고 공통 봉투를 유지한다는 계약이다.


원칙 3. 201 Created는 생성 식별자와 상대 Location을 반환한다

안 A — 생성된 전체 리소스를 반환한다. 추가 조회가 필요 없지만 생성 Command의 결과와 조회 Response가 결합된다. 조회 필드가 늘 때 생성 API까지 함께 바뀐다.

안 B — 생성 식별자와 Location을 반환한다. XxxCreatedResponse에는 식별자만 담고 헤더는 조회 URI를 알려준다. 전체 상태가 필요하면 그 URI로 조회한다.

안 C — 본문 없이 Location만 반환한다. HTTP 헤더만으로 충분하지만 공통 봉투와 생성 식별자 데이터가 사라진다.

골랐다 — 안 B. 안 A의 조회 모델 결합과 안 C의 봉투 예외를 피한다. 대신 생성 직후 전체 값이 필요하면 조회 요청이 한 번 더 든다.

OrderCreatedResponse data = OrderCreatedResponse.from(result);
HttpApiResponse<OrderCreatedResponse> response = HttpApiResponse.created(data);
URI location = URI.create("/orders/" + data.getOrderId());

return ResponseEntity
        .status(response.getStatus())
        .location(location)
        .body(response);

URI는 Controller가 조립한다. Service는 orderId만 반환하며 조회 경로를 알지 않는다.


원칙 4. 202 Accepted는 상태를 추적할 수 있는 비동기 작업에만 사용한다

안 A — 비동기 실행이면 항상 202를 반환한다. 단순하지만 접수 뒤 성공·실패를 확인할 방법이 없는 작업도 완료되지 않은 성공처럼 보인다.

안 B — 추적 가능한 작업에만 202를 반환한다. 응답에 operationId를 담고 Location은 작업 상태 조회 URI를 가리킨다.

안 C — 비동기 접수도 200으로 반환한다. 공통 처리는 쉽지만 실제 업무까지 끝난 것으로 오해하게 한다.

골랐다 — 안 B. 202가 보장하는 것은 완료가 아니라 접수이므로 이후 상태 확인 경로가 있어야 한다. 대신 단순 fire-and-forget 엔드포인트를 만들 수 없고 작업 상태 리소스를 운영해야 한다.

HTTP/1.1 202 Accepted
Location: /operations/123

본문에는 operationId를 담은 XxxAcceptedResponse를 반환한다.


원칙 5. 모든 Controller는 ResponseEntity<HttpApiResponse<T>>를 반환한다

안 A — 201과 202에서만 ResponseEntity를 사용한다. 200 조회는 짧지만 Controller 반환 타입과 실제 상태 설정 방식이 엔드포인트마다 달라진다.

안 B — 모든 Controller가 ResponseEntity<HttpApiResponse<T>>를 반환한다. 실제 HTTP 상태와 본문 상태를 항상 같은 코드 경로에서 맞춘다. 대신 단순 조회도 반환 코드가 조금 길어진다.

안 C — HttpApiResponseResponseEntity까지 만든다. Controller는 짧아지지만 공통 응답 모델이 Spring Web과 HTTP 헤더를 알게 된다.

골랐다 — 안 B. 안 A의 경로별 차이와 안 C의 계층 침범을 피한다. 응답 본문은 항상 HttpApiResponse<T>이고 Controller 메서드의 반환 타입은 항상 ResponseEntity<HttpApiResponse<T>>다.

HttpApiResponse<OrderResponse> response =
        HttpApiResponse.ok(OrderResponse.from(result));

return ResponseEntity
        .status(response.getStatus())
        .body(response);

Controller는 response.getStatus()로 실제 HTTP 상태를 설정한다. 별도의 숫자를 다시 적지 않는다.


원칙 6. 실제 HTTP 상태와 본문 status, 코드 앞 세 자리를 같게 유지한다

정해야 했던 건 본문의 status를 없애 중복을 제거할지였다.

안 A — status를 유지한다. 클라이언트가 본문만 봐도 상태를 명시적으로 읽는다. 대신 실제 상태, 본문 상태, 코드 앞자리 세 값을 맞춰야 한다.

안 B — status를 제거한다. 실제 HTTP 상태와 코드 앞자리만 남아 출처가 줄어든다. 하지만 기존 클라이언트 계약이 깨지고 본문 가독성이 떨어진다.

골랐다 — 안 A. 명시적인 응답 계약을 유지하는 대신 중복을 감수한다. 그 대가는 생성 구조와 MockMvc 테스트로 통제한다.

모든 응답은 다음 불변식을 지킨다.

실제 HTTP 상태 == body.status == body.code / 1_000_000

성공은 200000000, 201000000, 202000000이고 실패도 409001001처럼 같은 앞자리 규칙을 따른다.

예외는 하나다. 304에는 본문이 없으므로 이 불변식의 대상이 아니다. 세 값을 맞출 대상이 애초에 없다. 왜 그 하나만 예외인지는 원칙 12에서 정한다.


원칙 7. 성공 응답은 상태별 팩토리가 코드와 메시지를 함께 만든다

안 A — success(int status, T data)를 유지한다. 유연하지만 201 본문을 만든 뒤 실제 응답을 200으로 내보내거나 메시지를 계속 OK로 둘 수 있다.

안 B — ok, created, accepted 팩토리를 분리한다. 각 팩토리가 상태를 고르고 코드는 status * 1_000_000으로 계산하며 메시지도 함께 고정한다.

안 C — Controller가 상태·코드·메시지를 각각 지정한다. 업무별 표현은 자유롭지만 조합 하나가 빠질 때마다 응답 계약이 깨진다.

골랐다 — 안 B. 안 A와 안 C에서 가능한 잘못된 조합을 생성 경로에서 막는다. 대신 지원할 성공 상태가 늘면 팩토리를 하나 추가해야 한다.

public static <T> HttpApiResponse<T> ok(T data) {
    return success(200, "OK", data);
}

public static HttpApiResponse<Void> ok() {
    return success(200, "OK", null);
}

public static <T> HttpApiResponse<T> created(T data) {
    return success(201, "Created", data);
}

public static <T> HttpApiResponse<T> accepted(T data) {
    return success(202, "Accepted", data);
}

private static <T> HttpApiResponse<T> success(int status, String message, T data) {
    int code = status * 1_000_000;
    return new HttpApiResponse<>(true, status, code, message, data, List.of());
}

외부에 공개된 success(int status, T data)는 없앤다. 업무별 성공 메시지도 받지 않는다. 업무 결과는 data가 표현하고 message는 HTTP 결과만 나타낸다.


원칙 8. 실패 코드는 기존처럼 상태·도메인·일련번호에서 조립한다

성공 코드의 앞자리를 상태와 맞추면서 실패 코드의 파생 방향도 다시 확인했다.

안 A — 409001001을 직접 적고 상태를 나눈다. 로그 숫자로 소스를 바로 찾을 수 있지만 9자리의 도메인과 일련번호를 사람이 정확히 세어 입력해야 한다.

안 B — 상태·DomainCode·일련번호를 원본으로 두고 코드를 조립한다. STOCK_SHORTAGE(409, DomainCode.ORDER, 1, ...)에서 409001001을 계산한다.

골랐다 — 안 B. 기존 예외 처리 표준의 결정을 유지한다. status 필드는 존재하지만 code를 별도로 입력하지 않으므로 둘은 어긋날 수 없다. 대신 로그의 9자리 숫자를 소스에서 그대로 검색할 수 없다.

성공과 실패 모두 상태에서 9자리 코드가 파생된다는 방향은 같다. 성공의 나머지 여섯 자리는 공통 성공을 뜻하는 000000이다.


원칙 9. Location은 루트 기준 상대 URI로 만든다

안 A — 절대 URI를 사용한다. 완전한 주소지만 프록시 뒤에서 스킴과 호스트를 잘못 복원하거나 환경별 도메인 설정에 의존할 수 있다.

안 B — /orders/123 같은 루트 기준 상대 URI를 사용한다. 현재 origin을 그대로 사용하므로 배포 도메인과 게이트웨이에 흔들리지 않는다.

안 C — Location을 생략한다. 구현은 단순하지만 생성 리소스와 작업 상태를 찾는 표준 경로가 사라진다.

골랐다 — 안 B. 같은 API의 리소스를 가리키는 현재 범위에서는 안 A의 환경 의존을 피한다. 대신 다른 origin의 리소스를 가리켜야 한다면 이 규칙을 다시 검토해야 한다.


원칙 10. MockMvc로 실제 상태와 본문·헤더의 계약을 검증한다

안 A — HttpApiResponse 단위 테스트만 둔다. 코드 계산은 빠르게 검증하지만 Controller가 실제 HTTP 상태나 Location을 잘못 설정하는 경우를 잡지 못한다.

안 B — MockMvc로 HTTP 경계를 통과한다. 200·201·202와 데이터 없는 성공, 실패 응답을 실제 직렬화 결과로 검증한다.

골랐다 — 안 B. 세 값과 헤더의 일치는 객체가 아니라 HTTP 응답의 계약이다. 대신 Controller 테스트 수와 실행 시간이 늘어난다.

최소한 다음을 검증한다.

  • 200은 실제 상태, status, code, message200, 200000000, OK다.
  • 201은 201000000, Created, 생성 조회 Location을 가진다.
  • 202는 202000000, Accepted, 작업 상태 조회 Location을 가진다.
  • 반환 데이터 없는 성공은 200 봉투와 data: null을 가지며 204를 반환하지 않는다.
  • 실패는 실제 상태, 본문 status, code / 1_000_000이 같다.

원칙 11. 조회 재검증의 이름표는 완성된 응답 본문의 해시로 만든다

정해야 했던 건 이거였다 — 안 바뀐 조회 응답을 다시 보내지 않으려면 “안 바뀌었다”를 무엇으로 판정하는가.

안 A — 조건부 요청을 쓰지 않는다. 봉투 불변식이 예외 없이 하나로 유지되고 코드가 한 줄도 안 바뀐다. 버린 이유는 조회 응답이 커져도 대역폭을 줄일 수단이 서버 쪽에 하나도 남지 않는다는 것이다. 목록 조회가 200KB가 되는 순간 할 수 있는 게 없다.

안 B — 완성된 응답 본문을 해시해 이름표로 쓴다. Spring의 ShallowEtagHeaderFilter가 이 방식이다. 응답을 다 만든 뒤 해시해서 If-None-Match와 비교한다. Controller는 아무것도 하지 않는다.

안 C — updatedAt이나 version 값으로 이름표를 만든다. DB 조회 전에 판정하므로 조회와 직렬화 비용까지 아낀다. 버린 이유는 재료가 지금 경계 밖에 있다는 것이다. Domain 영속화 표준@Version을 Entity에만 두기로 했고 — 그래야 Domain이 version을 모르는 채로 낙관적 락이 돈다 — DB 시간 저장 표준updatedAt도 Entity에만 두고 DB가 관리하게 했다. 이 안을 택하면 두 표준을 다시 열고 XxxDomainXxxView에 영속성 개념을 올려야 한다. Domain은 영속성을 모르는 POJO라는 축이 흔들리고, 그 축 위에 애그리거트 경계와 낙관적 락이 서 있다.

골랐다 — 안 B. 안 A가 포기하는 대역폭을 되찾으면서, 안 C가 무너뜨리는 축은 건드리지 않는다. 필터 하나라 사람이 기억해야 지켜지는 규칙이 생기지 않는다.

이 안이 성립하는 전제를 하나 확인했다. HttpApiResponse의 필드는 success·status·code·message·data·errors 여섯 개뿐이고 타임스탬프가 없다. 같은 데이터면 본문 바이트가 같고, 따라서 해시도 같다. 봉투에 응답 시각을 넣었다면 이 안은 처음부터 불가능했다.

@Bean
FilterRegistrationBean<ShallowEtagHeaderFilter> etagFilter() {
    FilterRegistrationBean<ShallowEtagHeaderFilter> bean =
            new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());
    bean.addUrlPatterns("/*");
    return bean;
}

이 등록이 하는 일은 응답 본문을 바꾸는 것이 아니라 헤더 하나를 더 붙이고 재요청을 가로채는 것이다. 그래서 기존 Controller와 테스트는 그대로 통과한다.

대신 대가가 둘이다. DB 조회와 직렬화는 하나도 안 줄어든다 — 절감되는 것은 전송뿐이다. 그리고 해시를 만들려면 응답 본문 전체를 메모리에 버퍼링해야 한다. 목록 조회에 size 상한이 없으면 이 비용이 그대로 커지므로, 상한을 정하는 일이 이 원칙의 전제가 된다.


원칙 12. 304는 클라이언트가 If-None-Match를 보냈을 때만 나가고, 그때만 봉투를 면제한다

정해야 했던 건 이거였다 — 봉투 예외를 여는 것이 기존 응답 계약을 깨는가.

원칙 2에서 204를 버린 근거는 “클라이언트가 상태에 따라 JSON 파싱 여부부터 나눠야 한다”였다. 같은 근거를 304에 대보면 걸리지 않는다. 이유가 둘이다.

첫째, 304는 클라이언트가 자청해야 온다. If-None-Match를 보내지 않는 호출자에게는 언제나 200과 봉투가 간다. 서버 대 서버 호출자를 포함해 지금 있는 클라이언트의 계약은 한 글자도 바뀌지 않는다.

둘째, 브라우저의 fetch·XHR는 조건부 요청을 자기가 알아서 보내고 304를 받으면 캐시에 있던 200 본문을 돌려준다. JS 코드는 304를 보지 못한다. 204가 만들던 파싱 분기가 여기서는 생기지 않는다.

안 A — 봉투를 지키려고 304 대신 200에 “변경 없음” 플래그를 넣는다. 봉투 불변식이 예외 없이 유지되는 것이 이 안의 장점이다. 버린 이유는 본문을 어차피 다 보내므로 절감이 0이라는 것이다. 게다가 그 플래그는 우리만 아는 약속이라 브라우저 캐시도 프록시도 이해하지 못한다. HTTP가 이미 가진 기능을 흉내만 낸 셈이 된다.

안 B — 304를 봉투 규칙의 유일한 예외로 명시한다.

골랐다 — 안 B. 안 A가 얻는 것은 문서상의 일관성뿐이고, 실제로 절감되는 것은 없다. 대신 “모든 응답은 봉투를 갖는다”가 “클라이언트가 조건부 요청을 보내지 않는 한 모든 응답은 봉투를 갖는다”로 약해진다. 이 약화를 감수한다.

ETag만 붙이면 조건부 요청이 일어나지 않을 수 있다. 브라우저가 응답을 저장해두고 쓰기 전에 반드시 되묻게 만들어야 한다. 그 지시가 no-cache다 — 이름과 달리 “저장하지 마라”가 아니라 “재검증 없이 쓰지 마라”다. 개인화된 조회 응답이 공유 캐시에 남지 않도록 private을 함께 붙인다.

GET /orders/42
→ 200 OK
  ETag: "3f9a2c"
  Cache-Control: private, no-cache
  { "success": true, "status": 200, "code": 200000000, "data": { ... } }

GET /orders/42
  If-None-Match: "3f9a2c"          ← 브라우저가 자동으로 붙인다
→ 304 Not Modified                  ← 본문 없음. 봉투가 면제되는 유일한 자리
  ETag: "3f9a2c"

이 왕복이 보여주는 것은 “조회는 304를 쓴다”가 아니라 봉투가 사라지는 조건이 요청에 적혀 있다는 것이다. 요청에 If-None-Match가 없으면 아래쪽 응답은 존재하지 않는다.

규칙은 셋이다.

  • ETagGET의 2xx 응답에만 붙인다. 쓰기 응답과 오류 응답에는 붙이지 않는다.
  • 조회 응답에는 Cache-Control: private, no-cache를 함께 내린다. Controller마다 붙이지 않는다ETag와 같은 이유로 중앙에서 한 번에 붙인다. 여기서 Controller에 손을 대면 원칙 11이 피한 “사람이 기억해야 지켜지는 규칙”이 도로 생긴다.
  • 원칙 10의 MockMvc 계약에 한 줄을 더한다 — 첫 요청의 ETag를 그대로 If-None-Match로 되보내면 304가 오고 본문이 비어 있다. 필터의 기본 동작에 기대지 않고 이 계약을 테스트로 고정한다.

판단 기준 정리

질문 결론
성공 상태는 무엇으로 고르나 실제 처리 결과 200·201·202
반환 데이터가 없으면 공통 봉투 유지 200 + HttpApiResponse<Void>
생성 결과는 식별자와 조회 경로 XxxCreatedResponse + Location
비동기 접수는 추적 가능할 때만 operationId + Location
HTTP 상태는 누가 번역하나 Controller Service·Domain은 HTTP를 모른다
Controller 반환 타입은 상태와 본문을 함께 운반 ResponseEntity<HttpApiResponse<T>>
성공 코드는 상태에서 계산 status * 1_000_000
본문 status 유지 실제 상태·코드 앞자리와 일치
성공 생성 경로는 상태별 팩토리 ok·created·accepted
Location 형식은 현재 origin 기준 루트 상대 URI
조회 재검증 이름표는 완성된 본문의 해시 얕은 ETag 필터
봉투가 면제되는 곳은 304 하나뿐 If-None-Match가 온 요청에만
무엇으로 증명하나 HTTP 경계 테스트 MockMvc

이 표준을 정하기까지 — status 인자 하나에서 HTTP 경계의 책임까지 갔다

시작은 사용처 없는 success(int status, data)였다. 생성이 200인지 201인지, 삭제가 204인지 아무 규칙이 없었다. HTTP 메서드로 외우는 안을 검토했지만 POST가 항상 생성은 아니라서 실제 처리 결과를 기준으로 골랐다.

204를 검토하며 공통 봉투와 HTTP의 정교함이 충돌했다. 본문 없는 응답 하나 때문에 클라이언트 파싱 경로가 갈라지는 비용이 더 크다고 보고 데이터 없는 동기 성공도 200 봉투로 남겼다.

201과 202에는 다음 행동을 찾을 경로를 붙였다. 생성은 식별자와 조회 URI, 비동기 접수는 작업 식별자와 상태 조회 URI를 반환한다. 추적할 수 없는 fire-and-forget에는 202를 쓰지 않는다.

실제 HTTP 상태는 Controller 책임이라는 경계가 드러났다. Service와 Domain은 업무 결과만 만들고 Controller가 ResponseEntity로 번역한다. 본문의 status만 바꾸던 기존 팩토리로는 실제 상태를 보장하지 못해서 모든 Controller 반환 타입을 통일했다.

성공 코드를 고정된 200000000으로 둘 뻔하다가 최초 의도를 복원했다. 코드 앞 세 자리는 HTTP 상태 역할도 한다. 그래서 201과 202는 201000000, 202000000이고 status와 실제 응답도 함께 맞춘다.

실패 코드의 조립 방향도 다시 확인했다. 통짜 코드를 적고 상태를 나누는 안은 이미 검토하고 버린 안이었다. 상태·도메인·일련번호를 원본으로 두면 9자리 입력 실수를 줄인다. 성공 코드도 같은 방향으로 상태에서 계산한다.

마지막에는 생성 구조만 믿지 않고 MockMvc를 증거로 남겼다. 실제 상태와 JSON 본문, Location은 HTTP 경계를 통과해야 함께 검증된다.

하루 뒤에 이 표준이 조회 캐싱을 통째로 막고 있다는 걸 알았다. 브라우저에서 DB까지 조회 한 사이클을 짚다가 나왔다. 204를 버리며 세운 “모든 응답은 봉투를 갖는다”가 304에도 그대로 걸린다 — 304는 본문이 금지라 봉투를 씌울 수 없다. 버린 것은 204 하나인 줄 알았는데 조건부 GET까지 같이 버려져 있었다.

204를 죽인 근거를 304에 대보니 걸리지 않았다. 근거는 “클라이언트가 파싱을 분기해야 한다”였는데, 304If-None-Match를 보낸 클라이언트에게만 가고 브라우저는 그걸 투명하게 처리해 캐시된 본문을 돌려준다. 같은 “본문 없는 응답”인데 클라이언트가 치르는 비용이 달랐다. 하나의 규칙으로 묶여 있던 둘이 여기서 갈라졌다.

그다음 막힌 곳은 봉투가 아니라 이름표의 재료였다. 조회 전에 “안 바뀌었다”를 판정하려면 version이나 updatedAt이 필요한데, 이미 정해둔 두 표준이 그걸 Entity 안에 가둬놨다. 그래서 선택이 “봉투를 고칠까”에서 “어느 층에서 이름표를 만들까” 로 옮겨갔고, 축을 지키는 쪽인 본문 해시를 골랐다.

얕은 ETag가 성립하는지는 봉투의 필드를 세어보고 확인했다. 여섯 개 전부 데이터에서 나오고 타임스탬프가 없다. 봉투를 설계할 때 응답 시각을 넣었다면 지금 이 선택지는 존재하지 않았다. 의도하지 않은 자리에서 과거의 결정이 미래의 선택지를 열어준 경우다.


정리

  • 처리 결과가 200·201·202를 가르고 204는 사용하지 않는다.
  • 생성은 식별자와 상대 Location, 비동기 접수는 추적 식별자와 상태 조회 Location을 반환한다.
  • 모든 Controller는 ResponseEntity<HttpApiResponse<T>>를 반환한다.
  • 실제 HTTP 상태, 본문 status, 코드 앞 세 자리는 항상 같다.
  • 성공 코드는 상태에서 계산하고 ok·created·accepted 팩토리로만 만든다.
  • 조회 재검증은 완성된 본문을 해시하는 얕은 ETag로 하고 Cache-Control: private, no-cache를 함께 내린다.
  • 봉투가 면제되는 응답은 304 하나뿐이고, 그것도 If-None-Match를 보낸 요청에만 나간다.
  • MockMvc로 실제 상태와 본문·헤더를 함께 검증한다.

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

Claude Code — SKILL.md

---
name: http-success-response-standard
description: 성공 HTTP 상태, 응답 코드, 공통 봉투, Location 헤더와 조회 재검증(ETag·304)을 구성하는 규칙
---

- HTTP 메서드가 아니라 실제 처리 결과의 의미로 성공 상태를 정한다.
- 일반 조회·수정·동기 명령 완료는 `200 OK`를 사용한다.
- 새 리소스 생성은 `201 Created`를 사용하고 생성 식별자를 담은 `XxxCreatedResponse`와 루트 기준 상대 `Location`을 반환한다.
- 추적 가능한 비동기 작업 접수만 `202 Accepted`를 사용하고 `operationId`와 작업 상태 조회 `Location`을 반환한다.
- 추적할 수 없는 fire-and-forget 성공 API를 만들지 않는다.
- `204 No Content`를 사용하지 않는다. 반환 데이터가 없어도 `200 OK``HttpApiResponse<Void>` 봉투를 반환한다.
- `304 Not Modified`는 클라이언트가 `If-None-Match`를 보낸 요청에만 반환한다. 공통 봉투가 면제되는 유일한 응답이다.
- `ETag``ShallowEtagHeaderFilter`를 전역 등록해 만든다. Controller에서 직접 만들지 않는다.
- `ETag``GET`의 2xx 응답에만 붙인다. 쓰기 응답과 오류 응답에는 붙이지 않는다.
- 조회 응답에는 `Cache-Control: private, no-cache`를 함께 내린다.
- `ETag` 재료를 얻으려고 `@Version`이나 `updatedAt``XxxDomain`·`XxxView`로 올리지 않는다.
- 모든 Controller 반환 타입은 `ResponseEntity<HttpApiResponse<T>>`다. Service와 Domain에 `HttpStatus`, `ResponseEntity`, URI를 전달하지 않는다.
- `HttpApiResponse``ok`, `created`, `accepted` 팩토리를 제공하고 `success(int status, T data)`를 제공하지 않는다.
- 성공 팩토리는 각각 `OK`, `Created`, `Accepted` 메시지를 사용한다. 업무별 성공 메시지를 받지 않는다.
- 성공 코드는 `status * 1_000_000`으로 계산한다. 숫자 리터럴로 직접 적지 않는다.
- 실제 HTTP 상태, 본문 `status`, `code / 1_000_000`은 항상 같아야 한다.
- 실패 코드는 기존처럼 `status`, `DomainCode`, `serial`을 원본으로 받아 조립한다. 9자리 코드를 리터럴로 적지 않는다.
- MockMvc 계약 테스트로 `200`, `201`, `202`, 데이터 없는 성공, 실패의 세 상태 일치와 `Location`을 검증한다.
- MockMvc로 첫 응답의 `ETag``If-None-Match`로 되보내 `304`와 빈 본문을 검증한다.

GitHub Copilot — instructions.md

---
description: 성공 HTTP 상태와 HttpApiResponse 봉투를 일치시키고 조회 재검증을 구성하는 규칙
applyTo: "**/*Controller.java, **/HttpApiResponse.java, **/*ControllerTest.java, **/*Config.java"
---

- HTTP 메서드가 아니라 실제 처리 결과의 의미로 성공 상태를 정한다.
- 조회·수정·동기 명령 완료는 `200 OK`, 새 리소스 생성은 `201 Created`, 추적 가능한 비동기 접수는 `202 Accepted`를 사용한다.
- `204 No Content`를 사용하지 않는다. 반환 데이터가 없어도 `200 OK``HttpApiResponse<Void>`를 반환한다.
- `304 Not Modified`는 클라이언트가 `If-None-Match`를 보낸 요청에만 반환한다. 공통 봉투가 면제되는 유일한 응답이다.
- `ETag``ShallowEtagHeaderFilter`를 전역 등록해 만들고 `GET`의 2xx 응답에만 붙인다. Controller에서 직접 만들지 않는다.
- 조회 응답에는 `Cache-Control: private, no-cache`를 함께 내린다.
- `ETag` 재료를 얻으려고 `@Version`이나 `updatedAt``XxxDomain`·`XxxView`로 올리지 않는다.
- 모든 Controller 반환 타입은 `ResponseEntity<HttpApiResponse<T>>`다.
- Service와 Domain에 `HttpStatus`, `ResponseEntity`, URI를 전달하지 않는다.
- 생성은 식별자를 담은 `XxxCreatedResponse`와 루트 기준 상대 `Location`을 반환한다.
- 비동기 접수는 `operationId`와 작업 상태 조회 `Location`을 반환한다. 추적할 수 없는 fire-and-forget 성공 API를 만들지 않는다.
- `HttpApiResponse``ok`, `created`, `accepted` 팩토리만 공개한다. `success(int status, T data)`를 공개하지 않는다.
- 성공 코드는 `status * 1_000_000`으로 계산하고 메시지는 각각 `OK`, `Created`, `Accepted`로 고정한다.
- 실제 HTTP 상태, 본문 `status`, `code / 1_000_000`을 항상 같게 만든다.
- MockMvc로 200·201·202, 데이터 없는 성공, 실패의 상태·코드·메시지와 `Location`을 검증한다.
- MockMvc로 첫 응답의 `ETag``If-None-Match`로 되보내 `304`와 빈 본문을 검증한다.

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

댓글남기기