요청에서 응답까지 설명하기
65분 안팎
학습 목표
Spring MVC의 DTO·서비스·오류 응답 흐름을 테스트로 확인합니다.
개념
요청이 지나간 층을 자신의 말로 설명합니다
서비스 단위 테스트가 통과해도 HTTP 입력을 잘못 변환하거나 다른 서비스 인스턴스를 쓰면 사용자에게 다른 결과가 보입니다. 이 레슨에서는 POST /tasks 요청 한 개와 그 뒤 GET /tasks를 따라갑니다. DTO, 컨트롤러, 서비스, 저장소와 응답 변환을 연결해 어떤 값이 어디서 결정되는지 설명합니다. Spring MVC는 마법처럼 결과를 만드는 도구가 아니라 입력과 출력 사이의 반복적인 변환을 맡는 경계입니다.
starter의 생성 메서드는 정상 값을 저장하지만 조회 메서드는 List.of()를 반환합니다. 빈 상태에서 GET만 시험하면 이 결함이 통과합니다. 먼저 생성하고 조회하는 순서를 실행해야 항상 빈 목록을 돌려주는 코드가 틀렸다는 것을 알 수 있습니다. 코드의 줄을 설명할 때 정상처럼 보이는 한 호출만 보지 않고 앞 호출이 만든 상태가 뒤 호출에서 보이는지 연결합니다. 같은 앱의 두 경로라는 사실을 결과로 확인하는 것이 목표입니다.
입력 본문과 DTO를 연결합니다
POST에는 Content-Type application/json과 title 필드를 가진 JSON 본문이 있습니다. RequestBody는 MVC가 본문을 읽어 CreateTask로 전달하는 위치를 표시합니다. record의 title은 Object이므로 문자열, 숫자와 null을 구분할 수 있습니다. title 누락은 이 DTO에서 null로 들어갑니다. 본문 자체가 깨졌으면 DTO 호출 이전에 읽기 오류가 납니다. 정상적인 JSON과 유효한 업무 입력은 서로 다른 조건이며 둘 다 확인해야 생성에 도달합니다.
컨트롤러의 create는 request.title()을 서비스 add로 전달합니다. 이 줄 앞에서 String.valueOf를 쓰면 숫자 입력의 의미가 달라집니다. 컨트롤러에 공백 정리와 길이 제한을 따로 구현하면 정책이 중복됩니다. 대신 원형 입력을 그대로 넘기고 기존 TitlePolicy가 판정하도록 합니다. 전달값, 반환값과 상태를 종이에 적으면 AI가 만든 코드에서 업무 판단이 경계로 새어 나왔는지 볼 수 있습니다.
업무 상태는 서비스가 소유합니다
TaskService.add는 TitlePolicy.normalize를 먼저 실행합니다. 유효한 제목이 나오면 저장소 크기로 다음 번호를 계산하고 새 Task를 만듭니다. Task의 done은 false입니다. repository.save가 같은 서비스의 저장소에 보관하고 생성된 Task를 반환합니다. 검증 이전에 번호를 증가시키거나 저장하지 않으므로 실패가 상태를 바꾸지 않는 기존 계약을 지킬 수 있습니다. 컨트롤러는 반환된 Task를 수정하거나 다른 저장소에 다시 저장하지 않습니다.
TaskApplication의 Bean 메서드는 앱에 TaskService를 공급합니다. 컨트롤러 생성자는 그 서비스를 받습니다. 실제 앱 구성에서는 여러 요청이 같은 서비스 상태를 관찰하는 구조를 의도합니다. 서비스가 요청마다 새로 만들어지면 GET은 생성 결과를 보지 못합니다. 이번 standalone MockMvc는 new TaskController(new TaskService())로 그 객체 관계를 명시해서 검사합니다. 따라서 이 검사 통과가 자동 빈 구성까지 검증했다는 뜻은 아닙니다.
현재 메모리 저장소는 단일 요청 학습 모델입니다. 여러 HTTP 요청을 동시에 처리하는 상황에서 번호 계산과 저장의 원자성을 보장하는 설계가 아닙니다. 이 레슨은 동시성 안전이나 재시작 후 저장을 주장하지 않습니다. TaskApplication이 있어도 운영 서비스로 바로 배포할 산출물은 아닙니다. 구조를 이해하는 단계와 운영 조건을 확인하는 단계를 구분해야 시험하지 않은 범위를 성공이라고 잘못 기록하지 않습니다.
응답의 모양을 상태와 함께 읽습니다
create가 반환한 Task를 ResponseEntity의 body로 전달하면 MVC의 JSON 변환기가 응답 표현을 만듭니다. 계약에서 정한 상태는 201이며 JSON 필드는 id, title, done입니다. record의 toString에서 보이는 Task[id=...]와 HTTP JSON은 표현이 다릅니다. 로그 문자열을 API 본문이라고 기록하지 않습니다. 응답은 status와 JSON 값을 함께 비교하고 키 순서에 의존하기보다 각 필드와 전체 구조를 확인합니다.
list는 service.list()의 결과를 반환하며 성공 상태는 기본 200입니다. 응답은 객체 안의 tasks가 아니라 배열 자체입니다. 처음에는 []이고 생성 뒤에는 [{"id":1,"title":"복습","done":false}]가 됩니다. Python 참조 모델의 apply는 ok와 tasks를 포함하지만 HTTP 경계의 계약은 별도입니다. 서비스 상태를 보존하면서 표현만 바꾸는 경우이므로 내부 모델의 맵을 응답으로 그대로 내보내지 않습니다.
오류가 끝나는 위치를 구별합니다
빈 문자열이 아니라 공백만 있는 문자열도 normalize 뒤 EMPTY_TITLE입니다. 이 예외는 저장 전에 발생하고 컨트롤러 ExceptionHandler가 400과 {"error":"EMPTY_TITLE"}로 바꿉니다. 잘못된 숫자·누락 제목은 INVALID_TITLE_TYPE입니다. 길이 상한을 넘으면 TITLE_TOO_LONG입니다. 상태 400만 확인하면 다른 원인의 오류도 통과할 수 있어 error 코드까지 비교합니다. 뒤 조회와 다음 생성 번호로 부작용도 확인합니다.
JSON의 중괄호가 닫히지 않은 요청은 서비스 add까지 도달하지 않습니다. HttpMessageNotReadableException을 다루는 메서드가 INVALID_JSON을 만듭니다. 이 경로는 DTO 이후의 업무 정책 예외와 구분됩니다. 오류 응답에서 Java 스택이나 임의의 전체 예외 내용을 공개하지 않고 계약한 코드를 내보냅니다. 모든 Throwable을 같은 400으로 숨기는 처리는 제공하지 않습니다. 예상하지 못한 프로그램 결함을 입력 실수로 오인하지 않도록 범위를 읽습니다.
MockMvc가 확인하는 경계를 정확히 말합니다
MockMvcBuilders.standaloneSetup은 명시한 컨트롤러로 MVC 요청 처리와 JSON 응답을 테스트합니다. perform(post(...))는 실제 소켓을 열지 않고 요청을 구성합니다. andExpect(status().isCreated())는 HTTP 상태를 비교하고 content().json은 JSON 결과를 비교합니다. 두 호출을 한 테스트에 순서대로 넣으면 생성된 상태를 조회가 관찰하는지도 확인합니다. 단위 서비스 검사보다 넓은 경계지만 실제 네트워크 시험과는 다릅니다.
BeforeEach에서 새 서비스를 준비하므로 각 테스트의 시작 목록은 비어 있습니다. createThenList 안의 두 POST와 GET은 같은 테스트 인스턴스의 컨트롤러를 사용합니다. 목록 검사는 배열 순서와 전체 필드를 비교합니다. 40개 이모지는 문자열 코드 포인트 계약의 상한을 확인하고 41개 한글은 초과 오류를 확인합니다. 길이를 바이트나 UTF-16 유닛으로 바꾸는 회귀가 경계에서 드러나는지 함께 봅니다.
실행 기록과 흐름표를 대조합니다
TraceTest는 POST 성공, GET 결과와 BLANK 오류를 target/http-trace.txt에 씁니다. 명령 뒤 cat으로 그 파일을 읽으면 상태와 본문을 한 줄씩 볼 수 있습니다. 출력은 관찰 자료이며 성공 여부는 기대값을 비교하는 테스트로 확인합니다. starter에서 trace의 GET은 비어 있으므로 생성 응답과 모순됩니다. 학습자는 '입력→DTO→서비스→저장소→응답' 표에 각 단계의 값을 적고 조회가 어디서 저장소와 연결되지 않았는지 표시합니다.
expected 배열 길이 2인데 actual 0이면 응답 형식만 바꾸기보다 list가 service.list를 호출하는지 찾습니다. NullPointerException이 나오면 컨트롤러 생성자의 service 필드와 테스트 준비를 확인합니다. 404는 GET 매핑 경로를, 400 INVALID_JSON은 본문 문법을 확인합니다. 실제 포트가 없는 MockMvc에서 server.port를 바꾸면 이런 실패 원인을 해결할 수 없습니다. 메시지·실행 층·관련 코드를 연결해서 설명합니다.
레슨 실습에서는 조회의 고정 빈 배열을 서비스 조회로 바꾸고 기존 생성과 오류 처리를 유지합니다. 미션에서는 앞 모듈 solution의 명세·Python·Java 검사를 그대로 이어받은 starter에서 생성 201과 조회 연결 두 결함을 각각 수정합니다. 생성 수정과 조회 수정을 서로 다른 요청으로 기록하고 단계마다 테스트를 실행합니다. 앞 레슨 답안을 그대로 덮기보다 해당 단계에서 달라진 코드와 필요한 검사를 비교합니다.
제출할 흐름표에는 정상 생성, 조회, 빈 제목, 숫자 제목, 깨진 JSON 다섯 경로를 넣습니다. 호출한 메서드, 바뀐 상태와 HTTP 결과를 구분해 적습니다. MockMvc 계약 통과, 실제 서버 미확인, 빈 구성 미확인을 정확히 기록하면 핵심 흐름을 설명할 근거가 생깁니다. 일반적인 변수 추적표와 코드 읽기 습관은 더 읽기로 연결하고 이번 레슨은 새 HTTP 경계가 기존 상태 계약을 어떻게 보존하는지 실행으로 확인합니다.
기술 근거: Spring Framework 6.0.13의 Spring MVC Test 문서에서 MockMvc의 테스트 경계를 확인할 수 있습니다. 이 실습의 실제 동작은 제공된 의존성과 테스트로 별도 확인합니다.
따라하기
조회 결함을 계약 검사로 관찰
starter에서 전체 검사를 실행합니다. emptyList는 통과할 수 있지만 createThenList는 배열 길이와 전체 값에서 실패합니다. 생성 상태가 조회에 보이지 않는 원인을 list 메서드에서 찾습니다.
./mvnw test조회 경계 한 줄을 연결
TaskController.list의 List.of()를 service.list()로 바꿉니다. 서비스와 DTO는 유지하고 해당 시나리오 후 전체 검사를 실행합니다. 보고서의 실패·오류·건너뜀을 함께 기록합니다.
./mvnw -Dtest=HttpContractTest#createThenList test
./mvnw test정상과 실패의 흐름 읽기
solution에서 테스트가 작성한 실제 응답 파일을 읽습니다. POST·GET·BLANK를 DTO 이전 실패와 서비스 이후 실패로 구분하고 저장소 변화와 연결해 설명합니다.
cat target/http-trace.txt실행 결과
POST 201 {"id":1,"title":"복습","done":false}
GET 200 [{"id":1,"title":"복습","done":false}]
BLANK 400 {"error":"EMPTY_TITLE"}
응답 JSON과 상태를 나란히 비교
이 코드는 관찰된 HTTP JSON을 읽는 보조 검사입니다. MVC를 실행하는 코드는 아니며 실제 경계 검사는 HttpContractTest로 확인합니다.
import json
created = json.loads('{"id":1,"title":"복습","done":false}')
listed = json.loads('[{"id":1,"title":"복습","done":false}]')
print("생성값과 조회값 일치=" + str(created == listed[0]))
print("신규 완료 상태=" + str(created["done"]))실행 결과
생성값과 조회값 일치=True 신규 완료 상태=False
확인 문제
실습
TaskController.list가 service.list를 반환하도록 조회 결함을 고칩니다. HTTP 정상 생성→조회와 빈 제목·숫자·깨진 JSON의 요청→DTO→서비스→저장소→응답 흐름표를 review.md에 제출합니다. 해당 경로의 상태 변화와 오류 처리 위치를 구분합니다. MockMvc 검사는 소켓과 자동 빈 구성을 확인하지 않는다는 제한을 기록합니다.
실행 명령
./mvnw test
기대 결과
JUnit 검사 38개가 실패·오류·건너뜀 없이 통과하고 종료 코드 0입니다. 범위 검사 출력은 SCOPE OK입니다.
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 많은 파일이 바뀐 diff를 검토하는 방법을 설명해 주시면 됩니다.
- AI가 만든 코드가 실행될 때 추가로 확인할 내용을 설명해 주시면 됩니다.