Spring MVC HTTP 요청·응답 학습노트: Query·Form·JSON과 Servlet API

반응형

2022년에 Spring MVC 강의를 들으며 정리한 HTTP 요청·응답 노트를 현재 기준으로 다시 다듬었다. 원래 글에는 GET, POST, form, JSON과 Servlet API가 한 흐름에 섞여 있었다. 이번에는 HTTP 메서드, 데이터가 실리는 위치, 표현 형식을 서로 다른 축으로 나눴다.

요청 데이터를 전달하는 대표 방식

방식 데이터 위치 Content-Type 예 주로 쓰는 상황
Query parameter URL의 query 요청 본문이 아니므로 해당 없음 검색, 필터, 페이지 이동
HTML form 요청 본문 application/x-www-form-urlencoded 브라우저 form 제출
JSON body 요청 본문 application/json HTTP API 요청

POST라고 해서 form인 것도, PUT·PATCH라고 해서 JSON인 것도 아니다. 메서드는 요청의 의미를, Content-Type은 본문 표현 형식을 설명한다.

Query parameter 읽기

GET /request-param?username=hello&age=20

Servlet API에서는 다음 메서드로 parameter를 읽을 수 있다.

String username = request.getParameter("username");
String[] usernames = request.getParameterValues("username");
Map<String, String[]> parameters = request.getParameterMap();

?username=hello&username=kim처럼 같은 이름이 반복될 수 있다. 하나만 읽는 getParameter()에 기대기보다 복수 값이 가능한 계약이라면 처음부터 getParameterValues() 또는 Spring의 collection binding을 사용한다.

Query는 URL, 브라우저 기록, proxy와 access log에 남을 수 있다. 비밀번호·토큰 같은 비밀값을 싣지 않는 것이 안전하다.

HTML form과 parameter 파싱

POST /signup HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=hello&age=20

Servlet container는 form body를 query와 같은 parameter API로 제공할 수 있다. 그래서 request.getParameter("username")로 두 형식을 비슷하게 읽는다.

다만 query와 body의 출처가 사라질 수 있고, request body는 일반적으로 한 번 소비하면 다시 읽기 어렵다. 애플리케이션 계약에서는 parameter의 위치와 미디어 타입을 명시하는 편이 낫다.

Spring MVC에서는 다음처럼 의도를 드러낼 수 있다.

@PostMapping(path = "/signup", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<Void> signup(SignupForm form) {
    return ResponseEntity.noContent().build();
}

JSON body 읽기

POST /users HTTP/1.1
Content-Type: application/json

{"username":"hello","age":20}

Servlet의 InputStream이나 Reader로 원문을 직접 읽을 수 있지만, Spring MVC에서는 @RequestBody와 message converter를 쓰는 편이 경계가 분명하다.

public record CreateUserRequest(String username, int age) {}

@PostMapping(
    path = "/users",
    consumes = MediaType.APPLICATION_JSON_VALUE,
    produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<CreateUserRequest> create(
    @RequestBody CreateUserRequest request
) {
    return ResponseEntity.status(HttpStatus.CREATED).body(request);
}

Spring Boot의 Web starter는 일반적으로 Jackson 기반 JSON 변환을 구성한다. 입력 검증은 JSON 파싱 성공과 별개의 문제이므로 Bean Validation 같은 검증도 따로 설계해야 한다.

GET body를 일반적인 API 계약으로 쓰지 않는 이유

HTTP 메시지 형식상 GET 요청에 content가 들어갈 가능성 자체와, 그 content에 서버가 공통 의미를 부여하는 것은 다른 문제다. HTTP Semantics는 GET content에 일반적으로 정의된 의미가 없고 일부 구현이 연결을 거부할 수 있다고 설명한다.

검색 조건은 query로 보내고, 너무 크거나 구조가 복잡해 별도 body가 필요하다면 API 의미와 캐시 특성을 다시 설계하는 편이 안전하다.

Servlet 응답의 핵심

HttpServletResponse는 상태 코드, 헤더와 body를 만든다.

response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("text/plain");
response.setCharacterEncoding("UTF-8");
response.setHeader("Cache-Control", "no-store");

response.getWriter().write("ok");

쿠키는 문자열 헤더를 직접 조합하기보다 Cookie API나 프레임워크 지원을 사용하고, 인증 쿠키라면 Secure, HttpOnly, SameSite, 수명과 범위를 함께 검토한다.

Cookie cookie = new Cookie("preference", "compact");
cookie.setMaxAge(600);
cookie.setHttpOnly(true);
cookie.setSecure(true);
response.addCookie(cookie);

sendRedirect()는 일반적으로 302 응답과 Location 헤더를 만든다. 메서드를 유지해야 하는 redirect인지, GET으로 전환할 것인지에 따라 303·307·308 등 상태 코드를 의도적으로 선택할 수도 있다.

JSON 응답과 charset 메모

JSON 응답은 application/json을 사용한다. RFC 8259는 폐쇄된 생태계 밖에서 교환하는 JSON 텍스트에 UTF-8을 요구하며, application/json 미디어 타입에는 별도 선택적 parameter가 정의돼 있지 않다.

Spring MVC에서 객체를 반환하면 message converter가 직렬화와 인코딩을 처리한다. raw Servlet로 직접 쓸 때는 실제 byte 인코딩과 헤더가 일치하는지 확인해야 한다. “writer를 쓰면 무조건 잘못이고 output stream만 맞다”는 식의 규칙으로 외우지는 않는다.

다시 정리한 기준

  1. 메서드의 의미와 body 형식을 분리한다.
  2. query, form, JSON이 어디에 실리는지 명확히 한다.
  3. 반복 parameter와 body의 단일 소비 특성을 고려한다.
  4. Spring MVC에서는 binding·converter로 계약을 코드에 드러낸다.
  5. 상태 코드, 미디어 타입, 캐시와 쿠키 보안을 함께 확인한다.

강의 내용을 그대로 옮기기보다 이 다섯 가지 질문으로 다시 묶으니 Servlet 저수준 API와 Spring MVC의 편의 기능이 어디에서 만나는지 더 잘 보인다.

참고 문서

반응형
KEEP READING
카테고리 전체 보기 →

댓글