반응형
Spring Boot POST 요청 받기 — @RequestBody, 레코드, @Valid로 회원가입 API 완성하기

들어가며

지난 글에서 GET /hello{"message": "Hello, Spring Boot!"}를 받아봤다. 이번엔 반대 방향이다. 브라우저 주소창이 아니라 클라이언트가 JSON을 보내고, 서버가 그걸 받아서 처리하는 쪽.

결론만 이야기하면 이렇다. 이름과 이메일을 받는 회원가입 API를 만들고, curl로 JSON을 보내서 아래 응답을 받으면 끝이다.

curl -X POST http://localhost:8080/members \
  -H "Content-Type: application/json" \
  -d '{"name":"홍길동","email":"hong@example.com"}'
{"id":1,"name":"홍길동","email":"hong@example.com"}

여기서 멈추지 않고 한 걸음 더 간다. name을 빈 문자열로 보내거나 email@가 없는 값을 보내면 서버가 이유를 알려주는 것까지 만들어본다.

{"name":"name은 비어 있을 수 없습니다","email":"email 형식이 올바르지 않습니다"}

이 두 응답이 나오기까지 순서는 @RequestBody로 JSON 받기 → 자바 레코드로 매핑하기 → @Valid로 검증하기 → @RestControllerAdvice로 에러 메시지 정리하기, 네 단계다.


준비물

항목 버전 비고
JDK 21 LTS 지난 글과 동일
Spring Boot 3.3.x Spring Web + Validation 의존성 필요
IDE IntelliJ Community 없어도 터미널만으로 진행 가능
이전 프로젝트 지난 글demo 프로젝트 없다면 start.spring.io에서 새로 받아도 됨
참고 지난 글에서 만든 demo 프로젝트를 그대로 이어서 쓴다. 새로 시작한다면 start.spring.io에서 Spring WebValidation 두 개를 Dependencies에 추가해서 받으면 된다.

따라 하기

Step 1. Validation 의존성 추가

build.gradle을 열고 dependencies 블록에 한 줄을 추가한다. (spring-boot-starter-web은 지난 글에서 이미 있을 것이다)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    // @Valid, @NotBlank 같은 검증 애노테이션을 쓰려면 이 의존성이 필요하다
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

IntelliJ를 쓴다면 저장 후 오른쪽 Gradle 패널의 새로고침 버튼(♻)을 눌러 의존성을 다시 받는다.


Step 2. 요청/응답을 담을 레코드 만들기

com.example.demo 패키지에 MemberRequest.java를 새로 만든다. 자바 14부터 있는 record를 쓰면 getter, 생성자, equals/hashCode를 직접 안 써도 돼서 이런 단순 데이터 전달용 클래스에 딱 맞는다.

package com.example.demo;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

// 클라이언트가 보내는 JSON을 받을 그릇.
// 필드 이름(name, email)이 JSON의 key와 같아야 자동으로 매핑된다.
public record MemberRequest(
        @NotBlank(message = "name은 비어 있을 수 없습니다") String name,
        @NotBlank(message = "email은 비어 있을 수 없습니다")
        @Email(message = "email 형식이 올바르지 않습니다") String email
) {
}

@NotBlank는 null이거나 빈 문자열(공백만 있는 것도 포함)이면 걸러낸다. @Email@ 포함 여부 같은 최소한의 형식만 확인한다. 완벽한 이메일 검증은 아니지만 입문 단계에서는 이 정도로 충분하다.

이번엔 응답용 레코드도 만든다. MemberResponse.java.

package com.example.demo;

// 서버가 클라이언트에게 돌려줄 응답 형태
public record MemberResponse(Long id, String name, String email) {
}

요청과 응답을 굳이 나누는 이유는, 나중에 비밀번호 같은 필드가 요청에는 있지만 응답에는 절대 나가면 안 되는 상황이 생기기 때문이다. 처음부터 분리해두면 나중에 실수할 여지가 줄어든다.


Step 3. POST 엔드포인트 작성

MemberController.java를 만든다.

package com.example.demo;

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.atomic.AtomicLong;

@RestController
public class MemberController {

    // 실제로는 DB의 auto increment가 이 역할을 하지만,
    // 지금은 DB 없이 번호만 증가시키는 용도로 씀
    private final AtomicLong sequence = new AtomicLong(1);

    // @RequestBody: HTTP 요청 body의 JSON을 MemberRequest 객체로 변환
    // @Valid: 변환한 객체에 붙은 @NotBlank, @Email 등을 검사
    @PostMapping("/members")
    public MemberResponse register(@Valid @RequestBody MemberRequest request) {
        long id = sequence.getAndIncrement();
        return new MemberResponse(id, request.name(), request.email());
    }
}
주의 @Valid를 빼먹으면 @NotBlank@Email이 붙어 있어도 아무 검사도 안 하고 그냥 통과시킨다. 검증 애노테이션을 붙이는 것과 @Valid로 검사를 실행시키는 것, 두 가지가 다 있어야 한다는 걸 기억해두자.

Step 4. 실행하고 정상 케이스 확인

터미널에서 프로젝트 루트로 이동해 실행한다.

./gradlew bootRun
Started DemoApplication in 1.85 seconds (process running for 2.335)

새 터미널을 열고(서버는 계속 켜둔 채) curl로 요청을 보낸다.

curl -i -X POST http://localhost:8080/members \
  -H "Content-Type: application/json" \
  -d '{"name":"홍길동","email":"hong@example.com"}'
HTTP/1.1 200
Content-Type: application/json
Transfer-Encoding: chunked

{"id":1,"name":"홍길동","email":"hong@example.com"}

같은 요청을 한 번 더 보내면 id가 2로 올라간다. AtomicLong이 요청마다 증가하고 있다는 것을 확인할 수 있다.

curl -X POST http://localhost:8080/members \
  -H "Content-Type: application/json" \
  -d '{"name":"김철수","email":"kim@example.com"}'
{"id":2,"name":"김철수","email":"kim@example.com"}

Step 5. 검증 실패 케이스 확인

이번엔 일부러 틀린 값을 보낸다.

curl -i -X POST http://localhost:8080/members \
  -H "Content-Type: application/json" \
  -d '{"name":"","email":"not-an-email"}'
HTTP/1.1 400
Content-Type: application/json

{"timestamp":"2026-07-01T23:52:44.360+00:00","status":400,"error":"Bad Request","path":"/members"}

400은 정확히 나왔지만 어떤 필드가 왜 틀렸는지는 이 응답만으로 알 수 없다. 서버 콘솔에는 원인이 다 찍혀 있다.

WARN ... DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MethodArgumentNotValidException:
Validation failed for argument [0] ... [Field error in object 'memberRequest' on field 'email': rejected value [not-an-email]; ... default message [email 형식이 올바르지 않습니다]]
[Field error in object 'memberRequest' on field 'name': rejected value []; ... default message [name은 비어 있을 수 없습니다]] ]

서버 로그를 열어봐야만 원인을 알 수 있는 건 클라이언트 입장에서는 불편하다. 이 필드 에러 정보를 응답 body에 그대로 담아 돌려주도록 다음 단계에서 정리한다.


Step 6. 에러 메시지 응답으로 정리하기

GlobalExceptionHandler.java를 새로 만든다.

package com.example.demo;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

// @RestControllerAdvice: 모든 @RestController에서 발생하는 예외를 한 곳에서 처리
@RestControllerAdvice
public class GlobalExceptionHandler {

    // @Valid 검증 실패 시 스프링이 던지는 예외를 여기서 가로챈다
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        // 실패한 필드마다 (필드명, 에러메시지) 쌍을 담는다
        ex.getBindingResult().getFieldErrors().forEach(error ->
                errors.put(error.getField(), error.getDefaultMessage())
        );
        return errors;
    }
}

컨트롤러 코드는 한 글자도 바꾸지 않았다. @RestControllerAdvice가 붙은 클래스가 있으면 스프링이 알아서 예외를 가로채서 이쪽으로 보내준다. 서버를 재시작하고 같은 요청을 다시 보내보자.

curl -i -X POST http://localhost:8080/members \
  -H "Content-Type: application/json" \
  -d '{"name":"","email":"not-an-email"}'
HTTP/1.1 400
Content-Type: application/json

{"name":"name은 비어 있을 수 없습니다","email":"email 형식이 올바르지 않습니다"}

이제 서버 로그를 열어보지 않아도 클라이언트가 응답만 보고 뭘 고쳐야 하는지 알 수 있는 것을 확인할 수 있다.


전체 코드 (복붙용)

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.4'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

MemberRequest.java

package com.example.demo;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record MemberRequest(
        @NotBlank(message = "name은 비어 있을 수 없습니다") String name,
        @NotBlank(message = "email은 비어 있을 수 없습니다")
        @Email(message = "email 형식이 올바르지 않습니다") String email
) {
}

MemberResponse.java

package com.example.demo;

public record MemberResponse(Long id, String name, String email) {
}

MemberController.java

package com.example.demo;

import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.atomic.AtomicLong;

@RestController
public class MemberController {

    private final AtomicLong sequence = new AtomicLong(1);

    @PostMapping("/members")
    public MemberResponse register(@Valid @RequestBody MemberRequest request) {
        long id = sequence.getAndIncrement();
        return new MemberResponse(id, request.name(), request.email());
    }
}

GlobalExceptionHandler.java

package com.example.demo;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
                errors.put(error.getField(), error.getDefaultMessage())
        );
        return errors;
    }
}

요청 처리 흐름

graph LR
    A["클라이언트 POST /members<br/>JSON body"] --> B["@RequestBody가<br/>MemberRequest로 변환"]
    B --> C{"@Valid 검증"}
    C -- "통과" --> D["MemberController.register 실행"]
    D --> E["MemberResponse 반환 → JSON"]
    C -- "실패" --> F["MethodArgumentNotValidException 발생"]
    F --> G["GlobalExceptionHandler가 가로챔"]
    G --> H["필드별 에러 메시지 JSON 응답"]

실행 결과 확인

실제로 curl 네 번을 순서대로 보내서 나온 결과를 그대로 옮겨왔다.

요청 결과
정상 값 첫 번째 요청{"id":1,"name":"홍길동","email":"hong@example.com"}
정상 값 두 번째 요청{"id":2,"name":"김철수","email":"kim@example.com"} (id 증가 확인)
검증 실패 (핸들러 추가 전)400, body에는 원인 없음, 서버 로그에서만 확인 가능
검증 실패 (핸들러 추가 후)400, {"name":"...","email":"..."} 형태로 원인 그대로 응답

[TODO: 본인 환경에서 실행한 IntelliJ 콘솔/터미널 스크린샷을 여기에 추가하세요]


자주 만나는 에러와 해결

에러 1: Content-Type 헤더를 빠뜨림 — 415 Unsupported Media Type
# -H "Content-Type: application/json" 없이 보낸 경우
curl -i -X POST http://localhost:8080/members \
  -d '{"name":"홍길동","email":"hong@example.com"}'
HTTP/1.1 415
{"timestamp":"...","status":415,"error":"Unsupported Media Type","path":"/members"}

원인: -H 옵션 없이 curl로 -d만 쓰면 curl이 자동으로 Content-Type: application/x-www-form-urlencoded를 붙인다. 컨트롤러는 JSON을 기다리고 있는데 폼 데이터라고 알려주니 스프링이 처리를 거부한다.

해결: -H "Content-Type: application/json"을 꼭 넣는다. Postman이나 Insomnia 같은 GUI 도구를 쓴다면 Body 탭에서 raw + JSON을 선택했는지 확인하면 된다.

에러 2: JSON 문법 오류 — 400 Bad Request (파싱 단계)
curl -i -X POST http://localhost:8080/members \
  -H "Content-Type: application/json" \
  -d '{"name":"홍길동" "email":"hong@example.com"}'

콤마(,) 하나가 빠졌다. 서버 로그에는 이렇게 찍힌다.

HttpMessageNotReadableException: JSON parse error: Unexpected character ('"' (code 34)):
was expecting comma to separate Object entries

원인: JSON 문법 자체가 깨졌다. 이건 @Valid가 실행되기도 전, JSON을 자바 객체로 바꾸는 파싱 단계에서 걸리는 에러라 GlobalExceptionHandler의 검증 로직과는 별개다.

해결: JSON을 직접 손으로 타이핑하지 말고 jq -n '{name:"홍길동", email:"hong@example.com"}'처럼 도구로 생성하거나 Postman의 Body 탭에서 작성하면 이런 실수를 줄일 수 있다.

에러 3: @Valid를 빼먹어서 검증이 통과되어 버림
// @Valid 없이 @RequestBody만 있으면
public MemberResponse register(@RequestBody MemberRequest request) {

이 상태에서 name을 빈 문자열로 보내도 200이 그대로 나온다. @NotBlank를 붙여놨는데 왜 안 걸리지 하고 한참 찾다가 @Valid를 빼먹은 걸 뒤늦게 발견하는 경우가 많다.

확인: 검증이 안 먹는다면 제일 먼저 파라미터 앞에 @Valid가 있는지부터 본다.

에러 4: 필드명이 안 맞아서 값이 null로 들어옴
{"username":"홍길동","email":"hong@example.com"}

MemberRequest의 필드는 name인데 클라이언트가 username으로 보내면 스프링은 모르는 key는 조용히 무시하고 namenull이 된다. @NotBlanknull도 걸러내므로 결과적으로 400은 뜨지만 원인을 착각하기 쉽다.

해결: 요청 JSON의 key와 레코드 필드명이 정확히 일치하는지 대소문자까지 확인한다.


핵심 요약

  • @RequestBody로 JSON을 받고, 자바 record로 요청/응답 객체를 분리한다
  • @NotBlank, @Email 같은 검증 애노테이션은 @Valid가 있어야 실제로 실행된다
  • 검증 실패는 기본적으로 400만 주고 원인은 서버 로그에만 남는다
  • @RestControllerAdvice + @ExceptionHandler로 필드별 에러 메시지를 응답에 그대로 담을 수 있다
  • 415는 Content-Type 헤더 문제, 400은 JSON 문법 또는 검증 실패로 원인이 갈린다

마무리

GET /hello에서 시작해서 POST /members로 JSON을 받고, 입력값을 검증하고, 에러 메시지까지 정리하는 데까지 왔다. @RequestBody + 레코드 조합은 처음엔 낯설어도 한 번 손에 익으면 CRUD API 대부분을 이 패턴으로 찍어낼 수 있다.

다음 단계로 해볼 것들.

  • MariaDB 연결: 지금은 AtomicLong으로 흉내만 낸 id 채번을 실제 DB의 auto increment로 바꾸기
  • JPA로 첫 CRUD: @Entity, @Repository로 저장/조회/수정/삭제까지 완성하기
  • PUT/DELETE 추가: 회원 정보 수정과 삭제 엔드포인트 만들기
  • 커스텀 검증: @Email, @NotBlank로는 부족한 "이메일 중복 확인" 같은 규칙을 직접 만들기

입력값 검증은 지금 만든 정도로는 사실 부족하다. 이메일 중복 체크나 비밀번호 규칙처럼 DB를 확인해야 하는 검증은 다음 글(MariaDB 연결)에서 이어서 다룬다.

반응형