1. 문제 상황
프로젝트에 공통 응답 포맷인 ApiResponseDto<T>를 도입한 이후, Swagger UI에서 예상치 못한 문제가 발생했다.
- 현상
모든 API의 응답 데이터(data 필드)가 특정 DTO(예: UserResponseDto)로 고정되어 표시됨 - 영향
일기(Diary), 날씨(Weather) 등 서로 다른 도메인의 API 문서에서도 응답 예시가 전부 유저 정보로 출력되어
공통 응답 래퍼를 쓰는 구조 자체는 문제가 없어 보였지만, 문서 자동화 도구(Springdoc) 와 결합되면서 문제가 표면화되었다.
2. 원인 분석
이 문제의 핵심 원인은
Springdoc의 스키마 생성 방식과 제네릭 타입 정보가 문서화 과정에서 처리되는 방식에 있었다.
2-1. Springdoc은 “스키마 이름”을 기준으로 동작한다
Springdoc(OpenAPI 3 기반)은 내부적으로
#/components/schemas/{SchemaName} 을 MAP 형태로 관리한다.
즉,
- 스키마 이름이 같으면 → 같은 스키마
- 한 번 등록된 이름은 → 덮어쓰지 않음
이라는 전제가 있다.
그런데 ApiResponseDto 클래스에 다음과 같이 선언되어 있었다.
@Schema(name = "ApiResponseDto")
public class ApiResponseDto<T> { ... }
이 설정은 Springdoc에게 다음과 같은 의미를 준다.
“이 클래스는 제네릭 타입이 무엇이든, 항상 ApiResponseDto라는 단 하나의 스키마로 취급해라.”
2-2. 제네릭 타입은 런타임에 소실되고, 문서화 시점엔 더 취약하다
자바의 제네릭은 컴파일 타임 개념이며,
런타임에는 타입 정보가 소거(Type Erasure)된다.
Springdoc은 이를 보완하기 위해
컨트롤러 메서드의 반환 타입 시그니처를 분석해 제네릭 타입을 유추하지만,
- 스키마 이름이 고정되어 있으면
- 제네릭 인자(T)가 달라도
👉 같은 스키마로 병합되어 버린다.
2-3. “먼저 본 놈이 임자” 현상
Springdoc은 애플리케이션 기동 시 컨트롤러를 스캔하며 스키마를 생성합니다.
이때,
- 가장 먼저 발견된 응답 타입이
ApiResponseDto<UserResponseDto> 였고 - 이 구조가 ApiResponseDto 스키마로 등록됨
- 이후 등장하는
ApiResponseDto<DiaryResponseDto>,
ApiResponseDto<WeatherResponseDto> 는
이미 존재하는 스키마를 재사용
결과적으로 모든 응답의 data 필드가
UserResponseDto 구조로 고정되어 보이게 된 것이다.
2-4. 커스텀 에러 응답 로직이 문제를 증폭시켰다
추가로, 커스텀 에러 응답 처리기에서 다음과 같이 하드코딩된 참조가 있었다.
#/components/schemas/ApiResponseDto
이로 인해,
- 성공 응답용 스키마
- 에러 응답용 스키마
가 같은 이름 아래에서 뒤섞이며,
Swagger UI 상에서는 더 예측 불가능한 형태로 보이게 되었다.
3. 해결 과정
문제를 해결하는 핵심 전략은 다음 두 가지였다.
- Springdoc이 제네릭 타입을 구분할 수 있게 만들어준다
- 성공 응답과 에러 응답의 스키마 책임을 명확히 분리한다
Step 1. 고정된 스키마 이름 제거
가장 먼저 ApiResponseDto에 지정된 name 속성을 제거했다.
@Schema(description = "공통 응답 형식") // name 속성 제거
public class ApiResponseDto<T> { ... }
이제 Springdoc은 다음과 같이 동작합니다.
- ApiResponseDto<UserResponseDto>
→ ApiResponseDtoUserResponseDto - ApiResponseDto<DiaryResponseDto>
→ ApiResponseDtoDiaryResponseDto
즉, 제네릭 인자까지 포함한 고유 스키마를 자동 생성하게 된다.
Step 2. 에러 응답 전용 스키마를 명시적으로 등록
에러 응답의 특징은 명확하다.
- data는 항상 null
- 구조는 고정됨
- 성공 응답과 섞일 이유가 없음
그래서 에러 응답의 표준을
ApiResponseDto<Void> 로 정의했다.
다만 Void 타입은 Springdoc 자동 스캔 대상에서 누락될 수 있기 때문에,
OpenApiCustomizer를 사용해 명시적으로 스키마를 등록했다.
@Bean
public OpenApiCustomizer apiResponseDtoVoidSchemaCustomizer() {
return openApi -> {
Schema<?> voidSchema = new Schema<>()
.type("object")
.description("에러 응답용 포맷");
// code, message, data(nullable) 필드 정의
openApi.getComponents().addSchemas("ApiResponseDtoVoid", voidSchema);
};
}
이로써 에러 응답은 항상
ApiResponseDtoVoid 스키마를 참조하게 된다.
Step 3. 커스텀 에러 처리기의 참조 대상 수정
기존에는 에러 응답에서도 성공 응답과 동일한 스키마를 참조하고 있었다.
- 기존
- #/components/schemas/ApiResponseDto
- 변경
- #/components/schemas/ApiResponseDtoVoid
이 수정으로 성공/실패 응답 간의 스키마 오염 경로를 완전히 차단할 수 있었다.
Step 4. 컨트롤러 선언 방식 표준화
Springdoc의 장점을 살리기 위해
컨트롤러 레벨에서의 선언 규칙도 정리했습니다.
- 성공 응답
- @ApiResponse에서 content를 비워둠
- → 메서드 반환 타입을 기준으로 Springdoc이 자동 스키마 생성
- 에러 응답
- @ApiErrorCodeExamples 사용
- content = @Content 유지
- → 커스텀 예시(JSON)가 정확히 주입되도록 설정
이렇게 역할을 분리하니
설정도 단순해지고, 문서 일관성도 유지되었습니다.
4. 결과 및 교훈
결과
- 각 API 엔드포인트마다 실제 반환 DTO 구조가 정확히 Swagger UI에 노출
- 성공 응답과 에러 응답이 명확히 분리된 스키마로 표현
- 프론트엔드 협업 시 “이 API 응답 구조가 뭐냐”는 질문이 사라짐
교훈
- 제네릭 공통 응답 DTO에 고정된 @Schema(name)는 매우 위험하다
- Springdoc의 자동 스키마 생성은 강력하지만,
Void, 에러 응답처럼 특수한 케이스는 명시적으로 관리해야 한다 - Swagger 문제의 대부분은 “도구의 버그”가 아니라
도구가 의도한 방식과 코드 설계가 어긋난 결과인 경우가 많다
'Backend > Trouble Shooting' 카테고리의 다른 글
| JPA 자동 스키마 관리에서 Flyway 기반 DB 마이그레이션 (0) | 2026.02.03 |
|---|---|
| [SpringBoot] UTC 시간 처리, 코드가 아니라 설정으로 정리하기 (Hibernate 6 & Jackson) (0) | 2026.01.21 |
| [SpringBoot] 검증 로직의 위치(@Valid vs Service) : 테스트 코드로 드러난 설계 문제 (0) | 2026.01.14 |
| [SpringBoot] 날짜와 시간 처리를 UTC로 표준화하기 (0) | 2026.01.10 |
| [SpringBoot] 프로젝트에 SonarCloud 연동하기: Gradle + GitHub Actions (0) | 2026.01.05 |