연습 세션을 시작할 때 앱은 곡과 템포를 서버에 보냅니다. 템포는 JSON 정수만 받기로 했는데, 리뷰 중 "bpm": 96.7을 보냈더니 서버는 96 BPM으로 처리하고 HTTP 201을 돌려줬습니다. 숫자가 허용 범위에 드는지는 검사했지만, 그 전에 소수점이 사라졌습니다.

검증 전에 정수로 바뀐 BPM

당시 프로젝트의 Jackson 3.1.5 설정으로 입력 모양을 바꿔 봤습니다. 정수 칸에 보낸 문자열 "96"도 96이 됐고, 문자열이어야 하는 곡 ID에는 숫자 7을 보내도 "7"이 됐습니다. 불리언 칸에 보낸 1은 true가 됐습니다.

ID를 문자열로 정한 이유는 DB의 정수 ID를 클라이언트에서도 그대로 보존하기 위해서입니다. 예를 들어 JavaScript의 Number는 9,007,199,254,740,993을 정확하게 표현하지 못합니다. 응답에서 ID를 문자열로 주고 요청에서도 같은 형태를 받으면, 앱이 받은 ID를 숫자로 바꿨다가 다른 값을 보내는 일을 피할 수 있습니다.

이 상태에서 @Min이나 @Pattern을 붙이면 변환 결과만 봅니다. 검증기는 원래 숫자였던 ID와 처음부터 문자열이었던 ID를 구별할 수 없습니다. 타입이 API 계약의 일부라면 그 정보가 남아 있을 때 판정해야 합니다.

변환 전 JSON 토큰 검사

요청의 각 칸에 필요한 타입을 명시했습니다. BPM과 음표 구간은 Int 범위 안의 JSON 정수, ID는 JSON 문자열, 장단 소리 설정은 JSON 불리언으로 받습니다. 정수용 역직렬화기는 파서의 현재 토큰이 정수이고 숫자 타입이 INT일 때만 값을 꺼냅니다. 그 밖의 값은 요청 오류로 보냅니다.

Kotlin 요청 DTO에서는 역직렬화 설정을 Jackson이 읽는 생성자 인자에 @param:JsonDeserialize로 붙였습니다. 값의 필수 여부와 범위는 @field:NotNull과 @field:Min 같은 필드 검사로 맡겼습니다. BPM이 생략되면 DTO에는 null이 들어오고, 필수값 검사에서 400으로 응답합니다.

템포 단위에는 한 가지 경계가 더 있었습니다. enum으로 바로 읽으면 숫자 0이 선언 순서의 첫 값이 될 수 있었습니다. 먼저 문자열로 받은 다음 허용된 이름을 확인하고 enum으로 바꿨습니다. 요청에서 허용하는 표기와 Kotlin 내부에서 쓰는 타입을 이렇게 연결했습니다.

@field:Pattern에 넣을 정규식은 컴파일할 때 값이 정해져야 합니다. BpmUnit.entries.joinToString("|")은 실행할 때 문자열을 만들기 때문에, 허용할 이름을 const val 문자열에 적었습니다. 요청을 받을 때는 정규식 검사를 통과한 이름을 BpmUnit::valueOf로 변환합니다. enum에 단위를 추가할 때 정규식도 함께 갱신하도록 코드에 주석을 남겼습니다. Kotlin 컴파일 시점 상수

400 응답과 저장 0행

컨트롤러 검사에 자동 변환되던 입력 10가지를 추가했습니다. 수정 전에는 이 검사가 실패했고 토큰 검사를 넣은 뒤에는 입력을 400으로 거절했습니다. 기존 실제 HTTP·DB 통합 검사에서는 별도의 잘못된 본문 11종에 대해 400과 저장 0행을 확인했습니다.

정수 96을 보낸 정상 요청에서는 201과 저장된 세션을 확인했습니다. 이 검사는 요청을 거절했다는 응답과 서버가 실제로 아무것도 만들지 않았다는 결과를 함께 고정합니다.