본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
4장 : HTTP 기본 원리

URI·상태 코드·PRG

생성된 게시판 리소스의 위치와 2xx·4xx 상태를 일관되게 설계하고 HTML 폼 등록을 303 See Other 기반 PRG로 검증합니다.

HTTP 본문만 보고 성공과 실패를 판단하면 클라이언트마다 문자열을 분석해야 합니다. 상태 코드는 처리 결과의 범주를, Location은 다음 리소스나 이동 대상을 알려 줍니다. API 등록과 브라우저 폼 등록은 같은 서비스를 호출해도 후속 클라이언트 행동이 다르므로 201 생성과 303 리다이렉트를 구분합니다.


공개 식별자인 리소스 URI

게시글 컬렉션은 /api/posts, 개별 리소스는 /api/posts/{id}로 둡니다. URI에 데이터베이스 테이블 이름, Java 클래스, 컨트롤러 메서드를 드러내지 않습니다. 내부 저장 기술과 코드 구조가 바뀌어도 링크는 유지할 수 있어야 합니다.

등록 API는 서버가 ID를 정하므로 컬렉션에 POST하고 생성된 URI를 Location으로 반환합니다.

src/main/java/board/web/PostApiController.java
package board.web;

import java.net.URI;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/posts")
public class PostApiController {
    private final PostService service;

    public PostApiController(PostService service) {
        this.service = service;
    }

    @PostMapping
    ResponseEntity<PostResponse> register(
            @RequestBody CreatePostRequest request) {
        var saved = service.register(request.toCommand());
        var location = URI.create(
                "/api/posts/" + saved.id());
        return ResponseEntity
                .created(location)
                .body(PostResponse.from(saved));
    }
}

상대 Location은 현재 출처를 기준으로 해석할 수 있어 리버스 프록시 뒤의 스킴과 호스트 복원 오류를 줄입니다. 절대 URI가 계약상 필요하면 프레임워크의 전달된 헤더 처리와 신뢰 프록시 범위를 정확히 설정한 뒤 외부 출처으로 만듭니다. 클라이언트가 보낸 HostX-Forwarded-Host를 무조건 신뢰해 링크를 만들면 호스트 헤더 주입이 생길 수 있습니다.

상태 코드와 다음 행동

상태게시판 의미클라이언트 행동
200 OK조회·수정 표현 반환본문 해석
201 Created새 게시글 생성Location 저장·이동
202 허용됨내보내기 작업 접수작업 URI 폴링
204 No 콘텐츠삭제 성공, 본문 없음현재 화면 갱신
304 Not Modified캐시 표현 유효기존 본문 재사용
400 Bad Request문법·바인딩·검증 실패입력 수정
401 미인가인증 정보 없음·실패인증 수행
403 Forbidden동일성은 알지만 권한 없음권한 요청·중단
404 Not 발견리소스 없음 또는 존재 은닉링크 제거·재조회
409 충돌현재 상태와 충돌·중복최신 상태 확인
415 지원하지 않는 미디어 타입요청 표현 미지원Content-Type 수정

상태 하나로 모든 세부 사유를 표현하지 않습니다. ProblemDetail의 안정적인 애플리케이션 코드, 필드 오류 목록, 트레이스 ID를 본문에 함께 둡니다. 반대로 HTTP 200 본문에 {"success":false}만 넣으면 프록시와 관찰 가능성, 일반 클라이언트가 실패를 성공으로 집계합니다.

API 생성 응답 규칙

RestTestClient가 세 요소를 동시에 검증해야 컨트롤러가 200으로 퇴행하거나 위치를 잃는 변화를 잡습니다.

src/test/java/board/web/PostCreationContractTest.java
package board.web;

import static org.springframework.http.MediaType.APPLICATION_JSON;

import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.client.RestTestClient;

class PostCreationContractTest {
    @Test
    void 생성된_resource의_URI와_representation을_반환한다() {
        var service = new FixedIdPostService(42L);
        var client = RestTestClient
                .bindToController(
                        new PostApiController(service))
                .build();

        client.post()
                .uri("/api/posts")
                .contentType(APPLICATION_JSON)
                .body("""
                        {
                          "title": "HTTP status",
                          "content": "PRG 흐름을 정리합니다.",
                          "publishedOn": "2026-07-13"
                        }
                        """)
                .exchange()
                .expectStatus().isCreated()
                .expectHeader()
                .valueEquals(
                        "Location", "/api/posts/42")
                .expectBody()
                .jsonPath("$.id").isEqualTo(42)
                .jsonPath("$.title")
                .isEqualTo("HTTP status");
    }
}

등록 처리가 오래 걸려 비동기 대기열에 넣는다면 실제 게시글이 아직 없는데 201을 반환하지 않습니다. 202와 작업 리소스 Location: /api/registration-jobs/{id}를 반환하고 완료·실패 상태를 조회하게 합니다.

폼 POST 중복 전송

브라우저가 POST 응답으로 바로 HTML을 받으면 주소 표시줄에는 POST URI가 남습니다. 사용자가 새로고침할 때 브라우저가 폼 재전송을 경고하고, 확인하면 등록이 반복될 수 있습니다.

POST-리다이렉트-GET은 상태 변경 뒤 조회 URI로 이동시킵니다.

  1. 브라우저가 폼을 POST합니다.
  2. 서버가 한 번 등록합니다.
  3. 서버가 303과 조회 URI를 반환합니다.
  4. 브라우저가 Location으로 GET합니다.
  5. 새로고침은 마지막 GET만 반복합니다.
src/main/java/board/web/PostFormController.java
package board.web;

import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public class PostFormController {
    private final PostService service;

    public PostFormController(PostService service) {
        this.service = service;
    }

    @PostMapping("/posts")
    ResponseEntity<Void> register(
            @ModelAttribute CreatePostRequest request) {
        var saved = service.register(request.toCommand());
        return ResponseEntity
                .status(HttpStatus.SEE_OTHER)
                .location(URI.create(
                        "/posts/" + saved.id()))
                .build();
    }
}

API 클라이언트는 JSON 표현을 원하는 반면 브라우저 폼은 새 페이지로 이동하려고 합니다. 하나의 엔드포인트가 Accept에 따라 둘을 모두 처리할 수도 있지만 계약과 테스트가 복잡해집니다. /api/posts/posts처럼 상호작용 방식을 분리하면 상태와 표현이 선명합니다.

303 See Other

리다이렉트 상태는 메서드 처리 방식이 다릅니다.

상태대표 의미후속 메서드
301 Moved PermanentlyURI가 영구 이동클라이언트 관행 차이에 주의
302 발견임시 이동역사적으로 POST→GET 변환 혼재
303 See Other처리 결과를 다른 URI에서 조회GET
307 임시 리다이렉트임시 이동원래 메서드·본문 유지
308 Permanent 리다이렉트영구 이동원래 메서드·본문 유지

폼 PRG에는 303이 의도를 가장 잘 드러냅니다. POST 본문을 그대로 새 URI에 다시 보내야 하는 프록시 이동이라면 307/308을 검토합니다. 301/308은 클라이언트와 캐시에 오래 남을 수 있으므로 배포 중 임시 경로 전환에 쉽게 사용하지 않습니다.

src/test/java/board/web/PostRedirectGetTest.java
package board.web;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.client.RestTestClient;

class PostRedirectGetTest {
    @Test
    void form_등록_후_조회_URI로_303을_반환한다() {
        var service = new RecordingPostService(42L);
        var client = RestTestClient
                .bindToController(
                        new PostFormController(service))
                .build();

        client.post()
                .uri("/posts")
                .contentType(
                        MediaType.APPLICATION_FORM_URLENCODED)
                .body("title=PRG&content=PRG+흐름을+정리합니다")
                .exchange()
                .expectStatus().isEqualTo(303)
                .expectHeader()
                .valueEquals("Location", "/posts/42")
                .expectBody().isEmpty();

        assertThat(service.registrationCount())
                .isEqualTo(1);
    }
}

RestTestClient는 이 검증에서 리다이렉트를 자동 추적하지 않아 첫 응답을 직접 확인합니다. 종단 간 브라우저 테스트에서는 303 뒤 GET이 실행되고 주소 표시줄이 /posts/42인지 추가로 확인합니다.

실행 결과
PostCreationContractTest
  > 생성된_resource의_URI와_representation을_반환한다() PASSED
PostRedirectGetTest
  > form_등록_후_조회_URI로_303을_반환한다() PASSED
registrationCount = 1
BUILD SUCCESSFUL

리다이렉트의 한계

PRG는 새로고침이 POST를 반복하지 않게 하지만 최초 POST가 네트워크 재시도로 두 번 도착하는 문제는 해결하지 않습니다. API에는 멱등성 키, 폼에는 일회용 토큰과 데이터베이스 제약 조건 같은 별도 방어가 필요합니다.

검증 실패 때는 리다이렉트하지 않고 400 또는 폼 뷰를 같은 요청에서 다시 렌더링해 필드 오류와 입력값을 보여 줄 수 있습니다. 리다이렉트해야 한다면 플래시 속성의 수명과 쿠키·세션 의존을 이해해야 합니다. 민감한 폼 값을 URL 쿼리로 옮기지 않습니다.

연습 문제

게시판 내보내기 요청을 비동기로 바꾸세요. POST는 202와 /api/export-jobs/{id} 위치를 반환하고, 작업 조회는 진행 중 200, 완료 303으로 다운로드 리소스를 가리키게 합니다. 폼 등록은 303 PRG를 유지하며 검증 실패에서는 리다이렉트하지 않아야 합니다.

해설 보기

접수 시 아직 내보내기 파일이 없으므로 201 대신 202를 사용합니다.

return ResponseEntity
        .accepted()
        .location(URI.create(
                "/api/export-jobs/" + job.id()))
        .build();

작업 조회 응답을 200 JSON으로 계속 반환하고 상태 필드를 바꾸는 설계도 가능합니다. 완료 순간 다운로드 URI로 리다이렉트한다면 303을 사용해 GET으로 받게 합니다. 테스트는 접수 응답의 202와 위치, 진행 중 본문, 완료 후 303과 위치를 각각 고정합니다.

상태 코드만 나열하지 말고 클라이언트가 다음에 어떤 메서드로 어느 URI를 호출해야 하는지가 계약에서 이어져야 합니다.

다음 문서에서는 같은 리소스를 JSON과 CSV 같은 여러 표현으로 제공할 때 Content-Type, Accept, 언어·인코딩 헤더가 어떻게 컨버터와 캐시 키를 결정하는지 다룹니다.