지난 게시글과 이어지는 내용이다 🙄
한 줄의 오타로 인해 시작된 리팩토링은 나를 Spring Boot의 예외 처리 과정 공부 -> 직접 진행하는 디버깅 -> 오타 발견 -> 리팩토링 의 과정으로 이끌었다
간과했었던 "스프링 부트의 개념 공부"를 이번을 기회삼아 진행할 수 있었음에 의미있긴했지만
이렇게 끝내긴 너무 허무하다
그래서 조금 더! 고민해보기로 했다.
지금 구현된 Custom Exception이 최선의 구현 방법인가? 고민하게 된 것이다.
기존 코드에서 새 Exception을 정의하기 위해선 아래와 같은 과정을 거쳐야만 했다.
(1) Custom Exception 클래스 정의
이렇게 Exception을 지칭하는 클래스를 정의해준다. 해당 클래스는 적절한 표준 예외 클래스를 상속받도록 구현한다.
(2) Custom Exception 메세지 ENUM 추가
이렇게 내가 정의한 상황에 맞는 HTTP Status code와 detail 메세지를 가진 ENUM을 추가해준다.
(3) @RestControllerAdvice 클래스에 메서드 추가
위와 같이 내가 만든 예외를 핸들링해줄 메서드를 따로 정의해준다.
적절한 Response Status Code 설정을 위해 @ResponseStatus() 어노테이션도 붙여준다.
(4) 사용하기
내가 만든 예외들을 던져준다.
생각보다 많은 과정을 거치고 있음을 확인할 수 있다.
이것이 최선일 수도 있지만 한 번 더 의심해보고자! 리팩토링을 진행하게 되었다.
우선적으로 굳이 Custom Exception을 만들어야 하나? 통용되는 표준 예외들을 적절히 사용하면 되는 것이 아닌가?라는 고민이 들었다.
IllegalArgument
... 등등 이미 많이 통용되는 예외들은 그 자체로도 충분히 설명력을 가진다고 생각했기 때문이다.
그러다가 이 글을 보게 되었다!
내가 고민하던 부분을 상세히 담은 글이었고, 이 글 덕분에 고민의 질이 조금은 더 높아질 수 있었다고 생각한다 :)
해당 참조 글의 요지는 다음과 같다.
"사용자 정의 예외를 사용하라"는 입장과 "표준 예외만으로도 충분하다"는 입장은 둘 다 설득력있는 주장이다.
즉, 취지에 맞게 그리고 의도에 맞게 사용하라는 의미인 것이다.
글에서 설명하는 내용을 간략히 설명해본다면 아래와 같다 :)
1. 표준 예외의 예외 메세지만으로도 충분히 의미를 전달할 수 있다.
다들 알고 있듯, 표준 예외를 생성할 때 파라미터로 message를 넣어줄 수 있다.
@GetMapping("/test")
public BasicResponseDto<Void> test(){
throw new IllegalArgumentException("테스트 예외가 발생헀어요");
}
이것만으로도 충분히 의미가 전달될 수 있으니 custom한 예외 처리가 필요하지 않다는 의미이다.
2. 가독성이 높아진다.
앞서 내가 언급했듯, 이미 통용되고 있는 예외 클래스들은 그 자체로 높은 가독성을 가진다.
3. 일일히 예외 클래스를 만들면 지나치게 커스텀 예외가 많아진다.
마찬가지로 위에서 언급한 내용이다.
기존 프로젝트에 적용됐던 custom Exception 정의 방식은 새로운 예외를 정의할 때 추가되는 클래스가 매우 많아진다.
그렇다면 Custom Exception을 사용했을 때의 장점은 무엇이 있을까
1. 예외에 대한 응집도가 향상된다.
위와 같이 Custom Exception을 정의하면 한 package에서 관련된 내용을 관리할 수 있기 때문에 응집도가 향상된다.
또한 표준 예외 생성자에 message를 넣음으로써 정보를 전달할 수는 있지만 전달하려는 정보의 양이 많아질 수록 Service 코드와 같은 비즈니스 로직이 불가피하게 길어질 수 밖에 없다.
게다가 같은 예외를 발생시키는 장소가 많아진다면? 중복 코드의 양은 계속해서 많아질 것이다.
Custom Exception을 사용하게 되면 "예외에 필요한 메세지", "전달할 정보", "데이터 가공 메소드"를 한 곳에서 관리할 수 있게 된다. 따라서 객체의 책임이 분리된 깔끔한 코드를 만들 수 있게 되는 것이다.
2. 예외 발생 후 처리가 용이
spring에서는 ControllerAdvice
를 통해서 전역적인 예외 처리가 가능해진다. 재사용성이 높은 것은 표준 예외들의 장점이지만 그 장점 때문에 발생 위치를 정확하게 파악하기 힘들어질 수도 있다. Custom Exception을 구현하면 의도적으로 발생시킨 예외와 그렇지 않은 예외를 구분할 수 있게 된다.
3. 예외 발생 비용 감소
자바에서 예외를 생성하는 행위는 'stack trace'로 인해 생각보다 비용이 많이 소모된다. stack trace를 통해 예외가 발생한 정확한 위치를 파악할 수 있게 되는데, try/catch나 Advice를 통해 예외를 처리한다면 해당 예외의 stack trace는 사용하지 않을 때가 많다. 이는 매우 비효율적이다.
stack trace의 생성은 예외의 부모 클래스 중 Throwable의 fillInStackTrace() 메소드를 통해 이뤄진다.
Custom Exception은 이를 오버라이딩하여 trace를 생성하지 않거나 짧게 일부만을 생성할 수 있도록 변경할 수도 있다.
만약 구현해낸 Custom Exception이 단순히 메세지만 넘겨주는 예외라면 예외를 캐싱해두는 것도 비용 절감의 방법이라고 한다.
public class CustomException extends RuntimeException {
public static final CustomException CUSTOM_EXCEPTION = new CustomException("대충 예외라는 내용");
//...
}
나는 해당 글을 읽으면서 내가 굳이 Custom Exception을 도입해야한다면 꼭! Custom Exception을 정의함으로써 얻을 수 있는 이점을 살리는 방향으로 도입해야겠다는 생각을 하게 되었다.
현재 본 프로젝트의 예외는 대부분 비슷한 맥락을 띈다.
"객체 조회시 ID 오류"
"해당 Method에 대한 권한이 없음" (ex. 내가 작성하지 않은 게시글을 삭제하려고 할 때)
등등
비슷한 예외가 반복되는 만큼, 표준 예외가 아닌 Custom Exception을 활용하여 Exception 처리의 응집도를 높여주는 것이 좋을 텐데
다양한 예외 상황들이 서로 맥락을 공유하고 있음에도 불구하고 WrongEmtoionID
, WrongQuestionID
와 같은 각기 다른 class로 구분되어 있으므로 효율적이지 못하다는 생각이 들었다.
그리고 해당 Exception이 어디서 발생했는지에 대한 정보가 제공된다면 더 편한 트러블 슈팅이 가능해질 것 같은데 관련된 정보를 제공해주는 필드도 없다.
이를 고려하여 Custom Exception의 장점을 살리는 방향으로 ! 리팩토링을 진행하였다.
해당 프로젝트를 진행할 때 BE와 FE끼리 협업하여 Error Response 엔티티를 정의하였다.
Status Code와 message만을 넘겨주는 단순한 Response Entity였는데
나는 위에서 언급했듯, Exception이 발생한 위치에 대한 언급도 필요한 것 같아 아래와 같이 Entity를 변경하게 되었다.
@Getter
public class ExceptionResponse {
private String statusCode;
private String statusCodePhrase;
private String occurrencePackage;
private String message;
public ExceptionResponse(int statusCode, String statusCodePhrase, String occurrencePackage, String message) {
this.statusCode = String.valueOf(statusCode);
this.statusCodePhrase = statusCodePhrase;
this.occurrencePackage = occurrencePackage;
this.message = message;
}
}
기존 Entity와 마찬가지로 Status Code와 message를 받았고
위에서 언급한대로 예외가 발생한 위치를 파악하기 위해 occurrencePackage를 만들었으며
status Code (숫자)만 전달해주기 보다는 해당 code의 통용되는 메세지도 함께 전달되는 것이 더 좋은 것 같아 statusCodePhrase라는 필드도 추가하게 되었다.
딱히 Exception 리팩토링과는 관련 없지만 그래도 언급하고 넘어가고 싶은 부분은
(1) Setter 등 다양한 메서드가 포함된 강력하지만, 그래서 위험한 어노테이션 @Data
를 @Getter
로 변경하였고
(기존에는 아무 어노테이션도 붙이지 않았었는데 그래서 문제가 발생했었다. 관련해서는 6번 목차에서 설명할 예정)
(2) 가독성 높은 네이밍을 위해 노력하였다..
정도가 되겠다 👀
위에서 말했듯 본 프로젝트에서는 비슷한 예외가 반복되고 있기 때문에 전반적인 예외의 타입?들을 정리할 필요가 있었다.
파라미터로 넘어온 대상 entity의 ID가 잘못된 경우 발생하는 예외이다. 이 예외는 404 NOT FOUND의 성격을 띈다.
요청한 HTTP Method에 대한 권한이 없을 때 발생하는 예외이다. 이 예외는 403 FORBIDDEN의 성격을 띈다.
유효하지 않은 input data가 들어왔을 때 발생하는 예외이다. 이 예외는 400 BAD REQUEST의 성격을 띈다.
많은 예외 클래스들이 세 개의 큰 분류로 묶이는 것을 확인할 수 있다.
그러면 이것을 본 프로젝트에 적용해보자!
처음으로 정의한 Exception Class들은 아래와 같다.
InvalidInput
해당 예외의 경우, 예를 들어 "input으로 들어온 회원가입용 username이 이미 존재하는 username이라 invalid해요!"와 같은 것을 알려주길 원했다.
따라서 target값을 받아오고, invalid한 reason을 받아와서 메세지를 재정의 해주길 원했다.
따라서 여기서의 생성자는 (String target, String reason, ExceptionTypes exceptionTypes)
가 되는 것이다.
ExceptionTypes는 ExceptionOccurrencePackage의 이전 명칭이다. (가독성을 위해 명칭을 변경함)
public class InvalidInput extends RuntimeException{
private HttpStatus httpStatus;
private String hint;
public InvalidInput(String target, String reason, ExceptionTypes exceptionTypes) {
super("유효하지 않은 " + target + " 값. [이유] " + reason);
this.hint = exceptionTypes.toString();
this.httpStatus = HttpStatus.FORBIDDEN;
}
public int getHttpStatusCode() {
return httpStatus.value();
}
public String getHttpStatusType() {
return httpStatus.getReasonPhrase();
}
public String getHint() {
return hint;
}
}
NoAuthorization
해당 예외의 경우 "요청하신 id=3의 Answer에 대한 GET 권한이 없습니다!"와 같이 메세지를 던져주길 원했다.
따라서 해당 클래스의 생성자는 (long id, HttpMethod method, ExceptionTypes exceptionTypes)
가 된다.
public class NoAuthorization extends RuntimeException{
private HttpStatus httpStatus;
private String hint;
public NoAuthorization(long id, HttpMethod method, ExceptionTypes exceptionTypes) {
super(id + "의 Entity에 대한 " + method.name() + " 권한이 없습니다.");
this.hint = exceptionTypes.toString();
this.httpStatus = HttpStatus.FORBIDDEN;
}
public int getHttpStatusCode() {
return httpStatus.value();
}
public String getHttpStatusType() {
return httpStatus.getReasonPhrase();
}
public String getHint() {
return hint;
}
}
WrongID
해당 예외의 경우 "id=3의 Entity가 존재하지 않아요!" 라고 알려주길 원했다
따라서 해당 클래스의 생성자는 (long id, ExceptionTypes exceptionTypes)
이다.
public class WrongId extends RuntimeException {
private HttpStatus httpStatus;
private String hint;
public WrongId(long id, ExceptionTypes exceptionTypes) {
super(id + "의 Entity가 존재하지 않습니다.");
this.hint = exceptionTypes.toString();
this.httpStatus = HttpStatus.NOT_FOUND;
}
public int getHttpStatusCode() {
return httpStatus.value();
}
public String getHttpStatusType() {
return httpStatus.getReasonPhrase();
}
public String getHint() {
return hint;
}
}
그런데 뭔가 이상하다.
사용하는 Custom Exception 마다 생성자에 필요로하는 파라미터가 각기 다 달라서, 적용하는데 오히려 문제가 발생한다.
사용자가 특정 예외를 사용하고 싶을 땐, 매번 생성자를 까보면서 필요한 정보를 알아서 적절히 넣어줘야만하는 상황이 발생하는 것이다.
그래서 이를 해결하기 위해 공통적으로 필요한 정보를 수립하였다.
id와 같은 Request에 포함되는 정보는 필요하지 않다.
client는 내가 "어떤 id로 요청을 보냈는지" 알고 있기 때문에 이러한 내용까지 Message에서 전달해줄 필요는 없는 것이다.
HTTP Method도, 요청의 target이 되는 Entity class 정보도 마찬가지이다.
따라서 필요한 정보는 예외가 발생한 패키지 위치와 추가적으로 덧붙이고 싶은 디테일한 정보 (ex. 이미 존재하는 username)이 된다.
이에 맞춰 아래와 같이 Exception들의 생성자를 통일 시켰다.
InvalidInput
NoAuthorization
WrongID
생성자를 통일 시켜 동일한 정보를 받아오되, 각 예외의 상황에 맞게 HTTPStatus code를 설정하고 메세지를 반환하도록 구현했다.
위에서도 확인할 수 있듯 통일된 예외 생성자에서는 DetailInformations
라는 클래스의 객체를 받아오고 있다.
이렇게 구현하게 된 이유는 아래와 같다.
원래는 String detailInformation
으로서 사용자가 직접 이유를 작성하는 방식으로 구현했었다.
if (accountRepository.findByUsername(username).isPresent()) {
throw new InvalidInput(ACCOUNT, "중복된 user name");
}
그런데 정확히 같은 내용의 Exception message가 필요한 상황이 정말 많았음에도 불구하고, 직접 detail message를 작성해야했기 때문에 같은 내용의 Exception임에도 message 내용은 미묘하게 다른 상황이 발생했었다.
throw new WrongId("Emotion 객체 조회 오류")
throw new WrongId("Emotion 객체 오류")
이를 어떻게 해결할까? 하다가
예외에 관련된 detail message들은 주로 재사용되는 경우가 많음에 착안에 DetailInformations라는 ENUM 클래스를 구현하게 되었다.
package com.hanwul.kbscbackend.exception.common;
public enum DetailInformations {
DUPLICATED_USERNAME("중복된 user name"),
UNKNOWN_USERNAME("등록되지 않은 user name"),
WRONG_PASSWORD("잘못된 password");
private String information;
DetailInformations(String information) {
this.information = information;
}
@Override
public String toString() {
return this.information;
}
}
새로운 information을 전해줘야할 상황이 생기면 ENUM 클래스에 추가해주고 이를 이용하기만 하면 되는 것이다.
지금은 재사용이 많이 되는 예외들만 존재하기 때문에 해당 방식이 나름 합리적인 것 같긴하지만,
"중복된 usernaem"과 같은 특정한 예외들이 많이 발생하는 상황에선 이게 최선이 아닐 수도 있다.
각자 상황에 맞게 고민해가면서 최적의 방법이 찾는 것이 중요하다고 생각된다🙄
위와 같이 구현을 다 해놓고 돌려보니 웃기는 상황이 발생했었다.
내가 만든 ErrorResponse대로 적용이 되고 있지 않는 것이다!
Spring Boot의 log를 확인해보면 Custom Exception 에서 super(message)
가 잘 호출됨을 확인할 수 있는데, 정작 적절한 Exception Response Entity가 return 되고 있지 않았다.
내가 정의한 ExceptionResponse
public class ExceptionResponse { private String statusCode; private String statusCodePhrase; private String occurrencePackage; private String message public ExceptionResponse(int statusCode, String statusCodePhrase, String occurrencePackage, String message) { this.statusCode = String.valueOf(statusCode); this.statusCodePhrase = statusCodePhrase; this.occurrencePackage = occurrencePackage; this.message = message; } }
뭔가 잘못되고 있는 것이다. 이유를 알아보기 위해 디버깅을 진행했다.
120번째 줄에서도 볼 수 있듯 invokeAndHandle
메소드의 returnValue 값으로 우리가 원하는 ExceptionResponse
객체가 잘 들어왔음을 확인할 수 있다.
그런데 invokeAndHandle()
의 아래 코드를 타고 들어가
RequestResponseBodyMethodProcessor
의 handleReturnValue()
메소드에 진입해보면 (이름만 봐도 우리가 건들여야하는 메소드임이 짐작된다.)
이렇게 wirteWithMessageConverters()
라는 의미심장한 메소드가 등장하는데
해당 메소드 내부에선
cnaWrite()라는 뭔가.. 우리가 원하는 것 같은 메소드명이 등장한다.
실제로 이 메소드 안에 들어가는 valueType은 우리가 원하는 ExceptionResponse 객체값이 잘 들어가있음이 확인된다.
우리가 원하는 객체가 ParameterizedType이 아니어서 발생하는 문제인 것 같다.
넘어오는 예외 클래스 HttpMediaTypeNotAcceptableException을 검색해보니 아래와 같은 글이나왔다.
우리가 Response 값으로 반환하고 싶은 객체인 ExceptionResponse에 private 필드만 존재하고 그 필드를 가져올 Getter가 없어서 발생하는 문제였던 것이다!!
결국 @Getter를 붙임으로서 해당 문제를 잘 해결해나갈 수 있었다 😂😂😂
얼렁뚱땅 디버깅이었지만 메소드명을 읽어나가며 내가 발생한 문제와 관련된 메소드를 찾아나가는 과정이 나름 의미있었다.
최종적인 Exception Handling 코드는 다음과 같다.
문제가 발생하면 사용자는 아래와 같이 사용자 정의 예외를 던져주고
Emotion emotion = emotionRepository.findById(emotionID)
.orElseThrow(() -> new WrongId(EMOTION, EMOTION_EXCEPTION));
아래와 같은 사용자 정의 예외가 생성된다
public WrongId(ExceptionOccurrencePackages occurrencePackages, DetailInformations detailInformation) {
super("ID에 해당하는 객체가 존재하지 않음. [detail] " + detailInformation.toString());
this.httpStatus = HttpStatus.NOT_FOUND;
this.occurrencePackages = occurrencePackages.toString();
}
그리고 해당 예외가 던져졌을 때 아래와 같은 메소드가 실행되게 되고 (@RestControllerAdvice)
@ResponseStatus(value = HttpStatus.NOT_FOUND)
@ExceptionHandler(value = WrongId.class)
public ExceptionResponse wrongIdExceptionHandler(WrongId e) {
return new ExceptionResponse(e.getHttpStatusCode(), e.getHttpStatusType(), e.getOccurrencePackages(), e.getMessage());
}
그 결과로서 아래와 같은 Response를 받게 되는 것이다.
내용은 크게 없지만 그래도 정말 긴 여정이었다😂
거창한 리팩토링은 아니었지만
생각없이 코드를 작성하는 것이 아닌, 고민하고 합당한 이유를 찾아 내 의견을 가지고 코드를 작성해나가는 과정에서 많은 것을 배울 수 있었다
끊임없이 고민하는 개발자가 되기 위해 계속해서 노력하자:)
이것이 내 성장의 단단한 기반이 될 것임을 확신하기 때문에 :):)