[오류]전역적 예외처리

Inung_92·2023년 4월 5일
1

오류

목록 보기
5/6
post-thumbnail

기존의 예외처리

스프링을 공부하다보니 예외를 의도적으로 발생시켜 기능이 정상적이지 않을 경우 하나의 경고 수단으로 사용하는 방법을 배웠었다. 아래의 예시를 보자.

//DAO
public class TestDAO{
	private final SqlSessionTemplate sqlSessiongTemplate;
    
    // DB 등록 메소드
	public void insert(TestDTO testDTO) throws TestException {
    		int result = sqlSessionTemplate.insert("Test.insert", testDTO);
            if(result < 1) {
				throw new TestException("등록실패");
            }
    }
}

TestDTO라는 가상의 데이터를 DB에 저장하기 위하여 위의 메소드를 호출하였을 때, insert()의 반환값이 1보다 작을 경우 예외를 발생시킨다. 참고로 insert()의 반환값의 자료형은 int형이며 쿼리문 수행이 정상적으로 완료된 row의 수를 반환한다.
이렇게 관리하는 것이 잘못되거나 이상한 방법은 아니다. 하지만 여기서 DAO가 많아지고, 예외를 기능별로 class로 선언하여 관리한다고 가정하면 class의 갯수가 개발규모가 커질수록 많아지고 관리하기가 힘들어진다.

위의 그림처럼 수많은 예외들 중에서 해당하는 예외를 생성하고, 컨트롤러까지 예외를 throws해준다. 컨트롤러에 선언된 @ExceptionHandler가 해당 예외를 처리하고, 클라이언트에게 응답할 데이터를 반환한다.
각 기능을 수행하는 도중 예외를 이렇게한 형태로 처리한다면 컨트롤러가 비대해지는 것은 물론 예외별로 전달 메세지를 직접 입력하는 등의 효율성과 유지보수성이 떨어진다는 문제가 발생한다. 이러한 부분을 해결하기 위해 구글링을 해보니 좋은 방법이 있어서 적용해보았다.


전역적 예외처리

전역적 예외처리의 큰 원리는 ResponseEntityExceptionHandler를 상속받은 GlobalExceptionHandler이라는 하나의 class를 통해 해당 요청에서 발생한 모든 비즈니스 예외를 처리해 줄 것이다.

⚡️ 구성

먼저 예외처리에 사용된 객체들의 명칭 및 역할에 대해서 간단히 설명하고 넘어가겠다.

  • CustomException : RuntimeException을 상속받은 예외 객체로 필요한 위치에 생성하여 예외발생
  • ErrorCode : Enum(열거형) 객체로 다양한 예외를 상수로 보유하며 예외 발생 시 CustomException 생성자의 인자로 전달
  • GlobalExceptionHandler : @RestControllerAdvice로 요청 간 예외 발생 시 전역적 처리를 위한 객체로 ResponseEntityExceptionHandler를 상속받음
  • ExceptionResponse : 예외에 대한 데이터를 전달할 템플릿 객체

이렇게 4가지의 객체로 구성되어있다. 그럼 차근차근 코드를 보면서 알아가보자.

⚡️ 코드

🖥️ CustomException

@Getter
@RequiredArgsConstructor
public class CustomException extends RuntimeException {
	/* 예외처리에 필요한 code 및 메세지 */
	private final HttpStatus status;
	private final String detail;
	
	/* business exception 처리 시 사용 */
	public CustomException(ErrorCode errorCode) {
		super(errorCode.getDetail());
		
		this.status = errorCode.getStatus();
		this.detail = errorCode.getDetail();
	}
	/* business exception 처리 시 추가적인 내용이 필요할 경우
	 * ex : 상품 등록 실패 시 - new CustomException(ErrorCode.FAILED_REGIST, "상품");
	 */
	public CustomException(ErrorCode errorCode, String subject) {
		super(subject + " " + errorCode.getDetail());
		
		this.status = errorCode.getStatus();
		this.detail = subject + " " + errorCode.getDetail();
	}
	
	/* field exception 처리 시 사용 */
	public CustomException(ErrorCode errorCode, Throwable e) {
		super(errorCode.getDetail(), e);
		
		this.status = errorCode.getStatus();
		this.detail = errorCode.getDetail();
	}
}

CustomException은 말 그대로 내가 커스텀한 RuntimeException이다. 여기서 중요한 점은 status와 detail이라는 멤버변수를 보유하는 것인데 해당 멤버변수는 예외를 발생시키는 시점에서 생성자의 인자로 전달될 예정이다. 이때 해당 데이터를 보유하고 있는 것이 ErrorCode 객체이다.
즉, 예외가 발생하면 컴파일 단계에서 지정해둔 ErrorCode 객체의 상수를 생성자의 인자로 전달하여 값을 채워넣는 것이다. 그렇다면 ErrorCode가 어떻게 구성되어 있는지 알아보자.

🖥️ ErrorCode

/*
 * enum 생성자에 사용하기 위하여 static으로 import하여 사용
 * 프로젝트에서 발생할 수 있는 예외 HttpStatus를 판단하여 적용 및 추가
 */
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;

@Getter
public enum ErrorCode {

	/* 400 : 잘못된 문법으로 인하여 서버가 요청 이해불가 */
	FAILED_FILE_ERROR(INTERNAL_SERVER_ERROR, "선택된 이미지가 없습니다. 다시 시도해주세요."),
	MISMATCH_LOGIN_INFO(BAD_REQUEST, "로그인 정보가 올바르지 않습니다."),
	FAILED_REGIST(BAD_REQUEST, "등록 정보가 올바르지 않습니다."),
	FAILED_UPDATE(BAD_REQUEST, "수정 정보가 올바르지 않습니다."),

	/* 403 : 접근권한 미보유, 접근하려는 클라이언트가 미승인으로 서버가 거절 */
	VALID_TOKEN_SIGNATURE(FORBIDDEN, "유효한 토큰이 아닙니다."),
	VALID_TOKEN_EXPIRED(FORBIDDEN, "만료된 토큰입니다."),
	VALID_TOKEN_UNSUPPORTED(FORBIDDEN, "지원되지 않는 토큰입니다."),
	VALID_MEMBER(FORBIDDEN, "로그인 후 이용가능한 서비스 입니다."),
	VALID_BUSINESS_MEMBER(FORBIDDEN, "사업자회원만 이용가능한 서비스 입니다."),

	...생략
	
	/* 생성자로 값을 주입받고, Getter를 이용해 접근*/
	private final HttpStatus status;
	private final String detail;
	
	ErrorCode(HttpStatus status, String detail){
		this.status = status;
		this.detail = detail;
	}
	
	public String getCustomDetail(String msg) {
		return msg + detail;
	}
}

ErrorCode는 열거형 클래스(Enum)이다. 따라서 ErrorCode에서 선언한 상수들은 컴파일 시점에서 이미 값이 결정되어 있기 때문에 ErrorCode.xxx 형식으로 CustomException의 생성자에 인자로 전달해주면 된다. 이렇게 인자로 전달된 ErrorCode는 CustomException의 멤버변수인 status와 detail의 값으로 전달되며 클라이언트에게 해당 값을 응답하는 것이다.
이렇게 열거형을 사용했을때의 장점은 다음과 같다.

  • 직관적인 표현이 가능하다.
  • 유지보수 시 열거형 클래스 내에서만 내용을 수정하면 된다. 즉, 유지보수성이 높아진다.
  • 하나의 객체를 통해 예외의 종류를 설명할 수 있다.

이게 바로 내가 원하던 예외처리의 모습이었다. 그렇다면 이렇게 두가지 객체를 사용해서 예외가 발생되는 시점에 예외의 종류를 설명할 수 있게 되었지만 처리를 어떻게하는지 궁금할 것이다. 그 부분에 대해서 알아보자.

🖥️ GlobalExceptionHandler

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler{
	
	/* business Exception handler */
	@ExceptionHandler(CustomException.class)
	public ResponseEntity<ExceptionResponse> handleCustomException(CustomException ex){
		log.error("CuntomException:: ", ex);
		ExceptionResponse exceptionResponse = new ExceptionResponse(ex);
		return ResponseEntity.status(exceptionResponse.getStatus()).body(exceptionResponse);
	}
	
}

설명하기에 앞서 @ControllerAdvice와 @ExceptionHandler에 대해 간단히 설명하고 넘어가자.

@ControllerAdvice

  • @Controller, @RestController 어노테이션이 붙은 모든 Bean을 대상으로 동작
  • @RestControllerAdvice를 통해 응답결과에 대한 객체만 전송할 수 있음

@ExceptionHandler

  • @Controller, @RestController 어노테이션이 붙은 Bean에서만 사용가능(@Service 등 불가)
  • 반환하는 자료형은 자유롭게 지정해도 무관

결론적으로 @ControllerAdvice는 전역적 예외처리를 직접 수행하는 객체에게 사용하는 어노테이션이며, @ExceptionHandler는 예외처리가 필요한 객체 내부에서 예외처리를 하기 위한 하나의 수단을 표현하는 어노테이션이라고도 할 수 있다.

그렇다면 위 코드에 선언된 GlobalExceptionHandler를 다시보자. @RestControllerAdvice가 붙어있다. 이번 프로젝트는 모든 요청을 RestAPI로 구성했기 때문이다. 클래스 내에는 @ExceptionHandler를 통해 CustomException을 처리하도록 선언하였다. 이렇게하면 요청 시 발생한 CustomException은 GlobalExceptionHandler에 의해 처리가 되어 클라이언트에게 응답객체로 전달되어 지는 것이다.

예외에 대한 응답이 일관성이 있어야 하기에 예외 반환에 대한 템플릿 객체를 사용해주었다.

🖥️ ExceptionResponse

/* 예외를 클라이언트에게 응답 시 사용할 템플릿 */
@Getter
public class ExceptionResponse {
	private final HttpStatus status;
	private final String detail;
	
	public ExceptionResponse(CustomException ex) {
		this.status = ex.getStatus();
		this.detail = ex.getDetail();
	}
}

위 객체를 통해 발생된 예외별로 지정된 데이터를 주입하여 클라이언트로 전달하면 된다.

🖥️ 사용

사용하는 것은 아주 간단하다. 처음 예시로 들었던 코드를 다시 보면서 알아보자.

//DAO
public class TestDAO{
	private final SqlSessionTemplate sqlSessiongTemplate;
    
    // DB 등록 메소드
	public void insert(TestDTO testDTO) throws CustomException {
    		int result = sqlSessionTemplate.insert("Test.insert", testDTO);
            if(result < 1) {
				throw new CustomException(ErrorCode.FAILED_REGIST, "Test");
            }
    }
}

이렇게 ErrorCode를 인자로 전달해주면 딱 보기에도 등록에 실패했다는 예외가 발생된 것이며, 발생된 예외의 Subject는 "Test"라는 것을 알 수 있다. 만약 수정이 필요하다면 ErrorCode로 이동해 상수의 생성자 인자값만 변경하면 될 것이다.

나의 경우에는 DB를 이용하는 로직 이외에도 파일 저장, 파일 삭제와 select를 통한 유효성을 검증하는 등의 기능에서 if문 또는 try/catch문을 작성하여 예외를 발생시켜 처리하였다. 개발자마다 어떻게 적용할지에 대해서는 자유이니 더욱 효율적인 방법이 있다면 댓글 부탁한다.


마무리

개발을 처음 공부할 때 예외는 그저 빨간 글씨에 나를 힘들게하는 것이라고만 생각했다. 하지만 스프링을 배우면서 예외를 의도적으로 발생시켜 사용자에게 해당 내용을 전달하는 것도 하나의 방법이라는 것을 알게되었고, 예외를 적절한 위치에서 발생시키면 개발 단계에서도 굉장히 빠른 속도로 오류를 잡아낼 수 있었다.

또한, 사용자의 입장에서는 예외의 응답 데이터를 확인하여 고객센터에 문의할 수도 있기 때문에 고객이 반드시 알아야 할 기능의 오류는 반드시 예외를 처리하여 시각화 해주는 것이 중요하다고 생각한다. 아직 Enum의 활용이 익숙치 않고, 예외를 효과적으로 사용하지는 못하지만 이런 부분들을 계속 고민한다면 더욱 효율적인 방법들이 생각이 날 것이라 확신한다.

다음에는 비즈니스 예외 뿐만 아니라 필드 예외까지 처리할 수 있도록 해봐야겠다.

그럼 이만.👊🏽

profile
서핑하는 개발자🏄🏽

0개의 댓글