Development/썬카(SunCar) - 개인 프로젝트

[썬카/백엔드] 예외 전역 핸들러 및 커스텀 예외 구현

양선규 2025. 3. 7. 17:02
728x90
반응형

비즈니스 로직에서 발생하는 모든 예외를 캐치하고 일관된 형태로 응답하기 위해서, 전역 핸들러와 커스텀 예외를 구현했다.

예외 처리 로직을 비즈니스 로직으로부터 분리할 수 있고, 클라이언트는 일관된 형태의 예외를 응답받기 때문에 예외에 대한 처리를 유연하게 할 수 있게 된다.

 

 

예외 전역 핸들러(Global Exception Handler)

- 애플리케이션에서 발생하는 모든 예외를 일관되게 처리하는 메커니즘

- Rest api를 구현한 Spring Boot에서는 @RestControllerAdvice 어노테이션으로 이를 구현한다.

 

예외 전역 핸들러 - 필요성

- 코드 중복 제거 : 각 서비스마다 예외 처리 로직을 반복할 필요 없음, try-catch 블록 최소화

- 일관된 오류 응답 : 모든 API에서 일관된 형태의 오류 응답 리턴

- 관심사 분리 : 비즈니스 로직과 예외 처리 로직의 분리

 

커스텀 예외(Custom Exception)

- 특정 비즈니스 상황이나 오류를 명확히 표현하기 위해, 개발자가 직접 정의한 예외

 

커스텀 예외 - 필요성

- 의미 명확화 : 문제 식별 및 디버깅 용이

- 예외 계층 구조화 : 카테고리 별로 공통된 상위 클래스를 상속받게 하여 예외를 그룹화하고, 예외 처리의 세밀한 제어가 가능해짐

- 세부 정보 포함 : 단순한 예외 메시지 이상의, 필요한 정보들을 포함할 수 있음.

- 처리 방식 차별화 : 예외 유형에 적합한 상태코드를 직접 설정할 수 있고, 다양한 예외 처리 전략 사용 가능

 

 

 

 

 

 

1. Global Exception Handler구현

package com.yangsunkue.suncar.exception.handler;

import com.yangsunkue.suncar.exception.BaseException;
import com.yangsunkue.suncar.exception.InvalidPasswordException;
import com.yangsunkue.suncar.exception.NotFoundException;
import com.yangsunkue.suncar.exception.UnauthorizedException;
import com.yangsunkue.suncar.exception.dto.ErrorResponseDto;
import com.yangsunkue.suncar.service.log.ErrorLogService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
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.authentication.BadCredentialsException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * 전역 예외 처리 핸들러 입니다.
 *
 * 특정 예외에 대하여 다음과 같은 동작을 수행합니다 :
 * -> 로깅
 * -> 상태코드 및 에러 응답 반환
 */
@RestControllerAdvice // @ControllerAdvice + @ResponseBody -> 응답을 자동으로 JSON 변환
@RequiredArgsConstructor
@Slf4j
public class GlobalExceptionHandler {

    private final ErrorLogService errorLogService;

    /**
     * 모든 커스텀 예외를 처리합니다.
     */
    @ExceptionHandler(BaseException.class)
    public ResponseEntity<ErrorResponseDto> handleBaseException(
            BaseException e,
            HttpServletRequest request
    ) {
        // 에러 로깅
        errorLogService.logError(e, request);

        // 에러 응답 생성
        ErrorResponseDto response = ErrorResponseDto.of(
                e.getMessage(),
                e.getStatus(),
                request.getRequestURI()
        );

        return ResponseEntity
                .status(e.getStatus())
                .body(response);
    }

    /**
     * Spring Security 관련 예외들을 처리합니다.
     */
    @ExceptionHandler({
            InternalAuthenticationServiceException.class,
            BadCredentialsException.class,
            AccessDeniedException.class,
            InsufficientAuthenticationException.class
    })
    public ResponseEntity<ErrorResponseDto> handleSecurityExceptions(
            Exception e,
            HttpServletRequest request
    ) {
        BaseException baseException;

        // InternalAuthenticationServiceException 처리
        if (e instanceof InternalAuthenticationServiceException) {
            Throwable cause = e.getCause();
            if (cause instanceof NotFoundException) {
                // 내부에 NotFoundException이 있으면 그것을 사용
                baseException = (NotFoundException) cause;
            } else {
                // 다른 내부 예외인 경우
                baseException = new UnauthorizedException("인증 처리 중 오류가 발생했습니다.");
            }
        }
        // BadCredentialsException 처리 (잘못된 비밀번호)
        else if (e instanceof BadCredentialsException) {
            baseException = new InvalidPasswordException();
        }
        // AccessDeniedException 처리 (권한 부족)
        else if (e instanceof AccessDeniedException) {
            baseException = new com.yangsunkue.suncar.exception.AccessDeniedException("접근 권한이 없습니다.");
        }
        // 그 외 인증 관련 예외
        else {
            baseException = new UnauthorizedException("인증이 필요합니다.");
        }

        // 에러 로깅
        errorLogService.logError(baseException, request);

        // 에러 응답 생성
        ErrorResponseDto response = ErrorResponseDto.of(
                baseException.getMessage(),
                baseException.getStatus(),
                request.getRequestURI()
        );

        return ResponseEntity
                .status(baseException.getStatus())
                .body(response);
    }




    /**
     * 500 InternalServerError - 예상치 못한 모든 예외를 처리합니다.
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponseDto> handleUnexpectedException(
            Exception e,
            HttpServletRequest request
    ) {

        // 에러 로그 출력
        errorLogService.logError(e, request);

        // 에러 응답 생성
        ErrorResponseDto response = ErrorResponseDto.of(
                "Internal server error",
                HttpStatus.INTERNAL_SERVER_ERROR,
                request.getRequestURI()
        );

        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(response);
    }
}

 

전역 예외 핸들러이다. @RestControllerAdvice 어노테이션을 통해, @RestController가 붙은 컨트롤러와 그 하위에서 발생하는 모든 예외를 캐치하여 이 전역 핸들러가 처리할 수 있게 해 준다.

 

가운데에 있는 handleSecurityExceptions()는 스프링 시큐리티에서 발생할 수 있는 예외를 처리하도록 작성한 메서드이다. 나는 JWT를 구현했는데 로그인 과정에서 발생할 수 있는 예외를 처리할 수 있도록 만들었다.

 

BaseException을 상속받는, 모든 커스텀 예외는 가장 위에 있는 handleBaseException() 메서드가 가져와 처리한다.

 

커스텀 예외로 선언되지 않은 모든 예외는 가장 아래에 있는 handleUnexpectedException() 메서드가 가져와 처리한다. 이 메서드는 항상 500 Internal server error를 뱉는다.

 

2. 에러 로그 서비스 구현

package com.yangsunkue.suncar.service.log;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

/**
 * 에러 로그를 출력하는 서비스 입니다.
 */
@Service
@Slf4j
public class ErrorLogService {
    public void logError(Exception e, HttpServletRequest request) {
        log.error("Exception occurred: {} at URI: {}",
                e.getMessage(),
                request.getRequestURI(),
                e // 예외 객체 자체를 전달하여 스택트레이스를 로그에 포함
        );

        /**
         * TODO
         * 추가적인 로깅 로직 작성
         * ex) DB에 저장, 모니터링 시스템에 전송 등
         */
    }
}

 

전역 핸들러에서 사용될, 에러 로깅 기능을 가진 서비스 클래스를 만든다.

단순히 인자로 받은 에러를 로깅하는 역할을 한다.

 

3. 에러 응답 DTO 구현

package com.yangsunkue.suncar.exception.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

import java.time.LocalDateTime;

/**
 * 에러 메시지 응답용 dto 입니다.
 */
@Getter
@AllArgsConstructor
public class ErrorResponseDto {

    private final String message;
    private final int status;
    private final String code;
    private final LocalDateTime timestamp;
    private final String path;

    public static ErrorResponseDto of(String message, HttpStatus status, String path) {
        return new ErrorResponseDto(
                message,
                status.value(),
                status.name(),
                LocalDateTime.now(),
                path
        );
    }
}

 

예외 발생 시 항상 일관된 형태의 response를 제공하기 위한 dto이다.

 

ErrorResponseDto 응답 형태

 

해당 Dto에 담겨서 오는 응답의 형태는 이렇다.

 

4. Custom Exception이 상속받을, BaseException 제작

package com.yangsunkue.suncar.exception;

import lombok.Getter;
import org.springframework.http.HttpStatus;

/**
 * 커스텀 예외의 기본 틀이 되는 클래스
 */
@Getter
public abstract class BaseException extends RuntimeException {

    private final HttpStatus status;

    protected BaseException(String message, HttpStatus status) {
        super(message);
        this.status = status;
    }
}

 

모든 커스텀 예외가 상속받은 BaseException 클래스이다. 이 클래스는 RuntimeException을 상속받는다.

RuntimeException은 Unchecked Exception(확인되지 않은 예외)로써, 비즈니스 로직에서 발생하는 예외를 표현하기 적절한 예외라고 할 수 있다.

 

BaseException은 직접 객체화되면 안 되기 때문에 abstract 클래스로 선언하고, 앞으로 만들 커스텀 예외들이 BaseException을 상속받아 구현되게 된다.

 

5. Custom Exception 제작 ( Not Found )

package com.yangsunkue.suncar.exception;


import org.springframework.http.HttpStatus;

public class NotFoundException extends BaseException {

    /**
     * 404 Not Found
     */
    public NotFoundException(String message) {
        super(
                message,
                HttpStatus.NOT_FOUND
        );
    }
}

 

BaseException을 상속받은 Custom Exception이다. 404 Not Found를 처리하도록 구현했다.

상태코드는 404로 정해져 있으며, 비즈니스 로직에서 예외 처리 시 message만 인자로 넣어주면 된다.

 

Custom Exception 사용

 

이런 식으로, 찾는 User 객체가 없을 경우 원하는 에러 메시지와 함께 예외를 던져주면 된다.

에러 메시지는 상수로 관리한다.

 

커스텀 예외 Not Found

 

흐름 정리

1. Global Exception Handler 구현

2. 에러 로깅 서비스 구현

3. 에러 응답 DTO 구현

4. Custom Exception을 위한, Base Exception 제작

5. Base Exception을 상속받는 Custom Exception 제작 및 비즈니스 로직에서 활용

 

예외 처리 흐름

1. @RestController 컨트롤러 하위 비즈니스 로직에서 예외 발생

2. @RestControllerAdvice 전역 핸들러 클래스로 예외가 던져짐

3. @ExceptionHandler 어노테이션에 의해 특정 예외를 캐치하고, 그대로 클아이언트에게 리턴되거나 커스텀 예외로 변환되거나 등의 작업으로 적절하게 처리되어 반환된다.

 

=====================

 

썬카 노션

https://lava-move-d1e.notion.site/SunCar-1a754e6b788180f598cdea3bfaff3139?pvs=4

 

 

깃허브

프론트 : https://github.com/SunCar-Project/suncar-frontend

백엔드 : https://github.com/SunCar-Project/suncar-backend

728x90
반응형