문서와 일치하는 타입이 응답과도 일치하지는 않는다

AI Chat의 ChatResponseDto에는 토큰 합계와 도구 사용량 같은 내부 필드가 있었다. 이 필드들은 @Exclude()로 응답에서 빠지지만 @ApiProperty() 때문에 OpenAPI 문서에는 남았다. 프론트 타입을 그 문서에서 생성하면 서버가 보내지 않는 필드까지 타입에 들어간다.

문서 생성과 응답 직렬화가 서로 다른 메타데이터를 읽는 것이 원인이었다. 응답 도우미 toResponseDto는 plainToInstance에 excludeExtraneousValues: true를 넘겨 노출 필드를 고른다. Swagger는 자신의 API 속성 목록으로 문서를 만든다. 자동 생성은 문서를 복제할 뿐, 두 목록의 차이를 없애지 않는다.

변경은 내부 필드의 @ApiProperty를 제거하고 두 목록을 대조하는 테스트를 넣었다. 상속한 필드도 빠뜨리지 않도록 Swagger 메타데이터는 prototype 체인을 따라 수집한다.

생성물의 최신 상태와 직렬화 목록을 따로 검사한다

DTO 검사에서는 문서에만 있는 필드와 노출 목록에만 있는 필드를 모두 찾는다. 다음은 집합 비교를 설명하는 축약 코드다.

const onlyDoc = [...apiFields].filter(key => !exposedFields.has(key));
const onlyResponse = [...exposedFields].filter(key => !apiFields.has(key));
expect({ onlyDoc, onlyResponse }).toEqual({
	onlyDoc: [],
	onlyResponse: [],
});

응답 DTO 파일명만 찾던 수집 범위는 후속 변경에서 모든 .dto.ts로 넓어졌다. -response.dto.ts 파일의 클래스는 API 또는 노출 메타데이터로 찾고, 다른 파일의 클래스는 @Expose가 있을 때 수집한다. 요청 DTO의 @ApiProperty까지 무조건 포함하면 응답 검사에 오탐이 생기기 때문이다.

서로 다른 두 검사
검사비교 대상잡는 차이
DTO 필드 집합Swagger 속성과 @Expose문서와 직렬화 선언의 누락
생성물 재생성OpenAPI·클라이언트 타입과 HEAD저장된 생성물의 드리프트

openapi:check는 재생성 뒤 git diff --exit-code HEAD로 비교한다. index만 기준으로 삼으면 이미 stage에 들어간 변경을 놓칠 수 있다. 서버 문서와 클라이언트 타입은 각각 다시 생성해야 하므로, 검사 경로도 두 개를 유지한다.

필드 이름이 맞아도 실제 HTTP 응답은 별도 계약이다

테스트는 균형 잡힌 DTO, 문서에만 있는 필드, 비어 있지 않은 수집 결과와 수집 클래스의 집합 동등성을 확인한다. HTTP 응답을 호출하지 않으므로 필드 타입, nullable, 중첩 구조나 서비스가 실제로 도우미를 쓰는지까지 검증하지 않는다. -response.dto.ts 밖에서 @Expose도 없는 클래스는 수집 범위 밖이다.

중복 타입 선언을 줄이는 대신 헤드리스 Nest 문서 생성, 동적 DTO 로드와 class-transformer 내부 메타데이터 저장소에 검사 코드가 연결된다. 라이브러리를 바꿀 때 이 연결도 확인해야 한다. API 생성·조회 테스트가 실제 응답 경로와 fixture의 차이를 다뤘다면, 변경은 문서와 직렬화 선언이 갈라지는 지점을 검사한다.