API Design · Testing
아무도 찾고 있지 않던
버그 다섯 개
nestjs의 미완성 Swagger 문서를 고치는 데는 반나절이면 충분했다. 애노테이션이 컴파일된다는 걸 믿는 대신, 실제로 앱을 켜고 문서화된 모든 에러 경로를 curl로 확인해서 수정을 검증했더니 — 문서와는 아무 관계 없는 버그가 하나 나왔다. 같은 원칙을 나머지 4개 언어에 그대로 적용하자 네 개가 더 나왔고, 그중 하나는 실제 배포라면 스키마 마이그레이션이 조용히 멈춰 있었을 만큼 심각했다. 그 개수와는 별개로 과정에서 두 개의 버그가 더 얻어걸렸다 — 못지않게 진짜였다.
nestjs 구현체의 모든 @ApiOperation은 operationId만 있고 그 외엔 아무것도 없었다 — summary도, description도 없이. 모든 엔드포인트는 성공 응답만 문서화했고, 실제로 던지는 400·401·404는 하나도 문서화하지 않았다. 32개 DTO 전부 옵션 없는 맨 @ApiProperty()였다. 수정은 기계적이었다: 공유 ErrorResponseBody DTO, 각 핸들러 자신의 에러 매핑 코드와 대조해 붙인 명시적 @Api<Status>Response, 모든 필드에 붙인 설명.
diff를 읽는 대신 앱을 켜서 검증하기
이 수정은 실제 Postgres와 LocalStack을 상대로 앱을 실제로 띄우고 /docs-json과 실제 에러 경로를 curl로 확인해서 검증됐다 — 컴파일되는 애노테이션을 곧 참인 애노테이션이라 믿는 대신. 그 검증은 즉시 문서 리뷰만으로는 절대 못 잡았을 걸 찾아냈다: generateErrorResponse의 error 필드가 nestjs의 HttpStatus enum 역방향 조회를 쓰고 있었는데, 이건 "NOT_FOUND", 즉 SCREAMING_SNAKE_CASE를 만들어냈다 — 문서화된 계약의 "Not Found"와도, 전역 validation pipe에 하드코딩된 "Bad Request"와도 어긋났다. 순수 코드 리뷰였다면 둘을 각각 따로 읽고 서로 어긋난다는 걸 절대 못 알아챘을 것이다 — 실제 응답 본문이 실제 문서 옆에 나란히 놓였을 때만 그 불일치가 보였다.
"REST API가 있는 모든 언어에 이게 필요하다"
나머지 네 언어를 확인해보니 같은 공백의 네 가지 다른 형태가 나왔다. java-springboot와 kotlin-springboot는 springdoc-openapi 의존성 자체가 없었다 — OpenAPI 기능이 전무했고, 각 언어 자신의 문서에 "아직 도입 안 함"이라고 이미 스스로 적혀 있었다 — 더 이상 아무 의미도 없어질 만큼 오래 그대로 놓여 있던 메모였다. go는 Swagger에 대한 언급이 어디에도, 계획으로조차 없었다. fastapi가 흥미로운 경우였다: 프레임워크가 기본 OpenAPI 스켈레톤을 자동 생성해서 /docs에 뭔가가 렌더링되고 다 끝난 것처럼 보였다 — 하지만 어느 라우트에도 summary=, description=, responses=가 없었다, nestjs와 똑같은 공백이었고, 어쩌다 출력을 만들어내는 프레임워크 기본값에 가려져 있었을 뿐이다.
5개 언어 어디에도 완전성을 검사하는 하네스 규칙은 없었다 — 방금 고친 nestjs 구현조차 회귀 방지 장치가 없었다. 체크리스트에는 API 문서화 항목 자체가 없어서, 아무리 꼼꼼한 수동 검토라도 스스로 이걸 드러낼 수 없었을 것이다. 원인은 넷, 모양은 하나였다: 문서화하지 않고 넘어가기 쉽고, 강제하지 않고 넘어가기 쉬우며, fastapi의 경우엔 이미 끝났다고 착각하기까지 쉬운 공백.
롤아웃, 그리고 중요했던 지시 하나
나머지 네 언어를 고치는 건 4개의 병렬 에이전트를 뜻했다. 각자 nestjs의 완성된 구현을 레퍼런스로 받았고, 명시적인 지시 하나를 함께 받았다: 각 엔드포인트의 실제 에러 매핑 코드를 대조 확인하고, 코드가 뭘 하는 것처럼 보이는지가 아니라 실제로 돌아가는 앱을 상대로 검증하라. 문서화 작업이 다섯 개의 무관하고 이미 존재하던 프로덕션 결함으로 이어진 건 바로 이 지시 때문이었다 — 그 무엇도 누군가 찾고 있던 게 아니었고, 전부 실제로 요청을 보내고 응답을 읽어야만 보이는 것들이었다.
가장 심각한 건 java-springboot였다. Spring Boot 4가 Flyway 자동설정을 별도 스타터 모듈로 분리했는데, 그 의존성이 빠져 있었다. 데이터베이스 마이그레이션이 실제 데이터베이스를 상대로는 조용히 한 번도 실행되지 않고 있었다. 테스트 스위트는 이걸 한 번도 잡아낸 적이 없었다 — 테스트가 ddl-auto: create-drop을 쓰고 있었기 때문이다. 이건 엔티티 매핑에서 직접 스키마를 만들어버려서 Flyway가 아예 쓰일 일이 없다. 실제 배포였다면 깨끗하게 부팅되고 트래픽을 받았을 것이고, 마이그레이션은 단 하나도 적용되지 않은 채였을 것이다 — 스키마가 엔티티가 기대하는 것과 충분히 어긋나 요란하게 실패할 때까지, 그것도 이유를 알아내기 가장 나쁜 타이밍에 발견됐을 것이다.
나머지는 더 작지만 전부 진짜였고, 개수를 채웠다: kotlin의 Spring Security는 인증되지 않은 요청에 자체 일반 403을 반환해버려서, 앱 자신의 예외 핸들러가 문서화된 401 형태를 만들 기회조차 얻지 못했다. go의 인증 미들웨어는 401을 자기 문서가 약속한 JSON 스키마가 아니라 평문으로 보냈고, 여러 validation 400도 같은 문제였다. fastapi는 잘못된 JWT에 대한 예외 핸들러 자체가 없어서 — 잘못된 토큰이 깔끔한 401 대신 처리되지 않은 500을 만들어냈다. 여기까지가 제목이 세는 다섯 개다. 그 개수 밖에서, 못지않게 진짜인 두 개가 과정에서 더 나왔다: java 자신의 /v3/api-docs와 Swagger UI는 보려면 bearer 토큰이 필요했다 — API 문서 자체가 공개적으로 접근 불가능했던 것이다. 그리고 nestjs 자신의 스캐폴딩 생성기는, 이번 Swagger 수정이 건드리고 있던 바로 그 코드 안에 버그가 있었다: 생성된 cancel 핸들러의 "이미 취소됨" 도메인 에러가 컨트롤러의 catch 블록에서 한 번도 매핑된 적이 없어서, 의도한 400 대신 조용히 500을 만들어내고 있었다.
이 버그 중 어느 것도 문서화에 관한 게 아니었다. 전부 이미 프로덕션 형태의 코드 안에 앉아, 저자가 한 번도 우연히 보내본 적 없는 정확한 모양의 요청을 기다리고 있었다. 이 일곱 개를 찾아낸 건 더 똑똑한 리뷰어가 아니었다 — 균일하게 적용된 규칙 하나였다: 애노테이션을 컴파일되게 만드는 것으로 끝내지 말고, 그것이 서술하는 게 실제로 참인지 돌아가는 앱에 직접 물어봐서 증명하라.
아무도 계획하지 않았던 후속 작업
롤아웃 에이전트 4개 중 3개는 다른 모든 곳은 통과했지만 scaffolding-regression 검사만은 통과하지 못하는 수정을 배포했다 — 각 언어의 create-domain 생성기가 여전히 새로 요구되는 애노테이션 없이 엔드포인트를 뱉어내고 있었다, 생성기 템플릿에는 기준이 바뀌었다는 걸 아무도 알려주지 않았기 때문이다. kotlin의 에이전트는 다른 셋에게 이 일이 먼저 일어나는 걸 지켜본 뒤 자기 생성기를 선제적으로 고쳤고 첫 시도에 통과했다. 교훈은 깔끔하게 일반화됐다: 엔드포인트별 또는 파일별 내용을 검사하는 하네스 규칙은 거의 항상 짝을 이루는 스캐폴딩 생성기 업데이트가 필요하다 — 나중에 발견하는 예외 상황이 아니라, 처음부터 예산에 넣어둬야 할 두 번째 단계다.
docs/architecture/api-response.md — 이제 루트 레벨 요구사항이 된 완전성 기준 · api-documentation.evaluator.ts — 이제 그걸 강제하는 하네스 규칙