본문 바로가기
Backend

[spring swagger로 에러문서화하기] 에러 형식 정의 및 handler 정의 (1/2)

by 뜨거운 개발자 2024. 9. 21.

시작하며

공용 에러를 정의하고 스웨거를 통해 에러에 대해서 문서화를 하는 방법에 대해서 이야기 하도록 하겠다.

이 게시물은 공통으로 사용할 에러의 형식을 만들어서 설정하는 부분을 먼저 이야기 하고 다음 게시물에서 swagger로 에러를 문서화 하는 방법에 대해서 이야기하도록 하겠다.

 

이펙티브 자바 item 38 번

확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라 라는 내용이 있다.

public interface Operation {
	double apply(double x, dobule y);
}

public enum BasicOperation implements Operation {
  PLUS("+"){
  	public double apply(double x, dobule y) {return x + y; }
  }
}

public enum ExtendedOperation implements Operation {
  EXP("^"){
  	public double apply(double x, dobule y) {return Math.pow(x ,y ); }
  }
}

출처 : 이펙티브 자바 p.233

 

위 내용처럼 인터페이스를 선언한뒤에 다형성을 사용해서

사용하는 부분에서는 Operation 인터페이스만 바라보도록 할 수 있다.

위 방식을 사용해서 에러코드들을 도메인 별로 분리하는 방법과 이후에 swagger를 통해서 문서화 하는 방법에 대해 알아보자.

에러 형식

다음과 같이 에러 형식을 통일하고, 이제 이것에 맞게 코드를 짜보려고 한다.

{
  "success": false,
  "status": 401,
  "code": "AUTH_401_1",
  "reason": "인증 시간이 만료되었습니다. 인증토큰을 재 발급 해주세요",
  "timeStamp": "2024-09-19T11:24:42.948392",
  "path": "요청시 패스정보입니다."
}

 

ErrorReason이라는 객체를 먼저 만든다.

package com.groomiz.billage.global.dto;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class ErrorReason {
    private final Integer status;
    private final String code;
    private final String reason;
}

 

에러가 통일 된 형식을 가지고 싶기 때문에 다음과 같은 구조의 dto를 하나 제작해준다.

package com.groomiz.billage.global.dto;

import java.time.LocalDateTime;

import lombok.Getter;

@Getter
public class ErrorResponse {

    private final boolean success = false;
    private final int status;
    private final String code;
    private final String reason;
    private final LocalDateTime timeStamp;

    private final String path;

    public ErrorResponse(ErrorReason errorReason, String path) {
       this.status = errorReason.getStatus();
       this.code = errorReason.getCode();
       this.reason = errorReason.getReason();
       this.timeStamp = LocalDateTime.now();
       this.path = path;
    }

    public ErrorResponse(int status, String code, String reason, String path) {
       this.status = status;
       this.code = code;
       this.reason = reason;
       this.timeStamp = LocalDateTime.now();
       this.path = path;
    }
}

 

모든 Exception이 구현할 BaseErrorCode 인터페이스를 만들어준다.

public interface BaseErrorCode {
    public ErrorReason getErrorReason();

    String getExplainError() throws NoSuchFieldException;
}

 

 

이후 상수를 사용하기 편하게 static으로 사용할 클래스를 하나 만들어줬다.

package com.groomiz.billage.global.consts;

public class BillageStatic {
    public static final String AUTH_HEADER = "Authorization";
    public static final String BEARER = "Bearer ";
    public static final String ACCESS_TOKEN = "ACCESS_TOKEN";
    public static final String REFRESH_TOKEN = "REFRESH_TOKEN";

    public static final int MILLI_TO_SECOND = 1000;
    public static final int BAD_REQUEST = 400;
    public static final int UNAUTHORIZED = 401;
    public static final int FORBIDDEN = 403;
    public static final int NOT_FOUND = 404;

    public static final int CONFLICT = 409;
    public static final int INTERNAL_SERVER = 500;

    public static final Long NO_START_NUMBER = 1000000L;
    public static final Long MINIMUM_PAYMENT_WON = 1000L;
    public static final Long ZERO = 0L;

    public static final String KAKAO_OAUTH_QUERY_STRING =
       "/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code";

    public static final String[] SwaggerPatterns = {
       "/swagger-resources/**", "/swagger-ui/**", "/v3/api-docs/**", "/v3/api-docs",
    };
}


 

 

BaseErrorCode를 가지고 있는 GlobalCodeException 객체를 만들겠다.

@Getter
@AllArgsConstructor
public class GlobalCodeException extends RuntimeException {
    private BaseErrorCode errorCode;

    public ErrorReason getErrorReason() {
       return this.errorCode.getErrorReason();
    }
}

 

대부분의 에러는 GlobalCodeException을 상속 받아서 에러 객체를 만들 것이다.

그러나 모든 에러가 이렇게 형식적인 건 아니고 동적으로 에러를 만들고 싶을 수 있다.

@Getter
@AllArgsConstructor
public class GlobalDynamicException extends RuntimeException {
    private final int status;
    private final String code;
    private final String reason;
}

 

따라서 다음과 같은 GlobalDynamicException 클래스도 만들어줬다.

 

GlobalException Handler

ControllerAdvice를 이용해서 Exception handler를 대부분이 사용할텐데, 

 

GlobalExceptionHandler 전체 코드는 다음과 같다. 

전체적으로 주석으로 작성해뒀지만, 그래도 함수별로 천천히 코드를 설명해보겠다.

package com.groomiz.billage.global.exception;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.groomiz.billage.global.dto.ErrorReason;
import com.groomiz.billage.global.dto.ErrorResponse;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
	@Override
	protected ResponseEntity<Object> handleExceptionInternal(
		Exception ex, Object body, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
		ServletWebRequest servletWebRequest = (ServletWebRequest)request;
		HttpServletRequest httpServletRequest = servletWebRequest.getRequest(); // 예외가 발생한 URL과 같은 요청에 대한 세부 정보를 추출
		String url = httpServletRequest.getRequestURL().toString();

		HttpStatus httpStatus = (HttpStatus)status;
		ErrorResponse errorResponse =
			new ErrorResponse(httpStatus.value(), httpStatus.getReasonPhrase(),
				ex.getMessage(),
				url); // 사용자 정의 ErrorResponse 객체를 생성
		return super.handleExceptionInternal(ex, errorResponse, headers, status, request);
	}

	//주로 요청 본문이 유효성 검사를 통과하지 못할 때 발생합니다 (예: @Valid 어노테이션 사용 시) MethodArgumentNotValidException 예외를 처리하는 메서드
	@SneakyThrows // 메서드 선언부에 Throws 를 정의하지 않고도, 검사 된 예외를 Throw 할 수 있도록 하는 Lombok 에서 제공하는 어노테이션입
	@Override
	protected ResponseEntity<Object> handleMethodArgumentNotValid(
		MethodArgumentNotValidException ex,
		HttpHeaders headers,
		HttpStatusCode status,
		WebRequest request) {

		List<FieldError> errors = ex.getBindingResult().getFieldErrors();
		ServletWebRequest servletWebRequest = (ServletWebRequest)request;
		HttpServletRequest httpServletRequest = servletWebRequest.getRequest();
		String url = httpServletRequest.getRequestURL().toString();

		Map<String, Object> fieldAndErrorMessages =
			errors.stream()
				.collect(
					Collectors.toMap(
						FieldError::getField, FieldError::getDefaultMessage));

		String errorsToJsonString = new ObjectMapper().writeValueAsString(fieldAndErrorMessages);

		HttpStatus httpStatus = (HttpStatus)status;
		ErrorResponse errorResponse =
			new ErrorResponse(httpStatus.value(), httpStatus.getReasonPhrase(), errorsToJsonString, url);

		return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
	}

	/** Request Param Validation 예외 처리
	 * 유효성 검사 제약 조건이 위반되었을 때 발생합니다. (예: @NotNull, @Size, @Email 어노테이션 사용 시)
	 */
	@ExceptionHandler(ConstraintViolationException.class)
	public ResponseEntity<ErrorResponse> constraintViolationExceptionHandler(
		ConstraintViolationException ex, HttpServletRequest request) {
		Map<String, Object> bindingErrors = new HashMap<>(); // 유효성 검사 실패 필드와 해당 오류 메시지를 저장하기 위한 맵을 생성
		// 예외 객체에서 유효성 검사 위반 항목들을 가져옴.
		ex.getConstraintViolations()
			.forEach(
				constraintViolation -> {
					//위반된 속성의 경로를 가져옵니다. 이 경로는 문자열로 변환되어 점(.)을 기준으로 분할됩니다
					List<String> propertyPath =
						List.of(
							constraintViolation
								.getPropertyPath()
								.toString()
								.split("\\."));
					// 마지막 요소를 추출하여 실제 필드 이름을 가져옵니다. 예를 들어, 경로가 user.address.street라면 street가 추출됩니다.
					String path =
						propertyPath.stream()
							.skip(propertyPath.size() - 1L)
							.findFirst()
							.orElse(null);
					//위반된 필드 이름과 해당 오류 메시지를 맵에 저장
					bindingErrors.put(path, constraintViolation.getMessage());
				});

		ErrorReason errorReason =
			ErrorReason.builder()
				.code("BAD_REQUEST")
				.status(400)
				.reason(bindingErrors.toString())
				.build();
		ErrorResponse errorResponse =
			new ErrorResponse(errorReason, request.getRequestURL().toString());
		return ResponseEntity.status(HttpStatus.valueOf(errorReason.getStatus()))
			.body(errorResponse);
	}

	@ExceptionHandler(GlobalCodeException.class)
	public ResponseEntity<ErrorResponse> BillCodeExceptionHandler(
		GlobalCodeException e, HttpServletRequest request) {
		BaseErrorCode code = e.getErrorCode();
		ErrorReason errorReason = code.getErrorReason();
		ErrorResponse errorResponse =
			new ErrorResponse(errorReason, request.getRequestURL().toString());
		return ResponseEntity.status(HttpStatus.valueOf(errorReason.getStatus()))
			.body(errorResponse);
	}

	@ExceptionHandler(GlobalDynamicException.class)
	public ResponseEntity<ErrorResponse> BillDynamicExceptionHandler(
		GlobalDynamicException e, HttpServletRequest request) {
		ErrorResponse errorResponse =
			new ErrorResponse(
				e.getStatus(),
				e.getCode(),
				e.getReason(),
				request.getRequestURL().toString());
		return ResponseEntity.status(HttpStatus.valueOf(e.getStatus())).body(errorResponse);
	}

	//TODO: 이 경우 디코에 알림 가도록 구성해도 좋겠다.
	@ExceptionHandler(Exception.class)
	protected ResponseEntity<ErrorResponse> handleException(Exception ex, HttpServletRequest request)
		throws IOException {
		ServletWebRequest servletWebRequest = (ServletWebRequest)request;
		HttpServletRequest httpServletRequest = servletWebRequest.getRequest(); // 예외가 발생한 URL과 같은 요청에 대한 세부 정보를 추출
		String url = httpServletRequest.getRequestURL().toString();

		log.error("INTERNAL_SERVER_ERROR", ex);
		GlobalErrorCode internalServerError = GlobalErrorCode.INTERNAL_SERVER_ERROR;
		ErrorResponse errorResponse =
			new ErrorResponse(
				internalServerError.getStatus(),
				internalServerError.getCode(),
				internalServerError.getReason(),
				url);

		return ResponseEntity.status(HttpStatus.valueOf(internalServerError.getStatus()))
			.body(errorResponse);
	}
}

 

RestControllerAdvice를 이용해서 ExceptionHandler를 이용하고자 한다.

혹시나 ControllerAdvice에 대한 이해가 부족하다면 아래 게시글을 참고하도록 하자.

https://mangkyu.tistory.com/205

 

[Spring] @RestControllerAdvice를 이용한 Spring 예외 처리 방법 - (2/2)

예외 처리는 robust한 애플리케이션을 만드는데 매우 중요한 부분을 차지한다. Spring 프레임워크는 매우 다양한 에러 처리 방법을 제공하는데, 앞선 포스팅에서 @RestControllerAdvice를 사용해야 하는

mangkyu.tistory.com

1.  handleExceptionInternal

@Override
protected ResponseEntity<Object> handleExceptionInternal(
    Exception ex, Object body, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
    ServletWebRequest servletWebRequest = (ServletWebRequest)request;
    HttpServletRequest httpServletRequest = servletWebRequest.getRequest(); // 예외가 발생한 URL과 같은 요청에 대한 세부 정보를 추출
    String url = httpServletRequest.getRequestURL().toString();

    HttpStatus httpStatus = (HttpStatus)status;
    ErrorResponse errorResponse =
       new com.groomiz.billage.global.dto.ErrorResponse(httpStatus.value(), httpStatus.getReasonPhrase(),
          ex.getMessage(),
          url); // 사용자 정의 ErrorResponse 객체를 생성
    return super.handleExceptionInternal(ex, errorResponse, headers, status, request);
}

이 메소드는 기본적으로 ResponseEntityExceptionHandler에서 예외를 처리할 때 사용되는 메소드이다. 이 메소드를 오버라이드하여 사용자 정의 처리를 추가할 수 있다.

  • ServletWebRequest : WebRequest 객체를 ServletWebRequest 로 캐스팅해서, HTTP 서블릿 관련 요청 정보를 추출한다. 이를 통해 HTTP 요청 URL, HTTP 메소드 등과 같은 정보를 얻을 수 있다.
  • HttpServletRequest: 실제 서블릿 기반의 HTTP 요청 객체로, 이곳에서 예외가 발생한 URL을 추출하고 있다.
  • getRequestURL().toString(): 예외가 발생한 요청의 URL을 문자열 형태로 추출한다. 이는 클라이언트에게 반환할 에러 응답 객체에 포함된다.

이후 사용자 정의 ErrorResponse 객체를 생성한다.

  • HttpStatus httpStatus: HttpStatusCode 객체를 HttpStatus로 캐스팅하여 HTTP 상태 코드와 관련된 세부 정보를 얻는다.
  • ErrorResponse: 사용자 정의 에러 응답 객체이다. 이 객체는 클라이언트에게 전달할 에러 메시지, HTTP 상태 코드, 예외 메시지, 발생한 URL 등을 포함한다.
  • httpStatus.value(): HTTP 상태 코드를 숫자 형태로 가져온다. 예를 들어, 404 (NOT FOUND), 500 (INTERNAL SERVER ERROR) 등의 코드가 된다..
  • httpStatus.getReasonPhrase(): 상태 코드에 해당하는 문구를 가져온다. 예를 들어, 404이면 "Not Found"와 같은 문구가 반환된다.
  • ex.getMessage(): 발생한 예외의 메시지를 반환한다..
  • url: 예외가 발생한 요청의 URL이다.

ErrorResponse 객체는 클라이언트에게 반환되게 된다.

return super.handleExceptionInternal(ex, errorResponse, headers, status, request);

이 부분에서 부모 클래스인 ResponseEntityExceptionHandler의 handleExceptionInternal 메소드를 호출하고, 그 결과를 반환한다.
여기서 두 번째 파라미터로 사용자 정의 ErrorResponse 객체를 전달하여, 기본 응답 대신 우리가 정의한 내용으로 클라이언트에게 응답이 전달된다.

 

2. handleMethodArgumentNotValid

//주로 요청 본문이 유효성 검사를 통과하지 못할 때 발생합니다 (예: @Valid 어노테이션 사용 시) MethodArgumentNotValidException 예외를 처리하는 메서드
@SneakyThrows // 메서드 선언부에 Throws 를 정의하지 않고도, 검사 된 예외를 Throw 할 수 있도록 하는 Lombok 에서 제공하는 어노테이션입
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
    MethodArgumentNotValidException ex,
    HttpHeaders headers,
    HttpStatusCode status,
    WebRequest request) {

    List<FieldError> errors = ex.getBindingResult().getFieldErrors();
    ServletWebRequest servletWebRequest = (ServletWebRequest)request;
    HttpServletRequest httpServletRequest = servletWebRequest.getRequest();
    String url = httpServletRequest.getRequestURL().toString();

    Map<String, Object> fieldAndErrorMessages =
       errors.stream()
          .collect(
             Collectors.toMap(
                FieldError::getField, FieldError::getDefaultMessage));

    String errorsToJsonString = new ObjectMapper().writeValueAsString(fieldAndErrorMessages);

    HttpStatus httpStatus = (HttpStatus)status;
    ErrorResponse errorResponse =
       new ErrorResponse(httpStatus.value(), httpStatus.getReasonPhrase(), errorsToJsonString, url);

    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}

이 코드에서는 요청 본문이 유효성 검사를 통과하지 못할 때 발생하는 MethodArgumentNotValidException을 처리하는 메서드를 정의하고 있다. 이 예외는 Spring에서 @Valid나 @Validated와 같은 어노테이션을 사용하여 요청 본문에 대한 유효성 검사를 수행할 때, 필드 검사가 실패했을 경우 발생한다.

해당 메서드는 유효성 검사를 통과하지 못한 필드에 대한 오류 정보를 클라이언트에게 반환하는 역할을 한다.

 

  • @SneakyThrows는 Lombok에서 제공하는 어노테이션으로, 메소드에서 throws 절을 명시적으로 선언하지 않고도 checked exception를 던질 수 있도록 해준다.
  • 이 경우, 메소드 내부에서 ObjectMapper.writeValueAsString 메서드가 예외를 던질 수 있기 때문에, 이를 처리하기 위해 @SneakyThrows가 사용되었다. 이 어노테이션 덕분에 try-catch를 따로 작성하지 않아도 예외를 던질 수 있다.
List<FieldError> errors = ex.getBindingResult().getFieldErrors();
  • 유효성 검사를 통과하지 못한 필드의 에러를 추출할 수 있다.
  • getBindingResult(): 유효성 검사 실패에 대한 결과를 반환하는 메서드로, 이 메서드를 통해 검증 실패한 필드와 관련된 정보를 얻을 수 있다.
  • getFieldErrors(): 실패한 필드의 리스트를 반환한다. 이 리스트는 각각의 FieldError 객체로, 각 필드의 유효성 검사 실패 정보 (필드명과 에러 메시지)를 포함하고 있다.

이후 위에서 설명했듯, 예외 발생 URL을 추출한다.

 

Map<String, Object> fieldAndErrorMessages =
    errors.stream()
        .collect(
            Collectors.toMap(
                FieldError::getField, FieldError::getDefaultMessage));

 

스트림을 사용해서 ieldError 객체 리스트에서 필드명(FieldError::getField)과 그에 해당하는 기본 에러 메시지(FieldError::getDefaultMessage)를 맵으로 변환한다.

여기서 key는 필드 이름이고, value는 해당 필드의 에러 메시지이다. 이 방식으로 유효성 검사를 통과하지 못한 모든 필드와 메시지를 매핑한다.

 

이후 ObjectMapper를 이용해서 JSON을 문자열로 바꾸고, 사용자 정의 ErrorResponse 객체 생성한다.

 

 

3. constraintViolationExceptionHandler

/** Request Param Validation 예외 처리
 * 유효성 검사 제약 조건이 위반되었을 때 발생합니다. (예: @NotNull, @Size, @Email 어노테이션 사용 시)
 */
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> constraintViolationExceptionHandler(
    ConstraintViolationException ex, HttpServletRequest request) {
    Map<String, Object> bindingErrors = new HashMap<>(); // 유효성 검사 실패 필드와 해당 오류 메시지를 저장하기 위한 맵을 생성
    // 예외 객체에서 유효성 검사 위반 항목들을 가져옴.
    ex.getConstraintViolations()
       .forEach(
          constraintViolation -> {
             //위반된 속성의 경로를 가져옵니다. 이 경로는 문자열로 변환되어 점(.)을 기준으로 분할됩니다
             List<String> propertyPath =
                List.of(
                   constraintViolation
                      .getPropertyPath()
                      .toString()
                      .split("\\."));
             // 마지막 요소를 추출하여 실제 필드 이름을 가져옵니다. 예를 들어, 경로가 user.address.street라면 street가 추출됩니다.
             String path =
                propertyPath.stream()
                   .skip(propertyPath.size() - 1L)
                   .findFirst()
                   .orElse(null);
             //위반된 필드 이름과 해당 오류 메시지를 맵에 저장
             bindingErrors.put(path, constraintViolation.getMessage());
          });

    ErrorReason errorReason =
       ErrorReason.builder()
          .code("BAD_REQUEST")
          .status(400)
          .reason(bindingErrors.toString())
          .build();
    ErrorResponse errorResponse =
       new ErrorResponse(errorReason, request.getRequestURL().toString());
    return ResponseEntity.status(HttpStatus.valueOf(errorReason.getStatus()))
       .body(errorResponse);
}

 

이 코드는 위에서 본 것과 거의 같고 유효성 제약조건이 틀렸을 때 나오는 에러 메시지를 정의한다.

 

 

4. GlobalCodeExceptionHandler

@ExceptionHandler(GlobalCodeException.class)
public ResponseEntity<ErrorResponse> BillCodeExceptionHandler(
    GlobalCodeException e, HttpServletRequest request) {
    BaseErrorCode code = e.getErrorCode();
    ErrorReason errorReason = code.getErrorReason();
    ErrorResponse errorResponse =
       new ErrorResponse(errorReason, request.getRequestURL().toString());
    return ResponseEntity.status(HttpStatus.valueOf(errorReason.getStatus()))
       .body(errorResponse);
}

 

대부분 우리가 정의한 에러는 GlobalCodeException 클래스이기 때문에, 에러 메시지는 이렇게 만들어진다고 보면 된다.

 

 

5. GlobalDynamicExceptionHandler

@ExceptionHandler(GlobalDynamicException.class)
public ResponseEntity<ErrorResponse> GlobalDynamicExceptionHandler(
    GlobalDynamicException e, HttpServletRequest request) {
    ErrorResponse errorResponse =
       new ErrorResponse(
          e.getStatus(),
          e.getCode(),
          e.getReason(),
          request.getRequestURL().toString());
    return ResponseEntity.status(HttpStatus.valueOf(e.getStatus())).body(errorResponse);

GlobalDynamicException 같은 경우 우리가 미리 정의한 객체가 아닌 새롭게 만들어서 제작할 예정이다.

아직은 두개가 무슨 차이인지 감이 안 올 수도 있다. 일단 설정해두고 나중에 다시 이해하도록 하자.

 

6. handleException

//TODO: 이 경우 디코에 알림 가도록 구성해도 좋겠다.
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception ex, HttpServletRequest request)
    throws IOException {
    ServletWebRequest servletWebRequest = (ServletWebRequest)request;
    HttpServletRequest httpServletRequest = servletWebRequest.getRequest(); // 예외가 발생한 URL과 같은 요청에 대한 세부 정보를 추출
    String url = httpServletRequest.getRequestURL().toString();

    log.error("INTERNAL_SERVER_ERROR", ex);
    GlobalErrorCode internalServerError = GlobalErrorCode.INTERNAL_SERVER_ERROR;
    ErrorResponse errorResponse =
       new ErrorResponse(
          internalServerError.getStatus(),
          internalServerError.getCode(),
          internalServerError.getReason(),
          url);

    return ResponseEntity.status(HttpStatus.valueOf(internalServerError.getStatus()))
       .body(errorResponse);
}

handleException 에서 서버가 어떤 오류가 나더라도 위에있는 것에 걸리지 않았다면, 이 오류는 개발자가 생각한 오류가 아닐 것이다.

여기에서 디스코드나 메일 등으로 알림이 가도록 구성해준다면, 예기치 못한 오류에 대해서 모니터링 할 수 있다.

 

 

오류 정의하기

이제 오류를 처리하는 로직을 만들었으니, 어떻게 오류를 만들고 어떻게 throw해야 하는지 등에 대해서 작성해보겠다.

도메인 별로 에러를 정의할 것이고, 각 도메인마다 에러가 있다.

Auth에서 오류가 발생하는 경우를 생각해서 AuthException을 예시로 만들어보겠다.

 

 

1. 에러 정의하기

다음과 같이 GlobalCodeException을 상속 받는 Auth도메인에 있는 오류를 만들어준다.

package com.groomiz.billage.auth.exception;

import com.groomiz.billage.global.exception.GlobalCodeException;

import lombok.Getter;

@Getter
public class AuthException extends GlobalCodeException {

	public AuthException(AuthErrorCode errorCode) {
		super(errorCode);
	}
}

이렇게 AuthException을 정의해주고 앞으로 Auth에 관련된 예외는 이걸 이용해서 던지면 된다.

2. 에러코드 정의하기

이런 Enum을 이용해서 에러를 정의한다. 

형식은 다음과 같다.

package com.groomiz.billage.auth.exception;

import static com.groomiz.billage.global.consts.BillageStatic.*;

import java.lang.reflect.Field;
import java.util.Objects;

import com.groomiz.billage.global.anotation.ExplainError;
import com.groomiz.billage.global.dto.ErrorReason;
import com.groomiz.billage.global.exception.BaseErrorCode;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum AuthErrorCode implements BaseErrorCode {

    @ExplainError("accessToken 만료시 발생하는 오류입니다.")
    TOKEN_EXPIRED(UNAUTHORIZED, "AUTH_401_1", "인증 시간이 만료되었습니다. 인증토큰을 재 발급 해주세요"),
    @ExplainError("인증 토큰이 잘못됐을 때 발생하는 오류입니다.")
    INVALID_TOKEN(UNAUTHORIZED, "AUTH_401_2", "잘못된 토큰입니다. 재 로그인 해주세요"),

    @ExplainError("refreshToken 만료시 발생하는 오류입니다.")
    REFRESH_TOKEN_EXPIRED(FORBIDDEN, "AUTH_403_1", "인증 시간이 만료되었습니다. 재 로그인 해주세요."),
    @ExplainError("헤더에 올바른 accessToken을 담지않았을 때 발생하는 오류(형식 불일치 등)")
    ACCESS_TOKEN_NOT_EXIST(FORBIDDEN, "AUTH_403_2", "알맞은 accessToken을 넣어주세요.");

    private final Integer status;
    private final String code;
    private final String reason;

    @Override
    public ErrorReason getErrorReason() {
       return ErrorReason.builder().reason(reason).code(code).status(status).build();
    }

    @Override
    public String getExplainError() throws NoSuchFieldException {
       Field field = this.getClass().getField(this.name());
       ExplainError annotation = field.getAnnotation(ExplainError.class);
       return Objects.nonNull(annotation) ? annotation.value() : this.getReason();
    }
}

 

커스텀 어노테이션 제작

 

문서화를 하고 싶은 에러만 보여줄 수 있게 커스텀 어노테이션을 제작한다.

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface ExplainError {
	String value() default "";
}

 

 

사용 예시

public void logout(HttpServletRequest request, HttpServletResponse response) {
    // 헤더에서 RefreshToken 가져오기
    String refreshToken = request.getHeader("RefreshToken");
    if (refreshToken == null || !refreshToken.startsWith("Bearer ")) {
       throw new AuthException(AuthErrorCode.ACCESS_TOKEN_NOT_EXIST);
    }

    // Bearer 접두사 제거
    refreshToken = refreshToken.substring(7);

    // RefreshToken 만료 확인
    if (jwtUtil.isExpired(refreshToken)) {
       throw new AuthException(AuthErrorCode.REFRESH_TOKEN_EXPIRED);
    }

    // RefreshToken의 유효성 확인
    String category = jwtUtil.getCategory(refreshToken);
    String username = jwtUtil.getUsername(refreshToken);
    if (!"RefreshToken".equals(category) || !redisService.checkExistsValue(refreshToken)) {
       throw new AuthException(AuthErrorCode.INVALID_TOKEN);
    }

    // Redis에서 RefreshToken 삭제
    redisService.deleteValues(username);

    // 응답에서 RefreshToken 헤더를 제거
    response.setHeader("RefreshToken", "");
}

 

로그아웃 로직을 가져와봤다.

여기서 오류가 나는 경우에 생성하는 AuthException을 보고 이렇게 오류를 정의할 수 있다는 것을 기억하자.

 

마치며

이번 게시물은 오류 처리를 하기 위한 내용이 주를 이룬다.

Exception 처리 같은 경우도 매번 프로젝트마다 거의 통일 된 형식을 이루는데, 이걸 사용하는 보일 플레이트격의 게시물을 만들고 싶어서 다음과 같은 글을 쓰게 되었다.

다음 게시물에서는 스웨거를 이용해서 지금까지 만든 Exception을 문서화하는 방법에 대해서 알아보도록 하겠다.

https://haward.tistory.com/251

 

[spring swagger로 에러문서화하기] 에러를 문서로 만들기 (2/2)

일단 이전에 에러를 어떻게 만들었고 global handler를 어떻게 만들었는지를 이전 게시물을 통해 확인하고 이 글을 읽도록 하자.https://haward.tistory.com/250 [spring swagger로 에러문서화하기] 에러 형식 정

haward.tistory.com

 

728x90