Spring MVC REST API — MockMVC까지 한 번에

2026-05-02AWS SAA-C03 스터디

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 핵심 정리 · 2편 / 14편 — MockMVC까지 한 번에

이 글은 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 가지 핵심 제약 조건을 회사 비유로 풀면 이래요.

  1. 클라이언트-서버 분리 — UI 와 데이터 저장소는 서로 독립이에요. 안내 데스크와 사무실 부서는 따로 있고 각자 발전합니다.
  2. 무상태(Stateless) — 한 번의 요청이 그 자체로 완전해야 해요. 손님이 매번 자기 신분증을 가져오는 식이에요. JWT 토큰 인증이 이 원칙을 따릅니다.
  3. 계층화 시스템 — 손님은 안내 데스크 뒤에 어떤 부서가 있는지 몰라도 됩니다. 로드 밸런서·게이트웨이가 끼어 있어도 인터페이스만 같으면 OK 예요.
  4. 균일한 인터페이스 — 자원은 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 — 자원 삭제. 멱등. 이미 삭제된 자원을 다시 삭제해도 결과는 동일해요.

자주 쓰는 상태 코드:

상태 코드이름사용 예
200OKGET·PUT·PATCH 성공
201CreatedPOST 성공 (Location 헤더 필수)
204No ContentDELETE 성공·PUT/PATCH 응답 본문 없을 때
400Bad Request유효성 검증 실패
401Unauthorized인증 필요 (로그인 안 된 상태)
403Forbidden인가 실패 (권한 없음)
404Not Found자원을 찾을 수 없음
409Conflict충돌 (이미 존재하는 이메일 등)
422Unprocessable Entity의미상 오류 (비즈니스 규칙 위반)
500Internal 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.propertiesspring.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 + MockMvcStandalone 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 가 어떤 흐름으로 처리하는지 한 번에 정리해요.

단계컴포넌트역할
1DispatcherServlet모든 HTTP 요청의 첫 입구
2HandlerMappingURL·메서드를 보고 어느 컨트롤러로 갈지 결정
3HandlerAdapter컨트롤러 메서드 호출 + 인자 변환(@PathVariable·@RequestBody)
4Controller비즈니스 로직 실행 + 반환값 생성
5HttpMessageConverter반환 객체를 JSON 으로 직렬화 (Jackson)
6DispatcherServlet응답을 클라이언트에 보냄

예외가 발생하면 별도 흐름으로 빠져요 — 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 → 응답

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!