@Configuration
public class SwaggerConfig {
@Autowired
private ApplicationContext applicationContext;
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("앱 이름")
.description("앱 설명")
.version("v1.0.0"))
.addSecurityItem(securityRequirement)
.components(components);
}
@Bean
public OperationCustomizer operationCustomizer() {
return (Operation operation, HandlerMethod handlerMethod) -> {
// 실제 API 경로 정보 추출
String actualPath = extractActualPath(handlerMethod);
// 단일 에러 코드 어노테이션 처리
ApiErrorExample apiErrorExample = handlerMethod.getMethodAnnotation(ApiErrorExample.class);
if (apiErrorExample != null) {
generateErrorCodeResponseExample(operation, new ErrorCode[]{apiErrorExample.value()}, actualPath);
}
// 복수 에러 코드 어노테이션 처리
ApiErrorExamples apiErrorExamples = handlerMethod.getMethodAnnotation(ApiErrorExamples.class);
if (apiErrorExamples != null) {
generateErrorCodeResponseExample(operation, apiErrorExamples.value(), actualPath);
}
return operation;
};
}
// HandlerMethod에서 실제 API 경로 추출
private String extractActualPath(HandlerMethod handlerMethod) {
try {
RequestMappingHandlerMapping mapping = applicationContext.getBean(RequestMappingHandlerMapping.class);
Map<RequestMappingInfo, HandlerMethod> handlerMethods = mapping.getHandlerMethods();
for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : handlerMethods.entrySet()) {
if (entry.getValue().equals(handlerMethod)) {
RequestMappingInfo info = entry.getKey();
// PathPatternsCondition 확인
var pathPatternsCondition = info.getPathPatternsCondition();
if (pathPatternsCondition != null && !pathPatternsCondition.getPatterns().isEmpty()) {
return pathPatternsCondition.getPatterns().iterator().next().getPatternString();
}
// PatternsCondition 확인
var patternsCondition = info.getPatternsCondition();
if (patternsCondition != null && !patternsCondition.getPatterns().isEmpty()) {
return patternsCondition.getPatterns().iterator().next();
}
}
}
} catch (Exception e) {
// 경로 추출 실패시 기본값 사용
}
return "/api/example";
}
// 에러 코드들을 기반으로 Swagger 응답 예제를 생성
private void generateErrorCodeResponseExample(Operation operation, ErrorCode[] errorCodes, String actualPath) {
ApiResponses responses = operation.getResponses();
// HTTP 상태 코드별로 에러 코드들을 그룹화
Map<Integer, List<ExampleHolder>> statusWithExampleHolders = Arrays.stream(errorCodes)
.map(errorCode -> ExampleHolder.builder()
.example(createErrorExample(errorCode, actualPath))
.name(errorCode.name())
.httpStatus(errorCode.getStatus().value())
.build())
.collect(Collectors.groupingBy(ExampleHolder::getHttpStatus));
// 상태 코드별로 ApiResponse에 예제들 추가
addExamplesToResponses(responses, statusWithExampleHolders);
}
// ErrorCode를 기반으로 Example 객체 생성
private Example createErrorExample(ErrorCode errorCode, String actualPath) {
// 에러 응답 객체 생성
Map<String, Object> errorResponse = new LinkedHashMap<>();
errorResponse.put("timestamp", "2025-06-30T12:00:00.000000");
errorResponse.put("status", errorCode.getStatus().value());
errorResponse.put("code", errorCode.getCode());
errorResponse.put("message", errorCode.getMessage());
errorResponse.put("path", actualPath);
Example example = new Example();
example.description(errorCode.getMessage());
example.setValue(errorResponse);
return example;
}
// 상태 코드별로 그룹화된 예제들을 ApiResponses에 추가
private void addExamplesToResponses(ApiResponses responses, Map<Integer, List<ExampleHolder>> statusWithExampleHolders) {
statusWithExampleHolders.forEach((httpStatus, exampleHolders) -> {
// 해당 상태 코드에 대한 ApiResponse가 이미 존재하는지 확인
String statusKey = httpStatus.toString();
ApiResponse apiResponse = responses.get(statusKey);
if (apiResponse == null) {
apiResponse = new ApiResponse();
apiResponse.setDescription("에러 응답");
apiResponse.setContent(new Content());
}
// Content와 MediaType 설정
Content content = apiResponse.getContent();
MediaType mediaType = content.get("application/json");
if (mediaType == null) {
mediaType = new MediaType();
content.addMediaType("application/json", mediaType);
}
// Examples 맵 설정
Map<String, Example> examples = mediaType.getExamples();
if (examples == null) {
examples = new HashMap<>();
mediaType.setExamples(examples);
}
// 각 에러 코드별 예제 추가
for (ExampleHolder exampleHolder : exampleHolders) {
examples.put(exampleHolder.getName(), exampleHolder.getExample());
}
// ApiResponse를 responses에 추가
responses.addApiResponse(statusKey, apiResponse);
});
}
// 예제 정보를 담는 내부 클래스
@Getter
@Builder
private static class ExampleHolder {
private final Example example;
private final String name;
private final int httpStatus;
}
// 단일 에러 코드 예제를 위한 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiErrorExample {
ErrorCode value();
}
// 복수 에러 코드 예제를 위한 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiErrorExamples {
ErrorCode[] value();
}
// 성공 응답 예제를 위한 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiSuccessResponse {
String message() default "요청이 성공적으로 처리되었습니다.";
Class<?> dataType() default Object.class;
String dataExample() default "";
boolean isArray() default false;
}
}
CI/CD 파이프라인 구축하기
DB 구축하기
API 시그니처 작성
API 응답 통일 & 에러 핸들링 부분 완성하기
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 비즈니스 로직 예외 처리
*/
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e, HttpServletRequest request) {
log.error("BusinessException: {}", e.getMessage());
return ResponseEntity
.status(e.getErrorCode().getStatus())
.body(ApiResponse.error(e.getErrorCode(), request.getRequestURI()));
}
/**
* @Valid 검증 실패 및 바인딩 실패 예외 처리
*/
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
protected ResponseEntity<ApiResponse<Void>> handleValidationException(Exception e, HttpServletRequest request) {
log.error("Validation Exception: {}", e.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(ErrorCode.VALIDATION_ERROR, request.getRequestURI()));
}
/**
* 필수 파라미터 누락 예외 처리
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
protected ResponseEntity<ApiResponse<Void>> handleMissingParameterException(MissingServletRequestParameterException e, HttpServletRequest request) {
log.error("Missing Parameter: {}", e.getParameterName());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(ErrorCode.REQUIRED_FIELD_MISSING, request.getRequestURI()));
}
/**
* 파라미터 타입 불일치 예외 처리
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
protected ResponseEntity<ApiResponse<Void>> handleTypeMismatchException(MethodArgumentTypeMismatchException e, HttpServletRequest request) {
log.error("Type Mismatch: {} for parameter {}", e.getValue(), e.getName());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(ErrorCode.INVALID_INPUT_VALUE, request.getRequestURI()));
}
/**
* 지원하지 않는 HTTP 메서드 예외 처리
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
protected ResponseEntity<ApiResponse<Void>> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException e, HttpServletRequest request) {
log.error("Method Not Supported: {}", e.getMethod());
return ResponseEntity
.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(ApiResponse.error(ErrorCode.METHOD_NOT_ALLOWED, request.getRequestURI()));
}
/**
* JPA Optimistic Lock 버전 충돌 예외 처리
*/
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
protected ResponseEntity<ApiResponse<Void>> handleOptimisticLockingFailureException(ObjectOptimisticLockingFailureException e) {
log.warn("handleOptimisticLockingFailureException", e); // 충돌 발생 로깅
// 409 Conflict 상태 코드로 응답
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(ApiResponse.error(ErrorCode.CONCURRENCY_CONFLICT));
}
/**
* 기타 모든 예외 처리
*/
@ExceptionHandler(Exception.class)
protected ResponseEntity<ApiResponse<Void>> handleException(Exception e, HttpServletRequest request) {
log.error("Unexpected Exception: {}", e.getMessage(), e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(ErrorCode.INTERNAL_SERVER_ERROR, request.getRequestURI()));
}
}
// 에러코드 enum
@Getter
@AllArgsConstructor
public enum ErrorCode {
// 실제 사용되는 공통 에러들
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "COMMON_001", "잘못된 입력값입니다."),
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_002", "지원하지 않는 HTTP 메서드입니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_003", "서버 내부 오류가 발생했습니다.")
// TODO: 비즈니스 로직 개발하면서 필요한 에러코드들 추가
private final HttpStatus status;
private final String code;
private final String message;
}
// BusinessException 정의
import lombok.Getter;
@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public BusinessException(ErrorCode errorCode, String message, Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
}
public class WebConfig implements WebMvcConfigurer {
private final LoggingInterceptor loggingInterceptor;
@Override
public void addInterceptors(@NonNull InterceptorRegistry registry) {
registry.addInterceptor(loggingInterceptor)
// 인터셉터가 실행될 경로를 설정하는 필터
.addPathPatterns("/**")
// 인터셉터가 실행되지 않을 경로를 설정하는 필터
.excludePathPatterns("/h2-console/**", "/swagger-ui/**", "/v3/api-docs/**");
}
@Override
public void configurePathMatch(@NonNull PathMatchConfigurer configurer) {
// 모든 API 경로에 /api/v1 접두사 추가
configurer.addPathPrefix("/api/v1", c -> c.isAnnotationPresent(org.springframework.web.bind.annotation.RestController.class));
}
//인터셉터 설정
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import java.util.UUID;
import org.slf4j.MDC;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
// 모든 HTTP 요청을 가로채서 로그를 남기는 역할, 컨트롤러 실행 전후에 자동으로 실행
@Slf4j
@Component
public class LoggingInterceptor implements HandlerInterceptor {
// 컨트롤러 메서드가 실행되기 직전
@Override
public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) {
String traceId = UUID.randomUUID().toString().substring(0, 8);
// 스레드별 전역 저장소 traceId 저장 같은 요청에서 발생한 로그 추척 가능
MDC.put("traceId", traceId);
request.setAttribute("startTime", System.currentTimeMillis());
log.info("=== 요청 시작 ===");
log.info("Method: {}", request.getMethod());
log.info("URI: {}", request.getRequestURI());
log.info("Query String: {}", request.getQueryString());
log.info("Client IP: {}", request.getRemoteAddr());
return true;
}
// 컨트롤러 실행 및 모든 처리가 완료된 후
@Override
public void afterCompletion(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler, @Nullable Exception ex) {
// 실행시간 계산
Long startTime = (Long) request.getAttribute("startTime");
Long executionTime = System.currentTimeMillis() - startTime;
log.info("Status: {}", response.getStatus());
log.info("Execution Time: {}ms", executionTime);
if (ex != null) {
log.error("Exception: {}", ex.getMessage());
}
log.info("=== 요청 종료 ===\n");
// 메모리 누수 방지 MDC 정리
MDC.clear();
}
// Enum Validator
public class EnumValidator {
// enum 반환
public static <E extends Enum<E>> E validateEnum(Class<E> enumClass, String value) {
String upperValue = value.toUpperCase();
return Arrays.stream(enumClass.getEnumConstants())
.filter(e -> e.name().equals(upperValue))
.findFirst()
.orElseThrow(() -> new BusinessException(ErrorCode.INVALID_INPUT_VALUE,
String.format("'%s'는 %s enum에 존재하지 않는 값입니다.", value, enumClass.getSimpleName())));
}
//반환 x 유효성만 검사
public static <E extends Enum<E>> void validateEnumOrThrow(Class<E> enumClass, String value) {
String upperValue = value.toUpperCase();
if (Arrays.stream(enumClass.getEnumConstants()).noneMatch(e -> e.name().equals(upperValue))) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE,
String.format("'%s'는 %s enum에 존재하지 않는 값입니다.", value, enumClass.getSimpleName()));
}
}
//Boolean 반환
public static <E extends Enum<E>> boolean isValidEnum(Class<E> enumClass, String value) {
String upperValue = value.toUpperCase();
return Arrays.stream(enumClass.getEnumConstants())
.anyMatch(e -> e.name().equals(upperValue));
}
}
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final ObjectMapper objectMapper;
@PostConstruct
public void init() {
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**").disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.sameOrigin()))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/h2-console/**",
"/swagger-ui/**",
"/api/v1/v3/api-docs/**",
"/api/v1/auth/**",
"/api/v1/system/**"
).permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(customAuthenticationEntryPoint())
.accessDeniedHandler(customAccessDeniedHandler())
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) //jwt 없으면 필요없음
.build();
}
@Bean
public AuthenticationEntryPoint customAuthenticationEntryPoint() {
return (request, response, authException) -> {
response.setStatus(401);
response.setContentType("application/json;charset=UTF-8");
String requestPath = request.getRequestURI();
ApiResponse<Void> errorResponse = ApiResponse.error(ErrorCode.AUTHENTICATION_REQUIRED, requestPath);
String jsonResponse = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(jsonResponse);
};
}
@Bean
public AccessDeniedHandler customAccessDeniedHandler() {
return (request, response, accessDeniedException) -> {
response.setStatus(403);
response.setContentType("application/json;charset=UTF-8");
String requestPath = request.getRequestURI();
ApiResponse<Void> errorResponse = ApiResponse.error(ErrorCode.ACCESS_DENIED, requestPath);
String jsonResponse = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(jsonResponse);
};
}
}