TIL

TIL 2023-11-27 하나의 커스텀 예외 클래스로 모든 컨트롤러 예외 처리하기 feat(글로벌 예외 핸들러)

wonow_ 2023. 11. 27. 21:40

개요

팀 프로젝트를 진행하며, 마지막 단계에 들어섰는데, 예외 처리를 하는 부분이었다.
 
예외 처리...를 할 건데 늘 하는 방식으로 예외 처리를 하기엔 너무 진부했다.
try catch로 잡고 catch에서 리스폰스 엔티티에 body 채워서 클라이언트 반환...
 
좋다.. 좋은데.. 작업 방식이 너무 똑같아지고, 재미도 없었다.
 
그래서 어떤 방법이 좋을까? 고민했는데 첫 번째로 나온 의견이 Global Exception Handler 였다.
그리고 두 번째로 나온 의견이 Enum이었다.
사실 내가 말한 건데, 일부러 모르는 부분을 도전해보고 싶어서 핸들러랑 열거형 클래스를 말했다.
 

기존의 작업 방식

@DeleteMapping("/{reviewId}")
    public ResponseEntity<String> deleteReview(@PathVariable Long reviewId,
                                               @AuthenticationPrincipal UserDetailsImpl userDetails, @PathVariable Long movieId) {

        reviewService.deleteReview(reviewId, userDetails.getUser(), movieId);

        return ResponseEntity.status(200).body("리뷰 삭제에 성공하였습니다.");
    }

 
컨트롤러에 이런 메소드가 있다고 가정하면 기존에는 예외처리 작업 방식을
 

@DeleteMapping("/{reviewId}")
    public ResponseEntity<String> deleteReview(@PathVariable Long reviewId,
                                               @AuthenticationPrincipal UserDetailsImpl userDetails, @PathVariable Long movieId) {
		try {
        	reviewService.deleteReview(reviewId, userDetails.getUser(), movieId);
        } catch (Exception e) {
        	return ResponseEntity.status(400).body("오류 메시지");
        }
        

        return ResponseEntity.status(200).body("리뷰 삭제에 성공하였습니다.");
    }

 
이런 느낌으로 작업을 했을 것이다.
 
그리고 한 단계 더 나아가서 여러 커스텀 예외 클래스와 여러 캐치 블록으로 핸들러에 잡히게끔 작업을 했을 수도 있다.
 
근데, 난 더 나아가고 싶었다
 
Enum... 너 쓰고 싶다 이넘...
 

새로운 예외 처리 작업 방식

Enum, 커스텀 예외 클래스, 글로벌 예외 핸들러를 쓰기 위해서 프로젝트에서 겹치는 예외들을 전부 조사해왔다.
 

해당 영화는 존재하지 않습니다
해당 리뷰는 존재하지 않습니다
이미 리뷰를 작성 하셨습니다
작성자만 수정할 수 있습니다
사용자 아이디, 이메일 또는 닉네임이 이미 사용 중 입니다.
중복된 이메일 입니다.
중복된 닉네임 입니다.
비밀번호가 일치하지 않습니다.
수정할 권한이 없습니다.
해당 유저는 존재하지 않습니다
해당 리뷰는 존재하지 않습니다.
해당 댓글은 존재하지 않습니다.
해당 댓글을 수정할 권한이 없습니다.
해당 댓글은 존재하지 않습니다.
해당 댓글을 삭제할 권한이 없습니다.
해당 영화는 존재하지 않습니다.
해당 리뷰는 존재하지 않습니다.
해당 페이지에 영화가 없습니다.
선택한 영화는 존재하지 않습니다.

 
이 중복되는 예외들은 하나로 합치고 어떤 오류 코드와 메시지를 반환할지 정해주었다.
 

404 NOT_FOUND
해당 영화는 존재하지 않습니다
해당 리뷰는 존재하지 않습니다
해당 댓글은 존재하지 않습니다.
해당 유저는 존재하지 않습니다
400 BAD_REQUEST
이미 리뷰를 작성 하셨습니다
비밀번호가 일치하지 않습니다.
403 FORBIDDEN
작성자만 수정할 수 있습니다
작성자만 삭제할 수 있습니다.
권한이 없습니다.
409 CONFLICT
사용자 아이디, 이메일 또는 닉네임이 이미 사용 중 입니다.
중복된 이메일 입니다.
중복된 닉네임 입니다.

 
그리고, 대망의 enum class를 만들어 주었다
 

package com.sparta.salaryonetrillionmoviereviewnewsfeed.exception;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public enum ExceptionCode {

    // CONFLICT
    CONFLICT_ID_EMAIL_NICKNAME_IN_USE(HttpStatus.CONFLICT, "사용자 아이디, 이메일 또는 닉네임이 이미 사용 중 입니다."),
    CONFLICT_EMAIL_IN_USE(HttpStatus.CONFLICT, "중복된 이메일 입니다."),
    CONFLICT_NICK_IN_USE(HttpStatus.CONFLICT, "중복된 닉네임 입니다."),

    // FORBIDDEN
    FORBIDDEN_UPDATE_ONLY_WRITER(HttpStatus.FORBIDDEN, "작성자만 수정 할 수 있습니다."),
    FORBIDDEN_DELETE_ONLY_WRITER(HttpStatus.FORBIDDEN, "작성자만 삭제 할 수 있습니다."),
    FORBIDDEN_YOUR_NOT_COME_IN(HttpStatus.FORBIDDEN, "권한이 없습니다."),

    // NOT_FOUND
    NOT_FOUND_USER(HttpStatus.NOT_FOUND, "해당 유저는 존재하지 않습니다."),
    NOT_FOUND_MOVIE(HttpStatus.NOT_FOUND, "해당 영화는 존재하지 않습니다."),
    NOT_FOUND_REVIEW(HttpStatus.NOT_FOUND, "해당 리뷰는 존재하지 않습니다."),
    NOT_FOUND_REVIEW_COMMENT(HttpStatus.NOT_FOUND, "해당 댓글은 존재하지 않습니다."),

    // BAD_REQUEST
    BAD_REQUEST_ALREADY_WROTE_REVIEW(HttpStatus.BAD_REQUEST, "이미 리뷰를 작성 하셨습니다."),
    BAD_REQUEST_NOT_MATCH_PASSWORD(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다.");


    private final HttpStatus httpStatus;
    private final String message;
}

 
필드로 HttpStatus를 갖고 표시할 메세지를 갖고 있다.
각 enum 클래스마다 생성자에 알맞은 값을 넣어줬다.
 
그리고 하나의 커스텀 예외 클래스로 해결해야하니, 커스텀 예외 클래스를 만들어줬다.
 

package com.sparta.salaryonetrillionmoviereviewnewsfeed.exception;

public class CustomException extends RuntimeException {
    private final ExceptionCode exceptionCode;

    public CustomException(ExceptionCode exceptionCode) {
        super(exceptionCode.getMessage());
        this.exceptionCode = exceptionCode;
    }

    public ExceptionCode getErrorCode() {
        return exceptionCode;
    }
}

 
이 예외 클래스는 필드로 위에서 만든 enum 클래스를 가지고 있고 생성자로 enum 클래스를 받고 있다
 
예외 처리를 해줄 때 이 커스텀 예외 클래스의 생성자에 enum 클래스를 넣어주면 알맞는 값으로 치환이 되는 것이다.
 

package com.sparta.salaryonetrillionmoviereviewnewsfeed.exception;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ExceptionAdviceController {
    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ExceptionResponse> handlerException(CustomException e) {

        ExceptionResponse exceptionResponse = new ExceptionResponse(e.getErrorCode());

        return ResponseEntity.status(exceptionResponse.getStatus()).body(exceptionResponse);
    }
}

 
이제 글로벌 예외 핸들러인데 이 부분 먼저 설명하고 ExceptionResponse로 넘어가겠다.
 
CustomException.class라는 예외가 발생하면 바로 이곳으로 잡히게 된다.
ExceptionResponse의 생성자도 e.getErrorCode를 넣어주면 예외가 발생할 당시의 예외 코드가 CustomException의 ExceptionCode가 들어가게 된다.
그리고 ResponseEntity로 status 부분엔 getStatus(), body 부분엔 response를 통으로 넣어줬다.
 

package com.sparta.salaryonetrillionmoviereviewnewsfeed.exception;

import org.springframework.http.HttpStatus;

public class ExceptionResponse {
    private final HttpStatus status;
    private final String message;

    public ExceptionResponse(ExceptionCode exceptionCode) {
        this.status = exceptionCode.getHttpStatus();
        this.message = exceptionCode.getMessage();
    }

    public HttpStatus getStatus() {

        return status;
    }

    public String getMessage() {

        return message;
    }
}

 
Response다 HttpStatus와 Message를 갖고 있다.
생성자를 사용해 받아온 ExceptionCode의 status와, message를 갖는다
 
이걸 사용해서 반환을 해준다
 

public List<ReviewResponseDto> getReviewList(Long movieId) {

        Movie movie = movieRepository.findById(movieId)
                .orElseThrow(() -> new CustomException(ExceptionCode.NOT_FOUND_MOVIE));

        return reviewRepository.findAllByMovieOrderByReviewLikeDescCreatedAtDesc(movie).stream()
                .map(ReviewResponseDto::new).collect(Collectors.toList());
    }

 
기존의 서비스단에서 예외 처리되는 부분을 수정하면 된다
이렇게 하면 컨트롤러딴에서도 try catch로 안잡아도 되고 예외 클래스를 많이 만들 필요가 없어진다.