도서 추가와 상세 조회
95분 안팎
학습 목표
JSON DTO와 GET·POST 계약을 구현합니다.
개념
등록과 재조회가 하나의 흐름입니다
목록이 보이는 것만으로 도서 관리가 완성되지는 않습니다. 담당자는 제목을 보내 도서를 등록한 뒤 응답의 식별자로 같은 도서를 다시 조회해야 합니다. 이번에는 JSON 입력 DTO, 서버의 ID 할당, 201과 Location, 상세의 200·404를 묶어서 구현합니다. 앞 모듈의 Book 생성자와 저장소 계약을 바꾸지 않고 HTTP 경계에서 요청과 응답 형태를 추가합니다. 미션에서도 기존 CLI와 50개 테스트를 유지한 채 이 기능을 확장합니다.
요청 DTO는 외부 입력의 범위입니다
CreateBook은 title 한 필드를 갖는 record입니다. @RequestBody가 application/json 본문을 이 타입으로 읽습니다. 클라이언트는 요청에서 서버의 ID나 대여 상태를 지정하는 계약을 갖지 않습니다. 응답에는 BookView의 id, title, borrowed가 있습니다. 요청과 응답 타입을 나누면 생성할 때 받아야 하는 값과 서버가 결정하는 값을 구분할 수 있습니다. 알 수 없는 JSON 필드의 처리 정책은 현재 변환기 설정에 달려 있으므로 그것까지 엄격한 거부 계약으로 문서화하지 않습니다.
JSON 문자열의 큰따옴표와 셸 문자열의 따옴표는 서로 다른 경계입니다. curl 예제의 작은따옴표는 셸이 JSON 본문을 하나의 인자로 전달하게 합니다. 제목에 따옴표가 들어갈 수 있는 일반 입력은 json.dumps 같은 직렬화기로 만들어 전송합니다. JSON을 보냈다는 것과 유효한 title을 보냈다는 것은 다릅니다. 파싱은 변환기가 하고 제목 규칙은 서비스가 확인합니다.
최소 입력 검증으로 저장 경계를 지킵니다
BookService.create는 null이나 공백뿐인 제목, 탭·CR·LF가 들어간 제목을 400으로 거부합니다. 탭과 줄바꿈은 앞 모듈의 TSV 파일 형식을 깨뜨리므로 HTTP 입력에서 먼저 거부합니다. 제목 양끝 공백은 앞 모듈 계약대로 보존하며 임의로 trim해 저장하지 않습니다. 이 단계에서는 상태만 정하고 일관된 오류 code·message DTO는 m05에서 구현합니다.
잘못된 JSON은 메시지 변환 단계에서 400, text/plain 본문은 이 DTO에 맞는 변환기를 찾지 못해 415가 됩니다. 서비스 검증보다 먼저 요청이 거부될 수 있으므로 Book 생성자에 모든 오류가 도달한다고 가정하지 않습니다. 입력 실패 테스트는 응답 상태뿐 아니라 파일이 생성되지 않았는지도 확인합니다. 실패한 요청이 목록을 일부 변경한 뒤 400을 반환하는 구현은 계약을 만족하지 못합니다.
기존 파일 키와 새 ID를 연결합니다
이 API는 기존 도서 ID의 최댓값에 1을 더해 새 키로 사용합니다. 키가 없는 상태라면 첫 ID는 1입니다. 앞 모듈 파일에 ID 10이 있으면 다음 생성 ID는 11입니다. 목록 크기+1을 쓰면 삭제된 키나 불연속 키에서 충돌할 수 있습니다. Integer.MAX_VALUE가 최댓값이면 덧셈 전에 409로 거부해 양수 ID가 음수로 넘치는 경우를 막습니다.
이 방식은 영구 단조 증가 식별자 정책이 아닙니다. 최대 키를 삭제하면 그 키가 이후 다시 할당될 수 있습니다. 이번 API는 수정·삭제를 노출하지 않지만 이전 CLI와 저장 파일에는 삭제 결과가 있을 수 있습니다. DB 모듈에서 키 정책을 다시 정할 수 있도록 제한을 명시합니다. 서비스의 synchronized는 최대값 읽기와 add를 하나의 서비스 접근 구간에 묶으며 여러 프로세스가 같은 파일에 쓰는 문제까지 해결하지 않습니다.
저장 성공 뒤 생성 응답을 만듭니다
repository.add는 이전 모듈 구현대로 후보 복사본을 파일에 쓰고 성공한 뒤 현재 메모리를 교체합니다. create는 add가 성공한 다음 새 도서를 조회해 BookView를 반환합니다. 컨트롤러는 ResponseEntity.created에 /books/실제ID URI를 넣고 body에 DTO를 넣습니다. 이 경로가 201과 Location을 함께 구성합니다. 저장 호출 전에 201을 반환하거나 UncheckedIOException을 잡아 성공 응답으로 바꾸지 않습니다.
Location은 상대 URI로 제공하며 이 서버를 기준으로 상세 조회에 사용할 수 있습니다. ID가 항상 1이라고 적지 않습니다. 테스트는 빈 임시 저장소의 첫 등록에서 /books/1을 확인하고 기존 키 10이 있는 별도 테스트에서는 /books/11을 확인합니다. 제목을 반환했다는 사실만으로 저장 영속성을 증명하지 못하므로 새로운 컨텍스트에서 같은 파일을 읽는 검사를 함께 둡니다.
상세 경로는 조회 결과를 상태로 바꿉니다
@GetMapping의 /{id}와 @PathVariable int id는 URL의 한 부분을 정수로 받습니다. 서비스는 repository.find의 Optional에 값이 있으면 BookView로 변환하고 없으면 ResponseStatusException의 NOT_FOUND를 발생시킵니다. 없는 ID에 null을 반환하면 빈 본문 200처럼 의미가 모호해질 수 있으므로 상태를 명시합니다. 숫자가 아닌 abc는 정수 변환 실패로 400이며 숫자로 읽은 99의 부재와 다릅니다.
starter는 find에서 요청 ID 대신 1을 사용합니다. 첫 등록만 확인하면 오류를 놓칩니다. ID 7 도서를 미리 넣는 테스트가 이 실수를 드러냅니다. 컨트롤러에는 파일 경로 검사나 맵 검색을 넣지 않고 서비스에 받은 ID를 전달합니다. 상세와 목록 모두 BookView를 사용하므로 borrowed의 JSON 표현과 제목의 필드 이름이 두 엔드포인트에서 같습니다.
테스트는 실제 계약을 순서대로 확인합니다
createAndFollowLocation은 POST의 201, Location, title을 확인하고 응답에서 얻은 Location으로 GET을 수행합니다. 저장소 직접 조회만 검사하면 MVC 응답 헤더 누락을 놓칠 수 있습니다. reloadInNewContext는 새 Spring 컨텍스트와 새 FileRepository로 같은 파일을 읽어 한글 제목을 확인합니다. 이전 인스턴스의 메모리 캐시가 값을 갖고 있는 것으로 영속성을 대신하지 않습니다.
경계 검사는 빈 배열, 없는 ID, 숫자 아닌 ID, 공백·누락·탭·줄바꿈 제목, 잘못된 JSON, 잘못된 Content-Type, 지원하지 않는 PUT, ID 고갈을 포함합니다. 앞 모듈의 CRUD·깨진 파일·저장 실패 테스트도 남습니다. 누적 64개 테스트가 통과해야 합니다. 테스트가 많다는 사실보다 각 경계에서 어떤 잘못된 구현을 거부하는지 설명하는 것이 완료 증거입니다.
오류 메시지에 따라 수정 위치를 고릅니다
Status expected:201 but was:200이면 저장 결과가 틀린지보다 ResponseEntity를 만드는 경로를 먼저 봅니다. Response header Location expected라는 실패는 상태만 바꿔서는 통과하지 않음을 알려 줍니다. No value at JSON path는 필드 이름이나 응답 객체 형태가 계약과 다른 단서입니다. MethodArgumentTypeMismatchException은 상세 ID의 정수 변환 경계이고 HttpMessageNotReadableException은 JSON 파싱 경계입니다.
입력 검증 실패를 고치려고 테스트를 삭제하거나 모든 예외를 404로 바꾸지 않습니다. 저장 I/O 예외는 없는 도서와 다른 서버 문제입니다. MockMvc에서 처리되지 않은 서버 예외는 perform 자체의 예외로 보일 수 있으며 실제 실행 서버의 500 오류 전달을 전부 재현하는 것은 아닙니다. 이 단계의 테스트 범위를 넘는 오류 본문을 고정했다고 주장하지 않고 다음 오류 계약 모듈에 넘길 사항을 기록합니다.
미션에서 이전 성과를 보존합니다
미션 starter는 직전 모듈 solution의 코드를 포함하고 이번 컨트롤러 두 TODO 때문에 테스트 일부가 실패합니다. 도서 객체의 생성 규칙이나 파일 원자 이동 코드를 단순화해 새 테스트만 통과시키지 않습니다. API-SPEC.md에 성공·경계·실패 요청의 상태와 Location을 기록하고 파일 저장소의 단일 작성자 제한을 설명합니다. 단독 실행 테스트 명령은 ./mvnw test입니다. 작성자 검증은 같은 의존성 캐시에서 오프라인으로 실행했습니다. Maven 서버나 별도 API 프로세스를 남길 필요 없이 완료를 확인할 수 있습니다.
따라하기
생성과 상세의 실패를 구분합니다
starter를 실행하고 ApiContractTest의 201/200 불일치와 ID 7 상세 오류를 찾습니다. 앞 모듈 50개 테스트는 그대로 유지합니다.
./mvnw test경로 ID를 서비스에 전달합니다
BookController.find에서 상수 1을 제거하고 받은 id를 전달합니다. abc의 변환 실패와 없는 정수의 404는 본문의 서로 다른 경계입니다.
return service.find(id);201과 Location을 구성합니다
create 메서드의 서비스 저장 호출을 유지하고 반환문을 바꿉니다. URI import는 제공 파일에 있습니다.
return ResponseEntity.created(URI.create("/books/" + book.id())).body(book);누적 테스트를 집계합니다
먼저 ./mvnw test를 실행하고 성공한 뒤 아래 집계 명령을 실행합니다. output은 집계 명령의 실제 출력입니다.
전체 테스트 후 아래 실제 집계와 비교합니다. 새 컨텍스트 재로딩과 잘못된 입력 뒤 파일 불변 검사를 읽어 각 검사의 목적을 설명합니다.
python3 report.py실행 결과
Tests=64 Failures=0 Errors=0 Skipped=0
미션으로 연결합니다
미션 starter에도 같은 컨트롤러 TODO를 완성하고 API-SPEC.md에 계약과 재현 조건을 적습니다. 기존 ID가 있는 파일과 빈 파일의 차이를 설명합니다.
확인 문제
실습
BookController.find의 경로 ID 전달과 create의 201·Location 반환을 완성합니다. 빈 배열·기존 키 이후 ID 할당·없는 상세·잘못된 입력·새 컨텍스트 파일 복원을 확인합니다. 앞 모듈 테스트를 보존합니다.
실행 명령
./mvnw test
기대 결과
64개 테스트, 실패·오류·건너뜀 0입니다.
모범 답안
모범 답안 내려받기더 읽기
면접 질문
- 도서 생성 응답에 201과 Location을 함께 사용하는 이유는 무엇인가요?
- 요청 DTO와 응답 DTO를 분리하는 이유는 무엇인가요?