해커톤 대비 복습 및 TODO 정리

  1. 인프라 구축하기(보안 중요 x)
  • 인스턴스 생성
  • 보안그룹 설정(아마 모두 열어둘듯?)
  • DB 연결
  • S3 사용여부 확인
  1. Git 세팅, 협업 전략 세우기
  • git convention?
  1. swagger 구축하기
@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;
    }
}
  1. CI/CD 파이프라인 구축하기

  2. DB 구축하기

  3. API 시그니처 작성

  4. 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;
        }
    }
  1. 로깅을 위한 WebConfig 인텁셉터 설정
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));
    }
}
  1. Secutiry 설정
@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);
        };
    }
}
  1. 더미데이터 시연파일 만들어두기

0개의 댓글