현재 상황
현재 유지 보수하고 있는 웨일던 프로젝트는 스프링 부트 경험이 없던 내가 첫 프로젝트로 진행했던 터라, 크고 작은 버그가 많다. 개발할 때 인지했지만 개발 기간이 부족해서 이슈로만 등록해두고 일단 진행했었다.
(CMC라는 it 동아리에서 진행했던 터라 기간 내에 완성해야 했다)
따라서 과거에 내가 시간이 없다는 핑계로 뿌려뒀던 코드들을 하나씩 고치고 있다.
이번 리팩토링 내용은 서버의 예외 처리 및 로그에 관한 내용이다.
대단하거나 어려운 내용은 없지만 삽질했던 경험을 공유하고자 한다.
문제
현재 프로젝트 내에서 예외처리는 Custom Exception을 만들어서 인자로 enum Type의 ExceptionStatus를 넣어주고 있다.
따라서, 기본 구조는 Controller로 요청이 들어온 후, Service에서 데이터 접근 또는 로직 처리를 하는 도중에 예외가 있는 경우 마구마구 throw new CustomException(errorStatus)를 뿌리고 있다.
CustomException.java
@AllArgsConstructor
@Getter
public class CustomException extends RuntimeException{
private CustomExceptionStatus status;
}
CustomExceptionStatus.java
@Getter
@RequiredArgsConstructor
public enum CustomExceptionStatus {
// Server
INTERNAL_SERVER_ERROR(true, "SERVER001", "서버에 문제가 발생했어요. 잠시 후 다시 시도하세요."),
INVALID_AUTHORIZATION(true, "AUTH001", "해당 페이지에 권한이 없는 사용자입니다. 토큰을 확인해주세요."),
INVALID_AUTHENTICATION(true, "AUTH002", "클라이언트 인증이 실패한 사용자입니다."),
생략
이용
public void getEmailValidationStatus(EmailValidRequestDto email) {
if (userRepository.findByEmailAndStatus(email.getEmail(), Status.ACTIVE).isPresent()) {
throw new CustomException(CustomExceptionStatus.USER_EXISTS_EMAIL);
}
}
우선은 잘 되겠거니 하고 별 생각이 없었지만, 나중에 에러가 생겨서 로그를 보면 너무 지저분했다.
문제점
1. RuntimeException을 상속받은 CustomException이 throw 후 로그에 찍히는데 해당 내용은 null로 찍히고 있었다.
즉, 다양한 exception에 대해 StatusMessage로 구분하기를 기대했지만, 내용은 없고 CustomException: null로 찍혀서 어디서, 무슨 문제가 발생했는지 알 수 없었다.
[2022-06-27 16:43:30:732309] [http-nio-8080-exec-1]
ERROR [org.apache.juli.logging.DirectJDKLog.log:175]
- Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
com.server.whaledone.config.response.exception.CustomException: null
이후 error stack trace ~~
원인 분석
우선 예외를 throw 하는 orElseThrow에 breakPoint를 찍어두고 디버깅을 해봤다.
orElseThrow에서 value가 null이 아니면 해당 value를 반환하고, 아니면 인자로 입력받은 exceptionSupplier를 get 한다.
그럼 내 customException은 else 문에서 throw가 될 것이다. 따라서 CustomException이 찍혔을 것..
하지만 메시지는 null이다.
상속 순서
CustomException은 RuntimeException을 상속받았으며, 최종 상속은 Throwable이다.
CustomException (Unchecked Exception) -> RuntimeException -> Exception -> Throwable이며,
모든 생성자는 super로 상위 타입의 생성자를 호출해서 결국 Throwable의 오버로딩된 생성자를 사용한다.
RuntimeException
public RuntimeException() {
super();
}
public RuntimeException(String message) {
super(message);
}
public RuntimeException(String message, Throwable cause) {
super(message, cause);
}
public RuntimeException(Throwable cause) {
super(cause);
}
Exception
public Exception() {
super();
}
public Exception(String message) {
super(message);
}
public Exception(String message, Throwable cause) {
super(message, cause);
}
public Exception(Throwable cause) {
super(cause);
}
protected Exception(String message, Throwable cause,
boolean enableSuppression,
boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
Throwable
public Throwable() {
fillInStackTrace();
}
public Throwable(String message) {
fillInStackTrace();
detailMessage = message;
}
public Throwable(String message, Throwable cause) {
fillInStackTrace();
detailMessage = message;
this.cause = cause;
}
public Throwable(Throwable cause) {
fillInStackTrace();
detailMessage = (cause==null ? null : cause.toString());
this.cause = cause;
}
Throwable의 toString, 내부
/**
* Returns a short description of this throwable.
* The result is the concatenation of:
* <ul>
* <li> the {@linkplain Class#getName() name} of the class of this object
* <li> ": " (a colon and a space)
* <li> the result of invoking this object's {@link #getLocalizedMessage}
* method
* </ul>
* If {@code getLocalizedMessage} returns {@code null}, then just
* the class name is returned.
*
* @return a string representation of this throwable.
*/
public String toString() {
String s = getClass().getName();
String message = getLocalizedMessage();
return (message != null) ? (s + ": " + message) : s;
}
result는 class.getName() + : + getLocalizedMessage가 리턴된다고 한다.
getLocalizedMessage가 null일 경우 클래스명만 리턴된다고 한다.
com.server.whaledone.config.response.exception.CustomException: null
message가 null이면 getClass().getName()만 찍히나 보다.
다시 돌아가서
나의 CustomException에서는 AllArgsConstructor로 모든 인자를 받는 생성자를 만들고 있었으며, 롬복에 의해 밑의 생성자가 생성되었을 것이다.
public CustomException(CustomExceptionStatus status) {
this.status = status;
}
또한, 자식 클래스의 생성자가 실행될 경우 부모 생성자의 기본 생성자가 자동 호출되었을 것이다.
잠시 자바 복습을 하고 가면
자식 클래스의 생성자가 호출될 때, 부모 클래스의 생성자 역시 호출된다.
기본적으로 클래스 내 생성자를 만들지 않으면 컴파일러는 기본 생성자를 만들고 호출한다.
자식 클래스 생성자 호출 시 부모 클래스의 기본 생성자만 자동 호출된다.
즉, 부모 클래스 내 매개변수를 받는 생성자는 자동으로 호출해주지 않는다.
따라서, 부모 클래스에 기본 생성자가 없고 매개변수를 받는 생성자만 있는 경우 자식 클래스에서 생성자를 호출할 경우 에러가 난다.
super를 이용해서 부모 클래스의 생성자를 명시적으로 호출해주어야 한다.
또한 super는 자식 생성자의 첫 줄에 호출되어야 한다.
추가로, AllArgsConstructor의 경우 super()로 부모의 생성자를 호출해주지 못한다.
따라서 자식 클래스에서 해당 어노테이션을 사용할 경우, 부모 클래스에 기본 생성자가 있어야 한다.
부모 클래스에 기본 생성자가 만들어져있지 않으면 사용할 수 없다고 한다.
(매개변수를 받는 생성자는 명시적으로 호출해줘야 하기 때문! 롬복 어노테이션은 해당 기능은 하지 못한다!)
잠깐, 자바에 대해 공부를 하고 돌아왔는데 결국 결론을 말하자면
AllArgsConstructor로 자식 클래스에서 생성자 호출 + 부모 클래스의 기본 생성자 호출 -> RuntimeException super() -> Exception 역시 super() -> Throwable의 기본 생성자 호출이 되었을 것이다.
따라서 기본 생성자가 호출돼서 인자는 하나도 전달되지 않았고, Throwable 클래스에서 출력되는 로그의 내용을 담당하는 cause 필드와 detailMessage 역시 모두 NULL일 것이다.
그래서 exception log에 아무런 내용이 적히지 않고 className과 Null만 적혔던 것이라 생각한다.
결론적으로, 부모 생성자를 명시적으로 적어서 로그에 적고 싶은 메시지를 매개변수로 전달해야 한다.
자식 클래스 생성자만 호출했을 뿐, 실질적 Exception의 부모 클래스의 생성자는 기본 생성자만 호출한 것이다.
수정
@Getter
public class CustomException extends RuntimeException{
private CustomExceptionStatus status;
public CustomException(CustomExceptionStatus status) {
super(status.getMessage());
this.status = status;
}
}
AllArgsConstructor를 제거하고, 생성자를 만들어주었다.
위에서 말했듯이, super를 이용해서 부모 클래스의 생성자를 호출했으며 String type의 메시지를 넘겨주었다.
결과
[2022-06-27 18:13:24:20633] [http-nio-8080-exec-3]
ERROR [org.apache.juli.logging.DirectJDKLog.log:175]
- Servlet.service() for servlet [dispatcherServlet] threw exception
com.server.whaledone.config.response.exception.CustomException: 존재하는 이메일이 없어요.
Null 대신 생성자에 전달한 Status의 메시지가 출력되는 것을 확인할 수 있었다.
단순하게, String message를 인자로 보내주면 된다는 사실을 알았지만 왜 그래야 할까에 대해 혼자 고민해봤다.
그래도 이 시도가 의미 있었던 것이, RuntimeException Exception Throwable로 연결되는 클래스 내부를 살펴볼 수 있었고
자바 언어에서 상속하는 경우 생성자에 대한 경우를 복습할 수 있었다. 덤으로 AllArgsConstructor에 대한 정보까지!