Exception Handling
포스트
취소

Exception Handling

Exception Handling

Spring에서 제공하는 @RestControllerAdvice, @ExceptionHandler 를 활용하여 API 예외처리를 하였다.

그 후 다음과 같은 일관된 ErrorResponse 반환을 목표 하였다.

1
2
3
4
5
{
  "status": 400,
  "code": "U001",
  "message": "존재하지 않는 유저입니다."
}
1
2
3
4
5
{
  "status": 400,
  "code": "W001",
  "message": "이미 신고한 유저입니다."
}

도메인들을 공통적으로 처리해야 하기 때문에 global 패키지 구조로 작성하였다.

ErrorResponse

ErrorResponse는 다양한 예외 처리에 대해 대응하기 위하여, 기본적인 생성자 대신 입력 매개변수에 따라 유연하게 ErrorResponse 객체를 반환할 수 있는 정적 팩터리 메서드(Static Factory Method)를 활용 하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ErrorResponse {
    private int status;
    private String code;
    private String message;

    private ErrorResponse(final ErrorCode code) {
        this.message = code.getMessage();
        this.status = code.getStatus();
        this.code = code.getCode();
    }

    public static ErrorResponse of(final ErrorCode code) {
        return new ErrorResponse(code);
    }
}

of라는 정적 팩터리 매서드들은 여러가지 상황에 대응할 수 있다. 대표적으로는

  • javax.vaidation에서 제공하는 @Valid나 Spring Boot에서 제공하는 @Validated를 통해 검증 처리 시 binding error가 발생하는 경우

  • 일반적인 예외 핸들링

  • 데이터 유효성 검사 실패시 발생하는 예외에서 실패 정보를 담고 있는 ConstraintViolation를 가져오는 경우

ErrorCode 정의

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
 * ErrorCode Convention
 * - 도메인 별로 나누어 관리
 * - [주체_이유] 형태로 생성
 * - 코드는 도메인명 앞에서부터 1~2글자로 사용
 * - 메시지는 "~~다."로 마무리
 */

@Getter
@AllArgsConstructor
public enum ErrorCode {
    // Global
    INPUT_VALUE_INVALID(400, "G001", "유효하지 않은 입력입니다."),
    INPUT_TYPE_INVALID(400, "G002", "입력 타입이 유효하지 않습니다."),
    TIME_FORMAT_INVALID(400, "G003", "날짜, 시간 타입 형식이 유효하지 않습니다."),
    JOB_NOT_FOUND(400,"G004", "존재하지 않는 직업입니다."),

    // User
    USER_NOT_FOUND(400, "U001", "존재하지 않는 유저입니다."),
    USERNAME_ALREADY_EXIST(400, "U002", "이미 존재하는 사용자 이름입니다."),
    AUTHENTICATION_FAIL(401, "U003", "로그인이 필요한 화면입니다."),
    AUTHORITY_INVALID(403, "U004", "권한이 없습니다."),
    ACCOUNT_MISMATCH(401, "U005", "계정 정보가 일치하지 않습니다."),

// 중략 ...

    ;

    private final int status; // <- final 안달아 줘도 되는지 체크 필요
    private final String code;
    private final String message;
}

Enum 타입으로 위와 같이 한 곳에서 에러 코드를 관리하였다. 이는 에러 코드가 도메인 전체적으로 흩엊있을 경우, 코드 및 메세지의 중복이 발생하기 때문에 이를 해결 하기위한 가장 효율적인 방법이다.

GlobalExceptionHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler
    protected ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
        final ErrorCode errorCode = e.getErrorCode();
        final ErrorResponse response = ErrorResponse.of(errorCode);
        return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
    }

    // @Valid, @Validated 에서 binding error 발생 시 (@RequestBody)
    @ExceptionHandler
    protected ResponseEntity<ErrorResponse> handleBindException(BindException e) {
        final ErrorResponse response = ErrorResponse.of(INVALID_INPUT_VALUE, e.getBindingResult());
        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
    }
}

@RestControllerAdvice, @ExceptionHandler을 활용하여 모든 예외를 한 곳에서 처리할 수 있다.

@ControllerAdvice는 @ExceptionHandler@ModelAttribute@InitBinder가 적용된 메소드들을 AOP를 적용해 컨트롤러 단에 적용하기 위해 고안된 애너테이션이다.

Business Exception

Runtimeexception을 상속받아 최상위에 BusinessException을 정의해두면 아래와 같이 구체적인 Exeption에 대해 예외 처리를 통일감 있게 할 수 있다.

1
2
3
4
5
6
7
8
9
@Getter
public class BusinessException extends RuntimeException {
    private ErrorCode errorCode;

    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
}

이 BusinessException을 상속받은 세부적인 예외들은 도메인 내에서 예외가 발생할만한 부분마다 디테일하게 처리할 수 있다는 장점이 있다. 다만, 해당 예외 케이스들을 직접 한땀한땀 정의해야 하기 때문에 클래스가 많아질 수 있다는 점이 단점이라 할 수 있다.

다음은 이미 존재하는 entity에 대해 발생하는 예외를 처리하기 위한 클래스를 정의한 코드이다. 다른 디테일한 예외 클래스들도 이와 같은 형식으로 정의하면 된다.

1
2
3
4
5
6
public class EntityAlreadyExistException extends BusinessException {

    public EntityAlreadyExistException(ErrorCode errorCode){
        super(errorCode);
    }
}

결론적으론 서버 내부 코드에서 예외가 발생할 여지가 있다면 Exception을 발생시키고 위처럼 미리 정의한 예외 핸들링을 탈 수 있도록 설계하는 방식이다.

API Response

응답결과 또한 일관된 형식으로 통일 하고자 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
{
  "status": 200,
  "code": "U004",
  "message": "회원 프로필을 수정하였습니다.",
  "data": {
    "userSeq": 38,
    "email": "tester@test.com",
    "nickname": "아르마딜로",
    "job": "공무원",
    "imgUrl": "z/data/user/0/com.d205.sdutyplus/cache/temp_file_20221111_042718.jpg",
    "fcmToken": null
  }
}
1
2
3
4
5
6
{
  "status": 200,
  "code": "W001",
  "message": "신고가 완료되었습니다.",
  "data": true
}

위의 Error Code, Error Response를 정의한것과 비슷하게 Result Code, Result Response를 정의하면 된다.

Result Response

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@ApiModel(description = "결과 응답 데이터")
@Getter
@Data
public class ResultResponse {

    @ApiModelProperty(value = "Http 상태 코드")
    private int status;
    @ApiModelProperty(value = "Business 상태 코드")
    private String code;
    @ApiModelProperty(value = "응답 메세지")
    private String message;
    @ApiModelProperty(value = "응답 데이터")
    private Object data;

    public ResultResponse (ResultCode resultCode, Object data) {
        this.status = resultCode.getStatus();
        this.code = resultCode.getCode();
        this.message = resultCode.getMessage();
        this.data = data;
    }

    // 전송할 데이터가 있는 경우
    public static ResultResponse of(ResultCode resultCode, Object data) {
        return new ResultResponse (resultCode, data);
    }

    // 전송할 데이터가 없는 경우
    public static ResultResponse of(ResultCode resultCode) {
        return new ResultResponse (resultCode, "");
    }
}

Result Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
 * ResultCode Convention
 * - 도메인 별로 나누어 관리
 * - [동사_목적어_SUCCESS] 형태로 생성
 * - 코드는 도메인명 앞에서부터 1~2글자로 사용
 * - 메시지는 "~~다."로 마무리
 */

@Getter
@AllArgsConstructor
public enum ResultCode {
    // User
    LOGIN_SUCCESS(200, "U001", "로그인에 성공하였습니다."),
    GET_USERPROFILE_SUCCESS(200, "U002", "회원 프로필을 조회하였습니다."),
    UPLOAD_USER_IMAGE_SUCCESS(200, "U003", "회원 이미지를 등록하였습니다."),
    EDIT_PROFILE_SUCCESS(200, "U004", "회원 프로필을 수정하였습니다."),
    CHECK_NICKNAME_GOOD(200, "U005", "사용가능한 nickname 입니다."),
    CHECK_NICKNAME_BAD(200, "U006", "사용불가능한 nickname 입니다."),
    LOGIN_FAIL(200, "U007", "로그인에 실패하였습니다."),
    SAVE_PROFILE_SUCCESS(200, "U008", "회원 프로필을 저장하였습니다."),
    DELETE_SUCCESS(200, "U009", "회원 탈퇴에 성공하였습니다."),
    DELETE_FAIL(200, "U010", "회원 탈퇴에 실패하였습니다."),

    // Task
    CREATE_TASK_SUCCESS(200, "T001", "테스크가 생성되었습니다."),
    UPDATE_TASK_SUCCESS(200, "T002", "테스크가 수정되었습니다."),
    DELETE_TASK_SUCCESS(200, "T003", "테스크가 삭제되었습니다."),
    CREATE_SUBTASK_SUCCESS(200, "T004", "서브테스크가 생성되었습니다."),
    UPDATE_SUBTASK_SUCCESS(200, "T005", "서브테스크가 수정되었습니다."),
    DELETE_SUBTASK_SUCCESS(200, "T006", "서브테스크가 삭제되었습니다."),
    GET_TASK_DETAIL_SUCCESS(200, "T007", "테스크 상세 조회에 성공하였습니다."),
    GET_REPORT_SUCCESS(200, "T008", "리포트 조회에 성공하였습니다."),
    GET_REPORT_TOTALTIME_SUCCESS(200, "T009", "리포트 총 시간 조회에 성공하였습니다."),

// 중략...

    ;

    private final int status;
    private final String code;
    private final String message;
}

도메인 내에 있는 API마다 응답 결과를 위와 같이 세부적으로 정의할 수 있다.
Controller 계층에선 다음과 같이 앞서 정의한 ResultResponse를 ResponseEntity에 담아 반환하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
@ApiOperation(value = "회원 프로필 조회")
@ApiResponses({
        @ApiResponse(code = 200, message = "U002 - 회원 프로필을 조회하였습니다."),
        @ApiResponse(code = 401, message = "U003 - 로그인이 필요한 화면입니다.")
})
@GetMapping
public ResponseEntity<ResultResponse> getUserProfile(@ApiIgnore Authentication auth){
    Long userSeq = (Long)auth.getPrincipal();
    final UserProfileDto userProfileDto = userService.getUserProfile(userSeq);

    return ResponseEntity.ok(ResultResponse.of(GET_USERPROFILE_SUCCESS, userProfileDto));
}

참고 : https://velog.io/@songs4805/Exception-Handling%EA%B3%BC-Response-%EC%BD%94%EB%93%9C-%EA%B0%9C%EC%84%A0

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.