글로벌 익셉션

이로운·2025년 10월 22일

헬프홈

목록 보기
4/7

이번 프로젝트는 혼자 하는거라서 굳이 예외 처리를 야무지게 해줄 필요까진 없었지만 공부하는 차원에서 협업한다 생각하고 프론트엔드 개발자에게 친절한 백엔드 개발자가 되기로 마음먹었다.

그래서 조금 더 친절한 예외를 던져주고자 글로벌 익셉션을 만들었다.

그 흐름은 아래와 같다.

익셉션 관련 dto만들기

ErrorResponse라는 dto를 만들었다.

package com.example.helphomebackend.exception;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.List;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponse {
    private String status;
    private int code;
    private String message;
    private String path;
    private LocalDateTime timestamp;
    private List<ValidationError> validationErrors;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class ValidationError {
        private String field;
        private String message;
        private Object rejectedValue;
    }

    public static ErrorResponse of(String status, int code, String message, String path) {
        return ErrorResponse.builder()
                .status(status)
                .code(code)
                .message(message)
                .path(path)
                .timestamp(LocalDateTime.now())
                .build();
    }
}

에러 메세지들은 이 dto를 통해서 전달되게 된다. 클래스 내부에 있는 validationError은 엔티티의 Valid에서 난 오류에 대해서 메세지를 반환해주는 친구다. 얘가 리스트인 한국어, 영어, 중국어 이렇게 이름중에서 한가지 이상의 오류가 나면 리스트로 반환해주기 위함이다. 에러메세지를 예시들자면

{
  "status": "VALIDATION_FAILED",
  "code": 400,
  "message": "입력값 검증에 실패했습니다",
  "path": "/api/languages",
  "timestamp": "2025-10-22T16:35:20.456",
  "validationErrors": [
    {
      "field": "koName",
      "message": "한국어 이름은 필수입니다.",
      "rejectedValue": null
    },
    {
      "field": "enName",
      "message": "영어 이름은 50자를 초과할 수 없습니다.",
      "rejectedValue": "This is a very very very very long English name that exceeds the limit"
    }
  ]
}

음 너무 친절하다~

필드별 설명

기본 에러 정보

**private String status;          // 에러 상태 ("BAD_REQUEST", "NOT_FOUND" 등)
private int code;              // HTTP 상태 코드 (400, 404, 409 등)
private String message;        // 사용자에게 보여줄 에러 메시지
private String path;           // 에러가 발생한 API 경로
private LocalDateTime timestamp; // 에러 발생 시간**

검증 에러 전용

private List<ValidationError> validationErrors; // @Valid 검증 실패 시 상세 정보

내부 클래스: ValidationError

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ValidationError {
    private String field;          // 검증 실패한 필드명
    private String message;        // 검증 실패 메시지
    private Object rejectedValue;  // 거부된 입력값
}

정적 팩토리 메서드

public static ErrorResponse of(String status, int code, String message, String path) {
    return ErrorResponse.builder()
            .status(status)
            .code(code)
            .message(message)
            .path(path)
            .timestamp(LocalDateTime.now())  // ← 현재 시간 자동 설정
            .build();
}

조상 예외

BusinessException

여기가 부모다 비즈니스 로직에서 발생하는 모든 예외의 기본 클래스이다. 음…예상 가능한 비즈니스 에러들의 공통 조상이라 생각하면 된다. 이 녀석의 자식들로는…

  • DuplicateResourceException : 중복언어 처리
  • InvalidCategoryException : 유효하지 않은 카테고리
  • ResourceNotFoundException : 언어를 찾을 수 없을 때 (얘는 RuntimeException 상속)

이 있다.

근데 왜 굳이 BusinessException 이걸 사용했냐?

바로 확장성과 유지보수성 때문임

  1. 전용 예외 핸들러를 못찾는다? 이 녀석이 다 해결해서 400을 반환 해준다
  2. 별도 핸들러가 없어도 자동으로 이 녀석이 핸들러 처리를 해준다
  3. 비즈니스 예외들의 공통 처리 로직을 한곳에서 관리 가능
  4. 새로운 예외 추가 시 핸들러를 매번 직접 만들 필요 없음

하여튼 이렇게 설계 해놓으면 여러모로 기특한 일을 많이 해줌

살짝 예외

ResourceNotFoundException

이 녀석은 유일하게 RuntimeException을 상속받는 애인데 몇가지 이유가 있다.

  1. 의미적 차이
// BusinessException: "비즈니스 로직 위반" 
// → 클라이언트가 잘못된 요청을 보냄 (400 Bad Request)
throw new DuplicateResourceException("이미 존재하는 데이터입니다");
throw new InvalidCategoryException("유효하지 않은 카테고리입니다");

// ResourceNotFoundException: "리소스가 존재하지 않음"
// → 요청은 정상이지만 해당 데이터가 없음 (404 Not Found)  
throw new ResourceNotFoundException("해당 언어를 찾을 수 없습니다");
  1. 상태코드 전략
    1. BusinessException 계열 : 400번, 클라이언트 실수
    2. ResourceNotFoundException : 404 번, 리소스 부재

정리

BusinessException 상황

// 중복 등록 시도 - 비즈니스 로직 위반
POST /api/languages
{
  "category": "programming",
  "koName": "Java" // ← 이미 존재하는 언어
}DuplicateResourceException400 Bad Request"클라이언트야, 네가 중복된 데이터를 보냈어"

// 잘못된 카테고리 - 입력값 검증 실패
POST /api/languages  
{
  "category": "invalidCategory", // ← 허용되지 않는 카테고리
  "koName": "Python"
}InvalidCategoryException400 Bad Request"클라이언트야, 네가 잘못된 카테고리를 보냈어"

ResourceNotFoundException 상황

// 존재하지 않는 ID로 조회
GET /api/languages/999  // ← 999번 언어가 DB에 없음ResourceNotFoundException404 Not Found"요청은 정상이지만, 해당 리소스가 없어"

// 올바른 요청 형식이지만 데이터가 없는 경우
GET /api/languages?category=programming&name=NonExistentLanguageResourceNotFoundException404 Not Found

사용과 흐름

당연히 서비스에서 사용하는데 예시를 다볼수는 없고 중복처리 흐름만 보자!

private void checkDuplicate(Language language) {
    boolean exists = languageRepository.existsByCategoryAndKoNameAndDeletedYnFalse(language.getCategory(), language.getKoName());

    if (exists) {
        throw new DuplicateResourceException(
                String.format("이미 존재하는 언어입니다. (카테고리: %s, 이름: %s)\n",
                        language.getCategory(), language.getKoName()
                        )
        );
    }
}

요런 코드인데 보면 예외 던지는걸 내가 커스텀한걸로 던지는걸로 볼 수 있다! 그럼 스프링이 이걸 어떻게 찾아가냐? 바로 ControllerAdvice 어노테이션을 보고 찾아간다.

나는 GlobalExceptionHandler에 어노테이션을 적용시켜놨고 거기서 내가 커스텀한 익셉션을 찾아서 실행시킨다

// 중복 리소스 예외
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ErrorResponse> handleDuplicateResource(DuplicateResourceException e, HttpServletRequest request) {

    log.warn("Duplicate resource exception: {}", e.getMessage());

    ErrorResponse errorResponse = ErrorResponse.of(
            "CONFLICT",
            HttpStatus.CONFLICT.value(),
            e.getMessage(),
            request.getRequestURI()
    );

    return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
}

ExceptionHandler 어노테이션에 예외 처리에 대한 클래스를 설정해놔서 여기로 와서 이걸 실행해준다.

전체코드

전체 코드는 얼추 완성되면 깃허브 organization 열것임

profile
개발자가 세상을 구한다

0개의 댓글