이번 프로젝트는 혼자 하는거라서 굳이 예외 처리를 야무지게 해줄 필요까진 없었지만 공부하는 차원에서 협업한다 생각하고 프론트엔드 개발자에게 친절한 백엔드 개발자가 되기로 마음먹었다.
그래서 조금 더 친절한 예외를 던져주고자 글로벌 익셉션을 만들었다.
그 흐름은 아래와 같다.
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 검증 실패 시 상세 정보
@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 이걸 사용했냐?바로 확장성과 유지보수성 때문임
하여튼 이렇게 설계 해놓으면 여러모로 기특한 일을 많이 해줌
이 녀석은 유일하게 RuntimeException을 상속받는 애인데 몇가지 이유가 있다.
// BusinessException: "비즈니스 로직 위반"
// → 클라이언트가 잘못된 요청을 보냄 (400 Bad Request)
throw new DuplicateResourceException("이미 존재하는 데이터입니다");
throw new InvalidCategoryException("유효하지 않은 카테고리입니다");
// ResourceNotFoundException: "리소스가 존재하지 않음"
// → 요청은 정상이지만 해당 데이터가 없음 (404 Not Found)
throw new ResourceNotFoundException("해당 언어를 찾을 수 없습니다");
// 중복 등록 시도 - 비즈니스 로직 위반
POST /api/languages
{
"category": "programming",
"koName": "Java" // ← 이미 존재하는 언어
}
→ DuplicateResourceException → 400 Bad Request
→ "클라이언트야, 네가 중복된 데이터를 보냈어"
// 잘못된 카테고리 - 입력값 검증 실패
POST /api/languages
{
"category": "invalidCategory", // ← 허용되지 않는 카테고리
"koName": "Python"
}
→ InvalidCategoryException → 400 Bad Request
→ "클라이언트야, 네가 잘못된 카테고리를 보냈어"
// 존재하지 않는 ID로 조회
GET /api/languages/999 // ← 999번 언어가 DB에 없음
→ ResourceNotFoundException → 404 Not Found
→ "요청은 정상이지만, 해당 리소스가 없어"
// 올바른 요청 형식이지만 데이터가 없는 경우
GET /api/languages?category=programming&name=NonExistentLanguage
→ ResourceNotFoundException → 404 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 열것임