본문 바로가기
개발[프로젝트]/쿠스토랑

전역 에러 핸들링 구조 개선하기

by wcwdfu 2025. 6. 10.

전역 에러 핸들링 구조 개선하기


 웹 세션, api서버 jwt기반 사용자 인증 관련해서 리팩토링을 진행하던 도중, 에러핸들링을 하는 과정에 있어서 인증뿐만아니라 전반적인 기존의 구조가 깔끔하지 못하다는 점이 자꾸 눈에가시로 작용하였고, 칼을 뽑은 김에 한꺼번에 정리하기로 하였다.

 

1. 우선 기존 코드의 문제점을 파악해보자

기존의 패키지 구조는 다음과 같았다.

- 모든 도메인들에서 사용되는 모듈인 global패키지에 들어가 있다.

- 전역 에러 처리를 web과 api 둘로 나눠서 사용했다. 

web은 model에 담아 thymeleaf에 포워딩 해야하고 api는 json으로 보내야 했기 때문. 

- exception 패키지 내부는 @ResponseStatus로 정의한 예외들이 선언되어있다.

- errorResponse는 api서버에서 사용할 응답포맷이다.

겉보기엔 깔끔해 보인다. 하지만 내부를 들여다 본다면?

 

1. 응답 포맷의 불일치.

몇몇 코드는 ErrorResponse를 쓰다가도 몇몇 코드는 String을 그대로 반환하고 있다.

에러 파싱 로직이 엔드포인트마다 달라지게되 비합리적일 것이라 생각했다.

 

2. 몇몇 응답에서 @ExceptionHandler와 @ResponseStatus 를 혼용하고 있다.

스프링에서는 예외 발생시, 전역 @ControllerAdvice에서 @ExceptionHandler가 담당하고 있는 예외를 잡게되면 먼저 처리하게 된다.

이후 전역처리에서 처리되지 않은(다른곳에서 별도로 처리되지 않은) 에러가 @ResponseStatus에서 지정된 StatusCode로 응답하게 된다. 

 

3. 몇몇 에러코드를 오버랩해서 잘못쓰고 있다.

500에 해당하지 않는 내부 로직 오류 또한 500으로 잘못 반환하고 있었다.

또는 500이어야 할 오류를 400으로 잘못 반환하고 있었다.

 

4. 로깅의 레벨과 포맷이 전혀 일괄되지 못했다.

아직 모니터링을 도입하지 않았지만, 곧 모니터링또한 도입을 하게 될것이다. 이는 모니터링에서 원인을 파악하거나 집계하는데 합리적이지 못할것이라 생각했다.

 

5. 몇몇에서 응답 상태 문자열을 하드코딩해서 사용하고 있다. (ex: NOT FOUND)

전체적인 유지보수 측면에서 본다면 Not Found", "NOT FOUND" 등과 같이 혼재할 가능성도 있을 수 있다.

하나로 깔끔하게 관리하는것이 더 좋아보였다.

 

6. Spring Security 관련 에러 핸들링을 잘못 사용하고 있었다.

Spring Security는 요청이 컨트롤러에 도달하기 전에 여러 보안필터 (Filter Chain)에서 처리된다. 

따라서 Spring Security 필터 단계에서 발생하는 예외는 별도로 잡아 Security Config에 설정해주는것이 올바른 방식인것 같다.

 

인증은 되었지만, 권한이 부족한 경우 (403)

아예 인증이 되지 않은 경우 (401)

 

로 나누었고 statuscode는 첫 구현당시 파악해두었지만 올바르게 작동하지 못하고 있었다.


그래서 이렇게 바뀌고 있다.


한꺼번에 바뀔순 없다. 여기저기 구 클래스들을 참조하고있는곳이 많다.

 

1. 우선 api서버에서 error 코드로써 반환되는 포맷을 ApiErrorResponse로 통일시켰다.

- ErrorCode라는 enum클래스를 두었다. 예시로 인증 영역에서는 아래와 같이 작성되었다.

/* ──── AUTH ──── */
    UNAUTHORIZED (HttpStatus.UNAUTHORIZED, "AUTH-401-UNAUTHORIZED", "인증이 필요합니다."),
    AT_EXPIRED   (HttpStatus.UNAUTHORIZED, "AUTH-401-AT-EXPIRED",  "Access 토큰이 만료되었습니다."),
    AT_INVALID   (HttpStatus.UNAUTHORIZED, "AUTH-401-AT-INVALID",  "유효하지 않은 Access 토큰입니다."),
    RT_EXPIRED   (HttpStatus.UNAUTHORIZED, "AUTH-401-RT-EXPIRED",  "Refresh 토큰이 만료되었습니다."),
    RT_INVALID   (HttpStatus.UNAUTHORIZED, "AUTH-401-RT-INVALID",  "유효하지 않은 Refresh 토큰입니다."),
    ACCESS_DENIED (HttpStatus.FORBIDDEN, "AUTH-FORBIDDEN", "접근 권한이 없습니다.");

 

 

클라이언트가 받게될 반환 형식은 크게 아래와 같이 둘로 나뉘게 하였다.

// 404
{
  "status": 404,
  "code": "USER-001",
  "message": "사용자를 찾을 수 없습니다."
}

// 400 (Validation 실패)
{
  "status": 400,
  "code": "COMMON-001",
  "message": "잘못된 입력 값입니다.",
  "errors": [
    { "field": "nickname", "value": "윽", "reason": "닉네임은 2글자 이상 12글자 이하 여야 합니다." }
  ]
}

 

1. HttpStatus 코드, 서버에서 설정한 에러코드명, 대략적인 에러메세지 를 담는다.

2. 위에 더해 구체적인 에러메시지가 더 필요한경우, 그 정보까지 추가해서 전달한다

현재는 두개의 하위 패키지가 있지만 에러가 사용될 도메인에 따라서 패키지를 더 추가할 수 있다.

 

이번에는 주로 인증 영역을 신경썻기 때문에 인증영역을 예시로 보자.

@Getter
public abstract class JwtAuthException extends AuthenticationException {
    private final ErrorCode errorCode;
    protected JwtAuthException(ErrorCode ec) {
        super(ec.getMessage());
        this.errorCode = ec;
    }

    protected JwtAuthException(ErrorCode ec, Throwable cause) {
        super(ec.getMessage(), cause);
        this.errorCode = ec;
    }

}

다음과 같이 인증관련 예외의 부모격인 추상클래스를 두었고, 다시 구체적인 Exception들은 해당 추상클래스를 상속해서 구현하게 된다.

 

여기서 에러의 정보량의 필요성에 따라서 다시 두가지로 나뉘었는데,

-> 서버에 로그를 남겨야하는 경우와 남길 필요가 없다고 판단되는 경우 두개로 분류

 

1. ErrorCode를 강제하여 어떤 인증 오류인지 명시하도록 하였고

2. Throwable cause를 추가로 두어 내부 원인까지 파악이 필요한 경우 추적할 수 있도록 설계하였다.

Throwable : 예외 클래스의 루트 클래스로 try catch가 가능한 모든 클래스의 뿌리이다.

따라서 Exception이든 Error든 어떤 원인 객체도 전달할 수 있다.