HTTP는 브라우저와 서버가 요청·응답을 주고받는 규약이고, 그 응답 맨 앞에는 성공·실패를 알려주는 상태 코드가 붙는다. 그 코드 뒤에 실제 데이터가 담기는 자리가 본문이다.
예외 처리 표준의 HttpApiResponse에는 success(int status, T data)가 있었다. 여기서 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 OK와 HttpApiResponse<Void>를 반환한다. 삭제, 상태 변경, 비밀번호 변경처럼 결과 데이터가 없어도 성공·실패가 같은 봉투를 유지한다. 대신 204의 더 구체적인 의미를 포기한다.
골랐다 — 안 B. 안 A가 깨는 공통 응답 계약을 지킨다. 데이터가 없다는 사실은 data: null로 표현하고 errors는 빈 배열로 내린다.
이 판단은 서버가 스스로 본문을 생략하는 경우에만 해당한다. 클라이언트가 조건부 요청(이전에 받은 응답이 아직 그대로인지 서버에 되물어, 그대로면 본문 없이 끝내는 요청)으로 본문을 거절하는 304는 근거가 달라서 원칙 12에서 따로 갈랐다.
{
"success": true,
"status": 200,
"code": 200000000,
"message": "OK",
"data": null,
"errors": []
}
한 줄씩 따라가기. 이 중괄호 표기는 JSON 형식으로 적은 응답 본문이다. success는 요청이 성공했는지를 참/거짓으로 담고, status는 실제 HTTP 상태와 같은 숫자를 담는다. code는 그 상태에서 계산한 9자리 코드이고, message는 사람이 읽는 설명이다. 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);
한 줄씩 따라가기. OrderCreatedResponse.from(result)는 Service가 돌려준 결과에서 생성된 주문의 식별자만 꺼내 응답 모양으로 만든다. HttpApiResponse.created(data)는 그 데이터를 201 Created용 봉투로 감싼다. URI.create(...)는 문자열 주소를 URI 객체로 만든다 — 방금 만들어진 주문을 조회할 수 있는 경로다. 마지막 return 문은 ResponseEntity를 만드는데, .status(...)로 실제 HTTP 상태를 201로 정하고, .location(...)으로 응답 헤더에 그 URI를 Location으로 싣고, .body(...)로 본문에 봉투를 담아 한 번에 반환한다.
URI는 Controller가 조립한다. Service는 orderId만 반환하며 조회 경로를 알지 않는다.
2026-10-21부터 이 코드는 Controller에 쓰지 않는다. 봉투를 만들고 ResponseEntity를 채우는 일은 헬퍼 HttpApiResponses.created(location, data) 한 줄로 바뀌었다. 왜 옮겼는지는 원칙 13에 있다. 위 코드는 옮기기 전 모양이다.
원칙 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
한 줄씩 따라가기. 이 블록은 요청이 아니라 서버가 돌려준 응답이다. 첫 줄 HTTP/1.1 202 Accepted가 실제 HTTP 상태이고, 그 아래 Location 헤더가 이 작업의 진행 상태를 확인할 수 있는 주소를 알려준다.
본문에는 operationId를 담은 XxxAcceptedResponse를 반환한다.
원칙 5. 모든 Controller는 ResponseEntity<HttpApiResponse<T>>를 반환한다
안 A — 201과 202에서만 ResponseEntity를 사용한다. 200 조회는 짧지만 Controller 반환 타입과 실제 상태 설정 방식이 엔드포인트마다 달라진다.
안 B — 모든 Controller가 ResponseEntity<HttpApiResponse<T>>를 반환한다. 실제 HTTP 상태와 본문 상태를 항상 같은 코드 경로에서 맞춘다. 대신 단순 조회도 반환 코드가 조금 길어진다.
안 C — HttpApiResponse가 ResponseEntity까지 만든다. 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 상태를 설정한다. 별도의 숫자를 다시 적지 않는다.
반환 타입은 지금도 그대로다. 다만 이 조립을 누가 하느냐는 2026-10-21에 바뀌었다 — Controller가 아니라 헬퍼 HttpApiResponses가 한다(원칙 13). 안 C를 버린 이유인 “공통 응답 모델이 Spring Web을 알게 된다”는 헬퍼를 HttpApiResponse와 다른 클래스로 두어 피했다.
원칙 6. 실제 HTTP 상태와 본문 status, 코드 앞 세 자리를 같게 유지한다
정해야 했던 건 본문의 status를 없애 중복을 제거할지였다.
안 A — status를 유지한다. 클라이언트가 본문만 봐도 상태를 명시적으로 읽는다. 대신 실제 상태, 본문 상태, 코드 앞자리 세 값을 맞춰야 한다.
안 B — status를 제거한다. 실제 HTTP 상태와 코드 앞자리만 남아 출처가 줄어든다. 하지만 기존 클라이언트 계약이 깨지고 본문 가독성이 떨어진다.
골랐다 — 안 A. 명시적인 응답 계약을 유지하는 대신 중복을 감수한다. 그 대가는 생성 구조와 MockMvc 테스트로 통제한다.
모든 응답은 다음 불변식을 지킨다.
실제 HTTP 상태 == body.status == body.code / 1_000_000
한 줄씩 따라가기. 이 식은 세 값이 항상 같아야 한다는 규칙을 적은 것이다. 1_000_000은 자바에서 숫자 1000000을 읽기 쉽게 적는 표기로, 밑줄은 숫자 계산에 아무 영향을 주지 않고 사람이 자릿수를 세기 쉽게 끊어 주는 역할만 한다. 즉 body.code / 1_000_000은 code를 백만으로 나눈 몫이고, 이게 곧 코드 앞 세 자리다.
성공은 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());
}
한 줄씩 따라가면 — ok(T data)와 ok()는 이름이 같지만 매개변수가 다른 메서드 오버로딩이다. 자바는 이름이 같아도 매개변수 개수나 타입이 다르면 서로 다른 메서드로 구분하므로, 돌려줄 데이터가 있으면 ok(data)를, 없으면 ok()를 호출해서 쓰는 쪽에서 상황에 맞는 쪽을 고르게 한다. created·accepted도 각자 상태 코드(201·202)와 메시지를 고정해 넘기는 success를 호출할 뿐이고, success는 status에 1_000_000을 곱해 코드 앞자리를 만든 뒤 실제 응답 객체를 만들어 돌려준다.
외부에 공개된 success(int status, T data)는 없앤다. 업무별 성공 메시지도 받지 않는다. 업무 결과는 data가 표현하고 message는 HTTP 결과만 나타낸다.
2026-10-21에 이 팩토리들은 HttpApiResponse에서 헬퍼 HttpApiResponses로 옮겨 갔다(원칙 13). 상태별 팩토리가 코드와 메시지를 함께 만든다는 이 원칙은 그대로이고, 팩토리가 사는 클래스만 바뀌었다. 위 코드는 옮기기 전 모양이다.
원칙 8. 실패 코드는 기존처럼 상태·도메인·일련번호에서 조립한다
성공 코드의 앞자리를 상태와 맞추면서 실패 코드의 파생 방향도 다시 확인했다.
안 A — 409001001을 직접 적고 상태를 나눈다. 로그 숫자로 소스를 바로 찾을 수 있지만 9자리의 도메인과 일련번호를 사람이 정확히 세어 입력해야 한다.
안 B — 상태·DomainCode·일련번호를 원본으로 두고 코드를 조립한다. STOCK_SHORTAGE(409, DomainCode.ORDER, 1, ...)에서 409001001을 계산한다(괄호 안의 값들은 자바 enum이 상수를 만들 때 그 뒤에 정의된 생성자를 호출해 넘기는 인자들이다 — 409는 상태, DomainCode.ORDER는 도메인, 1은 일련번호다).
골랐다 — 안 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,message가200,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가 관리하게 했다. 이 안을 택하면 두 표준을 다시 열고 XxxDomain과 XxxView에 영속성 개념을 올려야 한다. Domain은 영속성을 모르는 POJO라는 축이 흔들리고, 그 축 위에 애그리거트 경계와 낙관적 락이 서 있다.
골랐다 — 안 B. 안 A가 포기하는 대역폭을 되찾으면서, 안 C가 무너뜨리는 축은 건드리지 않는다. 필터 하나라 사람이 기억해야 지켜지는 규칙이 생기지 않는다.
안 C가 버린 것은 이름표를 만들려고 version을 XxxDomain·XxxView로 올리는 일이다. 수정 화면이 있는 것의 조회 응답에 version을 싣는 경우는 따로 있다(2026-10-21). 두 사람이 같은 것을 동시에 고칠 때 뒤에 저장한 쪽이 앞사람의 수정을 덮어쓰지 않게 막는 것이 목적이라, 이름표와는 목적이 다르다. 그때 version을 싣는 것은 XxxView다. 조회 SQL(또는 프로젝션)이 version 컬럼을 같이 읽어 XxxView에 담고, XxxResponse가 그 값을 그대로 내보낸다. 수정 요청으로 돌아올 때는 XxxCommand가 든다. 나갈 때는 XxxView, 돌아올 때는 XxxCommand라서 Domain은 끝까지 version을 모른다. 위 안 C에서 「XxxView로 올리면 축이 흔들린다」고 한 것은 ETag 재료를 얻으려 할 때의 말이라 이것과 부딪히지 않는다. 이 왕복의 자세한 모양은 Domain 영속화 표준 원칙 5-1이 맡는다.
이 안이 성립하는 전제를 하나 확인했다. HttpApiResponse의 필드는 success·status·code·message·data·errors 여섯 개뿐이고 타임스탬프가 없다. 같은 데이터면 본문 바이트가 같고, 따라서 해시도 같다. 봉투에 응답 시각을 넣었다면 이 안은 처음부터 불가능했다.
이 전제는 2026-10-21까지 글에만 있고 규칙 파일에는 없었다. 코드를 쓰는 에이전트는 이 글을 읽지 않는다. 지켜야 할 것만 짧게 적어 둔 규칙 파일(coding-standards/.claude/rules/)을 읽고 코드를 쓴다. 그러니 규칙 파일에 없는 전제는 에이전트에게 없는 것과 같고, 봉투에 요청마다 바뀌는 값을 넣는 것을 막을 줄이 없었다. 실제로 열린 안건을 정리하며 한 프로젝트 코드를 대조하다가, 그 프로젝트의 HttpApiResponse에 처리 소요 시간 필드 elapsedMs가 들어 있는 것을 찾았다. 그 프로젝트는 아직 필터를 등록하지 않아서 드러나지 않았을 뿐이다. 그 상태로 필터를 켜면 데이터가 같아도 elapsedMs가 요청마다 달라 해시가 매번 다르다. 304는 한 번도 나가지 않는데 에러도 경고도 없다.
그래서 전제를 규칙으로 올렸다. 공통 봉투에는 요청마다 바뀌는 값(소요 시간·응답 시각 등)을 넣지 않는다. 소요 시간이 필요하면 본문이 아니라 Server-Timing이라는 응답 헤더로 보낸다. 필터는 본문만 해시하므로 헤더에 든 값은 이름표를 흔들지 않는다. ErrorCode 계약 표준 원칙 8이 추적 키 traceId를 봉투 필드로 넣지 않고 X-Trace-Id 헤더로 뺀 것과 같은 방식이다. 그 프로젝트의 봉투에서 elapsedMs를 빼는 일은 그 프로젝트가 맡는다.
@Bean
FilterRegistrationBean<ShallowEtagHeaderFilter> etagFilter() {
FilterRegistrationBean<ShallowEtagHeaderFilter> bean =
new FilterRegistrationBean<>(new ShallowEtagHeaderFilter());
bean.addUrlPatterns("/*");
return bean;
}
한 줄씩 따라가기. @Bean은 이 메서드가 만든 객체를 스프링이 관리하도록 등록하라는 표시다. FilterRegistrationBean<ShallowEtagHeaderFilter>는 스프링이 제공하는 ShallowEtagHeaderFilter를 애플리케이션의 요청 처리 과정에 끼워 넣기 위한 포장 객체다. new FilterRegistrationBean<>(new ShallowEtagHeaderFilter())로 그 필터를 만들고, addUrlPatterns("/*")로 모든 경로에 적용하도록 지정한다. 이 메서드가 돌려주는 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가 없으면 아래쪽 응답은 존재하지 않는다.
규칙은 셋이다.
ETag는GET의 2xx 응답에만 붙인다. 쓰기 응답과 오류 응답에는 붙이지 않는다.- 조회 응답에는
Cache-Control: private, no-cache를 함께 내린다. Controller마다 붙이지 않는다 —ETag와 같은 이유로 중앙에서 한 번에 붙인다. 여기서 Controller에 손을 대면 원칙 11이 피한 “사람이 기억해야 지켜지는 규칙”이 도로 생긴다. - 원칙 10의 MockMvc 계약에 한 줄을 더한다 — 첫 요청의
ETag를 그대로If-None-Match로 되보내면304가 오고 본문이 비어 있다. 필터의 기본 동작에 기대지 않고 이 계약을 테스트로 고정한다.
원칙 13. Controller는 ResponseEntity를 직접 조립하지 않고 HttpApiResponses 헬퍼로 반환한다
원칙 5는 반환 타입을 ResponseEntity<HttpApiResponse<T>> 하나로 맞췄다. 그런데 그 상자를 채우는 일은 Controller마다 손으로 했다. 2026-10-21에 그 프로젝트를 세어 보니 Controller 37개에 ResponseEntity.ok(HttpApiResponse.ok(...))가 114곳, 201 생성이 15곳, 202 접수가 3곳이었다. 그리고 그중 두 곳이 이미 표준을 어기고 있었다.
// AdminRecoveryCodeApiController.java:70 — 201인데 Location이 없다
return ResponseEntity
.status(HttpStatus.CREATED)
.body(HttpApiResponse.created(AdminRecoveryCodeIssueResponse.from(adminRecoveryCodeUseCase.reissue(userDetails.getAdminAccountId()))));
// AdminSessionApiController.java:107 — 202인데 Location이 없다
? ResponseEntity.status(HttpStatus.ACCEPTED).body(HttpApiResponse.accepted(body))
한 줄씩 따라가기. 첫 코드는 .status(HttpStatus.CREATED)로 실제 HTTP 상태를 201로 정하고, .body(...)로 본문에 201용 봉투를 담는다. 원칙 3은 201에 Location 헤더를 반드시 붙이라고 했는데, .location(...) 한 줄이 빠져 있다. 둘째 코드도 202 접수인데 Location이 없다 — 원칙 4가 요구한 “작업 상태를 조회할 주소”가 응답에 없다. 둘 다 컴파일되고 테스트도 통과한 채 들어갔다. 빠뜨렸다는 신호가 어디서도 안 났다.
이 모양에는 구멍이 둘 있다. 하나는 Location이 기억해야만 붙는다는 것이다. 다른 하나는 원칙 5의 모양도 기억해야만 지켜진다는 것이다. 원칙 5의 코드는 response.getStatus()로 봉투에서 상태를 꺼내 쓰므로 상태를 한 번만 적는다. 그런데 그 프로젝트의 코드는 그 모양을 따르지 않았다. 위 첫 코드는 HttpStatus.CREATED와 HttpApiResponse.created(...)로 상태를 두 번 적고, 114곳의 ResponseEntity.ok(HttpApiResponse.ok(...))도 ResponseEntity.ok와 HttpApiResponse.ok로 두 번 적는다. 이렇게 적으면 ResponseEntity.ok(HttpApiResponse.created(data))라고 써도 컴파일된다. 실제 HTTP 상태는 200인데 본문 status는 201이 되어 원칙 6의 불변식이 깨진다. 지금 114곳은 두 번 적은 값이 서로 맞아 있을 뿐이다. 상태를 두 번 적는 코드는 원칙 5를 Controller마다 손으로 지키게 둔 결과의 실물이다.
정해야 했던 건 이거였다 — Controller 132곳이 손으로 하는 조립을 어디로 옮길 것인가.
2026-08-04에 Controller가 몇 개 없을 때 이 질문을 한 번 끝까지 좁혔다. 안이 다섯이었다. 안 이름은 그때 장부의 번호를 그대로 썼다 — 안 C가 비어 있는 까닭은 기록에 남지 않았다.
안 A — 지금처럼 Controller가 조립한다. 얻는 것은 엔드포인트 하나만 읽어도 상태·헤더·봉투가 그 자리에 다 보인다는 것이다. 버린 이유는 위의 두 구멍이 조립하는 자리 수만큼 열려 있다는 것이다. 132곳 중 2곳에서 Location이 이미 샜고, 막아 줄 장치는 사람의 기억뿐이다.
안 B-1 — 헬퍼가 HttpApiResponse의 팩토리를 감싼다. 봉투 클래스는 그대로 두고 헬퍼가 HttpApiResponse.created(data)를 불러 ResponseEntity에 담는다. 버린 이유는 팩토리가 두 벌이 된다는 것이다. 성공 상태를 하나 추가할 때 두 파일을 고쳐야 한다.
안 B-2 — 헬퍼가 완성된 봉투를 받는다. HttpApiResponses.of(HttpApiResponse.created(data))처럼 봉투를 받아 상태를 꺼내 담는다. 팩토리는 한 벌이다. 버린 이유는 location을 안 넘겨도 컴파일된다는 것이다. 원칙 3이 여전히 사람의 기억에 남는다.
안 B-3 — 성공 팩토리를 헬퍼로 옮긴다. ok·created·accepted가 HttpApiResponse에서 헬퍼 HttpApiResponses로 이사한다. 팩토리는 한 벌이고, created·accepted의 location을 필수 인자로 만들 수 있다.
안 D — HttpApiResponse가 ResponseEntity까지 만든다. 원칙 5에서 안 C로 이미 버린 안이다. 공통 응답 모델이 스프링 웹과 HTTP 헤더를 알게 된다.
그때 안 B-3까지 골라 놓고 접었다. 접은 이유는 둘이었다. 첫째, 헬퍼 이름으로 잡은 ApiResponses가 Swagger 애노테이션 @ApiResponses와 같은 파일에서 겹쳤다. Controller마다 둘 중 하나를 패키지 이름까지 붙여 적어야 했다. 둘째, 조립하는 곳이 아직 몇 군데 없었다. 다시 열 조건은 “복사본이 쌓이면”이었다.
2026-10-21에 둘 다 풀렸다. 이름은 앞에 Http를 붙여 HttpApiResponses로 지으면 겹치지 않는다. 조립하는 곳은 위에서 센 대로 132곳이 됐고, 그중 두 곳이 실제로 샜다.
// HttpApiResponse와 같은 패키지에 둔다. HttpApiResponse의 생성자는 package-private이다.
public final class HttpApiResponses {
private HttpApiResponses() {
}
public static <T> ResponseEntity<HttpApiResponse<T>> ok(T data) {
HttpApiResponse<T> body = success(200, "OK", data);
return ResponseEntity.status(body.getStatus()).body(body);
}
public static ResponseEntity<HttpApiResponse<Void>> ok() {
HttpApiResponse<Void> body = success(200, "OK", null);
return ResponseEntity.status(body.getStatus()).body(body);
}
public static <T> ResponseEntity<HttpApiResponse<T>> created(URI location, T data) {
Objects.requireNonNull(location, "201 Created에는 Location이 필요하다");
HttpApiResponse<T> body = success(201, "Created", data);
return ResponseEntity.status(body.getStatus()).location(location).body(body);
}
public static <T> ResponseEntity<HttpApiResponse<T>> accepted(URI location, T data) {
Objects.requireNonNull(location, "202 Accepted에는 Location이 필요하다");
HttpApiResponse<T> body = success(202, "Accepted", data);
return ResponseEntity.status(body.getStatus()).location(location).body(body);
}
private static <T> HttpApiResponse<T> success(int status, String message, T data) {
return new HttpApiResponse<>(true, status, status * 1_000_000, message, data, List.of());
}
}
한 줄씩 따라가기. 맨 위 주석이 먼저다. 이 헬퍼는 HttpApiResponse와 같은 패키지에 두고, HttpApiResponse의 생성자에는 접근 제어자를 붙이지 않는다(package-private). 그러면 new HttpApiResponse<>(...)는 같은 패키지인 이 헬퍼 안에서만 쓸 수 있다. 다른 패키지의 Controller는 헬퍼를 거치지 않고는 봉투를 만들 수 없으므로, 상태와 코드가 어긋난 잘못된 조합을 헬퍼 밖에서는 만들 수 없다. private HttpApiResponses()는 이 클래스로 객체를 만들지 못하게 막는 생성자다 — 메서드를 부르기만 하는 도구 상자라서다.
공개 메서드 넷은 모두 ResponseEntity<HttpApiResponse<...>>를 돌려주므로 Controller는 이 결과를 그대로 return하면 된다. 각 메서드는 상태 숫자와 메시지를 한 번만 적어 success에 넘기고, success는 그 숫자 하나로 본문의 status와 9자리 code(원칙 7의 status * 1_000_000)를 만든다. 실제 HTTP 상태는 다시 적지 않고 body.getStatus()로 봉투에서 꺼낸다 — 원칙 5의 모양 그대로다. 세 값이 같은 숫자에서 나오니 어긋날 수 없다.
인자 없는 ok()는 반환 데이터가 없는 성공용이다. 원칙 2대로 200과 data: null인 HttpApiResponse<Void>를 돌려준다. 데이터가 없을 때 ok(null)은 쓰지 않는다.
created와 accepted는 URI location을 첫 번째 인자로 받는다. URI 인자를 빼먹는 것은 컴파일이 막고, null을 넘기면 헬퍼가 그 자리에서 예외로 막는다. created(null, data)처럼 null을 넘기는 코드는 컴파일이 되기 때문에 두 번째 장치가 필요하다. Objects.requireNonNull(location, "...")은 location이 null이면 그 메시지를 담은 NullPointerException을 바로 던진다. Location을 빼고 조용히 응답을 내보내는 길은 두지 않았다.
Controller는 이렇게 줄어든다.
OrderCreatedResponse data = OrderCreatedResponse.from(result);
URI location = URI.create("/orders/" + data.getOrderId());
return HttpApiResponses.created(location, data);
한 줄씩 따라가기. 첫 줄은 원칙 3과 같이 생성 식별자만 담은 응답을 만들고, 둘째 줄은 조회 주소를 만든다. 셋째 줄이 헬퍼에 둘을 넘기면, 상태 201·Location 헤더·봉투가 한 번에 채워진 ResponseEntity가 돌아온다.
골랐다 — 안 B-3. 안 A는 Location과 원칙 5의 모양을 132곳에서 손으로 지키게 두고, 안 B-1은 팩토리를 두 벌로 만들고, 안 B-2는 Location 누락을 못 막고, 안 D는 봉투가 스프링 웹을 알게 만든다. 안 B-3만 팩토리를 한 벌로 두면서, 원칙 5의 모양을 헬퍼 한 곳에 가두고 Location 누락을 컴파일과 즉시 예외로 막는다. 대신 원칙 7의 팩토리가 HttpApiResponse에서 헬퍼로 옮겨 가므로, 봉투를 만드는 코드가 봉투 클래스 밖에 산다. 그 프로젝트의 132곳을 헬퍼로 바꾸는 일은 그 프로젝트가 맡는다.
원칙 14. Controller는 UseCase 결과를 타입을 적은 지역변수로 받은 뒤 헬퍼로 반환한다
원칙 13으로 상자를 채우는 일은 헬퍼로 갔다. 남은 것은 Controller 메서드 본문이 한 번에 읽히느냐다. 원칙 13에서 본 첫 코드를 다시 보면, 마지막 줄 하나에 UseCase 호출 → 응답 변환 → 봉투 → ResponseEntity가 괄호 네 겹으로 겹쳐 있다. 그 프로젝트에서 이렇게 괄호가 네 겹 이상 겹친 return이 63곳이었다(2026-10-21). 안쪽에서 바깥으로 거꾸로 읽어야 무슨 일이 먼저 일어나는지 보인다.
정해야 했던 건 이거였다 — Controller 본문을 위에서 아래로 흐름대로 읽히게 하려면 어떤 모양으로 쓸 것인가.
여기서 검토한 안은 넷이고, 셋은 2026-08-04에 이미 버렸다. 그때 이 질문을 연 불만은 넷이었다 — 타입 이름이 길어 선언부가 화면을 먹는다, result·data라는 이름이 무엇인지 안 알려준다, OrderResponse.from(result)는 밖에서 안으로 읽히는데 흐름은 안에서 밖으로 간다, 네 단계가 세 줄에 뭉쳐 있다. 안 이름 F-1·F-3이 이 글에 없는 것은 옛 장부의 번호를 그대로 썼기 때문이다 — 그 둘이 무엇이었는지는 기록에 남지 않았다.
안 F-5 — Optional 체인으로 잇는다. Optional.of(...).map(...).map(...)처럼 이으면 읽는 방향과 단계가 보인다. 버린 이유는 셋이 깨진다는 것이다. 경계 DTO null 표준이 Optional을 “반환값의 부재”로 못 박아 뒀는데 뜻이 둘이 된다. 체인 끝에는 값을 꺼내는 orElseThrow()가 붙는데, 이것이 어느 쪽으로든 손해다. map 중간에 null이 나오면 예외가 아니라 빈 Optional이 되고, 끝의 orElseThrow()가 의도와 다른 자리에서 ErrorCode 체계 밖의 NoSuchElementException을 던진다. 반대로 null이 나올 수 없는 체인이면 그 orElseThrow()는 절대 안 터지는 죽은 코드가 된다.
안 F-4 — 실패까지 값으로 흘린다. Service가 결과를 감싸 돌려주는 그릇 ServiceResult를 성공과 실패를 둘 다 담는 값으로 만드는 안이다. 이 안은 아예 막혀 있다. Domain 영속화 표준 원칙 2가 트랜잭션 경계를 Service의 UseCase 메서드에 두었다. 그 메서드에 붙은 @Transactional은 메서드가 예외로 끝나면 롤백(작업을 전부 되돌림)하고, 정상으로 끝나면 커밋(DB에 확정)한다. 롤백은 예외가 나야만 일어난다. 실패를 예외 대신 값으로 돌려주면 실패가 그대로 커밋된다.
안 F-2 — 우리 타입에 map을 단다. map은 그릇 안의 값을 다른 타입으로 바꾼 새 그릇을 돌려주는 메서드다. 우리 결과 그릇은 셋이다 — 단건 ServiceResult, 여러 건 ServiceResults, 페이지 단위 목록 PagingResult. 단건에서는 이득이 없었다. 셋 중 PagingResult에만 map이 있는 것은 빠뜨린 게 아니라 page·size·totalCount를 함께 옮겨야 해서다. ServiceResult는 필드가 data 하나라 map을 달아도 뒤에 getData()가 붙는다.
남은 안이 지역변수(F-6)였다.
골랐다 — F-6, 타입을 적은 지역변수. UseCase 결과를 먼저 지역변수에 받고, 다음 줄에서 헬퍼로 반환한다.
// 이렇게 하지 않는다 — 안쪽부터 거꾸로 읽어야 한다
return HttpApiResponses.ok(CaregiverResponse.from(caregiverQueryUseCase.getCaregiver(caregiverNo)));
// 이렇게 한다 — 위에서 아래로 읽힌다
ServiceResult<CaregiverDomain> result = caregiverQueryUseCase.getCaregiver(caregiverNo);
return HttpApiResponses.ok(CaregiverResponse.from(result));
한 줄씩 따라가기. 아래 코드의 첫 줄은 UseCase를 불러 caregiverNo에 해당하는 한 명의 조회 결과를 받는다. Service는 결과를 CaregiverDomain 그대로가 아니라 그릇 ServiceResult에 한 겹 감싸 돌려주므로(계층별 DTO 네이밍 표준), 지역변수 result의 타입도 ServiceResult<CaregiverDomain>이라고 적는다. 둘째 줄의 CaregiverResponse.from(result)는 그 그릇에서 CaregiverDomain을 꺼내 응답 모양으로 바꾸고, 헬퍼가 그것을 봉투에 담는다. 무엇을 얻었고 그것을 무엇으로 바꿔 내보내는지가 줄 순서 그대로 읽힌다.
이 안에는 먼저 그어야 할 선이 하나 있었다 — 지역변수에 var를 쓰는가. 자바 10부터 ServiceResult<CaregiverDomain> result = ... 대신 var result = ...라고 쓰면 컴파일러가 오른쪽을 보고 타입을 알아서 정한다. 이 안건을 처음 적을 때는 var를 쓴 곳이 0건이라 급하지 않았다. 2026-10-21에 다시 세어 보니 그 프로젝트에 6건(전부 테스트 코드), farm-predict 자체 코드에 2~3건이 생겨 있었다. 선을 안 그으면 모양이 갈리기 시작한 상태였다.
var는 자바 코드 전체에서 쓰지 않는다. 테스트 코드도 같다. 이유는 셋이다.
- 독자가 주니어다. 이 저장소 표준의 독자는 자바 기초 문법만 아는 사람이다.
var result만 보고는 그 값이 Service가 돌려준ServiceResult<CaregiverDomain>인지 이미 바꾼CaregiverResponse인지 알 수 없고, 계층마다 타입을 갈라 둔 계층별 DTO 네이밍 표준의 구분이 화면에서 사라진다. - PR 화면에는 IDE의 타입 힌트가 없다. IDE에서는 마우스를 올리면 타입이 보이지만, 코드 리뷰는 대개 그 힌트가 없는 웹 화면에서 한다.
- 「여기선 되고 저기선 안 된다」를 두면 규칙만 는다. “타입이 오른쪽에 뻔히 보이면 써도 된다” 같은 예외를 두면, 무엇이 뻔한지를 매번 판단해야 한다.
var 금지 줄은 이 글이 아니라 계층별 DTO 네이밍 표준의 규칙 파일에 둔다. 그 규칙은 모든 자바 파일에 걸리고, var가 가리는 것이 바로 그 표준이 갈라 둔 계층별 타입이라서다.
대가는 기계가 이 규칙을 지켜 주지 않는다는 것이다. var는 컴파일러가 타입을 채워 넣고 나면 컴파일 결과물에 흔적이 남지 않는다. 컴파일된 클래스를 읽어 구조를 검사하는 ArchUnit으로는 var를 썼는지 못 잡는다. 소스를 읽는 도구(Checkstyle 같은 정적 분석)로 막는 길은 아직 정하지 않았다. 당분간은 규칙 문장과 리뷰로 지킨다. 지역변수 한 줄이 늘어 본문이 길어지는 것도 감수한다. 그리고 F-6에 var 금지를 더하면 이 질문을 연 첫 불만 — 타입 이름이 길어 선언부가 화면을 먹는다 — 은 풀리지 않고 오히려 커진다. 지역변수마다 ServiceResult<CaregiverDomain> 같은 긴 타입 이름을 한 번 더 적기 때문이다. 주니어 독자에게 타입이 보이는 쪽을 택하고 이 불만은 대가로 남겼다.
원칙 15. 프론트와 API를 같은 출처에 두고 백엔드에 CORS 설정을 두지 않는다
원칙 12에서 공통 봉투가 면제되는 응답은 304 하나뿐이라고 정했다. 그런데 봉투가 안 나가는 실패가 하나 더 있을 수 있었다.
브라우저는 화면이 뜬 주소와 다른 출처(origin)의 서버에 요청을 보낼 때, 조건에 따라 본 요청 전에 OPTIONS 메서드로 “이 요청을 보내도 되느냐”를 먼저 묻는다. 이 사전 질문을 preflight라고 부른다. preflight는 서버의 CORS 설정이 받아서 답하고, Controller에도, 실패를 공통 봉투로 감싸 내보내는 Advice에도 닿지 않는다. 그래서 거절되면 공통 봉투 없이 실패가 나간다. 열린 안건은 “그 실패를 봉투로 어떻게 낼 것인가, 허용할 출처는 어디서 설정할 것인가”였다.
정해야 했던 건 이거였다 — preflight 실패의 응답 모양을 정할 것인가, 아니면 preflight가 생기지 않게 할 것인가.
안 A — 백엔드에 CORS 설정을 두고 preflight 실패의 응답을 정한다. 얻는 것은 어느 출처의 화면이든 이 API를 부를 수 있다는 것이다. 버린 이유는 지금 그게 필요한 프로젝트가 없다는 것이다. 2026-10-21에 세어 보니 그 프로젝트와 farm-predict 모두 CorsConfiguration도 @CrossOrigin도 0건이다. 필요 없는 설정을 두는 순간 원칙 12의 “봉투가 면제되는 응답은 하나뿐”에 두 번째 예외가 생기고, 그 예외의 응답 모양까지 새로 정해야 한다.
안 B — 프론트와 API를 리버스 프록시로 같은 출처에 둔다. 브라우저가 보기에 화면과 API가 같은 주소 아래에 있으면 다른 출처 요청이 아니므로 preflight 자체가 없다. 그 프로젝트는 이미 이렇게 돌고 있다. 운영 문서의 nginx 설정이 화면과 같은 도메인의 /api/ 경로로 들어온 요청만 뒤쪽 API 서버로 넘긴다.
location /api/ {
proxy_pass http://127.0.0.1:8011;
}
한 줄씩 따라가기. location /api/는 주소가 /api/로 시작하는 요청을 고르는 조건이다. proxy_pass는 그 요청을 같은 서버 안의 8011번 포트에서 도는 API 서버로 넘긴다. 브라우저는 화면을 받은 주소와 같은 주소로 요청을 보내고, 그 뒤에서 nginx가 API 서버로 넘기는 것은 모른다.
골랐다 — 안 B. 안 A는 쓰지 않는 기능 때문에 봉투 예외와 그 응답 규칙을 새로 만든다. 안 B는 preflight를 없애서 “그 실패를 봉투로 어떻게 내나”라는 질문 자체를 사라지게 한다. 그래서 백엔드에 CORS 설정을 두지 않는다. 대신 이 규칙은 배치에 묶인다 — 화면과 API가 반드시 같은 프록시 뒤에 있어야 한다.
이 결정은 전제 하나 위에 선다 — 이 API를 부르는 것은 같은 출처에 둔 우리 프론트뿐이다. 다른 출처의 화면이 이 API를 부르면 브라우저는 여전히 preflight를 보내고, 백엔드에 CORS 설정이 없으니 거절은 봉투 없이 나간다. 하지만 그 화면은 이 API의 손님이 아니다. 다른 출처에서 이 API를 불러야 하는 손님(외부 공개 API 등)이 생기면 전제가 깨진 것이니 그때 다시 연다.
이 결정을 이 글에 둔 이유는 두 가지다. preflight 실패가 걸리는 곳이 원칙 12의 “봉투가 면제되는 유일한 응답”이고, 이 글의 규칙이 걸리는 경로(*Config.java·*Controller.java)가 CORS 설정과 @CrossOrigin이 놓이는 자리와 같다.
판단 기준 정리
| 질문 | 답 | 결론 |
|---|---|---|
| 성공 상태는 무엇으로 고르나 | 실제 처리 결과 | 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 필터 |
| 봉투에 소요 시간·응답 시각을 넣나 | 넣지 않는다 | 소요 시간은 Server-Timing 헤더 |
| 봉투가 면제되는 곳은 | 304 하나뿐 |
If-None-Match가 온 요청에만 |
ResponseEntity는 누가 조립하나 |
헬퍼 | HttpApiResponses.ok(data)·ok()·created(location, …)·accepted(location, …) |
| Controller 본문은 | UseCase 결과를 지역변수로 받고 반환 | 타입을 적는다. var는 쓰지 않는다 |
| CORS는 | 두지 않는다 | 리버스 프록시로 같은 출처 |
| 무엇으로 증명하나 | 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에 대보니 걸리지 않았다. 근거는 “클라이언트가 파싱을 분기해야 한다”였는데, 304는 If-None-Match를 보낸 클라이언트에게만 가고 브라우저는 그걸 투명하게 처리해 캐시된 본문을 돌려준다. 같은 “본문 없는 응답”인데 클라이언트가 치르는 비용이 달랐다. 하나의 규칙으로 묶여 있던 둘이 여기서 갈라졌다.
그다음 막힌 곳은 봉투가 아니라 이름표의 재료였다. 조회 전에 “안 바뀌었다”를 판정하려면 version이나 updatedAt이 필요한데, 이미 정해둔 두 표준이 그걸 Entity 안에 가둬놨다. 그래서 선택이 “봉투를 고칠까”에서 “어느 층에서 이름표를 만들까” 로 옮겨갔고, 축을 지키는 쪽인 본문 해시를 골랐다.
얕은 ETag가 성립하는지는 봉투의 필드를 세어보고 확인했다. 여섯 개 전부 데이터에서 나오고 타임스탬프가 없다. 봉투를 설계할 때 응답 시각을 넣었다면 지금 이 선택지는 존재하지 않았다. 의도하지 않은 자리에서 과거의 결정이 미래의 선택지를 열어준 경우다.
두 달 반 뒤(2026-10-21), 열린 안건을 그 프로젝트의 코드에 대 보다가 그 전제가 이미 깨져 있는 것을 찾았다. 그 프로젝트의 봉투에 소요 시간 elapsedMs가 있었다. 필터를 아직 안 켜서 조용했을 뿐이다. 전제가 글에만 있고 규칙 파일에 없어서 막을 줄이 없었던 것이다. 그래서 “봉투에 요청마다 바뀌는 값을 넣지 않는다”를 규칙으로 올리고, 소요 시간은 traceId 때와 같이 헤더(Server-Timing)로 뺐다.
같은 날 접어 두었던 헬퍼 안을 다시 열었다. 성공 봉투 조립을 헬퍼로 옮기는 안은 이름이 @ApiResponses와 겹치고 조립하는 곳이 몇 군데 없어서 접었었다. 세어 보니 그 프로젝트에서만 132곳이 됐고, 201과 202에서 Location을 빠뜨린 곳이 하나씩 나왔다. “쌓이면 다시 본다”던 조건이 실제로 쌓여 있었다. 이름은 Http를 붙여 겹침을 피했고, created·accepted가 URI를 필수 인자로 받게 했다. URI 인자를 빼먹는 것은 컴파일이 막고, null을 넘기면 헬퍼가 그 자리에서 예외로 막는다.
헬퍼를 정하자 Controller 본문의 모양이 따라 정해졌다. Optional 체인·실패를 값으로·우리 타입에 map은 이미 버렸고 지역변수만 남아 있었는데, 그 안은 var를 쓸지부터 정해야 했다. 안건을 처음 적을 때 0건이던 var가 두 프로젝트에 생겨 있었다. 주니어 독자, 타입 힌트 없는 PR 화면, 예외 없는 한 줄을 이유로 자바 전체에서 var를 쓰지 않기로 했다. 기계로 막는 장치는 아직 없다는 것도 같이 적었다.
마지막으로 CORS preflight 실패를 봉투로 어떻게 낼지는 묻지 않기로 했다. 두 프로젝트 모두 CORS 설정이 0건이었고, 그 프로젝트는 nginx로 화면과 API를 같은 출처에 두고 있었다. 이 API를 부르는 것이 같은 출처의 우리 프론트뿐이라면 preflight가 없고, 그 실패도 없다. 질문에 답하는 대신 질문이 생기는 배치를 지웠다. 다른 출처의 손님이 생기면 그 전제가 깨지므로 다시 연다. 304 하나뿐이던 봉투 예외도 그대로 하나로 남는다.
정리
- 처리 결과가 200·201·202를 가르고 204는 사용하지 않는다.
- 생성은 식별자와 상대 Location, 비동기 접수는 추적 식별자와 상태 조회 Location을 반환한다.
- 모든 Controller는
ResponseEntity<HttpApiResponse<T>>를 반환한다. - 실제 HTTP 상태, 본문 status, 코드 앞 세 자리는 항상 같다.
- 성공 코드는 상태에서 계산하고
ok·created·accepted팩토리로만 만든다. - 조회 재검증은 완성된 본문을 해시하는 얕은
ETag로 하고Cache-Control: private, no-cache를 함께 내린다. - 봉투에는 요청마다 바뀌는 값을 넣지 않는다. 소요 시간은
Server-Timing헤더로 보낸다 - 봉투가 면제되는 응답은
304하나뿐이고, 그것도If-None-Match를 보낸 요청에만 나간다. - Controller는
ResponseEntity를 직접 조립하지 않고HttpApiResponses로 반환한다.created·accepted는LocationURI 없이는 부를 수 없고,null이면 즉시 예외가 난다 - Controller는 UseCase 결과를 타입을 적은 지역변수로 받은 뒤 반환한다.
var는 자바 전체에서 쓰지 않는다 - 프론트와 API는 같은 출처에 두고 백엔드에 CORS 설정을 두지 않는다. 다른 출처에서 불러야 할 때 다시 연다
- MockMvc로 실제 상태와 본문·헤더를 함께 검증한다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기