Spring Boot 3 핵심 정리 시리즈 2편. Spring MVC로 REST API 만드는 법을 처음부터 풀어 갑니다 — REST URI 설계 원칙, HTTP 상태 코드 정확히 쓰기, @RestController 와 @PathVariable·@RequestBody, ResponseEntity 로 응답 제어, @ControllerAdvice 와 @ExceptionHandler 전역 예외 처리, RFC 7807 ProblemDetail, MockMVC 로 컨트롤러 테스트까지. 회사 안내 데스크 비유로 풀어쓴 친절한 2편.
이 글은 Spring Boot 3 핵심 정리 시리즈의 두 번째 편입니다. 1편에서 Spring Boot 가 무엇을 자동으로 해 주는 도구인지, IoC·DI 가 어떻게 회사 직원 관리 부서처럼 동작하는지를 잡았다면, 2편에서는 Spring MVC 로 본격적인 REST API 를 만드는 흐름을 풀어 갑니다.
Spring MVC 는 자바 백엔드 개발자가 가장 자주 접하는 영역이에요. @RestController 한 줄만 박으면 JSON 응답을 자동으로 직렬화해 주고, @PathVariable·@RequestBody 로 요청을 깔끔하게 파싱해 주고, ResponseEntity 로 상태 코드·헤더·본문을 마음껏 제어할 수 있어요. 이 글에서는 그 흐름 전체를 — REST 설계 원칙, 컨트롤러 패턴, 예외 처리, MockMVC 테스트까지 — 한 번에 묶어 정리합니다.
본문은 Spring MVC = 회사 안내 데스크 비유를 따라 풀어 가요. 손님(클라이언트)이 와서 어떤 자원을 원하는지 말하면, 안내 데스크(@RestController)가 적절한 부서(서비스)로 연결해 주고, 응답 봉투(ResponseEntity)에 담아 다시 손님에게 돌려주는 흐름이에요. 이 비유 하나만 잡고 있으면 어노테이션이 한꺼번에 정리됩니다.
왜 Spring MVC 가 처음엔 어렵게 느껴질까요
이유는 네 가지예요.
첫째, REST·HTTP 개념과 어노테이션이 함께 쏟아져요. @GetMapping·@PostMapping·@PathVariable·@RequestBody·@ResponseBody·@RequestMapping·@ResponseStatus… 한 컨트롤러에 이 단어들이 줄지어 등장하면 머리가 어지러워집니다.
둘째, 상태 코드를 어떻게 정확히 보내야 하는지 안 보여요. "그냥 200 OK 만 보내면 되지 않나" 싶은데, REST 규약은 생성 시 201, 삭제 성공 시 204, 자원 없으면 404 같이 꽤 깐깐하게 구분해요.
셋째, 예외 처리가 컨트롤러 안에 박힌다고 생각해요. 실제로 Spring MVC 는 @ControllerAdvice 라는 전역 예외 핸들러를 따로 두는 게 정석인데, 처음 보는 사람한테는 이 분리가 어색해요.
넷째, MockMVC 라는 테스트 프레임워크가 한 번 더 진입 장벽을 만들어요. "테스트인데 왜 MockMvc·MockitoBean·jsonPath 같은 새 단어가 또 나오지" 싶은 마음이 들죠.
해결법은 한 가지예요. Spring MVC 를 "회사 안내 데스크" 로 잡고 손님 응대 흐름을 따라가면 갑자기 명확해집니다. 컨트롤러 = 안내 데스크 직원, ResponseEntity = 응답 봉투, ControllerAdvice = 본사 차원의 응대 매뉴얼, MockMVC = 모의 손님을 보내 응대 절차를 시험해 보는 훈련소예요. 이 글은 그 비유를 따라 처음부터 풀어 갑니다.
REST API 설계 원칙 — Spring MVC 가 따르는 큰 그림
Spring MVC 로 코드를 짜기 전에 REST 가 어떤 원칙을 따르는지 한 번 짚어요. REST(Representational State Transfer) 는 HTTP 프로토콜의 기존 기능을 최대한 활용해 자원(Resource)을 URI 로 표현하고, 행위는 HTTP 메서드로 표현하는 아키텍처 스타일이에요.
REST 의 4 가지 핵심 제약 조건을 회사 비유로 풀면 이래요.
- 클라이언트-서버 분리 — UI 와 데이터 저장소는 서로 독립이에요. 안내 데스크와 사무실 부서는 따로 있고 각자 발전합니다.
- 무상태(Stateless) — 한 번의 요청이 그 자체로 완전해야 해요. 손님이 매번 자기 신분증을 가져오는 식이에요. JWT 토큰 인증이 이 원칙을 따릅니다.
- 계층화 시스템 — 손님은 안내 데스크 뒤에 어떤 부서가 있는지 몰라도 됩니다. 로드 밸런서·게이트웨이가 끼어 있어도 인터페이스만 같으면 OK 예요.
- 균일한 인터페이스 — 자원은 URI 로, 행위는 HTTP 메서드로. 이게 REST 의 가장 빛나는 아이디어예요.
URI 설계 한 줄 요약은 — 자원은 명사로, 행위는 HTTP 메서드로. 복수형 권장이에요.
REST URI 설계 예시:
GET /api/v1/products → 전체 상품 목록 조회
GET /api/v1/products/{id} → 특정 상품 조회
POST /api/v1/products → 새 상품 생성
PUT /api/v1/products/{id} → 상품 전체 수정
PATCH /api/v1/products/{id} → 상품 부분 수정
DELETE /api/v1/products/{id} → 상품 삭제
여기서 시험 함정이 하나 있어요. /api/v1/getProducts 같은 동사형 URI 는 REST 가 아니에요. 행위는 이미 HTTP 메서드(GET)에 들어 있으니 URI 에는 명사만 남아야 합니다.
HTTP 메서드와 상태 코드 — 회사 응대 매뉴얼
Spring MVC 컨트롤러를 잘 짜려면 HTTP 메서드와 상태 코드 의미를 정확히 알아야 해요. REST API 는 응답의 상태 코드만 보고 클라이언트가 무슨 일이 일어났는지 이해할 수 있어야 하거든요. 단순히 200 만 박는 건 회사 안내 데스크가 모든 요청에 "네"만 대답하는 식이에요.
HTTP 메서드 의미를 한 줄씩 정리하면:
GET— 자원 조회. 안전(Safe) + 멱등(Idempotent). 여러 번 호출해도 서버 상태가 안 바뀝니다.POST— 새 자원 생성. 멱등하지 않음. 같은 요청을 두 번 보내면 자원 두 개가 생겨요.PUT— 자원 전체 교체. 멱등. 같은 PUT 요청을 여러 번 해도 결과는 같아요.PATCH— 자원 부분 수정. 반드시 멱등할 필요는 없음.DELETE— 자원 삭제. 멱등. 이미 삭제된 자원을 다시 삭제해도 결과는 동일해요.
자주 쓰는 상태 코드:
| 상태 코드 | 이름 | 사용 예 |
|---|---|---|
| 200 | OK | GET·PUT·PATCH 성공 |
| 201 | Created | POST 성공 (Location 헤더 필수) |
| 204 | No Content | DELETE 성공·PUT/PATCH 응답 본문 없을 때 |
| 400 | Bad Request | 유효성 검증 실패 |
| 401 | Unauthorized | 인증 필요 (로그인 안 된 상태) |
| 403 | Forbidden | 인가 실패 (권한 없음) |
| 404 | Not Found | 자원을 찾을 수 없음 |
| 409 | Conflict | 충돌 (이미 존재하는 이메일 등) |
| 422 | Unprocessable Entity | 의미상 오류 (비즈니스 규칙 위반) |
| 500 | Internal Server Error | 서버 내부 오류 |
여기서 시험 함정이 하나 있어요. 401(Unauthorized)와 403(Forbidden)을 헷갈리지 마세요. 401 은 "당신이 누구인지 모르겠어요(로그인 필요)", 403 은 "당신이 누군지는 알지만 이건 못 봐요(권한 부족)" 예요. 영어 단어가 이름에 안 맞게 좀 헷갈리게 붙어 있어서 처음 볼 때 자주 틀립니다.
@RestController — 안내 데스크의 정체
Spring MVC 에서 컨트롤러를 만드는 가장 흔한 어노테이션이 @RestController 예요. 이건 사실 두 어노테이션의 합성이에요.
| 구성 어노테이션 | 역할 |
|---|---|
@Controller | 일반 컨트롤러 — 메서드 반환값을 ViewResolver 가 뷰 이름으로 해석 |
@ResponseBody | 메서드 반환값을 HTTP 응답 본문으로 직접 직렬화 (JSON·XML) |
@RestController | 위 둘의 합성 — REST API 전용 |
JSON 직렬화는 클래스패스에 Jackson 라이브러리가 있으면 자동이에요. Spring Boot 의 spring-boot-starter-web 에 이미 포함돼 있어서 별도 설정이 필요 없어요. 회사 비유로 — Jackson 은 안내 데스크가 자바 객체를 JSON 봉투에 자동으로 담아 주는 포장 직원이에요.
전형적인 REST 컨트롤러 한 벌:
@RestController
@RequestMapping("/api/v1/products") // 공통 URL 접두사
@RequiredArgsConstructor // 생성자 주입 자동
public class ProductController {
private final ProductService productService;
// GET /api/v1/products - 목록 조회
@GetMapping
public List<ProductDTO> listProducts() {
return productService.listProducts();
}
// GET /api/v1/products/{productId} - 단건 조회
@GetMapping("/{productId}")
public ResponseEntity<ProductDTO> getProductById(@PathVariable("productId") UUID productId) {
return productService.getProductById(productId)
.map(ResponseEntity::ok) // 찾으면 200 OK
.orElse(ResponseEntity.notFound().build()); // 없으면 404 Not Found
}
// POST /api/v1/products - 새 상품 생성
@PostMapping
public ResponseEntity<ProductDTO> handlePost(@RequestBody @Validated ProductDTO product) {
ProductDTO savedProduct = productService.saveNewProduct(product);
// 생성된 자원의 URI를 Location 헤더에 포함하여 201 반환
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(savedProduct.getId())
.toUri();
return ResponseEntity.created(location).build();
}
// PUT /api/v1/products/{productId} - 전체 수정
@PutMapping("/{productId}")
public ResponseEntity<Void> updateById(@PathVariable("productId") UUID productId,
@RequestBody @Validated ProductDTO product) {
if (productService.updateProductById(productId, product).isEmpty()) {
throw new NotFoundException(); // 자원 없으면 404
}
return ResponseEntity.noContent().build(); // 204 No Content
}
// DELETE /api/v1/products/{productId} - 삭제
@DeleteMapping("/{productId}")
public ResponseEntity<Void> deleteById(@PathVariable("productId") UUID productId) {
if (!productService.deleteById(productId)) {
throw new NotFoundException();
}
return ResponseEntity.noContent().build(); // 204 No Content
}
// PATCH /api/v1/products/{productId} - 부분 수정
@PatchMapping("/{productId}")
public ResponseEntity<Void> patchById(@PathVariable("productId") UUID productId,
@RequestBody ProductDTO product) {
productService.patchProductById(productId, product);
return ResponseEntity.noContent().build();
}
}
여기서 시험 함정이 하나 있어요. @PathVariable 의 이름을 항상 명시하세요. 메서드 매개변수 이름과 URL 템플릿 변수 이름이 다를 때, 컴파일 플래그(-parameters) 설정에 따라 동작할 수도 안 할 수도 있어요. @PathVariable("productId") UUID productId 처럼 이름을 명시하면 컴파일 환경에 상관없이 항상 안전합니다.
> 한 줄 정리 — @RestController = @Controller + @ResponseBody. JSON 직렬화는 Jackson 자동, Path 변수는 항상 이름 명시.
ResponseEntity — 응답 봉투의 자유로운 제어
ResponseEntity 는 HTTP 응답의 상태 코드·헤더·본문을 완전히 제어할 수 있게 해 주는 클래스예요. 그냥 객체만 반환하면 200 OK 가 자동으로 붙는데, 201·204·404 등 다양한 상태 코드를 보내고 Location 같은 헤더를 함께 보내려면 ResponseEntity 가 필수예요.
회사 비유로 — ResponseEntity 는 응답 봉투에 어떤 도장(상태 코드)을 찍을지, 어떤 부속 메모(헤더)를 끼워 넣을지, 본문에 무엇을 담을지 자유롭게 정하는 장치예요.
자주 쓰는 패턴 모음:
// 1. 200 OK with body
return ResponseEntity.ok(productDTO);
// 2. 200 OK with custom headers
HttpHeaders headers = new HttpHeaders();
headers.add("X-Custom-Header", "value");
return ResponseEntity.ok().headers(headers).body(productDTO);
// 3. 201 Created with Location header
URI location = URI.create("/api/v1/products/" + savedProduct.getId());
return ResponseEntity.created(location).build();
// 4. 204 No Content (본문 없음)
return ResponseEntity.noContent().build();
// 5. 404 Not Found
return ResponseEntity.notFound().build();
// 6. 400 Bad Request with error body
return ResponseEntity.badRequest().body(errorResponse);
// 7. 커스텀 상태 코드
return ResponseEntity.status(HttpStatus.CONFLICT).body("Already exists");
// 8. Optional을 활용한 간결한 패턴
return productService.getProductById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
여기서 시험 함정이 하나 있어요. POST 로 자원을 생성한 후 201 Created 응답에는 반드시 Location 헤더를 포함해야 합니다. REST 규약상 클라이언트가 생성된 자원을 즉시 조회할 수 있어야 하기 때문이에요. ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(savedId).toUri() 패턴이 가장 깔끔합니다.
> 한 줄 정리 — ResponseEntity = 응답 봉투의 도장·메모·본문 제어. POST 후 201 에는 반드시 Location 헤더.
Spring MVC 예외 처리 — @ControllerAdvice 가 본사 매뉴얼
Spring MVC 에서 예외 처리는 컨트롤러 안에 박지 않고 @ControllerAdvice 로 따로 빼는 게 정석이에요. 이렇게 하면 비즈니스 로직과 예외 처리 로직이 분리되고, 모든 컨트롤러가 일관된 에러 응답 형식을 갖게 됩니다.
회사 비유로 — @ControllerAdvice 는 본사 차원의 응대 매뉴얼이에요. 손님이 컴플레인을 했을 때 각 부서가 자기 식대로 처리하면 회사 이미지가 일관되지 못하잖아요. 본사에서 "이런 컴플레인은 이렇게 응대" 매뉴얼을 정해 두면 회사 전체가 같은 톤으로 응대합니다.
예외 처리 4 가지 방법을 권장도 순으로 정리하면:
방법 1: @ResponseStatus 어노테이션 (간단·제한적)
// 404에 해당하는 커스텀 예외
@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "Value Not Found")
public class NotFoundException extends RuntimeException {
public NotFoundException() { super(); }
public NotFoundException(String message) { super(message); }
}
// 컨트롤러에서 예외를 던지면 자동으로 적절한 상태 코드 반환
@GetMapping("/{productId}")
public ProductDTO getProductById(@PathVariable UUID productId) {
return productService.getProductById(productId)
.orElseThrow(NotFoundException::new);
}
상태 코드만 컨트롤하기에는 충분하지만 응답 본문 형식을 마음껏 제어하긴 어려워요.
방법 2: @ExceptionHandler (컨트롤러 로컬)
특정 컨트롤러에서만 유효한 예외 핸들러예요. 작은 단위로 처리할 때 OK 인데, 같은 예외가 여러 컨트롤러에서 발생하면 중복 코드가 생겨요.
방법 3: @ControllerAdvice (전역, 권장)
@RestControllerAdvice // = @ControllerAdvice + @ResponseBody
public class CustomExceptionHandler {
// 유효성 검증 실패 시 처리
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<List<Map<String, String>>> handleBindErrors(
MethodArgumentNotValidException exception) {
List<Map<String, String>> errorList = exception.getBindingResult().getFieldErrors()
.stream()
.map(fieldError -> {
Map<String, String> errorMap = new HashMap<>();
errorMap.put("fieldName", fieldError.getField());
errorMap.put("errorMessage", fieldError.getDefaultMessage());
return errorMap;
})
.collect(Collectors.toList());
return ResponseEntity.badRequest().body(errorList);
}
// 자원 없음 예외 처리
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<Void> handleNotFoundException(NotFoundException e) {
return ResponseEntity.notFound().build();
}
// 기타 예상치 못한 예외 처리
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, String>> handleGenericException(Exception e) {
Map<String, String> errorMap = new HashMap<>();
errorMap.put("error", "Internal Server Error");
errorMap.put("message", e.getMessage());
return ResponseEntity.internalServerError().body(errorMap);
}
}
여기서 정말 중요한 시험 함정 — @ControllerAdvice 와 @RestControllerAdvice 차이예요. @ControllerAdvice 만 쓰면 응답 본문이 직렬화되지 않아요. @RestControllerAdvice 를 써야 자동으로 @ResponseBody 가 포함됩니다. 또는 @ControllerAdvice + 메서드마다 @ResponseBody 를 박아도 됩니다.
방법 4: ProblemDetail (RFC 7807, Spring Boot 3 표준)
Spring Boot 3 부터는 RFC 7807 표준에 맞는 ProblemDetail 을 지원해요. application.properties 에 spring.mvc.problemdetails.enabled=true 를 설정하면 자동으로 활성화됩니다. RFC 7807 표준 에러 응답 구조에 대한 자세한 내용은 Spring Framework 공식 문서에서 확인할 수 있어요.
@RestControllerAdvice
public class ProblemDetailExceptionHandler {
@ExceptionHandler(NotFoundException.class)
ProblemDetail handleNotFoundException(NotFoundException e) {
ProblemDetail problemDetail = ProblemDetail
.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
problemDetail.setProperty("timestamp", LocalDateTime.now());
return problemDetail;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail handleValidationException(MethodArgumentNotValidException e) {
ProblemDetail problemDetail = ProblemDetail
.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation Failed");
problemDetail.setProperty("errors", e.getBindingResult().getFieldErrors()
.stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.collect(Collectors.toList()));
return problemDetail;
}
}
ProblemDetail 응답은 application/problem+json MIME 타입으로 자동 반환돼요. 표준이라 다양한 클라이언트가 자동으로 파싱할 수 있는 게 큰 장점이에요.
> 한 줄 정리 — Spring MVC 예외 처리 정답은 @RestControllerAdvice. Spring Boot 3 라면 ProblemDetail 도 강력 추천.
MockMVC — 모의 손님 보내기
REST 컨트롤러를 테스트할 때 가장 흔한 도구가 MockMVC 예요. 실제 HTTP 서버를 띄우지 않고도 Spring MVC 의 DispatcherServlet·필터·핸들러 매핑 등 웹 레이어 전체를 모의해서 테스트할 수 있어요.
회사 비유로 — MockMVC 는 모의 손님 훈련소예요. 실제 매장을 열지 않고도 안내 데스크 직원에게 다양한 손님 시나리오를 던져 응대 절차를 검증해 볼 수 있어요.
MockMVC 설정 방식 비교:
| 구분 | @WebMvcTest | @SpringBootTest + MockMvc | Standalone MockMvc |
|---|---|---|---|
| 컨텍스트 로드 | 웹 레이어만 | 전체 애플리케이션 | 없음 |
| 속도 | 빠름 | 느림 | 가장 빠름 |
| 통합도 | 중간 | 높음 | 낮음 |
| 서비스 Mock | 필요 (@MockitoBean) | 선택적 | 필요 |
| 보안 필터 | 포함 | 포함 | 미포함 |
| 주요 사용처 | 컨트롤러 단위 테스트 | 통합 테스트 | 단순 컨트롤러 테스트 |
가장 자주 쓰는 패턴은 @WebMvcTest + @MockitoBean 이에요. 웹 레이어만 빠르게 띄우고 서비스는 Mock 으로 채워 넣는 방식이에요.
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
MockMvc mockMvc; // MockMVC 자동 주입
@MockitoBean // Spring Boot 3.4+에서 @MockBean 대체 (Mockito 5.x)
ProductService productService;
@Autowired
ObjectMapper objectMapper; // JSON 직렬화/역직렬화
ProductDTO testProduct;
@BeforeEach
void setUp() {
testProduct = ProductDTO.builder()
.id(UUID.randomUUID())
.productName("Sample Product")
.category("Books")
.price(new BigDecimal("9.99"))
.quantityOnHand(10)
.build();
}
@Test
void testGetProductById() throws Exception {
// given - 서비스가 특정 값을 반환하도록 Mock 설정
given(productService.getProductById(testProduct.getId()))
.willReturn(Optional.of(testProduct));
// when & then - MockMVC로 요청 수행 및 검증
mockMvc.perform(get("/api/v1/products/" + testProduct.getId())
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(testProduct.getId().toString()))
.andExpect(jsonPath("$.productName").value("Sample Product"));
}
@Test
void testGetProductByIdNotFound() throws Exception {
given(productService.getProductById(any(UUID.class)))
.willReturn(Optional.empty());
mockMvc.perform(get("/api/v1/products/" + UUID.randomUUID())
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
void testCreateProduct() throws Exception {
ProductDTO productToCreate = ProductDTO.builder()
.productName("New Product")
.category("Electronics")
.price(new BigDecimal("14.99"))
.build();
given(productService.saveNewProduct(any(ProductDTO.class)))
.willReturn(testProduct);
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(productToCreate)))
.andExpect(status().isCreated())
.andExpect(header().exists("Location")); // Location 헤더 존재 확인
}
@Test
void testDeleteProduct() throws Exception {
given(productService.deleteById(testProduct.getId())).willReturn(true);
mockMvc.perform(delete("/api/v1/products/" + testProduct.getId()))
.andExpect(status().isNoContent());
// verify - 서비스 메서드가 정확히 한 번 호출되었는지 확인
verify(productService).deleteById(testProduct.getId());
}
}
여기서 시험 함정이 하나 있어요. @WebMvcTest 를 쓸 때 의존하는 서비스 빈을 @MockitoBean 으로 등록하지 않으면 컨텍스트 로드가 실패합니다. 에러 메시지는 No qualifying bean of type 'ProductService' available 이에요. 이 에러를 보면 거의 항상 @MockitoBean 누락이에요.
또 하나의 함정 — Spring Boot 3.4+ 에서는 기존 @MockBean 대신 @MockitoBean 을 권장해요. Mockito 5.x 와의 호환성 때문에 점진적으로 교체되고 있어요. 새 프로젝트라면 @MockitoBean 으로 시작하세요.
ArgumentCaptor — 서비스 호출 인자까지 검증
MockMVC 로 응답만 검증하는 게 아니라, 컨트롤러가 서비스를 어떤 인자로 호출했는지까지 검증하고 싶을 때 ArgumentCaptor 를 써요.
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@MockitoBean
ProductService productService;
@Captor
ArgumentCaptor<UUID> uuidArgumentCaptor;
@Captor
ArgumentCaptor<ProductDTO> productArgumentCaptor;
@Test
void testPatchProduct() throws Exception {
UUID productId = UUID.randomUUID();
Map<String, Object> productMap = new HashMap<>();
productMap.put("productName", "New Name");
// PATCH 요청 및 204 응답 확인
mockMvc.perform(patch("/api/v1/products/" + productId)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(productMap)))
.andExpect(status().isNoContent());
// ArgumentCaptor로 서비스에 전달된 값 검증
verify(productService).patchProductById(uuidArgumentCaptor.capture(), productArgumentCaptor.capture());
assertThat(productId).isEqualTo(uuidArgumentCaptor.getValue());
assertThat(productMap.get("productName"))
.isEqualTo(productArgumentCaptor.getValue().getProductName());
}
}
회사 비유로 — ArgumentCaptor 는 안내 데스크 직원이 부서에 전달한 메모지를 가로채서 내용을 확인하는 감독관이에요. "그 직원이 정말 정확한 정보를 부서에 전달했는지" 까지 검증할 수 있어요.
자주 만나는 함정 5 가지
1. @PathVariable 이름 누락
변수 이름이 다르면 컴파일 환경에 따라 깨져요. 항상 @PathVariable("productId") 처럼 이름 명시.
2. POST 테스트에서 Content-Type 누락
// 잘못된 예 - 415 Unsupported Media Type 발생
mockMvc.perform(post("/api/v1/products")
.content(objectMapper.writeValueAsString(product)))
.andExpect(status().isCreated()); // 실패!
// 올바른 예
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON) // 필수!
.content(objectMapper.writeValueAsString(product)))
.andExpect(status().isCreated());
3. @WebMvcTest 와 @MockitoBean 누락
서비스 빈을 Mock 으로 등록하지 않으면 컨텍스트 로드 실패.
4. @ControllerAdvice 에서 @ResponseBody 누락
@RestControllerAdvice 를 쓰거나 메서드마다 @ResponseBody 추가.
5. 201 Created 시 Location 헤더 누락
REST 규약상 생성된 자원의 URI 를 Location 헤더에 반드시 포함.
MockMVC 디버깅 — andDo(print()) 활용
테스트가 깨질 때 가장 유용한 한 줄이 andDo(print()) 예요. 요청·응답의 전체 내용이 콘솔에 찍혀요.
mockMvc.perform(get("/api/v1/products"))
.andDo(print()) // 디버깅용: 요청/응답 전체 출력
.andExpect(status().isOk());
회사 비유로 — andDo(print()) 는 안내 데스크 응대 과정을 CCTV 로 녹화해서 다시 보여 주는 도구예요. 어떤 요청이 들어왔고 어떤 응답이 나갔는지 한 화면에 다 보여 줍니다.
Spring MVC 응답 흐름 정리
마지막으로 한 요청이 들어왔을 때 Spring MVC 가 어떤 흐름으로 처리하는지 한 번에 정리해요.
| 단계 | 컴포넌트 | 역할 |
|---|---|---|
| 1 | DispatcherServlet | 모든 HTTP 요청의 첫 입구 |
| 2 | HandlerMapping | URL·메서드를 보고 어느 컨트롤러로 갈지 결정 |
| 3 | HandlerAdapter | 컨트롤러 메서드 호출 + 인자 변환(@PathVariable·@RequestBody) |
| 4 | Controller | 비즈니스 로직 실행 + 반환값 생성 |
| 5 | HttpMessageConverter | 반환 객체를 JSON 으로 직렬화 (Jackson) |
| 6 | DispatcherServlet | 응답을 클라이언트에 보냄 |
예외가 발생하면 별도 흐름으로 빠져요 — DispatcherServlet → HandlerExceptionResolver → @ControllerAdvice → 에러 응답 변환 → 응답.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 Spring MVC REST 2 편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- REST URI = 명사, 동사형(
/getProducts) 절대 X - HTTP 메서드 멱등성 — GET·PUT·DELETE = 멱등 / POST·PATCH = 비멱등
- GET = 안전(Safe) + 멱등 / POST = 비안전 + 비멱등
- 상태 코드 — POST 성공 = 201(+Location 헤더), DELETE 성공 = 204, 자원 없음 = 404
- 401(Unauthorized) = 인증 안 됨 / 403(Forbidden) = 권한 부족 — 이름과 의미가 살짝 어긋나는 함정
@RestController=@Controller+@ResponseBody— JSON 직렬화 자동@PathVariable("productId")— 항상 이름 명시@RequestBody+@Validated— 요청 본문 받으면서 유효성 검증- ResponseEntity — 상태 코드·헤더·본문 완전 제어
- POST 후 201 Created — Location 헤더 필수 (
ServletUriComponentsBuilder.fromCurrentRequest()) - 예외 처리 정답 —
@RestControllerAdvice(=@ControllerAdvice+@ResponseBody) - Spring Boot 3 라면 ProblemDetail (RFC 7807) 도 강력 추천
@ExceptionHandler(MethodArgumentNotValidException.class)— 유효성 검증 실패 처리- MockMVC 패턴 —
@WebMvcTest+@MockitoBean(Spring Boot 3.4+) @MockitoBean누락 시 —No qualifying bean of type ... available에러- POST 테스트에서
contentType(MediaType.APPLICATION_JSON)누락 시 415 - jsonPath —
$.fieldName으로 응답 JSON 의 필드 검증 - ArgumentCaptor — 컨트롤러가 서비스를 어떤 인자로 호출했는지 검증
- 디버깅 한 줄 —
andDo(print())으로 요청·응답 전체 확인 - Spring MVC 흐름 — DispatcherServlet → HandlerMapping → HandlerAdapter → Controller → HttpMessageConverter → 응답
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.
- 1편 — Spring Boot 입문
- 2편 — Spring MVC REST · MockMVC (현재 글)
- 3편 — Spring Data JPA · 검증
- 4편 — MySQL · Flyway · TestContainers
- 5편 — CSV 업로드 · 페이징 · 동적 쿼리
- 6편 — JPA 관계 매핑 심화
- 7편 — Spring Security · OAuth 2.0 · JWT
- 8편 — RestTemplate · RestClient
- 9편 — Reactive Programming · WebFlux 입문
- 10편 — WebFlux 심화 · MongoDB · WebClient
- 11편 — Cloud Gateway · Maven/Gradle · Buildpack
- 12편 — OpenAPI · Spring AI
- 13편 — Actuator · 관측성
- 14편 — Spring Cache · 이벤트
- 15편 — Docker · Compose · Kubernetes
- 16편 — 마이크로서비스 · Apache Kafka
- 17편 — Spring Professional · 베스트 프랙티스 (완)