Spring Boot 예외 처리와 보안 알림

봇 스캔 404는 무시하고 진짜 서버 오류만 Slack 알림을 보내는 전역 예외 처리 패턴

마지막 수정: 2026-05

Spring Boot 예외 처리와 보안 알림

봇이 발생시키는 404, 405는 Slack 알림을 보내면 노이즈가 된다.
@RestControllerAdvice로 예외를 분류하고, 진짜 서버 오류(5xx)만 알림을 보내는 패턴이다.


예외 분류 기준

예외HTTP원인알림
NoResourceFoundException404봇 스캔, 잘못된 URL없음
HttpRequestMethodNotSupportedException405GET/POST 메서드 불일치없음
MethodArgumentNotValidException400입력값 검증 실패없음
Exception (그 외)500진짜 서버 오류Slack 알림

전역 예외 처리 구현

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    private final SlackNotifier slackNotifier;  // 선택 사항 — 없으면 null 주입 가능

    public GlobalExceptionHandler(@Autowired(required = false) SlackNotifier slackNotifier) {
        this.slackNotifier = slackNotifier;
    }

    // ────────────────────────────────────────────
    // 봇 스캔 / 잘못된 경로 → 404, 알림 없음
    // ────────────────────────────────────────────
    @ExceptionHandler(NoResourceFoundException.class)
    public ResponseEntity<Void> handleNoResource(NoResourceFoundException e,
                                                  HttpServletRequest request) {
        // WARN 레벨로만 기록, Slack 알림 없음
        log.warn("404 Not Found: method={} uri={}", request.getMethod(), request.getRequestURI());
        return ResponseEntity.notFound().build();
    }

    // ────────────────────────────────────────────
    // 메서드 불일치 → 405, 알림 없음
    // ────────────────────────────────────────────
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<Map<String, String>> handleMethodNotSupported(
            HttpRequestMethodNotSupportedException e,
            HttpServletRequest request) {

        log.warn("405 Method Not Allowed: method={} uri={}", request.getMethod(), request.getRequestURI());
        return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
                .body(Map.of("error", "Method not allowed"));
    }

    // ────────────────────────────────────────────
    // 입력값 검증 실패 → 400, 알림 없음
    // ────────────────────────────────────────────
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidation(
            MethodArgumentNotValidException e) {

        List<String> errors = e.getBindingResult().getFieldErrors().stream()
                .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
                .collect(Collectors.toList());

        log.warn("400 Validation failed: {}", errors);
        return ResponseEntity.badRequest()
                .body(Map.of("error", "Validation failed", "details", errors));
    }

    // ────────────────────────────────────────────
    // 그 외 모든 예외 → 500, Slack 알림
    // ────────────────────────────────────────────
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, String>> handleException(
            Exception e, HttpServletRequest request) {

        log.error("500 Internal Server Error: method={} uri={}",
                request.getMethod(), request.getRequestURI(), e);

        // 진짜 서버 오류만 Slack 알림
        notifySlack(e, request);

        return ResponseEntity.internalServerError()
                .body(Map.of("error", "Internal server error"));
    }

    private void notifySlack(Exception e, HttpServletRequest request) {
        if (slackNotifier == null) return;
        try {
            slackNotifier.sendError(
                "[서버 오류] " + e.getClass().getSimpleName(),
                request.getMethod() + " " + request.getRequestURI(),
                e.getMessage()
            );
        } catch (Exception ex) {
            log.warn("Slack 알림 전송 실패", ex);
        }
    }
}

SlackNotifier 구현 예시

@Component
@ConditionalOnProperty(name = "slack.webhook-url")  // 설정 없으면 Bean 미등록
public class SlackNotifier {

    private static final Logger log = LoggerFactory.getLogger(SlackNotifier.class);

    @Value("${slack.webhook-url}")
    private String webhookUrl;

    private final RestTemplate restTemplate = new RestTemplate();

    public void sendError(String title, String location, String message) {
        String payload = """
            {
              "text": "*%s*\\n위치: `%s`\\n메시지: %s"
            }
            """.formatted(title, location, message);

        try {
            restTemplate.postForEntity(webhookUrl,
                    new HttpEntity<>(payload,
                            buildHeaders()),
                    String.class);
        } catch (Exception e) {
            log.warn("Slack 전송 실패: {}", e.getMessage());
        }
    }

    private HttpHeaders buildHeaders() {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        return headers;
    }
}
# application.yaml — Slack Webhook URL 설정
slack:
  webhook-url: ${SLACK_WEBHOOK_URL:}   # 환경변수 미설정 시 Bean 미등록

봇 스캔 로그 레벨 조정

봇이 발생시키는 404 로그가 많아 INFO 레벨이 오염되는 경우 application.yaml에서 레벨을 올린다.

logging:
  level:
    org.springframework.web.servlet.resource: WARN
    org.springframework.web.servlet.DispatcherServlet: WARN

적용 확인

# 존재하지 않는 경로 호출 → 404, 알림 없음
curl -s -o /dev/null -w "%{http_code}" https://example.com/wp-login.php

# 잘못된 메서드 → 405, 알림 없음
curl -s -o /dev/null -w "%{http_code}" -X DELETE https://example.com/api/user/get

# 로그에서 봇 스캔 패턴 확인
grep "404 Not Found" /var/log/your-app/app.log | tail -20