구름톤 유니브 스터디

[구름톤 유니브] Spring 커뮤니티 게시판 @RestControllerAdvice 기반 전역 예외 처리 구조 설계 구현

랑 이 2025. 3. 30. 17:06
반응형

사용자가 서비스를 이용하면서 발생하는 예외가 발생되면 500번 코드만 보내줘서 클라이언트에서는 어떤 사항 때문에 에러가 발생한 지 확인 어려움이 있다고 생각합니다

 

직접 예외 코드와 메시지를 정의하여 정확한 상황 파악을 할 수 있도록 응답하는 시스템을 구현하기 위해서 @ControllerAdvice를 통해서 전역에서 예외를 처리할 수 있도록 구조를 설계하고 각 예외에 맞는 에러 메시지를 클라이언트에게 응답할 수 있도록 구현했습니다

 

예외처리 방법 구현에 대해서 설명합니다

1. ErrorCode 열거형 (enum) 정의

ErrorCode

package com.domain.openboard.error;

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

@Getter
@AllArgsConstructor
public enum ErrorCode {

    VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "VALIDATION_ERROR", "잘못된 요청입니다."),
    POST_NOT_FOUND(HttpStatus.NOT_FOUND, "POST_NOT_FOUND", "게시글을 찾을 수 없습니다."),
    PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "PASSWORD_MISMATCH", "비밀번호가 일치하지 않습니다."),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다.");

    // 에러의 HTTP 상태 코드
    private final HttpStatus httpStatus;

    // 에러 코드
    private final String code;

    // 에러 메시지
    private final String message;
}

모든 예외에 공통적으로 적용할 코드,메시지,상태코드를 관리하도록 구현했습니다

2. 공통 예외 응답 객체 정의

ErrorResponse

package com.domain.openboard.error;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
// 에러 메시지 응답용 객체
public class ErrorResponse {

    private String code;
    private String message;
}

예외가 발생 했을때 클라이언트에게 공통적으로 반환할 DTO입니다

"code" "message"만 포함하여 응답하도록 구현했습니다

3. 커스텀 예외 클래스 정의

CustomException

package com.domain.openboard.error;

import lombok.Getter;

@Getter
// RuntimeException을 상속한 사용자 정의 예외 클래스
public class CustomException extends RuntimeException {

    private final ErrorCode errorCode;

    public CustomException(ErrorCode errorCode) {
        super(errorCode.getMessage()); // 부모 클래스에 전달
        this.errorCode = errorCode;
    }
}

RuntimeException을 상속한 사용자 정의 예외 클래스입니다 

try-catch 없이도 예외를 발생시킬 수 있고 해당 클래스를 상속받아 각각의 예외 클래스를 구현하도록 했습니다

 

처음에 정의했던 ErrorCode를 필드로 가지고 있습니다

4. 전역 예외 처리기 클래스 구현

GlobalExceptionHandler

package com.domain.openboard.error;

import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
// 전역 예외 처리 클래스
// 모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리
// 유효성 검사 실패,커스텀 예외,일반 예외를 구분하여 응답
public class GlobalExceptionHandler {

    // DTO 유효성 검사 실패 예외 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        ErrorCode errorCode = ErrorCode.VALIDATION_ERROR;
        String message = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .findFirst()
                .map(DefaultMessageSourceResolvable::getDefaultMessage)
                .orElse(errorCode.getMessage());

        return ResponseEntity.badRequest()
                .body(new ErrorResponse(errorCode.getCode(),message));
    }

    // 커스텀 예외 처리
    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ErrorResponse> handleCustom(CustomException ex) {
        ErrorCode errorCode = ex.getErrorCode();
        return ResponseEntity.status(errorCode.getHttpStatus())
                .body(new ErrorResponse(
                        errorCode.getCode(),
                        errorCode.getMessage()));
    }

    // 그외 예외 처리
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception ex) {
        ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR;
        return ResponseEntity.status(errorCode.getHttpStatus())
                .body(new ErrorResponse(errorCode.getCode(), errorCode.getMessage()));
    }
}

모든 컨트롤러에서 발생되는 예외를 한 곳에서 처리해 주는 클래스입니다

@RestControllerAdivce를 활용해서 구현했습니다

@RestControllerAdvice

모든 컨트롤러에서 발생되는 예외를 가로채서 처리하며 응답을 JSON 형태로 반환합니다

유효성 검사 실패 예외 처리

    // DTO 유효성 검사 실패 예외 처리
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        ErrorCode errorCode = ErrorCode.VALIDATION_ERROR;
        String message = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .findFirst()
                .map(DefaultMessageSourceResolvable::getDefaultMessage)
                .orElse(errorCode.getMessage());

        return ResponseEntity.badRequest()
                .body(new ErrorResponse(errorCode.getCode(),message));
    }

validation으로 유효성 검사를 구현했던 DTO에 대한 예외 처리를 담당합니다

@valid 어노테이션으로 검증이 실패했을 때 실행됩니다

 

에러 메시지 중 첫 번째 필드 에러 메시지를 가져와 사용 없는 경우 ErrorCode의 "VALIDATION_ERROR"메시지 사용

커스텀 예외 처리

    // 커스텀 예외 처리
    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ErrorResponse> handleCustom(CustomException ex) {
        ErrorCode errorCode = ex.getErrorCode();
        return ResponseEntity.status(errorCode.getHttpStatus())
                .body(new ErrorResponse(
                        errorCode.getCode(),
                        errorCode.getMessage()));
    }

사용자 정의 예외에 대한 처리를 담당합니다

비밀번호 불일치,게시글을 찾을 수 없음의 예외를 처리하도록 구현했습니다

 

PostNotFoundException

package com.domain.openboard.error.exception;

import com.domain.openboard.error.CustomException;
import com.domain.openboard.error.ErrorCode;

public class PostNotFoundException extends CustomException {
    public PostNotFoundException() {
        super(ErrorCode.POST_NOT_FOUND);
    }
}

PasswordMismatchException

package com.domain.openboard.error.exception;

import com.domain.openboard.error.CustomException;
import com.domain.openboard.error.ErrorCode;

public class PasswordMismatchException extends CustomException {
    public PasswordMismatchException() {
        super(ErrorCode.PASSWORD_MISMATCH);
    }
}

CustomException을 상속받는 각각의 예외 처리 클래스를 정의하여 사용합니다

이런 커스텀 예외가 발생되면 해당 메서드에서 처리하여 응답하게 됩니다

커스텀 예외 적용

PostService

// 게시글 삭제
public void delete(Long id,String inputPassword){
    Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new);

    // 입력 받은 password와 게시글에 저장된 password를 비교
    if(!passwordEncoder.matches(inputPassword,post.getPassword())){
        throw new PasswordMismatchException();
    }
    postRepository.delete(post);
}

// 게시글 수정
public Post update(Long id, PostUpdateRequestDto dto){
    Post post = postRepository.findById(id).orElseThrow(PostNotFoundException::new);

    if(!passwordEncoder.matches(dto.getPassword(),post.getPassword())){
        throw new PasswordMismatchException();
    }
    post.update(dto.getTitle(),dto.getContent());
    return post;
}

서버에서만 발생되는 에러 코드를 커스텀 예외를 적용하여 클라이언트에 각 상황에 맞는 응답하는 구조로 변경하였습니다

알 수 없는 예외 처리

    // 그외 예외 처리
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(Exception ex) {
        ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR;
        return ResponseEntity.status(errorCode.getHttpStatus())
                .body(new ErrorResponse(errorCode.getCode(), errorCode.getMessage()));
    }

위에서 정의하지 않은 예외는 이곳에서 처리하도록 했습니다

내부 서버 오류 메시지를 응답합니다

응답 형식

{
  "code": "POST_NOT_FOUND",
  "message": "게시글을 찾을 수 없습니다."
}

테스트

 

게시글 작성

제목 없음
내용 없음
작성자 없음
비밀번호 길이 미준수
비밀번호 없음

게시글 단건 조회

게시글 ID 잘못 입력

게시글 삭제

비밀번호 불일치

게시글 수정

비밀번호 불일치
URL 오류

다음으로 유닛 테스트 구현 방법에 대해서 작성하겠습니다

반응형