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을 하나의 응답 설계로 묶습니다. 테스트는 앞 글들과 같은 Java 17·Spring 6.2.11 별도 단위에서 실행합니다.
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
- 요청을 성공적으로 처리했습니다. 이 API에서는 삭제 완료를 알립니다.
- 응답에는 추가 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· 요청을 이해했지만 수행을 거부함 (예: 권한 부족)404· 현재 표현을 찾지 못했거나 존재를 공개하지 않음409· request가 현재 resource 상태와 충돌함 — 모든 중복을 뜻하지 않습니다.
이 API는 거부 사유를 RFC 9457 Problem Details representation으로 전달합니다.
status code 하나만 고르는 문제가 아니다. 완료 시점, primary resource, 후속 retrieval, monitor, 거부 원인을 함께 모델링한다.
그림은 처리가 끝났는지와 이어서 가리킬 리소스가 있는지에 따라 상태 코드와 Location을 고르는 기준을 보여 줍니다. 생성을 마쳤으면 201, 접수만 했으면 202, 본문 없이 성공했으면 204, HTML 폼의 결과를 다른 URI에서 조회하게 하려면 303이며, 아래쪽은 400, 401, 403, 404, 409 거부 응답을 Problem Details로 구분합니다. 415와 304의 자세한 규칙은 각각 콘텐츠 협상과 HTTP 캐시와 조건부 요청에서 다룹니다.
상태 코드는 다음 행동까지 알린다
게시글 컬렉션은 /api/posts, 게시글 하나는 /api/posts/{id}로 둡니다. 공개 URI에는 데이터베이스의 테이블 이름이나 Java의 클래스, 메서드 이름을 넣지 않습니다. 저장 기술과 코드 구조가 바뀌어도 클라이언트가 저장해 둔 링크는 그대로 쓸 수 있어야 하기 때문입니다.
201은 생성 완료를 뜻한다
201 Created는 요청이 끝나 새 리소스가 하나 이상 만들어졌다는 뜻입니다. 만들어진 주 리소스는 응답에 Location이 있으면 그 값으로, 없으면 요청의 대상 URI로 식별되므로, 모든 201에 Location이 반드시 있어야 하는 것은 아닙니다. 그러나 컬렉션 POST /api/posts가 새 구성원 /api/posts/42를 만들었다면 대상 URI와 새 게시글의 URI가 다르므로, 이 게시판 API는 상태 201과 Location: /api/posts/42, 그리고 만들어진 게시글 표현을 본문으로 돌려주기로 정합니다.
Location의 문법은 URI가 아니라 URI 참조(URI-reference)이므로 /api/posts/42 같은 상대 참조도 올바릅니다. 받는 쪽은 요청의 대상 URI를 기준으로 이것을 해석합니다. 바깥의 절대 URI가 꼭 필요할 때만, 신뢰하는 프록시의 범위와 전달 헤더 처리를 먼저 정한 뒤 스킴과 권한 정보를 되살립니다.
상대 참조는 문자열을 단순히 이어 붙이는 것이 아닙니다. 대상이 https://board.example/api/posts일 때 /api/posts/42는 같은 서버의 https://board.example/api/posts/42가 되지만, 42는 마지막 경로 조각 posts를 바꾸어 https://board.example/api/42가 됩니다. 대상이 /api/posts/처럼 /로 끝날 때만 42가 그 뒤에 붙습니다. URI 경로 병합 규칙에 따라 계산하고, 돌려줄 새 리소스의 경로를 분명하게 정합니다.
202는 접수이지 완료 약속이 아니다
202 Accepted는 처리를 받아들였지만 아직 끝내지 않았다는 뜻입니다. 이후의 작업은 실행되지 않거나 실패할 수도 있으므로 202를 최종 성공처럼 기록해서는 안 되며, HTTP에는 나중에 같은 응답으로 최종 상태를 다시 보내는 기능도 없습니다. RFC 9110은 202의 표현이 현재 상태를 설명하고 상태를 확인할 곳(monitor)을 가리키거나 담는 것이 좋다고 권합니다. 이 교재의 내보내기 API는 그 권고를 이 API만의 규칙으로 구체화해, POST /api/export-jobs에 202를 돌려주고 본문에 state: "PENDING"과 monitor: "/api/export-jobs/7"을 둡니다. 같은 모니터 URI를 Location에도 넣는 것은 이 API가 문서로 정한 선택이며, 모든 202에 적용되는 HTTP의 규칙이 아닙니다. 클라이언트는 모니터를 조회해 완료와 실패, 만료를 구분합니다.
204는 성공했지만 보낼 본문이 없다는 뜻이다
204 No Content는 요청을 성공적으로 처리했고 응답 본문이 없다는 뜻입니다. 204 응답은 헤더 영역의 끝에서 끝나며, 콘텐츠나 트레일러(trailer)를 담을 수 없습니다. 삭제한 뒤 클라이언트는 빈 문자열을 JSON으로 해석하려 하지 말고, 로컬 목록에서 항목을 지우거나 필요한 표현을 다시 조회합니다. 또 RFC 9110의 Content-Length 규칙은 204 응답에 그 필드 자체를 보내지 못하게 합니다. 아래 테스트에서 본문의 바이트 길이 0을 확인하는 것은 본문이 없다는 관찰일 뿐, 서버가 Content-Length: 0을 보내도 된다는 뜻이 아닙니다.
| 결과 | 게시판 API의 뜻 | 클라이언트가 다음에 할 일 |
|---|---|---|
201 Created | 새 게시글 생성 완료, 새 URI와 표현을 돌려줌 | Location을 저장하거나 받은 표현을 사용 |
202 Accepted | 내보내기 작업 접수, 최종 결과는 아직 모름 | 문서화된 모니터를 조회 |
204 No Content | 삭제 완료, 본문 없음 | 로컬 상태를 고치거나 다시 GET |
400 Bad Request | 요청 문법이나 바인딩, 입력 규칙 위반 | 문제 필드를 고쳐 새 요청을 작성 |
401 Unauthorized | 유효한 인증 자격 증명이 없음 | WWW-Authenticate가 요구하는 방식으로 인증 |
403 Forbidden | 요청을 이해했지만 수행을 거부 | 권한을 요청하거나 작업을 중단 |
404 Not Found | 지금의 표현을 찾지 못했거나 존재를 밝히지 않음 | 저장한 링크를 지우거나 URI를 다시 찾음 |
409 Conflict | 대상 리소스의 현재 상태와 충돌 | 최신 상태를 읽고 충돌을 해결한 뒤 다시 제출 |
401을 돌려주는 원 서버는 그 리소스에 맞는 인증 요구(challenge)를 하나 이상 담은 WWW-Authenticate를 보내야 합니다. 409는 "중복처럼 보이는 모든 오류"가 아니라 현재 리소스 상태와의 충돌이며, 사용자가 원인을 알아보고 해결할 수 있는 정보를 본문에 담아야 합니다.
RFC 9457 Problem Details로 오류를 기계가 읽게 한다
상태 코드 하나에 업무상의 이유와 필드 오류를 모두 담지 않습니다. API 오류에는 application/problem+json과 RFC 9457의 문제 세부 정보(Problem Details)를 씁니다. 각 멤버의 역할은 다음과 같습니다.
| 멤버 | 역할 |
|---|---|
type | 문제의 종류를 가리키는 주 식별자인 URI 참조이며, API가 소유하고 오래 유지할 값 |
title | 문제 종류에 대한 짧고 안정적인 설명이며, 발생할 때마다 바뀌는 메시지가 아님 |
status | 이 발생에 쓴 HTTP 상태 코드이며, 실제 응답 상태와 같아야 함 |
detail | 이번 발생을 사람이 이해하고 고칠 수 있게 돕는 설명이며, 클라이언트가 문자열을 해석해 분기하지 않음 |
instance | 특정 문제 발생을 가리키는 URI 참조 |
code, errors, traceId 등 | 애플리케이션이 정하는 확장 멤버이며, 클라이언트는 모르는 확장을 무시해야 함 |
아래 테스트 픽스처는 instance를 예시 전용 URN 이름 공간과 처리기 안의 순번으로 urn:example:board:problem:{n}처럼 만들고 응답마다 고유한지 확인합니다. 운영에서는 프로세스를 다시 시작해도 겹치지 않는 요청 추적 정책으로 만듭니다. 스택 트레이스나 SQL, 내부 클래스 이름처럼 공격에 쓰이거나 개인정보를 드러낼 수 있는 진단 정보는 Problem Details에 넣지 않습니다. 분기에는 안정적인 code와 구조화된 errors를 쓰고, 사람이 읽는 detail은 문구가 바뀔 수 있다고 생각합니다.
RFC 9457의 status는 소비하는 쪽을 돕는 보조 정보입니다. 서버는 실제 HTTP 응답과 같은 값을 넣어야 하지만, 클라이언트가 본문의 값으로 실제 HTTP 상태를 덮어쓰지는 않습니다. 두 값이 다르면 전달 경로나 응답을 만드는 쪽에 문제가 있다고 보고 기록해 조사합니다. 문제의 종류는 type과 문서화한 확장 멤버로 구분하고, 번역될 수 있는 title이나 발생마다 다른 detail로 분기하지 않습니다.
다음 테스트 파일은 JDK 17, JUnit 5, Jackson, Spring Framework 6.2.11의 MockMvc로 혼자 컴파일할 수 있는 완결된 단위입니다. 독립형 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("id") long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
@GetMapping(
path = "/api/private-posts/{id}",
produces = MediaType.APPLICATION_JSON_VALUE)
public CreatePostResponse privatePost(
@PathVariable("id") 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과 표현, 이 API가 정한 202의 모니터, 204의 빈 본문, 401의 인증 요구, 409의 현재 상태 충돌, Problem Details 확장 멤버가 실제 JSON의 어디에 놓이는지를 함께 확인합니다. ProblemDetailJacksonMixin을 직접 지정했으므로, code와 errors가 별도의 properties 객체가 아니라 RFC 9457의 확장 멤버로 직렬화되는지도 확인 대상입니다.
이 픽스처가 구현하지 않은 흐름도 분명히 해 둡니다. acceptExport()는 작업 식별자와 PENDING을 만들 뿐이고, completedJobCount()는 언제나 0을 돌려줍니다. 실제로 파일을 만드는 작업자(worker), 모니터 GET 엔드포인트, 실패와 만료로의 전이는 이 코드에 없습니다. 그러니 이 테스트가 통과했다는 것은 접수 응답의 형식을 확인한 것일 뿐, 비동기 작업이 끝까지 처리된다는 보장이 아닙니다. 인증 예제의 고정 토큰 비교도 실제 인증 서버의 검증을 구현한 것이 아닙니다.
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만 되풀이됩니다.
303 PRG는 POST 결과를 조회하고 307·308은 method를 보존한다
POST·등록·303 Location·별도 GET·새로고침 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
정상 흐름에서 등록 결과를 GET으로 조회한다
browser → server ·
POST /postsform 입력을 보내 상태 변경을 요청합니다.
server · 등록 서비스 실행
정상적인 1회 제출에서는 등록 서비스를 한 번 호출해
post 42를 만듭니다. 이 fixture는 메모리에 결과를 기록합니다.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까지 보존하므로 재전송 위험을 함께 검토해야 합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 위에서 아래로 시간이 진행한다. 브라우저 POST 뒤 서버가 등록 service를 호출하고 303 Location을 반환한다. 브라우저는 새 GET으로 결과 HTML을 읽으며 이후 새로고침도 그 GET을 반복한다. 자기 호출은 등록 업무이고 점선 역방향은 응답이다. 최초 POST 응답을 잃은 실패 흐름은 이 정상 시퀀스 밖이며 원 해설의 멱등성 계약을 따른다.
- 브라우저의 POST에 서버가 등록 결과를 만든 뒤 303 Location으로 응답합니다. 브라우저는 결과 URI를 GET하며 결과 페이지 새로고침도 GET을 반복합니다. 이 과정 자체가 최초 POST의 중복 실행 방지나 durable commit을 보장하지 않습니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 15 | 위에서 아래로 시간이 진행한다. 브라우저 POST 뒤 서버가 등록 service를 호출하고 303 Location을 반환한다. 브라우저는 새 GET으로 결과 HTML을 읽으며 이후 새로고침도 그 GET을 반복한다. 자기 호출은 등록 업무이고 점선 역방향은 응답이다. 최초 POST 응답을 잃은 실패 흐름은 이 정상 시퀀스 밖이며 원 해설의 멱등성 계약을 따른다. |
| 35 | 브라우저의 POST에 서버가 등록 결과를 만든 뒤 303 Location으로 응답합니다. 브라우저는 결과 URI를 GET하며 결과 페이지 새로고침도 GET을 반복합니다. 이 과정 자체가 최초 POST의 중복 실행 방지나 durable commit을 보장하지 않습니다. |
| 83 | PRG는 새로고침 경계만 바꾸며 중복 실행 방지를 보장하지 않는다 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| POST에서 등록 브라우저 POST가 서버의 등록 service를 호출합니다. | 요청: POST · 업무: 등록 · 후속 GET: 아직 없음 | 등록 업무가 실행됩니다. |
| 303과 Location 응답 서버가 결과 URL을 Location에 넣어 303을 반환합니다. | POST: 응답 완료 · 응답: 303 · Location: 결과 URL | 현재 POST가 끝나고 다음 요청의 목적지가 전달됩니다. |
| 별도 GET으로 표시 브라우저가 Location으로 새 GET을 보내 결과 HTML을 읽습니다. | 요청: 새 GET · 업무: 조회·HTML · 등록: 다시 호출하지 않음 | 새 요청이 결과 화면을 표시합니다. |
| 새로고침도 GET 표시된 결과 화면에서 새로고침하면 그 GET을 반복합니다. | 요청: GET 반복 · 화면: 결과 HTML · 범위: 정상 PRG 경로 | 새로고침의 대상이 POST에서 GET으로 바뀝니다. |
303 PRG는 POST 결과를 조회하고 307·308은 method를 보존한다
POST·등록·303 Location·별도 GET·새로고침 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
정상 흐름에서 등록 결과를 GET으로 조회한다
browser → server ·
POST /postsform 입력을 보내 상태 변경을 요청합니다.
server · 등록 서비스 실행
정상적인 1회 제출에서는 등록 서비스를 한 번 호출해
post 42를 만듭니다. 이 fixture는 메모리에 결과를 기록합니다.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까지 보존하므로 재전송 위험을 함께 검토해야 합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 위에서 아래로 시간이 진행한다. 브라우저 POST 뒤 서버가 등록 service를 호출하고 303 Location을 반환한다. 브라우저는 새 GET으로 결과 HTML을 읽으며 이후 새로고침도 그 GET을 반복한다. 자기 호출은 등록 업무이고 점선 역방향은 응답이다. 최초 POST 응답을 잃은 실패 흐름은 이 정상 시퀀스 밖이며 원 해설의 멱등성 계약을 따른다.
- 브라우저의 POST에 서버가 등록 결과를 만든 뒤 303 Location으로 응답합니다. 브라우저는 결과 URI를 GET하며 결과 페이지 새로고침도 GET을 반복합니다. 이 과정 자체가 최초 POST의 중복 실행 방지나 durable commit을 보장하지 않습니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 15 | 위에서 아래로 시간이 진행한다. 브라우저 POST 뒤 서버가 등록 service를 호출하고 303 Location을 반환한다. 브라우저는 새 GET으로 결과 HTML을 읽으며 이후 새로고침도 그 GET을 반복한다. 자기 호출은 등록 업무이고 점선 역방향은 응답이다. 최초 POST 응답을 잃은 실패 흐름은 이 정상 시퀀스 밖이며 원 해설의 멱등성 계약을 따른다. |
| 35 | 브라우저의 POST에 서버가 등록 결과를 만든 뒤 303 Location으로 응답합니다. 브라우저는 결과 URI를 GET하며 결과 페이지 새로고침도 GET을 반복합니다. 이 과정 자체가 최초 POST의 중복 실행 방지나 durable commit을 보장하지 않습니다. |
| 83 | PRG는 새로고침 경계만 바꾸며 중복 실행 방지를 보장하지 않는다 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| POST에서 등록 브라우저 POST가 서버의 등록 service를 호출합니다. | 요청: POST · 업무: 등록 · 후속 GET: 아직 없음 | 등록 업무가 실행됩니다. |
| 303과 Location 응답 서버가 결과 URL을 Location에 넣어 303을 반환합니다. | POST: 응답 완료 · 응답: 303 · Location: 결과 URL | 현재 POST가 끝나고 다음 요청의 목적지가 전달됩니다. |
| 별도 GET으로 표시 브라우저가 Location으로 새 GET을 보내 결과 HTML을 읽습니다. | 요청: 새 GET · 업무: 조회·HTML · 등록: 다시 호출하지 않음 | 새 요청이 결과 화면을 표시합니다. |
| 새로고침도 GET 표시된 결과 화면에서 새로고침하면 그 GET을 반복합니다. | 요청: GET 반복 · 화면: 결과 HTML · 범위: 정상 PRG 경로 | 새로고침의 대상이 POST에서 GET으로 바뀝니다. |
그림은 이 정상 흐름을 위에서 아래로 보여 줍니다. 브라우저의 POST에 서버가 등록 서비스를 호출하고 303과 Location으로 응답하면, 브라우저는 새 GET으로 결과 HTML을 읽고 이후의 새로 고침도 그 GET을 반복합니다. 첫 POST의 응답을 잃어버린 실패 흐름은 이 그림 밖의 문제이며, 아래쪽 표와 상자는 리다이렉트 상태마다 다음 메서드가 어떻게 되는지와 PRG가 해결하지 않는 문제를 정리합니다.
303의 Location은 원래 대상 URI의 새 이름이 아니라, 원래 요청에 대한 간접 응답을 조회할 다른 리소스를 가리킵니다. HTTP에서는 받는 쪽이 그 URI에 GET이나 HEAD 같은 조회 요청을 보낼 수 있고, 브라우저의 PRG에서는 GET을 씁니다. HEAD에 대한 응답이 아니라면 303의 표현에 같은 URI 참조로 이어지는 짧은 하이퍼링크 안내를 넣는 것이 일반적인 권고입니다. 아래의 폼 엔드포인트는 브라우저가 Location을 따라간다는 전제로 본문 없는 303을 골랐고, 빈 본문 확인은 이 엔드포인트에만 해당합니다.
리다이렉트 상태를 모두 "다음은 GET"으로 외우면 메서드를 보존해야 하는 경우를 놓칩니다.
| 상태 | URI의 관계 | POST 뒤 자동 리다이렉트의 메서드 |
|---|---|---|
301 Moved Permanently | 영구 이동 | 역사적 호환성 때문에 사용자 에이전트가 POST를 GET으로 바꿀 수 있음 |
302 Found | 임시 이동 | 역사적 호환성 때문에 사용자 에이전트가 POST를 GET으로 바꿀 수 있음 |
303 See Other | 원래 요청의 간접 응답을 다른 URI에서 조회 | 조회 요청이며, PRG에서는 GET |
307 Temporary Redirect | 임시 이동 | 원래 메서드를 바꾸면 안 됨 |
308 Permanent Redirect | 영구 이동 | 원래 메서드를 바꾸면 안 됨 |
폼 등록의 결과를 GET으로 보여 주려면 303이 가장 분명합니다. 원래 POST 본문을 다른 임시 엔드포인트에 그대로 넘겨야 한다면 307을, 영구적으로 옮기면서 메서드와 본문을 보존해야 한다면 308을 검토합니다. 다만 307과 308은 POST 본문까지 다시 보내므로 같은 변경이 또 실행될 위험이 있고, 301과 308은 영구 이동이라는 뜻이 있으므로 잠깐의 배포 전환에 습관처럼 쓰지 않습니다.
API의 JSON 등록과 브라우저의 폼 등록은 같은 서비스 계층을 호출할 수 있지만, 상호작용 방식은 나누는 편이 단순합니다. /api/posts는 201과 JSON 표현을, /posts는 303과 HTML을 조회할 URI를 돌려주도록 나누면 Accept에 따른 분기 하나에 두 흐름을 감추지 않아도 됩니다. 다음 테스트는 폼 등록의 응답과 결과 GET을 자동으로 따라가지 않고 각각 확인합니다. 리다이렉트 뒤에 어떤 메서드를 쓸지는 사용자 에이전트가 정하므로, 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("id") 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("status") 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의 재시도는 HTTP 메서드 속성에서 본 멱등성 키와 결과 기록으로 다룹니다.
검증에 실패한 HTML 폼은 같은 요청에서 400과 폼 화면을 렌더링해 입력값과 필드 오류를 보여 줄 수 있습니다. 리다이렉트가 꼭 필요하다면 한 번만 쓰는 플래시(flash) 속성의 수명과 세션 의존을 설계에 포함하고, 민감한 값을 쿼리로 옮기지 않습니다. 또 303은 "등록을 딱 한 번만 실행"하게 하는 데이터베이스 제약이 아닙니다. 사용자의 이중 클릭이나 여러 탭, 전송 재시도에는 한 번만 쓰는 폼 토큰, 유일성 제약 조건, 멱등성 기록 같은 별도의 방어가 필요합니다.
정리
- 상태 코드는 처리 결과의 범주를,
Location과 본문은 클라이언트가 다음에 할 일을 알려 주며,Location은 상대 URI 참조여도 됩니다. - 201은 생성이 끝났다는 뜻이고, 202는 접수만 했을 뿐 성공을 보장하지 않으며, 204는 본문과
Content-Length없이 성공을 알립니다. - API 오류는
application/problem+json의 Problem Details로 보내고, 분기는type과 안정적인 확장 멤버로 하며detail문구로 하지 않습니다. - PRG는 폼 POST 뒤 303과
Location으로 결과를 GET하게 해 새로 고침이 GET만 반복하게 합니다. 307과 308은 메서드와 본문을 보존합니다. - PRG는 첫 POST의 응답 유실이나 이중 제출로 생기는 중복을 막지 못하므로, 멱등성 기록이나 유일성 제약 조건이 따로 필요합니다.
연습 문제
1. 비동기 내보내기의 상태 머신
게시판 내보내기를 비동기 작업으로 바꾸고 클라이언트의 상태 머신을 설계하세요. 접수 응답은 202와 지금의 PENDING 표현을 돌려주고, 이 API가 고른 모니터 URI를 본문과 Location에 일관되게 둡니다. 모니터 조회는 진행 중, 완료, 실패, 만료를 서로 다른 표현으로 구분하며, 완료된 다운로드를 다른 URI에서 GET하게 할 때만 303을 검토합니다.
해설 보기
접수하는 순간에는 내보내기 파일이 아직 없으므로, 파일 생성 완료를 뜻하는 201을 돌려주지 않습니다. 202도 작업의 성공을 보장하지 않으므로 PENDING에서 SUCCEEDED와 FAILED 양쪽으로 넘어갈 수 있어야 합니다. 모니터 조회에서 FAILED를 받았다면 처음의 202를 성공으로 바꾸어 기록하지 않습니다.
Location: /api/export-jobs/7은 이 애플리케이션이 정한 모니터라고 문서에 밝히고, 클라이언트는 이것을 모든 202의 표준 의미로 일반화하지 않습니다. 완료된 표현에 다운로드 URI를 필드로 넣어 GET하게 할 수도 있고, 모니터가 303 See Other로 다운로드 URI를 가리키게 할 수도 있습니다. 어느 쪽이든 상태와, 다음에 쓸 메서드와 대상 URI가 끊기지 않아야 합니다. 앞의 접수 픽스처에는 모니터와 작업자가 없으므로 이 전이들은 따로 구현하고 확인해야 합니다.
2. 받은 응답으로 다음 URI와 행동 정하기
다음 세 상황은 서로 독립적이며, URI 계산의 대상은 https://board.example/api/posts입니다. ① 201의 Location이 /api/posts/42일 때와 42일 때 해석한 절대 URI는 각각 무엇인가요? 두 번째를 게시글 42의 URI로 쓰려면 무엇을 바꿔야 하나요? ② 실제 HTTP 응답은 409인데 Problem Details 본문의 status는 400입니다. 서버의 잘못은 무엇이며, 클라이언트가 detail 문구만 읽고 입력 오류로 단정해도 될까요? ③ 별도의 삭제 요청이 204를 돌려주었다면 JSON 본문과 Content-Length: 0을 요구할 수 있나요?
해설 보기
① 각각 https://board.example/api/posts/42와 https://board.example/api/42입니다. 새 게시글을 가리키려면 /api/posts/42를 돌려주거나, 경로 상대 참조 42를 쓰려면 대상이 /api/posts/로 끝나게 정해야 합니다. 서버 주소 문자열에 값을 무조건 이어 붙이는 방식은 틀립니다.
② 서버는 본문의 status와 실제 응답 상태를 일치시켜야 합니다. 클라이언트는 실제 HTTP 상태를 본문 값으로 덮어쓰지 않고 불일치를 기록합니다. 문제의 구체적인 종류는 type과 약속된 확장 멤버로 확인하고, 바뀔 수 있는 detail을 해석해 분기하지 않습니다.
③ 204에는 콘텐츠가 없으므로 JSON을 요구하지 않으며, Content-Length 필드 자체도 보내지 않아야 합니다.
3. 결과 페이지의 마지막 요청
폼 POST의 등록이 성공했고, 브라우저가 303을 받은 뒤 Location으로 GET해 HTML을 열었습니다. 지금 새로 고치면 어떤 메서드와 URI가 반복될까요? 반대로 첫 POST는 커밋되었지만 303 응답을 받지 못했다면, 브라우저가 결과 페이지의 GET까지 갔다고 판단할 수 있을까요?
해설 보기
첫 경우의 마지막 요청은 결과 URI의 GET이므로 새로 고침은 그 조회를 반복합니다. 두 번째 경우에는 클라이언트가 303과 Location을 받지 못했으므로 GET까지 갔다고 판단할 수 없고, 결과를 찾는 방법이나 멱등성 기록을 확인해야 합니다. PRG는 이렇게 결과를 모르는 상태에서 첫 POST의 저장 효과를 없던 일로 만드는 규칙이 아닙니다.
이 글의 기준은 RFC 9110의 Location, 2xx와 3xx, 4xx 상태, RFC 9457 Problem Details, Spring Framework 6.2의 MockMvc 설정입니다. 다음 글 콘텐츠 협상에서는 415와 406을 고르는 규칙과, 서버가 요청에 맞는 표현을 고르는 과정을 다룹니다.