URI·상태 코드·PRG
생성·비동기 접수·본문 없는 성공과 선택적 4xx의 다음 행동을 URI로 연결하고, RFC 9457 Problem Details와 303 PRG 계약을 MockMvc로 검증합니다.
HTTP 응답은 서버 내부에서 무슨 일이 있었는지만 설명하지 않습니다.
상태 코드는 처리 결과의 범주를, 응답 필드와 본문은 클라이언트가 다음에 해야 할 행동을 전달합니다. 같은 등록 서비스도 API에는 새 리소스 표현을, 브라우저 폼에는 결과를 조회할 이동 경로를 반환할 수 있습니다.
이 문서는 즉시 생성된 201 Created, 아직 끝나지 않은 202 Accepted, 본문이 없는 204 No Content, 선택적인 4xx와 303 See Other 기반 POST-Redirect-GET을 하나의 응답 계약으로 묶습니다.
RESPONSE OUTCOME · STATUS · LOCATION
처리 완료 여부와 다음 리소스가 status·Location을 함께 결정한다
먼저 처리가 끝났는지, 이어서 식별할 resource가 있는지 구분합니다. status는 처리 결과를, Location은 다음 URI-reference를 전달합니다. URI-reference는 상대 참조여도 유효합니다.
COMPLETE · PRIMARY RESOURCE
201 Created
- 요청으로 생성된 primary resource를 식별합니다.
- 이 API: 상대
Location: /api/posts/42와 생성된 representation을 함께 보냅니다. - 일반 규칙:
Location이 없다면 target URI가 primary resource를 식별할 수 있습니다.
ACCEPTED · NOT COMPLETE
202 Accepted
- 요청을 접수했을 뿐 처리는 완료되지 않았으며, 최종 완료나 성공도 보장하지 않습니다.
- representation은 현재 상태를 설명하고 status monitor를 가리키도록 하는 것이 권고됩니다.
- 이 API profile에서는 monitor에 상대
Location: /api/export-jobs/7을 선택하며, 이는 HTTP 공통 필수가 아닙니다.
COMPLETE · NO CONTENT
204 No Content
- mutation은 성공적으로 완료되어 server 상태에 반영됐습니다.
- 응답에는 추가 content가 없으므로 representation body를 보내지 않습니다.
- 성공을 알리되 새 representation을 전송할 필요가 없을 때 선택합니다.
HTML POST · FOLLOW-UP RETRIEVAL
303 See Other + Location
- HTML form의
POST처리를 완료합니다. - 결과를 조회할 URI-reference를
Location으로 돌려줍니다. - client는 그 URI에 후속
GETretrieval을 수행합니다.
API REJECTION · RFC 9457 PROBLEM DETAILS
명시적 거부는 원인에 맞는 status로 구분한다
400· request validation 실패401· 인증 자격이 없거나 유효하지 않음 +WWW-Authenticate403· 인증된 주체에게 operation 권한이 없음404· target resource를 찾을 수 없음409· request가 현재 resource 상태와 충돌함 — 모든 중복을 뜻하지 않습니다.
이 API는 거부 사유를 RFC 9457 Problem Details representation으로 전달합니다.
status code 하나만 고르는 문제가 아니다. 완료 시점, primary resource, 후속 retrieval, monitor, 거부 원인을 함께 모델링한다.
상태 코드는 다음 행동까지 계약한다
게시글 컬렉션은 /api/posts, 개별 게시글은 /api/posts/{id}로 둡니다. 공개 URI에는 데이터베이스 테이블 이름이나 Java 클래스·메서드 이름을 넣지 않습니다. 저장 기술과 코드 구조가 바뀌어도 클라이언트가 저장한 링크는 유지할 수 있어야 합니다.
201은 생성 완료를 뜻한다
201 Created는 요청이 완료되어 하나 이상의 새 리소스가 생겼다는 뜻입니다. 생성된 주 리소스는 응답에 Location이 있으면 그 값으로, 없으면 요청의 target URI로 식별됩니다. 따라서 모든 201에 Location이 보편적으로 필수인 것은 아닙니다.
그러나 컬렉션 POST /api/posts가 새 구성원 /api/posts/42를 만들었다면 target URI와 새 게시글 URI가 다릅니다. 이 게시판 API는 다음 계약을 명시적으로 선택합니다.
- 상태는
201 Created입니다. Location: /api/posts/42로 주 리소스를 식별합니다.- 본문에는 생성된 게시글 표현을 반환합니다.
Location의 문법은 URI가 아니라 URI-reference이므로 /api/posts/42 같은 상대 참조도 유효합니다. 수신자는 요청 target URI를 기준으로 이를 해석합니다. 외부 절대 URI가 제품 계약에 꼭 필요할 때만 신뢰하는 프록시 범위와 전달 헤더 처리를 먼저 고정한 뒤 스킴과 권한 정보를 복원합니다.
202는 접수이지 완료 약속이 아니다
202 Accepted는 처리를 받아들였지만 아직 완료하지 않았다는 뜻입니다. 이후 작업이 실행되지 않거나 실패할 수도 있으므로, 202를 최종 성공처럼 기록해서는 안 됩니다. HTTP 자체에는 나중에 같은 응답으로 최종 상태를 다시 보내는 기능도 없습니다.
RFC 9110은 202 표현이 현재 상태를 설명하고 상태 모니터를 가리키거나 포함하는 것이 좋다고 말합니다. 이 교재의 내보내기 API는 그 권고를 다음과 같은 애플리케이션 프로필로 구체화합니다.
POST /api/export-jobs는202 Accepted를 반환합니다.- 본문에는
state: "PENDING"과monitor: "/api/export-jobs/7"을 둡니다. - 같은 모니터 URI를
Location에도 넣는 것은 이 API가 문서화한 선택이지, 모든 202에 적용되는 HTTP 핵심 규칙이 아닙니다. - 클라이언트는 모니터를 조회하여 완료·실패·만료를 구분합니다.
204는 성공했지만 보낼 본문이 없다는 뜻이다
204 No Content는 요청을 성공적으로 처리했고 응답 본문이 없다는 뜻입니다. 204 응답은 헤더 구역 끝에서 종료되며 content나 trailer를 담을 수 없습니다. 삭제 후 클라이언트는 빈 문자열을 JSON으로 해석하려 하지 말고, 로컬 목록에서 항목을 제거하거나 필요한 표현을 다시 조회합니다.
| 결과 | 게시판 계약 | 클라이언트의 다음 행동 |
|---|---|---|
201 Created | 새 게시글 생성 완료, 이 API는 새 URI와 표현 반환 | Location 저장 또는 생성된 표현 사용 |
202 Accepted | 내보내기 작업 접수, 최종 결과는 미정 | 문서화된 monitor 조회 |
204 No Content | 삭제 완료, 본문 없음 | 로컬 상태 갱신 또는 후속 GET |
400 Bad Request | 요청 문법·바인딩·입력 계약 위반 | 문제 필드를 고쳐 새 요청 작성 |
401 Unauthorized | 유효한 인증 자격 증명 없음 | WWW-Authenticate challenge에 맞게 인증 |
403 Forbidden | 요청을 이해했지만 수행을 거부 | 권한 요청 또는 작업 중단 |
404 Not Found | 현재 표현을 찾지 못했거나 존재를 공개하지 않음 | 저장한 링크 제거 또는 URI 재탐색 |
409 Conflict | target resource의 현재 상태와 충돌 | 최신 상태를 읽고 충돌을 해결한 뒤 재제출 |
401을 반환하는 origin server는 해당 target resource에 적용되는 challenge를 하나 이상 담은 WWW-Authenticate를 보내야 합니다. 409는 단순히 “중복처럼 보이는 모든 오류”가 아니라 현재 리소스 상태와의 충돌이며, 사용자가 원인을 알아보고 해결할 수 있는 정보를 본문에 제공해야 합니다.
RFC 9457 Problem Details로 오류를 기계가 읽게 한다
상태 코드 하나에 도메인 사유와 필드 오류를 모두 압축하지 않습니다. API 오류에는 application/problem+json과 RFC 9457의 Problem Details를 사용합니다.
type은 문제 종류의 주 식별자인 URI-reference입니다. API가 소유하고 장기간 유지할 값을 사용합니다.title은 문제 종류의 짧고 안정적인 설명입니다. 개별 발생마다 바뀌는 메시지가 아닙니다.status는 이 발생에 사용한 HTTP 상태 코드입니다. 실제 응답 상태와 어긋나지 않게 합니다.detail은 이 발생을 사람이 이해하고 수정하도록 돕는 설명입니다. 클라이언트가 문자열을 파싱해 분기하지 않습니다.instance는 특정 문제 발생을 식별하는 URI-reference입니다. 아래의 결정론적 테스트 fixture는 예시 전용 URN namespace와 handler-local 순번으로urn:example:board:problem:{n}을 만들고 각 응답의 고유성을 검증합니다. 운영에서는 프로세스 재시작 뒤에도 충돌하지 않는 request·support correlation 정책으로 생성합니다.code,errors,traceId같은 extension은 애플리케이션 계약으로 정의할 수 있습니다. 클라이언트는 모르는 extension을 무시해야 합니다.
스택 트레이스, SQL, 내부 클래스 이름처럼 공격 표면이나 개인정보를 드러내는 진단 정보는 Problem Details에 넣지 않습니다. 안정적인 code와 구조화한 errors를 분기 입력으로 사용하고, 사람용 detail은 문구가 바뀔 수 있다고 가정합니다.
다음 테스트 파일은 JDK 17, JUnit 5, Jackson, Spring Framework 6.2.11의 MockMvc로 독립 컴파일할 수 있는 한 개의 완결된 compilation unit입니다. standalone MockMvc는 컨트롤러와 필요한 MVC 인프라를 직접 등록하므로, 실제 애플리케이션 설정을 검증하는 통합 테스트는 별도로 둡니다.
package board.web;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.http.converter.json.ProblemDetailJacksonMixin;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
final class ApiOutcomeContractTest {
private static final ObjectMapper JSON =
new ObjectMapper().addMixIn(
ProblemDetail.class,
ProblemDetailJacksonMixin.class);
@Test
void creation_identifies_the_primary_resource_and_representation()
throws Exception {
var fixture = fixture();
var result = fixture.mvc().perform(post("/api/posts")
.contentType(APPLICATION_JSON)
.accept(APPLICATION_JSON)
.content("""
{"title":"URI contract"}
"""))
.andReturn();
var body = body(result);
assertAll(
() -> assertEquals(
201, result.getResponse().getStatus()),
() -> assertEquals(
"/api/posts/42",
result.getResponse().getHeader(
HttpHeaders.LOCATION)),
() -> assertCompatible(
APPLICATION_JSON,
result.getResponse().getContentType()),
() -> assertEquals(42L, body.path("id").longValue()),
() -> assertEquals(
"URI contract",
body.path("title").textValue()));
}
@Test
void accepted_response_exposes_the_documented_monitor()
throws Exception {
var fixture = fixture();
var result = fixture.mvc().perform(
post("/api/export-jobs")
.accept(APPLICATION_JSON))
.andReturn();
var body = body(result);
assertAll(
() -> assertEquals(
202, result.getResponse().getStatus()),
() -> assertEquals(
"/api/export-jobs/7",
result.getResponse().getHeader(
HttpHeaders.LOCATION)),
() -> assertEquals(
"PENDING", body.path("state").textValue()),
() -> assertEquals(
"/api/export-jobs/7",
body.path("monitor").textValue()),
() -> assertEquals(
0, fixture.service().completedJobCount()));
}
@Test
void no_content_response_has_no_body() throws Exception {
var fixture = fixture();
var result = fixture.mvc().perform(
delete("/api/posts/42"))
.andReturn();
assertAll(
() -> assertEquals(
204, result.getResponse().getStatus()),
() -> assertEquals(
0,
result.getResponse()
.getContentAsByteArray().length),
() -> assertTrue(fixture.service().wasDeleted(42L)));
}
@Test
void problem_details_keep_client_actions_distinct()
throws Exception {
var fixture = fixture();
var invalid = fixture.mvc().perform(post("/api/posts")
.contentType(APPLICATION_JSON)
.accept(
APPLICATION_JSON,
MediaType.APPLICATION_PROBLEM_JSON)
.content("""
{"title":" "}
"""))
.andReturn();
var unauthenticated = fixture.mvc().perform(
get("/api/private-posts/42")
.accept(
APPLICATION_JSON,
MediaType.APPLICATION_PROBLEM_JSON))
.andReturn();
var conflict = fixture.mvc().perform(post("/api/posts")
.contentType(APPLICATION_JSON)
.accept(
APPLICATION_JSON,
MediaType.APPLICATION_PROBLEM_JSON)
.content("""
{"title":"already-used"}
"""))
.andReturn();
var invalidBody = body(invalid);
var unauthorizedBody = body(unauthenticated);
var conflictBody = body(conflict);
var instances = List.of(
invalidBody.path("instance").textValue(),
unauthorizedBody.path("instance").textValue(),
conflictBody.path("instance").textValue());
assertAll(
() -> assertEquals(
400, invalid.getResponse().getStatus()),
() -> assertCompatible(
MediaType.APPLICATION_PROBLEM_JSON,
invalid.getResponse().getContentType()),
() -> assertEquals(
"INVALID_REQUEST",
invalidBody.path("code").textValue()),
() -> assertTrue(
invalidBody.path("errors").isArray()),
() -> assertEquals(
401,
unauthenticated.getResponse().getStatus()),
() -> assertCompatible(
MediaType.APPLICATION_PROBLEM_JSON,
unauthenticated.getResponse().getContentType()),
() -> assertEquals(
"Bearer realm=\"board\"",
unauthenticated.getResponse().getHeader(
HttpHeaders.WWW_AUTHENTICATE)),
() -> assertEquals(
"AUTHENTICATION_REQUIRED",
unauthorizedBody.path("code").textValue()),
() -> assertEquals(
409, conflict.getResponse().getStatus()),
() -> assertCompatible(
MediaType.APPLICATION_PROBLEM_JSON,
conflict.getResponse().getContentType()),
() -> assertEquals(
"CURRENT_STATE_CONFLICT",
conflictBody.path("code").textValue()),
() -> assertEquals(
"https://api.example.test/problems/"
+ "current-state-conflict",
conflictBody.path("type").textValue()),
() -> assertEquals(
List.of(
"urn:example:board:problem:1",
"urn:example:board:problem:2",
"urn:example:board:problem:3"),
instances),
() -> assertEquals(
3, instances.stream().distinct().count()));
}
private static Fixture fixture() {
var service = new BoardService();
var controller = new BoardApiController(service);
var mvc = MockMvcBuilders
.standaloneSetup(controller)
.setControllerAdvice(new ApiProblemHandler())
.setMessageConverters(
new MappingJackson2HttpMessageConverter(JSON))
.build();
return new Fixture(service, mvc);
}
private static JsonNode body(MvcResult result) throws Exception {
return JSON.readTree(
result.getResponse().getContentAsByteArray());
}
private static void assertCompatible(
MediaType expected, String actual) {
assertTrue(actual != null);
assertTrue(expected.isCompatibleWith(
MediaType.parseMediaType(actual)));
}
record Fixture(BoardService service, MockMvc mvc) {
}
record CreatePostRequest(String title) {
}
record CreatePostResponse(long id, String title) {
static CreatePostResponse from(Post post) {
return new CreatePostResponse(post.id(), post.title());
}
}
record Post(long id, String title) {
}
record ExportJob(long id, String state) {
}
record ExportJobResponse(
long id, String state, String monitor) {
static ExportJobResponse from(ExportJob job) {
return new ExportJobResponse(
job.id(),
job.state(),
"/api/export-jobs/" + job.id());
}
}
public static final class BoardService {
private final AtomicLong postIds = new AtomicLong(41);
private final AtomicLong jobIds = new AtomicLong(6);
private final Map<Long, Boolean> deletions =
new java.util.concurrent.ConcurrentHashMap<>();
Post create(CreatePostRequest request) {
if (request.title() == null
|| request.title().isBlank()) {
throw new InvalidInputException(
"title", "Title must not be blank.");
}
if ("already-used".equals(request.title())) {
throw new CurrentStateConflictException(
"The collection already contains this title.");
}
return new Post(
postIds.incrementAndGet(), request.title());
}
ExportJob acceptExport() {
return new ExportJob(
jobIds.incrementAndGet(), "PENDING");
}
void delete(long id) {
deletions.put(id, true);
}
boolean wasDeleted(long id) {
return deletions.getOrDefault(id, false);
}
int completedJobCount() {
return 0;
}
}
@RestController
public static final class BoardApiController {
private final BoardService service;
public BoardApiController(BoardService service) {
this.service = service;
}
@PostMapping(
path = "/api/posts",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CreatePostResponse> create(
@RequestBody CreatePostRequest request) {
var post = service.create(request);
return ResponseEntity
.created(URI.create(
"/api/posts/" + post.id()))
.body(CreatePostResponse.from(post));
}
@PostMapping(
path = "/api/export-jobs",
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ExportJobResponse> export() {
var job = service.acceptExport();
var monitor = URI.create(
"/api/export-jobs/" + job.id());
return ResponseEntity
.accepted()
.location(monitor)
.body(ExportJobResponse.from(job));
}
@DeleteMapping("/api/posts/{id}")
public ResponseEntity<Void> delete(@PathVariable long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
@GetMapping(
path = "/api/private-posts/{id}",
produces = MediaType.APPLICATION_JSON_VALUE)
public CreatePostResponse privatePost(
@PathVariable long id,
@RequestHeader(
value = HttpHeaders.AUTHORIZATION,
required = false)
String authorization) {
if (!"Bearer valid-token".equals(authorization)) {
throw new AuthenticationRequiredException();
}
return new CreatePostResponse(id, "private");
}
}
@RestControllerAdvice
public static final class ApiProblemHandler {
private final AtomicLong occurrenceIds = new AtomicLong();
@ExceptionHandler(InvalidInputException.class)
public ResponseEntity<ProblemDetail> invalidInput(
InvalidInputException exception) {
var problem = problem(
HttpStatus.BAD_REQUEST,
"https://api.example.test/problems/"
+ "invalid-request",
"Invalid request",
exception.getMessage(),
"INVALID_REQUEST");
problem.setProperty(
"errors",
List.of(Map.of(
"field", exception.field(),
"message", exception.getMessage())));
return response(HttpStatus.BAD_REQUEST, problem);
}
@ExceptionHandler(AuthenticationRequiredException.class)
public ResponseEntity<ProblemDetail> authenticationRequired() {
var status = HttpStatus.UNAUTHORIZED;
var problem = problem(
status,
"https://api.example.test/problems/"
+ "authentication-required",
"Authentication required",
"Supply valid credentials for this resource.",
"AUTHENTICATION_REQUIRED");
return ResponseEntity
.status(status)
.header(
HttpHeaders.WWW_AUTHENTICATE,
"Bearer realm=\"board\"")
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}
@ExceptionHandler(CurrentStateConflictException.class)
public ResponseEntity<ProblemDetail> currentStateConflict(
CurrentStateConflictException exception) {
var status = HttpStatus.CONFLICT;
var problem = problem(
status,
"https://api.example.test/problems/"
+ "current-state-conflict",
"Current state conflict",
exception.getMessage(),
"CURRENT_STATE_CONFLICT");
return response(status, problem);
}
private ProblemDetail problem(
HttpStatus status,
String type,
String title,
String detail,
String code) {
var problem = ProblemDetail.forStatusAndDetail(
status, detail);
problem.setType(URI.create(type));
problem.setTitle(title);
problem.setInstance(URI.create(
"urn:example:board:problem:"
+ occurrenceIds.incrementAndGet()));
problem.setProperty("code", code);
return problem;
}
private static ResponseEntity<ProblemDetail> response(
HttpStatus status, ProblemDetail problem) {
return ResponseEntity
.status(status)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}
}
static final class InvalidInputException
extends RuntimeException {
private final String field;
InvalidInputException(String field, String message) {
super(message);
this.field = field;
}
String field() {
return field;
}
}
static final class AuthenticationRequiredException
extends RuntimeException {
}
static final class CurrentStateConflictException
extends RuntimeException {
CurrentStateConflictException(String message) {
super(message);
}
}
}이 테스트는 응답의 상태만 보지 않습니다. 201의 상대 Location과 표현, 애플리케이션 프로필로 정한 202 monitor, 204의 빈 본문, 401 challenge, 409의 현재 상태 충돌, Problem Details extension의 실제 JSON 위치를 함께 고정합니다. ProblemDetailJacksonMixin을 명시했으므로 code와 errors가 별도 properties 객체가 아니라 RFC 9457 extension member로 직렬화되는지도 검증 대상이 됩니다.
303으로 POST-Redirect-GET을 만든다
브라우저가 폼 POST 응답으로 결과 HTML을 바로 받으면 주소 표시줄에 POST URI가 남습니다. 사용자가 새로고침하면 브라우저가 폼 재전송을 경고할 수 있고, 동의하면 상태 변경 요청이 다시 전달될 수 있습니다.
POST-Redirect-GET(PRG)은 상태 변경과 결과 조회를 분리합니다.
- 브라우저가
POST /posts로 폼을 전송합니다. - 서버가 등록을 한 번 수행합니다.
- 서버가
303 See Other와Location: /posts/42를 반환합니다. - 브라우저가 그 URI를
GET으로 조회합니다. - 주소 표시줄에는
/posts/42가 남고, 새로고침은 마지막 GET을 반복합니다.
POST · 303 SEE OTHER · RETRIEVAL · METHOD PRESERVATION
303 PRG는 POST 결과를 조회하고 307·308은 method를 보존한다
PRG는 상태 변경과 결과 조회를 두 요청으로 분리합니다. 결과를 보여 줄 때는 303 뒤 GET을 남기고, 원 요청을 다른 endpoint로 전달해야 할 때만 307·308의 method·content 보존을 선택합니다.
01 · POST-REDIRECT-GET
상태 변경은 POST 한 번, 주소 표시줄에는 조회 GET을 남긴다
browser → server ·
POST /postsform 입력을 보내 상태 변경을 요청합니다.
server · business commit once
등록 transaction이 한 번 commit되어
post 42가 durable state가 됩니다.server → browser ·
303 See Other이 endpoint는 빈 body와 상대
Location: /posts/42를 반환하는 방식을 선택해 원 POST의 간접 결과를 가리킵니다.browser → server ·
GET /posts/42browser가 Location을 현재 요청 URI 기준으로 해석해 결과 표현을 조회합니다.
server → browser · 결과 HTML
주소 표시줄에는
/posts/42가 남고 사용자는 등록 결과를 봅니다.refresh · 마지막
GET만 반복결과 페이지 새로고침은 상태 변경 POST를 다시 보내지 않습니다.
303 representation: 이 endpoint의 빈 body는 선택입니다. 일반적으로 HEAD가 아닌 303 응답의 representation에는 같은 Location으로 연결되는 짧은 hypertext note를 포함하는 것이 바람직합니다(RFC 9110).
02 · REDIRECT CONTRACT
status code가 후속 method와 content의 운반 계약을 고른다
301 Moved Permanently·302 Found— 역사적 호환성 때문에 user agent가 POST를 GET으로 바꿀 수 있습니다. 둘을 method 보존 계약으로 사용하지 않습니다.303 See Other— 다른 URI에서 원 요청의 간접 응답을 retrieval request로 조회합니다. HTTP 의미는 GET 또는 HEAD이며 browser PRG에서는 GET을 사용합니다.307 Temporary Redirect— automatic redirect가 원 method와 content를 바꾸면 안 됩니다. 최초 요청이 POST라면 body도 임시 endpoint로 다시 전송되어 같은 mutation을 실행할 위험이 있습니다.308 Permanent Redirect— 영구 이동에서도 원 method와 content를 보존합니다. POST body 재전송이 의도한 계약인지 확인하지 않고 단기 전환에 사용하지 않습니다.
선택 기준: 결과를 조회하게 할 때는 303, 원 요청을 그대로 전달할 때는 307 또는 308입니다.
03 · OWNERSHIP BOUNDARY
PRG는 새로고침 경계만 바꾸며 중복 실행을 보장하지 않는다
LAST-PAGE REFRESH
PRG가 피하는 문제
마지막 결과 페이지를 새로고침할 때 form POST를 재제출하는 동작만 피합니다. 주소 표시줄과 refresh의 기준 요청을 조회용 GET으로 바꾸는 browser interaction 계약입니다.
FIRST-POST AMBIGUITY
별도 방어가 소유하는 문제
최초 POST 응답 유실, network retry, 이중 클릭과 여러 tab은 여전히 같은 mutation을 다시 실행할 수 있습니다. 이 경계는 ch4-5의 idempotency 기록과 operation scope, 일회용 form token, unique constraint가 소유합니다.
결과 조회와 원 요청 전달은 다른 계약입니다. 303은 POST 결과를 GET으로 조회하게 하고, 307·308은 POST method와 body까지 보존하므로 재전송 위험을 함께 검토해야 합니다.
303 See Other의 Location은 원래 target URI와 동등한 새 이름이 아니라, 원래 요청에 대한 간접 응답을 조회할 다른 리소스를 가리킵니다. HTTP에서는 수신자가 그 URI에 GET 또는 HEAD 같은 retrieval request를 수행할 수 있고, 브라우저 PRG에서는 GET을 사용합니다.
HEAD에 대한 응답이 아니라면 303 표현에 Location과 같은 URI-reference의 짧은 hyperlink note를 넣는 것이 일반적인 권고입니다. 아래 폼 endpoint는 브라우저가 Location을 따라간다는 제품 계약에 따라 본문 없는 303을 선택하며, 빈 본문 assertion은 이 endpoint만 고정합니다.
리다이렉트 상태를 모두 “다음에는 GET”으로 외우면 메서드 보존 계약을 깨뜨립니다.
| 상태 | URI 관계 | POST 뒤 자동 리다이렉트의 메서드 |
|---|---|---|
301 Moved Permanently | 영구 이동 | 역사적 호환성 때문에 user agent가 POST를 GET으로 바꿀 수 있음 |
302 Found | 임시 이동 | 역사적 호환성 때문에 user agent가 POST를 GET으로 바꿀 수 있음 |
303 See Other | 원 요청의 간접 응답을 다른 URI에서 조회 | retrieval request, PRG에서는 GET |
307 Temporary Redirect | 임시 이동 | 원래 메서드를 바꾸면 안 됨 |
308 Permanent Redirect | 영구 이동 | 원래 메서드를 바꾸면 안 됨 |
폼 등록 결과를 GET으로 보여 주려는 의도에는 303이 가장 명확합니다. 원래 POST 본문을 다른 임시 endpoint에 그대로 전달해야 한다면 307, 영구적으로 옮기되 메서드와 본문을 보존해야 한다면 308을 검토합니다. 301과 308은 영구 이동 의미가 있으므로 단기 배포 전환에 습관적으로 사용하지 않습니다.
API의 JSON 등록과 브라우저 폼 등록은 같은 서비스 계층을 호출할 수 있지만 상호작용 계약은 분리하는 편이 단순합니다. /api/posts는 201과 JSON 표현을, /posts는 303과 HTML 조회 URI를 반환하도록 나누면 Accept 분기 하나에 두 흐름을 숨기지 않아도 됩니다.
다음 compilation unit은 폼 등록 응답과 결과 GET을 자동 추적에 의존하지 않고 각각 관찰합니다. 리다이렉트의 후속 메서드 결정은 user agent의 책임이므로 MockMvc에서는 첫 응답을 고정하고, 테스트가 Location으로 명시적인 GET을 수행합니다.
package board.web;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import java.net.URI;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.util.HtmlUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
final class PrgRedirectContractTest {
@Test
void form_post_contract_returns_303_with_an_empty_body()
throws Exception {
var fixture = fixture();
var result = submit(fixture.mvc());
assertAll(
() -> assertEquals(
303, result.getResponse().getStatus()),
() -> assertEquals(
"/posts/42",
result.getResponse().getHeader(
HttpHeaders.LOCATION)),
() -> assertEquals(
0,
result.getResponse()
.getContentAsByteArray().length,
"This endpoint contract uses an empty 303 body."),
() -> assertEquals(
1, fixture.service().registrationCount()));
}
@Test
void explicit_get_and_refresh_do_not_repeat_registration()
throws Exception {
var fixture = fixture();
var submitted = submit(fixture.mvc());
var location = submitted.getResponse().getHeader(
HttpHeaders.LOCATION);
var firstGet = fixture.mvc().perform(get(location))
.andReturn();
var refresh = fixture.mvc().perform(get(location))
.andReturn();
assertAll(
() -> assertEquals(
200, firstGet.getResponse().getStatus()),
() -> assertCompatible(
MediaType.TEXT_HTML,
firstGet.getResponse().getContentType()),
() -> assertTrue(firstGet.getResponse()
.getContentAsString()
.contains("<h1>PRG</h1>")),
() -> assertTrue(firstGet.getResponse()
.getContentAsString()
.contains("<script>alert(")),
() -> assertFalse(firstGet.getResponse()
.getContentAsString()
.contains("<script>")),
() -> assertEquals(
firstGet.getResponse().getContentAsString(),
refresh.getResponse().getContentAsString()),
() -> assertEquals(
1, fixture.service().registrationCount()));
}
@Test
void redirect_statuses_remain_distinct() throws Exception {
var fixture = fixture();
var statusCodes = new int[] {301, 302, 307, 308};
for (var statusCode : statusCodes) {
var result = fixture.mvc().perform(
post("/redirects/{status}", statusCode))
.andReturn();
assertAll(
() -> assertEquals(
statusCode,
result.getResponse().getStatus()),
() -> assertEquals(
"/posts/42",
result.getResponse().getHeader(
HttpHeaders.LOCATION)));
}
assertEquals(0, fixture.service().registrationCount());
}
private static MvcResult submit(MockMvc mvc) throws Exception {
return mvc.perform(post("/posts")
.contentType(
MediaType.APPLICATION_FORM_URLENCODED)
.param("title", "PRG")
.param(
"content",
"<script>alert('x')</script>"))
.andReturn();
}
private static Fixture fixture() {
var service = new RecordingPostService();
var mvc = MockMvcBuilders
.standaloneSetup(new PostFormController(service))
.build();
return new Fixture(service, mvc);
}
private static void assertCompatible(
MediaType expected, String actual) {
assertTrue(actual != null);
assertTrue(expected.isCompatibleWith(
MediaType.parseMediaType(actual)));
}
record Fixture(RecordingPostService service, MockMvc mvc) {
}
record Post(long id, String title, String content) {
}
public static final class PostForm {
private String title;
private String content;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
}
public static final class RecordingPostService {
private final AtomicLong ids = new AtomicLong(41);
private final AtomicInteger registrations =
new AtomicInteger();
private final AtomicReference<Post> latest =
new AtomicReference<>();
Post register(PostForm form) {
registrations.incrementAndGet();
var post = new Post(
ids.incrementAndGet(),
form.getTitle(),
form.getContent());
latest.set(post);
return post;
}
Post find(long id) {
var post = latest.get();
if (post == null || post.id() != id) {
throw new IllegalArgumentException(
"Unknown post: " + id);
}
return post;
}
int registrationCount() {
return registrations.get();
}
}
@Controller
public static final class PostFormController {
private final RecordingPostService service;
public PostFormController(RecordingPostService service) {
this.service = service;
}
@PostMapping(
path = "/posts",
consumes =
MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<Void> register(
@ModelAttribute PostForm form) {
var post = service.register(form);
return ResponseEntity
.status(HttpStatus.SEE_OTHER)
.location(URI.create(
"/posts/" + post.id()))
.build();
}
@GetMapping(
path = "/posts/{id}",
produces = MediaType.TEXT_HTML_VALUE)
@ResponseBody
public ResponseEntity<String> show(@PathVariable long id) {
var post = service.find(id);
var html = "<!doctype html><html><body><h1>"
+ HtmlUtils.htmlEscape(post.title())
+ "</h1><p>"
+ HtmlUtils.htmlEscape(post.content())
+ "</p></body></html>";
return ResponseEntity
.ok()
.contentType(MediaType.TEXT_HTML)
.body(html);
}
@PostMapping("/redirects/{status}")
public ResponseEntity<Void> redirect(
@PathVariable int status) {
var redirectStatus = switch (status) {
case 301 -> HttpStatus.MOVED_PERMANENTLY;
case 302 -> HttpStatus.FOUND;
case 307 -> HttpStatus.TEMPORARY_REDIRECT;
case 308 -> HttpStatus.PERMANENT_REDIRECT;
default -> throw new IllegalArgumentException(
"Unsupported redirect: " + status);
};
return ResponseEntity
.status(redirectStatus)
.location(URI.create("/posts/42"))
.build();
}
}
}PRG가 해결하는 문제와 해결하지 않는 문제
PRG는 결과 페이지에서 새로고침했을 때 마지막 GET이 반복되도록 만듭니다. 최초 POST 응답이 네트워크에서 사라져 클라이언트가 같은 POST를 다시 보낼지 판단하지 못하는 문제까지 해결하지는 않습니다. 모호한 최초 POST 재시도는 앞 문서의 멱등성 키와 결과 기록 계약이 소유합니다.
검증에 실패한 HTML 폼은 같은 요청에서 400과 폼 화면을 렌더링해 입력값·필드 오류를 보여 줄 수 있습니다. 리다이렉트가 필요하다면 일회성 flash 속성의 수명과 세션 의존을 계약에 포함하고, 민감한 값을 query로 옮기지 않습니다.
또한 303은 “등록을 딱 한 번 실행”시키는 데이터베이스 제약이 아닙니다. 사용자 이중 클릭, 여러 탭, 전송 재시도에는 일회용 폼 토큰, 유일성 제약, 멱등성 기록처럼 상태 변경 경계의 별도 방어가 필요합니다.
연습 문제
게시판 내보내기를 비동기 작업으로 바꾸고 클라이언트 상태 머신을 설계하세요.
- 접수 응답은
202 Accepted와 현재PENDING표현을 반환합니다. - 이 API 프로필이 선택한 monitor URI를 본문과
Location에 일관되게 둡니다. - monitor 조회는 진행 중·완료·실패·만료를 서로 다른 표현으로 구분합니다.
- 완료된 다운로드를 다른 URI에서 GET하게 만들 때만 303을 검토합니다.
해설 보기
접수 순간에는 내보내기 파일이 아직 없으므로 파일 생성 완료를 뜻하는 201을 반환하지 않습니다. 202도 작업 성공을 보장하지 않으므로 PENDING에서 SUCCEEDED와 FAILED 양쪽으로 전이할 수 있어야 합니다.
Location: /api/export-jobs/7은 이 애플리케이션이 정한 monitor 계약이라고 문서화합니다. 클라이언트는 이를 모든 202의 표준 의미로 일반화하지 않습니다. 완료 표현에 다운로드 URI를 필드로 넣어 GET하게 할 수도 있고, monitor가 303 See Other로 다운로드 URI를 가리키게 할 수도 있습니다. 어느 쪽이든 상태와 다음 메서드·target URI가 끊기지 않아야 합니다.
415 Unsupported Media Type과 406 Not Acceptable의 선택 규칙은 콘텐츠 협상 문서가 소유합니다. 304 Not Modified의 validator와 캐시 재사용 흐름은 HTTP 캐시와 조건부 요청 문서에서 다룹니다.
공식 기준은 RFC 9110의 Location, 2xx 상태, 3xx 상태, 4xx 상태, RFC 9457 Problem Details, Spring Framework 6.2 MockMvc setup입니다.