CS팀에서 전화가 왔다. '고객이 에러 화면 캡처해서 보냈는데, 여기에 SQL 쿼리가 다 보여요.' 심장이 멎는 줄 알았다. Spring Boot의 기본 에러 페이지가 Whitelabel Error Page인 건 알았지만, 스택트레이스가 통째로 노출될 줄은 몰랐다. 그날부터 Exception Handling에 진심이 됐다.

일관된 에러 응답 형식 설계

먼저 모든 에러 응답이 따를 공통 형식을 정의합니다.

public record ErrorResponse(
    String code,
    String message,
    Map<String, String> errors,
    LocalDateTime timestamp,
    String path
) {
    public static ErrorResponse of(String code, String message, String path) {
        return new ErrorResponse(code, message, Map.of(),
            LocalDateTime.now(), path);
    }

    public static ErrorResponse of(String code, String message,
            Map<String, String> errors, String path) {
        return new ErrorResponse(code, message, errors,
            LocalDateTime.now(), path);
    }
}

클라이언트는 항상 같은 형식의 에러 응답을 받게 되므로, 프론트엔드에서 에러 처리 로직을 일관되게 구현할 수 있습니다.

커스텀 예외 클래스 정의

비즈니스 로직에서 발생하는 예외를 체계적으로 관리하기 위해 커스텀 예외를 정의합니다.

// 비즈니스 예외 기본 클래스
public abstract class BusinessException extends RuntimeException {
    private final ErrorCode errorCode;

    protected BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() {
        return errorCode;
    }
}

// 에러 코드 Enum
public enum ErrorCode {
    // 인증 관련
    UNAUTHORIZED("AUTH_001", "인증이 필요합니다", HttpStatus.UNAUTHORIZED),
    TOKEN_EXPIRED("AUTH_002", "토큰이 만료되었습니다", HttpStatus.UNAUTHORIZED),
    ACCESS_DENIED("AUTH_003", "접근 권한이 없습니다", HttpStatus.FORBIDDEN),

    // 리소스 관련
    RESOURCE_NOT_FOUND("RES_001", "리소스를 찾을 수 없습니다", HttpStatus.NOT_FOUND),
    DUPLICATE_RESOURCE("RES_002", "이미 존재하는 리소스입니다", HttpStatus.CONFLICT),

    // 비즈니스 로직
    INSUFFICIENT_STOCK("BIZ_001", "재고가 부족합니다", HttpStatus.BAD_REQUEST),
    ORDER_ALREADY_COMPLETED("BIZ_002", "이미 완료된 주문입니다", HttpStatus.BAD_REQUEST),

    // 시스템
    INTERNAL_ERROR("SYS_001", "시스템 오류가 발생했습니다", HttpStatus.INTERNAL_SERVER_ERROR);

    private final String code;
    private final String message;
    private final HttpStatus httpStatus;

    ErrorCode(String code, String message, HttpStatus httpStatus) {
        this.code = code;
        this.message = message;
        this.httpStatus = httpStatus;
    }

    // getter 생략
}

// 구체적인 예외 클래스
public class EntityNotFoundException extends BusinessException {
    public EntityNotFoundException(String entityName, Object id) {
        super(ErrorCode.RESOURCE_NOT_FOUND);
    }
}

public class InsufficientStockException extends BusinessException {
    public InsufficientStockException() {
        super(ErrorCode.INSUFFICIENT_STOCK);
    }
}

전역 예외 핸들러 구현

@RestControllerAdvice를 사용하여 애플리케이션 전체의 예외를 한 곳에서 처리합니다.

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 비즈니스 예외 처리
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ErrorResponse> handleBusinessException(
            BusinessException e, HttpServletRequest request) {

        ErrorCode errorCode = e.getErrorCode();
        log.warn("Business exception: {} - {}", errorCode.getCode(), e.getMessage());

        return ResponseEntity
            .status(errorCode.getHttpStatus())
            .body(ErrorResponse.of(
                errorCode.getCode(),
                errorCode.getMessage(),
                request.getRequestURI()));
    }

    // Validation 예외 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException e, HttpServletRequest request) {

        Map<String, String> errors = new HashMap<>();
        e.getBindingResult().getFieldErrors().forEach(error ->
            errors.put(error.getField(), error.getDefaultMessage())
        );

        log.warn("Validation failed: {}", errors);

        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(ErrorResponse.of(
                "VALID_001",
                "입력값 검증에 실패했습니다",
                errors,
                request.getRequestURI()));
    }

    // 타입 변환 예외
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(
            MethodArgumentTypeMismatchException e, HttpServletRequest request) {

        String message = String.format("파라미터 '%s'의 값 '%s'이 올바르지 않습니다",
            e.getName(), e.getValue());

        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(ErrorResponse.of("VALID_002", message, request.getRequestURI()));
    }

    // 그 외 모든 예외 (최후의 방어선)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception e, HttpServletRequest request) {

        log.error("Unexpected error occurred", e);

        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of(
                "SYS_001",
                "시스템 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
                request.getRequestURI()));
    }
}

실전 활용 패턴

Service 레이어에서의 예외 발생

@Service
@Transactional(readOnly = true)
public class OrderService {

    public OrderResponse getOrder(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new EntityNotFoundException("Order", orderId));

        return OrderResponse.from(order);
    }

    @Transactional
    public OrderResponse createOrder(OrderCreateRequest request) {
        Product product = productRepository.findById(request.productId())
            .orElseThrow(() -> new EntityNotFoundException("Product", request.productId()));

        if (product.getStock() < request.quantity()) {
            throw new InsufficientStockException();
        }

        Order order = Order.create(product, request.quantity());
        return OrderResponse.from(orderRepository.save(order));
    }
}

Validation과 조합

public record OrderCreateRequest(
    @NotNull(message = "상품 ID는 필수입니다")
    Long productId,

    @Min(value = 1, message = "수량은 1개 이상이어야 합니다")
    @Max(value = 999, message = "수량은 999개 이하여야 합니다")
    int quantity,

    @NotBlank(message = "배송 주소는 필수입니다")
    @Size(max = 200, message = "배송 주소는 200자 이하여야 합니다")
    String shippingAddress
) {}

@Valid와 함께 사용하면, 검증 실패 시 GlobalExceptionHandler의 handleValidationException이 호출되어 클라이언트에게 필드별 에러 메시지를 반환합니다.

운영 환경에서의 주의사항

에러 메시지에 내부 정보 노출 금지

SQL 오류나 스택 트레이스가 클라이언트에게 노출되면 보안 위험이 됩니다.

// 잘못된 예시 - SQL 오류가 그대로 노출
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ErrorResponse> handleDbError(DataAccessException e) {
    return ResponseEntity.status(500)
        .body(ErrorResponse.of("DB_001", e.getMessage(), ""));  // 위험!
}

// 올바른 예시 - 일반적인 메시지만 노출
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ErrorResponse> handleDbError(
        DataAccessException e, HttpServletRequest request) {
    log.error("Database error: {}", e.getMessage(), e);  // 서버 로그에만 기록
    return ResponseEntity.status(500)
        .body(ErrorResponse.of("SYS_001",
            "데이터 처리 중 오류가 발생했습니다.",
            request.getRequestURI()));
}

로깅 레벨 구분

이렇게 구분하면 모니터링 시스템에서 실제 문제가 되는 에러만 알림을 받을 수 있습니다.

실무 경험 공유: 에러 코드 체계를 도입하기 전에는, 프론트엔드에서 에러가 발생하면 사용자에게 "서버 오류입니다. 관리자에게 문의하세요."라는 한 줄 메시지만 보여줬습니다. 당연히 사용자들은 CS팀에 전화를 걸었고, CS팀은 다시 개발팀에 문의하는 비효율적인 루프가 반복됐습니다. 에러 코드 체계(AUTH_001, BIZ_001 등)를 도입하고 프론트엔드에서 코드별로 구체적인 안내 메시지를 보여주기 시작하니, CS팀 문의가 한 달 만에 약 30% 줄었습니다. "토큰이 만료되었습니다. 다시 로그인해주세요" 같은 메시지만으로도 사용자가 스스로 문제를 해결할 수 있게 된 거죠. CS팀 리더가 직접 고맙다고 연락왔을 때, 에러 핸들링의 중요성을 다시 한번 실감했습니다.

에러 핸들링을 제대로 하고 나서 달라진 점

잘 설계된 예외 처리는 API의 신뢰성을 높이고, 프론트엔드 개발자와의 협업을 원활하게 합니다. 처음 프로젝트를 시작할 때 이 구조를 만들어 두면, 이후 비즈니스 로직 구현에만 집중할 수 있습니다.

에러 핸들링은 개발 초기에는 귀찮고 시간이 드는 작업처럼 느껴지지만, 운영 단계에 들어가면 그 투자가 몇 배로 돌아옵니다. 특히 에러 코드와 메시지를 체계적으로 관리하면, 모니터링 시스템에서 에러 패턴을 분석하기도 훨씬 쉬워집니다. "SYS_001 에러가 이번 주에 200건 발생했네" 같은 분석이 가능해지는 것이죠.

Jaeseong
Jaeseong

10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.

GitHub →

💬 댓글