Spring MVC와 REST API
7주 요청 DTO·검증·오류 응답·메모리 CRUD로 REST 계약과 상태 코드 확인하기
이번 문서에서 만드는 것
7주는 6주 프로젝트에 회원 CRUD와 입력 검증을 추가합니다. 응답 DTO MemberResponse는 그대로 사용하고 Controller·Service를 아래 코드로 교체합니다. 모든 Java 파일의 위치는 src/main/java/com/example/lion/입니다.
이번 저장소는 단일 서버 메모리이므로 재시작하면 사라집니다. 가상 데이터로 로컬에서만 연습하며 공개 쓰기 API는 인증·권한을 추가한 뒤 제공합니다.
먼저 요청·응답 계약 정하기
| 요청 | 성공 | 실패 예 |
|---|---|---|
| GET /api/members | 200 배열, 없으면 \[\] | 서버 오류 |
| GET /api/members/\{id\} | 200 회원 | 없는 ID 404 |
| POST /api/members | 201 회원과 Location | 잘못된 이름 400 |
| PUT /api/members/\{id\} | 200 수정된 회원 | 없는 ID 404, 입력 400 |
| DELETE /api/members/\{id\} | 204, 본문 없음 | 없는 ID 404 |
이 예제의 입력 필드는 name 하나라 PUT으로 전체 수정합니다. 일부 필드 수정은 별도 PATCH 계약이 필요합니다. 삭제 반복 요청에서 404를 반환해도 최종 상태가 동일하면 HTTP 멱등성의 의미에 어긋나지 않습니다.
입력 DTO와 오류 형식
MemberInput.java:
package com.example.lion;
import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.Size;
public record MemberInput(@NotBlank @Size(max = 40) String name) {}ApiExceptionHandler.java:
package com.example.lion;
import java.util.NoSuchElementException;import org.springframework.http.HttpStatus;import org.springframework.http.ProblemDetail;import org.springframework.web.bind.MethodArgumentNotValidException;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvicepublic class ApiExceptionHandler { @ExceptionHandler(NoSuchElementException.class) public ProblemDetail notFound(NoSuchElementException error) { return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다."); }
@ExceptionHandler(MethodArgumentNotValidException.class) public ProblemDetail invalidInput(MethodArgumentNotValidException error) { return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "이름은 공백이 아닌 1~40자여야 합니다."); }}ProblemDetail은 status·title·detail 등으로 오류를 표현합니다. 이번 handler는 위 두 예외만 형식을 정합니다. JSON 문법 오류나 타입 변환 오류까지 같은 상세 문구가 나온다고 가정하지 않습니다. 의도하지 않은 예외를 200이나 404로 바꾸어 숨기지 않습니다.
메모리 Service
MemberService.java 전체를 교체합니다.
package com.example.lion;
import java.util.List;import java.util.Map;import java.util.NoSuchElementException;import java.util.TreeMap;import org.springframework.stereotype.Service;
@Servicepublic class MemberService { private final Map<Long, MemberResponse> members = new TreeMap<>(); private long nextId = 1;
public synchronized List<MemberResponse> findAll() { return List.copyOf(members.values()); }
public synchronized MemberResponse findOne(long id) { MemberResponse member = members.get(id); if (member == null) throw new NoSuchElementException(); return member; }
public synchronized MemberResponse create(MemberInput input) { MemberResponse member = new MemberResponse(nextId++, input.name().strip()); members.put(member.id(), member); return member; }
public synchronized MemberResponse update(long id, MemberInput input) { findOne(id); MemberResponse member = new MemberResponse(id, input.name().strip()); members.put(id, member); return member; }
public synchronized void delete(long id) { findOne(id); members.remove(id); }}synchronized는 이 Bean의 메모리 접근을 같은 모니터로 보호합니다. 서버 여러 대의 공유 저장이나 DB 트랜잭션을 대신하지 않습니다. 메모리 예제를 운영 저장소로 확장하지 말고 다음 주 JPA로 교체합니다.
HTTP Controller
MemberController.java 전체를 교체합니다.
package com.example.lion;
import java.net.URI;import java.util.List;import jakarta.validation.Valid;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;
@RestController@RequestMapping("/api/members")public class MemberController { private final MemberService service; public MemberController(MemberService service) { this.service = service; }
@GetMapping public List<MemberResponse> findAll() { return service.findAll(); }
@GetMapping("/{id}") public MemberResponse findOne(@PathVariable("id") long id) { return service.findOne(id); }
@PostMapping public ResponseEntity<MemberResponse> create(@Valid @RequestBody MemberInput input) { MemberResponse member = service.create(input); return ResponseEntity.created(URI.create("/api/members/" + member.id())).body(member); }
@PutMapping("/{id}") public MemberResponse update(@PathVariable("id") long id, @Valid @RequestBody MemberInput input) { return service.update(id, input); }
@DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable("id") long id) { service.delete(id); return ResponseEntity.noContent().build(); }}요청 보내고 결과 확인하기
member.json을 프로젝트 루트에 UTF-8로 저장합니다.
{"name":"가람"}서버 실행 후 별도 터미널에서 실행합니다. Windows PowerShell에서는 alias 혼동을 피하려고 curl 대신 curl.exe를 사용합니다.
curl -i http://localhost:8080/api/memberscurl -i -X POST http://localhost:8080/api/members -H "Content-Type: application/json" --data-binary @member.jsonPowerShell에서는 --data-binary '@member.json'처럼 파일 인자를 따옴표로 묶습니다. 응답에서 실제 id를 읽고 {id}를 그 숫자로 바꾸어 아래 요청을 실행합니다. 중괄호를 그대로 전송하지 않습니다.
curl -i http://localhost:8080/api/members/{id}curl -i -X PUT http://localhost:8080/api/members/{id} -H "Content-Type: application/json" --data-binary @member.jsoncurl -i -X DELETE http://localhost:8080/api/members/{id}수정 전 JSON의 name을 다른 가상 이름으로 바꿉니다. 삭제 응답 204에는 JSON 본문이 없으므로 프론트에서 무조건 response.json()을 호출하면 안 됩니다.
세션 실습과 완료 기준
권장 110분: 계약 15분, 코드 연결 40분, 정상·실패 요청 35분, 리뷰 20분입니다. 빈 이름, 41자 이름, 없는 ID, 잘못된 JSON을 보내고 상태·본문·DB 대신 현재 메모리 상태를 함께 기록합니다.
완료 기준은 위 다섯 API를 실행하고 201의 Location·204의 빈 본문·400과 404의 차이를 설명하는 것입니다. 다음 JPA 문서로 넘어갈 때 URL과 DTO 계약을 유지하며 저장 방식만 교체합니다.
더 읽어보기
연결된 PBL 미션과 VOD
| 주차·미션 | 참고 VOD 범위 |
|---|---|
| 7주 · REST CRUD | Spring Boot 실습 3·14장 |
6–10주의 팝오버에는 Spring Boot 실습 강의명·장만 있고 직접 강좌 URL이 없다. 해당 미션의 참고 VOD 안내를 사용한다. 10주 VOD의 프론트 예제는 SvelteKit이며 React 전환을 필수로 요구하지 않는다.