Spring Boot에 Sentry를 적용할 때 놓치기 쉬운 부분 확인하기

Spring Boot

Language :

이 코드는 Spring Boot 3.2.5를 기준으로 작성되었습니다.

이전 글: Sentry를 Spring Boot에 간단하게 적용하기 이후 사용자 정의 처리한 내용을 기록합니다.

Sentry를 적용할 때 API 호출에서 발생하는 오류의 경우, 단순 오류(유효성 검증, 로그인 실패 등)는 수집할 필요가 없다고 판단하여 500 오류만 보고되도록 작업했습니다. 또한 스케줄러와 비동기 작업에서 발생되는 오류들도 놓치지 않고 보고되도록 추가적인 작업을 진행했습니다.

1. Sentry 설정

공식 문서: Sentry Docs

Spring Boot에서 Sentry를 사용하기 위한 의존성을 추가합니다.

build.gradle

Plain Text

plugins {
    id "io.sentry.jvm.gradle" version "3.12.0"
}

dependencies {
    implementation 'io.sentry:sentry-spring-boot-starter-jakarta:6.30.0'  
    implementation 'org.jetbrains:annotations:26.0.2' // BeforeSendCallback 에서 Nullable 체크용
}

src/main/resources/application.yml

Plain Text

sentry:  
  dsn: {sentry_url}
  send-default-pii: true
  traces-sample-rate: 1.0
  logging:  
    enabled: false

dsn: 이벤트를 보낼 위치를 설정합니다.

sentry.logging.enabled: @Slf4j 로그 자동 기록 여부

  • sentry-spring-boot-starter 의존성을 추가하면, 앱 시작 시 SentryLogbackInitializer가 동작하여 ROOT 로거에 SentryAppender를 등록합니다.
  • 기본값은 true이며 log.error 등이 Sentry에 자동 기록됩니다.
  • 여기서는 GlobalExceptionHandler에서 직접 제어하기 위해 false로 설정했습니다.

send-default-pii: 요청자 IP 등 개인 식별 정보를 기록합니다.

traces-sample-rate: 트랜잭션 전송 비율 (0.0 \~ 1.0)

  • 예: 0.2 설정 시 약 20%의 트랜잭션이 전송됩니다.

2. Global Exception Handler (ControllerAdvice)

API 호출에서 발생되는 오류들을 선별하여 처리합니다.

GlobalExceptionHandler.java

Java

import io.sentry.Sentry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * Spring Security에서 발생되는 오류는 Sentry에 전송하지 않습니다.
     */
    @ExceptionHandler({AccessDeniedException.class, AuthenticationException.class})
    public ResponseEntity<ErrorResponseDto> handleSecurityException(Exception e) {
        log.warn("Security Exception: {}", e.getMessage());
        HttpStatus status = (e instanceof AccessDeniedException) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED;
        return ResponseEntity.status(status)
                .body(new ErrorResponseDto("SECURITY_ERROR", e.getMessage()));
    }

    /**
     * 모든 예외 통합 처리 (Exception)
     * - 여기서 4xx 에러인지 5xx 에러인지 동적으로 판단하여 Sentry 전송 여부를 결정합니다.
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponseDto> handleUnexpectedException(Exception e) {
        // Spring 표준 웹 예외 (ErrorResponse 구현체) 확인
        // (예: NoResourceFoundException, HttpRequestMethodNotSupportedException, ResponseStatusException 등)
        if (e instanceof ErrorResponse errorResponse) {
            HttpStatus status = HttpStatus.valueOf(errorResponse.getStatusCode().value());
            
            // 4xx 에러인 경우: WARN 로그만 남기고 Sentry 전송을 하지 않음
            if (status.is4xxClientError()) {
			    if (errorResponse instanceof MethodArgumentNotValidException m) {  
			        if (m.getBindingResult().getFieldError() != null && StringUtils.hasText(m.getBindingResult().getFieldError().getDefaultMessage())) {  
			            return ResponseEntity.status(status)
                        .body(new ErrorResponseDto("BAD_REQUEST", m.getBindingResult().getFieldError().getDefaultMessage());  
			        }  
			    }
                log.warn("Client Error ({}): {}", status, e.getMessage());
                return ResponseEntity.status(status)
                        .body(new ErrorResponseDto("BAD_REQUEST", e.getMessage()));
            }
        }

        // 진짜 500 서버 에러 (그 외 모든 예외)
        log.error("Unexpected Error", e);
        
        // Sentry 전송
        Sentry.captureException(e);

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(new ErrorResponseDto("SERVER_ERROR", "서버 내부 오류가 발생했습니다."));
    }

    public record ErrorResponseDto(String code, String message) {}
}

3. BeforeSendCallback

Sentry로 오류 내용을 전달하기 직전에 이벤트를 가로채서 수정하거나 필터링합니다.

SentryBeforeSendCallback.java

Java

import io.sentry.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.springframework.stereotype.Component;
import java.util.List; // 추가됨

@Component
public class SentryBeforeSendCallback implements SentryOptions.BeforeSendCallback {
    @Override
    public @Nullable SentryEvent execute(@NotNull SentryEvent event, @NotNull Hint hint) {
        // 1. 특정 예외는 아예 보내지 않음
        if (event.getThrowable() instanceof IllegalArgumentException) {
            return null; 
        }

        // 2. 민감한 정보 마스킹 처리 (예: 카드 번호 등)
        // Sentry 서버 설정에서 'Data Scrubber'를 켜는 방법도 있습니다.
        
        // 3. 핑거프린트 커스텀 (같은 에러로 묶기)
        // DB 연결 에러는 쿼리가 달라도 다 같은 이슈로 묶고 싶을 때 사용
        if (event.getThrowable() instanceof java.sql.SQLException) {
            event.setFingerprints(List.of("database-connection-error"));
        }

        return event;
    }
}

4. Scheduler Error Handling

스케줄러(@Scheduled)에서 발생하는 오류는 전역 예외 처리기로 잡히지 않으므로 별도 설정이 필요합니다.

ScheduledConfig.java

Java

import io.sentry.Sentry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;

@Configuration
@EnableScheduling
public class ScheduledConfig implements SchedulingConfigurer {  
  
    @Bean  
    public ThreadPoolTaskScheduler taskScheduler() {  
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();  
        scheduler.setPoolSize(5);  
        scheduler.setAwaitTerminationSeconds(60);  
        scheduler.setWaitForTasksToCompleteOnShutdown(true);  
        scheduler.setThreadNamePrefix("scheduler-thread-task-");  
        
        // 에러 핸들러에 Sentry 캡처 등록
        scheduler.setErrorHandler(Sentry::captureException);  
        
        scheduler.initialize();  
        return scheduler;  
    }  
  
    @Override  
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {  
        taskRegistrar.setTaskScheduler(taskScheduler());  
    }  
}

5. Message Channel Error Handling

Spring Integration 등 메시지 채널을 사용하는 비동기 작업의 오류 처리입니다.

MyJobEndpoint.java

Java

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.integration.annotation.MessageEndpoint;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.MessageHandler;
import org.springframework.messaging.MessagingException;
import org.springframework.messaging.support.ErrorMessage;

@Slf4j
@MessageEndpoint
@RequiredArgsConstructor
public class MyJobEndpoint {

    private final MessageChannel errorChannel;

    @Bean
    @ServiceActivator(inputChannel = "executorChannel", async = "true")
    public MessageHandler messageHandler() {
        return new MessageHandler() {
            @Override
            public void handleMessage(Message<?> message) throws MessagingException {
                try {
                    // 처리 로직
                } catch (Exception e) {
                    var errMsg = new ErrorMessage(e);
                    errorChannel.send(errMsg);
                }
            }
        };
    }
}

AsyncJobConfig.java

Plain Text

import org.springframework.integration.annotation.ServiceActivator;

@Configuration  
@EnableIntegration  
public class AsyncJobConfig {

    @ServiceActivator(inputChannel = "errorChannel")  
    public void handleError(Message<?> message) {  
        Object payload = message.getPayload();  
        if (payload instanceof MessagingException ex) {  
            Sentry.captureException(ex);  
        } else if (payload instanceof Throwable throwable) {  
            Sentry.captureException(throwable);  
        }  
    }  
}

마무리

이렇게 설정하면 4xx 클라이언트 에러는 무시하고, 5xx 서버 에러와 비동기 에러까지 놓치지 않고 모니터링할 수 있습니다.

이 설정들을 적용하면 불필요한 알람 피로(Alert Fatigue)는 줄이고, 진짜 중요한 서버 장애와 비동기 작업의 실패는 놓치지 않는 견고한 모니터링 환경을 구축할 수 있습니다.

민갤

Back-End Developer

백엔드 개발자입니다.