프론트엔드와 백엔드 간 소통 매개체로 Swagger UI가 자주 사용됩니다.
API를 개발하다 보면 에러 응답 코드를 정의해야하는 경우가 많은데, @ApiResponse 어노테이션을 사용하여 에러 코드를 일일이 작성하다 보면 비즈니스 코드가 점점 비대해지고, 비즈니스 로직과 Swagger 문서화 로직이 혼재되기 시작합니다.
이번 글에서는 커스텀 어노테이션과 에러코드 enum을 활용하여, 비즈니스 코드와 문서화 로직을 분리하면서 Swagger UI에 에러 응답 예시를 자동으로 생성하는 패턴을 소개합니다.
도입 배경
기존 방식에는 두 가지 문제가 있었습니다:
-
비즈니스 로직과 Swagger 문서화 로직의 혼재:
- 에러코드가 추가될 때마다 해당 API 메서드에
@ApiResponse어노테이션을 하나씩 작성해야 했습니다. API가 늘어날수록 Controller 코드는 비대해지고, 비즈니스 로직과 문서화 로직이 뒤섞여 가독성이 떨어졌습니다.
- 에러코드가 추가될 때마다 해당 API 메서드에
-
에러 응답의 통합 관리 부재:
- 에러별 HTTP Status 코드와 메시지를 확인하려면 비즈니스 코드를 직접 열어봐야 했습니다. 에러 정보가 여러 파일에 파편화되어 전체 에러 체계를 한눈에 파악하기 어려웠습니다.
아키텍처
전체 흐름
처리 흐름:
- Controller 메서드에
@ApiErrorCodeExamples어노테이션을 적용하여 발생 가능한 에러코드를 선언 -
OperationCustomizer가 어노테이션을 읽어 Swagger UI에 에러 예시를 자동 생성 - Service에서
ApiException을 throw하면GlobalExceptionHandler가 enum에 매핑된 HTTP Status와 메시지를 반환
핵심 컴포넌트
| 컴포넌트 | 역할 |
|---|---|
ApiErrorCode |
에러코드와 HTTP Status를 매핑하는 enum |
ApiException |
에러코드 기반 커스텀 예외 클래스 |
ApiErrorCodeExamples |
Controller에 적용하는 Swagger 어노테이션 |
GlobalExceptionHandler |
예외를 HTTP 응답으로 변환하는 핸들러 |
SwaggerConfig |
OperationCustomizer로 Swagger 문서 자동 생성 |
파일 구조
src/main/java/com/example/
├── common/
│ ├── ApiErrorCode.java # 에러코드 enum
│ └── ErrorResponse.java # 에러 응답 DTO
├── exception/
│ ├── ApiException.java # 커스텀 예외 클래스
│ ├── ApiErrorCodeExamples.java # Swagger 어노테이션
│ └── GlobalExceptionHandler.java # 예외 핸들러
└── config/
└── SwaggerConfig.java # OperationCustomizer 설정
구현 가이드
Step 1: ApiErrorCode (에러코드 enum)
모든 에러의 코드, HTTP Status, 메시지를 한곳에서 관리하는 enum입니다.
@Getter
@RequiredArgsConstructor
public enum ApiErrorCode {
// 400 Bad Request
BAD_REQUEST("E400", HttpStatus.BAD_REQUEST),
INVALID_PARAMETER("E400.01", HttpStatus.BAD_REQUEST),
// 401 Unauthorized
UNAUTHORIZED("E401", HttpStatus.UNAUTHORIZED),
// 404 Not Found
NOT_FOUND("E404", HttpStatus.NOT_FOUND),
// 409 Conflict
CONFLICT("E409", HttpStatus.CONFLICT),
ALREADY_EXISTS("E409.01", HttpStatus.CONFLICT),
// 500 Internal Server Error
INTERNAL_SERVER_ERROR("E500", HttpStatus.INTERNAL_SERVER_ERROR),
EXTERNAL_API_ERROR("E500.01", HttpStatus.INTERNAL_SERVER_ERROR);
private final String code;
private final HttpStatus status;
}
E400, E400.01 형태로 대분류와 세부 에러를 구분합니다. HTTP Status를 enum에 직접 매핑하여, 에러 코드만 알면 어떤 HTTP Status로 응답해야 하는지 자동으로 결정됩니다.
Step 2: ApiException (커스텀 예외)
@Getter
public class ApiException extends RuntimeException {
private final ApiErrorCode errorCode;
public ApiException(ApiErrorCode errorCode) {
super(errorCode.getCode());
this.errorCode = errorCode;
}
public ApiException(ApiErrorCode errorCode, Throwable cause) {
super(errorCode.getCode(), cause);
this.errorCode = errorCode;
}
}
에러코드 enum만 전달하면 예외가 생성됩니다. 메시지는 GlobalExceptionHandler에서 MessageSource를 통해 조회하므로, 예외 클래스 자체는 단순하게 유지됩니다.
Step 3: ApiErrorCodeExamples (Swagger 어노테이션)
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiErrorCodeExamples {
ApiErrorCode[] value();
}
Controller 메서드에 적용하여 해당 API에서 발생할 수 있는 에러코드를 선언합니다.
런타임에 OperationCustomizer가 이 어노테이션을 읽어 Swagger 문서를 자동 생성합니다.
관점 지향 프로그래밍(AOP)의 관점에서 보면, Swagger 문서화라는 횡단 관심사(cross-cutting concern)를 어노테이션으로 분리하여 비즈니스 로직의 순수성을 유지하는 구조입니다.
Step 4: ErrorResponse (에러 응답 DTO)
Swagger 문서와 실제 응답에서 공통으로 사용할 에러 응답 형식을 정의합니다.
@Getter
@AllArgsConstructor
public class ErrorResponse {
private final String code;
private final String message;
}
Step 5: GlobalExceptionHandler (예외 핸들러)
-
@Order(HIGHEST_PRECEDENCE)로 다른 예외 핸들러보다 우선 처리되어, 기존 예외 처리 로직과 독립적으로 동작합니다. - MessageSource에서 에러코드로 메시지를 조회하므로 국제화(i18n)를 지원합니다.
src/main/resources/
├── messages_ko.properties # 한국어
├── messages_en.properties # 영어
└── messages_ja.properties # 일본어
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final MessageSourceAccessor messageSourceAccessor;
@ExceptionHandler(ApiException.class)
public ResponseEntity<ErrorResponse> handleApiException(ApiException ex) {
ApiErrorCode errorCode = ex.getErrorCode();
String message = messageSourceAccessor.getMessage(
errorCode.getCode(), "에러가 발생했습니다");
log.error("ApiException: code={}, status={}, message={}",
errorCode.getCode(), errorCode.getStatus(), message, ex);
ErrorResponse response = new ErrorResponse(errorCode.getCode(), message);
return ResponseEntity.status(errorCode.getStatus()).body(response);
}
}
Step 6: SwaggerConfig (OperationCustomizer)
이 클래스가 자동 문서화의 핵심입니다. Controller의 @ApiErrorCodeExamples 어노테이션을 읽어 Swagger UI에 에러 응답 예시를 자동으로 추가합니다.
@Configuration
@RequiredArgsConstructor
public class SwaggerConfig {
private final MessageSourceAccessor messageSourceAccessor;
@Bean
public OperationCustomizer operationCustomizer() {
return (Operation operation, HandlerMethod handlerMethod) -> {
ApiErrorCodeExamples annotation =
handlerMethod.getMethodAnnotation(ApiErrorCodeExamples.class);
if (annotation != null) {
generateErrorExamples(operation, annotation.value());
}
return operation;
};
}
private void generateErrorExamples(Operation operation, ApiErrorCode[] errorCodes) {
Map<Integer, List<ApiErrorCode>> groupedByStatus = Arrays.stream(errorCodes)
.collect(Collectors.groupingBy(code -> code.getStatus().value()));
groupedByStatus.forEach((status, codes) -> {
MediaType mediaType = new MediaType();
codes.forEach(code -> mediaType.addExamples(
code.getCode(), createExample(code)));
operation.getResponses().addApiResponse(
String.valueOf(status),
new ApiResponse()
.description("에러 응답")
.content(new Content()
.addMediaType("application/json", mediaType))
);
});
}
private Example createExample(ApiErrorCode errorCode) {
String message = messageSourceAccessor.getMessage(
errorCode.getCode(), "에러가 발생했습니다");
Example example = new Example();
example.setValue(Map.of(
"code", errorCode.getCode(),
"message", message
));
example.setDescription(message);
return example;
}
}
동작 원리:
- 첫 번째 Swagger UI(또는
/v3/api-docs) 접근 시,OperationCustomizer가 모든 API Operation을 1회 순회하며@ApiErrorCodeExamples어노테이션을 확인 (이후 캐싱되므로 런타임 성능에 영향 없음) - 어노테이션에 선언된 에러코드들을 HTTP Status별로 그룹핑
- 각 에러코드별로 Example 객체를 생성하여 Swagger 응답에 추가
- Swagger UI에서는 동일 HTTP Status 내 에러코드를 드롭다운으로 선택하여 확인 가능
Step 7: 메시지 설정
message.properties:
# 400 Bad Request
E400=잘못된 요청입니다.
E400.01=유효하지 않은 파라미터입니다.
# 401 Unauthorized
E401=인증이 필요합니다.
# 404 Not Found
E404=요청한 리소스를 찾을 수 없습니다.
# 409 Conflict
E409=요청이 충돌했습니다.
E409.01=이미 존재하는 데이터입니다.
# 500 Internal Server Error
E500=서버 에러가 발생했습니다.
E500.01=외부 API 호출 중 에러가 발생했습니다.
에러코드를 key로 사용하므로, enum에 새 에러코드를 추가할 때 여기에도 메시지를 정의하면 Swagger 문서와 실제 응답 모두에 반영됩니다.
Swagger API 명세서에 에러 코드 추가하기
AS-IS: @ApiResponse를 일일이 정의하는 방식
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@ApiResponses({
@ApiResponse(responseCode = "400", description = "잘못된 요청입니다.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "401", description = "인증이 필요합니다.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "404", description = "요청한 리소스를 찾을 수 없습니다.",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUser(id));
}
}
에러코드가 추가될 때마다 @ApiResponse 블록이 늘어나고, 메시지가 하드코딩되어 실제 응답과 불일치할 위험이 있습니다.
TO-BE: @ApiErrorCodeExamples를 사용하는 방식
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@ApiErrorCodeExamples({
ApiErrorCode.BAD_REQUEST, ApiErrorCode.NOT_FOUND, ApiErrorCode.UNAUTHORIZED
})
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUser(id));
}
}
@ApiErrorCodeExamples에 에러코드를 나열하면 Swagger UI에 해당 에러 응답이 자동으로 표시됩니다.
어노테이션 적용 후 Swagger UI에서는 HTTP Status별로 에러 예시가 드롭다운으로 표시됩니다:
Service에서 예외 처리 방법
@Service
public class UserService {
public UserDto getUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new ApiException(ApiErrorCode.NOT_FOUND));
}
}
예외 체이닝 (원인 예외 포함)
외부 API 호출 등에서 원인 예외를 함께 전달하고 싶을 때:
try {
externalApiCall();
} catch (Exception e) {
throw new ApiException(ApiErrorCode.EXTERNAL_API_ERROR, e);
}
⚠️ 주의사항
- 에러코드 중복 방지: 새로운 에러코드 추가 시 기존 코드와 중복되지 않는지 확인
-
메시지 동기화:
ApiErrorCodeenum 추가 시 반드시message.properties에 메시지 정의 - HTTP Status 일관성: 동일한 성격의 에러는 동일한 HTTP Status 사용
결론
이 패턴을 적용하면 다음과 같은 효과를 얻을 수 있습니다:
-
반복 코드 제거: Swagger UI에 에러코드를 명시하기 위해
@ApiResponse를 일일이 작성하지 않아도 됩니다 - 에러 처리 중앙화: 에러코드, HTTP Status, 메시지가 모두 한곳에서 관리됩니다
- 문서 정합성: Swagger 문서와 실제 에러 응답이 항상 동일하게 유지됩니다
- 타입 안전성: enum 기반이므로 존재하지 않는 에러코드를 참조할 수 없습니다
어노테이션 하나로 Swagger 문서와 에러 응답을 동시에 관리할 수 있으므로, API가 많아질수록 효과가 커집니다. 에러 응답 문서화에 반복적인 노력을 들이고 있다면 도입을 검토해 보시기 바랍니다.