봇/해킹 탐지 — Spring 예외 처리로 Slack 알림 보내기

MultipartException·의심 경로 탐지를 SecurityAlertService로 분리하고 IP 쿨다운을 적용해 Slack 보안 알림을 보내는 패턴

마지막 수정: 2026-05

봇/해킹 탐지 — Spring 예외 처리로 Slack 알림 보내기

Spring Boot 서버를 운영하다 보면 워드프레스 취약점 스캔, multipart POST 프로브 등
봇의 공격 시도가 끊임없이 들어온다. 이를 실시간으로 Slack 알림으로 받는 방법을 설명한다.


기존 예외 처리와의 차이

Spring Boot 예외 처리와 보안 알림 문서에서는
봇이 발생시키는 404/405는 알림 없이 처리하는 패턴을 다뤘다.

이 문서는 한 단계 더 나아가 탐지 가치가 있는 공격 시도를 Slack으로 능동적으로 알린다.

구분처리 방식
일반 404 (잘못된 URL)무시, 알림 없음
의심 경로 404 (wp-login, .env 등)보안 알림 발송
multipart POST 프로브보안 알림 발송
진짜 서버 오류 (500)서버 오류 알림 발송

SecurityAlertService — 핵심 컴포넌트

보안 탐지 로직을 GlobalExceptionHandler에서 분리해 별도 서비스로 관리한다.

@Slf4j
@Service
@RequiredArgsConstructor
public class SecurityAlertService {

    private final SlackService slackService;

    private static final long COOLDOWN_MS = 10 * 60 * 1000L; // 10분

    private static final List<String> SUSPICIOUS_PATTERNS = List.of(
        "wp-", "wordpress", "phpinfo", ".env", ".git", ".aws",
        "actuator", "cgi-bin", "shell", "eval", "xmlrpc", "jndi"
    );

    private final ConcurrentHashMap<String, Long> lastAlertByIp = new ConcurrentHashMap<>();

    public void reportAttack(HttpServletRequest request, String attackType) {
        String ip = extractClientIp(request);
        if (isOnCooldown(ip)) {
            log.debug("보안 알림 억제 (cooldown): ip={}", ip);
            return;
        }
        markAlerted(ip);
        slackService.sendSecurityAlert(ip, request.getMethod(),
                                       request.getRequestURI(), attackType);
    }

    public boolean isSuspiciousPath(String path) {
        String lower = path.toLowerCase();
        return SUSPICIOUS_PATTERNS.stream().anyMatch(lower::contains);
    }

    private String extractClientIp(HttpServletRequest request) {
        String xff = request.getHeader("X-Forwarded-For");
        return (xff != null && !xff.isBlank()) ? xff.split(",")[0].trim()
                                               : request.getRemoteAddr();
    }

    private boolean isOnCooldown(String ip) {
        Long last = lastAlertByIp.get(ip);
        return last != null && System.currentTimeMillis() - last < COOLDOWN_MS;
    }

    private void markAlerted(String ip) {
        // 1000개 초과 시 자동 정리
        if (lastAlertByIp.size() > 1000) lastAlertByIp.clear();
        lastAlertByIp.put(ip, System.currentTimeMillis());
    }
}

GlobalExceptionHandler 적용

@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {

    private final SlackService slackService;
    private final SecurityAlertService securityAlertService;

    // multipart POST 프로브 → 400 + 보안 알림
    @ExceptionHandler(MultipartException.class)
    public Object handleMultipart(MultipartException e, HttpServletRequest request,
                                  HttpServletResponse response) {
        securityAlertService.reportAttack(request, "multipart 파일 업로드 스캔");
        return ResponseEntity.badRequest().body(ApiResponse.fail("잘못된 요청입니다."));
    }

    // 존재하지 않는 경로 — 의심 경로만 알림
    @ExceptionHandler(NoResourceFoundException.class)
    public Object handleNoResource(NoResourceFoundException e, HttpServletRequest request,
                                   HttpServletResponse response) {
        if (securityAlertService.isSuspiciousPath(request.getRequestURI())) {
            securityAlertService.reportAttack(request, "취약점 경로 스캔");
        }
        return ResponseEntity.notFound().build();
    }

    // 진짜 서버 오류 → 500 + 서버 오류 알림 (보안 알림 아님)
    @ExceptionHandler(Exception.class)
    public Object handleGeneral(Exception e, HttpServletRequest request,
                                HttpServletResponse response) {
        slackService.sendError(request.getRequestURI(), e);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ApiResponse.fail("서버 오류가 발생했습니다."));
    }
}

SlackService — 보안 알림 포맷 분리

서버 오류 알림과 보안 알림을 별도 메서드로 분리해 수신자가 구분할 수 있게 한다.

// 진짜 서버 오류 알림
public void sendError(String requestUri, Throwable ex) {
    String message = """
            *[500 에러]* 서버 오류 발생
            • 경로: `%s`
            • 예외: `%s`
            • 메시지: %s""".formatted(requestUri, ex.getClass().getSimpleName(), ex.getMessage());
    send(message);
}

// 보안 탐지 알림 (별도 포맷)
public void sendSecurityAlert(String clientIp, String method, String uri, String detail) {
    String message = """
            *[보안 탐지]* 봇/해킹 시도
            • IP: `%s`
            • 요청: `%s %s`
            • 유형: %s""".formatted(clientIp, method, uri, detail);
    send(message);
}

포인트 정리

  • IP 추출: X-Forwarded-For 헤더로 Nginx 뒤의 실제 클라이언트 IP 추출
  • 쿨다운: 같은 IP가 10분 내 반복 공격해도 Slack 알림은 1회만 발송
  • 자동 정리: ConcurrentHashMap 항목이 1000개 초과 시 전체 초기화로 메모리 보호
  • 알림 분리: 서버 오류(sendError)와 보안 탐지(sendSecurityAlert) 포맷 구분

수신 알림 예시

[보안 탐지] 봇/해킹 시도
• IP: 198.51.100.10
• 요청: POST /
• 유형: multipart 파일 업로드 스캔
[보안 탐지] 봇/해킹 시도
• IP: 198.51.100.20
• 요청: GET /wp-login.php
• 유형: 취약점 경로 스캔