본문 바로가기
JAVA/요점정리

[Spring] ControllerAdvice, ExceptionHandler, static factory method pattern을 사용하여 예외 처리하기

by 진짠 2024. 12. 8.
728x90
사설
더보기

인프런에서 김영한 선생님이 강의에서 종종 해주시는 말씀이 있다. 고객을 편하게 하려면 개발자가 불편해야 한다. 스프링 강의를 듣고 있으면 스프링을 사용하고 있는 내(고객)가 편하게 개발할 수 있게 스프링을 개발한 개발자들이 정말 많이 신경써서 개발했다는 것을 느낄 수 있다.

 

현재 새로운 프로젝트의 구조를 세우고 있는 상황인 나(개발자)도 어떻게 하면 개발자(고객)가 이 구조 안에서 더 편하게 개발을 할 수 있을까에 대해 고민하게 된다. 그리고 그 중 하나가 예외처리이다. 우리는 웹 안에서 발생하는 다양한 상황에 맞는 예외를 구현해야 한다. 그것을 좀 더 간편하게 하기 위해 스태틱 팩토리 메서드 패턴을 사용하여 구현해보고자 한다.

 

배경

 

예외처리는 현재 다음과 같이 만들어져 있다.

@Getter
@AllArgsConstructor
public class TestException extends RuntimeException {
    private final int errorCode;
    private final String message;
    private final Object data;

    public TestException(int errorCode, String message) {
        this.errorCode = errorCode;
        this.message = message;
        this.data = null;
    }
}

 

런타임 예외를 상속받아 만든 TestException 이 있다. TestException은 errorCode와 예외 메시지를 처리하는 message, 뿌려줘야 할 데이터가 필요할 경우 사용하는 Object객체의 data가 있다. 예제에는 data가 필요하지 않기 때문에 null처리를 해주었다.

 

@ControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {

    /*
    * ExceptionHandler
    * */
    @ExceptionHandler(TestException.class)
    @ResponseBody
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleClientException(TestException ex) {
        String message = ex.getMessage();
        String code = ex.getCode();
        
        return new ErrorResponse(code, message);
    }
}

 

그리고 다음과 같이 GlobalExceptionHandler를 만들어서 정의한 TestException에 대한 내용을 ErrorResponse 객체에 담아 보내게 된다.

 

@ControllerAdvice 가 그것을 가능하게 해주는데, 이 어노테이션은 Spring MVC에서 제공하며 예외를 전역적으로 처리하고 데이터를 바인딩하며 모델 객체를 조작할 수 있다. 예제에서는 사용자 정의로 만든 TestException 인스턴스에 대한 글로벌 예외 처리를 담당했지만 이외에도 다양한 Exception에 대한 처리가 가능하다.

 

다음 코드는 클라이언트 오류(400)에 관한 처리이며 @ResponseStatus를 HttpStatus.BAD_REQUEST 로 설정해주었다. HttpStatus 내부에 다양한 코드가 존재하니 맞는 것을 사용하면 된다. 만약 여러 코드에 대한 내용을 정의하고 싶다면 @ResponseStatus 어노테이션 대신 리턴값을 ResponseEntity<> 형태로 바꿔주고 handleClientException 내부에서 관련 로직을 처리하면 될 것이다.

...
public ResponseEntity<ErrorResponse> handleClientException(TestException ex) {
    String message = ex.getMessage();
    String code = ex.getCode();
	
    // ErrorResponse 객체를 생성
    ErrorResponse errorResponse = new ErrorResponse(code, message);
    
    // 상황에 맞는 상태코드를 반환
    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
...

 

 

문제

 

클라이언트 코드는 다음과 같이 예외처리를 할 수 있게 된다.

 

// if(예외상황 발생!) {
	throw new TestException("code001", "error message");
// }

 

언뜻 봐서는 간단해 보이는 이 예외처리는 문제점이 존재한다.

 

1. code, message값을 직접 세팅한다. -> 다양한 예외처리를 하게 되고 해당하는 코드와 메시지가 증가하게 되면 다음과 같은 처리는 버그를 발생시킬 가능성이 높다. 가령 code058을 써야 하는데 048을 갖다 쓴다던가, 아니면 code058에 해당하는 에러메시지를 전부 수정할 일이 발생했을때 일일히 찾아가서 수정해야 한다.

 

2. 객체를 직접 생성해줘야 한다. -> 지금은 TestException 객체를 생성시 두개의 파라미터를 받고 있지만 이후에 안에 데이터나 리스트를 포함해야 해서 파라미터의 개수가 늘어나게 된다면 관리하기가 힘들어진다. 궁극적으로 클라이언트 코드가 사용할 코드의 내부 구조를 알고 있어야 된다는 상황이다.

 

이렇게 될 경우 사설과는 반대로 (구조를 만든) 개발자는 편한데 (사용하는) 개발자는 불편한 상황이 된다. 이 상황을 반대로 하기 위해서 정적 팩토리 메서드 패턴을 사용하여 간단하게 바꾸어보고자 한다.

 

정적 팩토리 메서드 패턴

 

내가 정적 팩토리 메서드 패턴을 사용하기로 한 이유는 다음과 같다.

 

1. 객체의 내부 구조를 몰라도 된다.

2. 생성 목적에 대한 표현을 직접적으로 이름에다 할 수 있다.

3. 재사용이 가능하여 메모리 절약이 가능하다.(차이는 크게 없지만)

 

다음 코드를 보자.

public abstract class BaseException extends RuntimeException {
    private final MsgType type;
    private final Throwable exception;

    public BaseException(MsgType type, Throwable exception) {
        super(exception);
        this.type = type;
        this.exception = exception;
    }

    public MsgType getType() {
        return type;
    }

    public Throwable getException() {
        return exception;
    }
}

 

BaseException 이라는 추상 클래스를 하나 만들었다. RuntimeException을 상속받은 이유는 Unchecked Exception이기 때문이다. Unchecked Exception은 컴파일 단계가 아닌 런타임 단계에서 예외를 처리한다. 따라서 사용자 인터렉션에 따른 예외 처리라는 목적에 맞게 사용할 수 있었다. 

 

BaseException은 MsgType와 Throwable 객체를 리턴값으로 받는다. Throwable은 해당 예외에 대한 스택 트레이스를 위해 받았고 이는 디버깅과 로깅에 유리하다. 

 

public class TestException extends BaseException {
    private TestException(MsgType type, Throwable exception) {
        super(type, exception);
    }

    public static BaseException withType(MsgType msgType) {
        return new TestException(msgType, new RuntimeException());
    }
}

 

다음은 BaseException을 상속받은 TestException이다. super() 를 통해 type과 exception을 인자로 받는 BaseException 객체를 생성했다.

 

그 이유는 밑에 있는 정적 메소드 사용을 위해서이다. static 이 붙었으니 클래스 단계에서 관리하며 인스턴스를 별도로 생성하지 않는다. 클래스 단계의 관리라 함은 클래스 로딩 시 메모리에 로딩되며 그것을 공유하는 방식이다. 그리고 인스턴스가 없기 때문에 인스턴스 변수를 사용할 수 없어 객체의 상태를 관리할 수 없다.

 

정적 메소드의 장점은 별도의 생성자 없이 사용이 가능하다는 것이다.

 

// if(예외발생!) {
	throw TestException.withType(MsgType.INVALID_DATA);
// }

 

처음 예외 처리 부분과 비교해보면 뭔가 상당히 간단해졌다. 일단 TestException.withType을 생성자 없이 한번에 접근한다는 것이다. 이것은 내가 가장 크게 장점으로 와닿았던 부분이다. '생성 목적에 대한 표현을 직접적으로 이름에다 할 수 있다.' 이는 가독성은 물론 생성자를 직접 호출하지 않으니 '객체의 내부 구조를 몰라도 된다.' 또, 직접 생성을 하지 않으니 '재사용이 가능하여 메모리 절약이 가능하다.'

 

메모리 절약은 사실 그렇게 차이가 크진 않다. 이 안에 굉장히 복잡한 로직이 들어가진 않기 때문이다. 하지만 예외 처리 관련된 로직을 내부에서 관리하니 사용자 입장에서는 MsgType 객체만 신경써서 어떤 예외를 처리할지 정해주면 된다.

 

MsgType은 Enum 타입으로 관리했다.

 

@Getter
public enum MsgType {
    LOGIN_FAILED("CODE0001", "exception.login.failed"),
    DATA_NOT_FOUND("CODE0002", "exception.data.not.found"),
    INVALID_DATA("CODE0003", "exception.invalid.data"),
    ;

    private final String code;
    private final String message;

    MsgType(String code, String message) {
        this.code = code;
        this.message = message;
    }
}

 

다음과 같이 code와 message를 정의했다. enum은 클래스 레벨에서 접근하기 때문에 정적 메소드에서 파라미터로 받을수가 있다. message의 경우 message properties에서 국제화를 적용시켜 키값으로 호출이 가능하다. 내부에서 이렇게 구조를 만들어두면 개발자는 상황에 맞는 enum 클래스를 찾아서 withType() 의 인자로 넣어주기만 하면 된다.

 

# message_ko.properties

exception.login.failed: 아이디, 비밀번호를 확인해주세요.
exception.data.not.found: 데이터가 존재하지 않습니다.
exception.invalid.data: 유효하지 않은 데이터입니다.

 

/resources/messages/ 하위 경로로 다음과 같이 프로퍼티 파일을 추가해주면 스프링에서 메시지 파일로 자동 인식한다.

@ControllerAdvice
@RequiredArgsConstructor
@Slf4j
public class GlobalExceptionHandler {
    private final MessageSource messageSource;

    /*
    * ExceptionHandler
    * */
    @ExceptionHandler(TestException.class)
    @ResponseBody
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleTestException(TestException ex) {
        String exCode = ex.getType().getCode();
        String exMessage = messageSource.getMessage(ex.getType().getMessage(), null, Locale.getDefault());

        log.error("TestException: {}", exMessage);
        return new ErrorResponse(exCode, exMessage);
    }
}

 

ExceptionHandler도 약간의 수정을 거쳐주었다. MessageSource.class를 사용하여 프로퍼티에서 정의한 메시지를 msgType의 키 값을 통해 호출할 수 있도록 했다. exCode와 exMessage를 ErrorResponse에 객체에 담아서 뿌려줌으로써 예외가 발생했을 경우 일관성 있는 메시지를 리턴할 수 있게 되었다.

 

로그인이 잘못되었을 때,

{

    "code" : "CODE001",

    "message" : "아이디, 비밀번호를 확인해주세요."

}

 

와 같이 응답 메시지를 뿌려줄 수 있는 것이다. 프론트에서도 다음 코드값과 메시지를 사용하여 알림 메시지를 적절히 뿌려줄 수 있다.

 

 

 

728x90

댓글