Notice
Recent Posts
Recent Comments
Link
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
Tags
- zsh theme
- mysql 표
- 티스토리챌린지
- 데이크스트라
- 네트워킹데이
- mysql 표 출력
- 알고리즘
- 동적 프로그래밍 방법
- spring boot
- Pager
- table status
- 맥
- 터미널
- 스터디2기
- 이진탐색
- 인프런워밍업클럽
- 인프런
- CS스터디
- 순차탐색
- 오블완
- 오일러 경로
- 욕심쟁이 방법
- 분할정복 방법
- zsh
- oh-my-zsh
- MySQL
- VI
- Less
- mycli
- cs
Archives
- Today
- Total
Develop
[Spring Boot] 게시판 - 04. 전역 예외 처리 (Global Exception Handler) 본문
안녕하세요 .ᐟ
게시판 프로젝트 4일차 입니다. 오늘은 API의 안정성을 높이는 전역 예외 처리를 구현했습니다.
오늘의 학습 목표
- 예외 처리가 필요한 이유
- @RestControllerAdvice와 @ExceptionHandler
- Custom Exception 설계
- 통일된 에러 응답 구현
예외 처리를 왜 해야 할까?
기존 코드의 문제점을 살펴봅시다.
// 기존 코드
public User findById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
}
문제점
- HTTP 상태 코드가 부적절함
- EntityNotFoundException이 발생하면 500 에러가 반환됨
- 실제로는 404 Not Found가 적절함
- 에러 응답 형식이 불규칙함
- 어떤 에러는 메시지만, 어떤 에러는 스택트레이스가 노출됨
- 프론트엔드에서 에러 처리하기 어려움
- 에러 종류 구분이 어려움
- 모든 예외가 RuntimeException 계열로 처리됨
- 어떤 상황의 에러인지 구분하기 힘듦
목표: 통일된 에러 응답
{
"timestamp": "2024-11-22T10:30:00",
"status": 404,
"error": "Not Found",
"code": "4040",
"message": "사용자를 찾을 수 없습니다. ID: 999"
}
사용할 도구
@RestControllerAdvice
- 모든 Controller에서 발생하는 예외를 한 곳에서 처리
- @ControllerAdvice + @ResponseBody의 조합
- 전역 예외 처리기를 만들 때 사용
@ExceptionHandler
- 특정 예외 타입을 처리하는 메서드를 지정
- @RestControllerAdvice 클래스 내에서 사용
- 예외 타입별로 다른 처리 로직 구현 가능
구현할 파일 목록
src/main/java/com/cho/board/
├── global/
│ └── exception/
│ ├── ErrorCode.java # 에러 코드 정의
│ ├── BusinessException.java # 기본 비즈니스 예외
│ ├── ResourceNotFoundException.java # 404 예외
│ ├── DuplicateResourceException.java # 409 예외
│ ├── AccessDeniedException.java # 403 예외
│ ├── GlobalExceptionHandler.java # 전역 예외 처리기
│ └── ErrorResponse.java # 에러 응답 DTO
ErrorCode - 에러 코드 중앙 관리
HTTP 상태 코드를 기준으로 에러 코드를 정의했습니다.
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
// ========== 400 Bad Request ==========
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "4000", "잘못된 입력값입니다"),
INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "4000", "잘못된 타입입니다"),
DELETED_COMMENT(HttpStatus.BAD_REQUEST, "4000", "삭제된 댓글은 수정할 수 없습니다"),
// ========== 403 Forbidden ==========
ACCESS_DENIED(HttpStatus.FORBIDDEN, "4030", "접근 권한이 없습니다"),
POST_ACCESS_DENIED(HttpStatus.FORBIDDEN, "4030", "게시글에 대한 권한이 없습니다"),
COMMENT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "4030", "댓글에 대한 권한이 없습니다"),
// ========== 404 Not Found ==========
USER_NOT_FOUND(HttpStatus.NOT_FOUND, "4040", "사용자를 찾을 수 없습니다"),
POST_NOT_FOUND(HttpStatus.NOT_FOUND, "4040", "게시글을 찾을 수 없습니다"),
COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "4040", "댓글을 찾을 수 없습니다"),
CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "4040", "카테고리를 찾을 수 없습니다"),
// ========== 409 Conflict ==========
EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "4090", "이미 존재하는 이메일입니다"),
NICKNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "4090", "이미 존재하는 닉네임입니다"),
// ========== 500 Internal Server Error ==========
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "5000", "서버 내부 오류가 발생했습니다");
private final HttpStatus status;
private final String code;
private final String message;
}
코드 네이밍 규칙
- HTTP 상태 코드를 그대로 사용해서 직관적으로 만들었습니다.
- 같은 HTTP 상태 코드를 사용하는 에러들은 같은 코드를 공유하고, 구체적인 내용은 message로 구분합니다.
| 4000 | 400 | Bad Request |
| 4030 | 403 | Forbidden |
| 4040 | 404 | Not Found |
| 4090 | 409 | Conflict |
| 5000 | 500 | Internal Server Error |
Custom Exception - 예외 클래스 계층 구조
- ErrorCode(enum)만 있으면 예외를 던질수가 없습니다.
- Java 에서 throw 를 하려면 Throwable을 상속받은 클래스를 이용해야 합니다.
- 그래서 커스텀에러를 만들고 RuntimeException을 상속받아서 던질 준비를하고, enum 을 주입받아 사용합니다.
Throwable
├── Error (시스템 에러)
└── Exception
├── CheckedException (컴파일 시점에 체크)
└── RuntimeException (런타임에 발생) ← 이용할 것
BusinessException (기본 예외)
- 모든 비즈니스 예외의 부모 클래스입니다.
@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
}
하위 예외 클래스들
- 각 상황에 맞는 예외 클래스를 만들어서 catch 문에서 구분이 가능하도록 했습니다.
// 404 Not Found
public class ResourceNotFoundException extends BusinessException {
public ResourceNotFoundException(ErrorCode errorCode) {
super(errorCode);
}
public ResourceNotFoundException(ErrorCode errorCode, String message) {
super(errorCode, message);
}
}
// 409 Conflict
public class DuplicateResourceException extends BusinessException { ... }
// 403 Forbidden
public class AccessDeniedException extends BusinessException { ... }
예외 클래스 계층 구조
RuntimeException
└── BusinessException
├── ResourceNotFoundException (404)
├── DuplicateResourceException (409)
└── AccessDeniedException (403)
ErrorResponse - 통일된 에러 응답 DTO
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ErrorResponse {
private final LocalDateTime timestamp;
private final int status;
private final String error;
private final String code;
private final String message;
private final List<FieldError> fieldErrors;
@Getter
@Builder
public static class FieldError {
private final String field;
private final String value;
private final String reason;
}
public static ErrorResponse of(int status, String error, String code, String message) {
return ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(status)
.error(error)
.code(code)
.message(message)
.build();
}
}
@JsonInclude(NON_NULL)
- null인 필드는 JSON 응답에서 제외됩니다.
- fieldErrors가 없는 경우 응답에 포함되지 않아 깔끔해집니다.
GlobalExceptionHandler - 전역 예외 처리기
- 모든 예외를 한 곳에서 처리하는 핵심 클래스입니다.
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* BusinessException 처리
*/
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
log.error("BusinessException: {}", e.getMessage());
ErrorCode errorCode = e.getErrorCode();
ErrorResponse response = ErrorResponse.of(
errorCode.getStatus().value(),
errorCode.getStatus().getReasonPhrase(),
errorCode.getCode(),
e.getMessage()
);
return ResponseEntity.status(errorCode.getStatus()).body(response);
}
/**
* @Valid 검증 실패 시 발생
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
log.error("Validation failed: {}", e.getMessage());
List<ErrorResponse.FieldError> fieldErrors = e.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> ErrorResponse.FieldError.builder()
.field(error.getField())
.value(error.getRejectedValue() != null ?
error.getRejectedValue().toString() : "")
.reason(error.getDefaultMessage())
.build())
.collect(Collectors.toList());
ErrorResponse response = ErrorResponse.of(
HttpStatus.BAD_REQUEST.value(),
HttpStatus.BAD_REQUEST.getReasonPhrase(),
"4000",
"입력값 검증에 실패했습니다",
fieldErrors
);
return ResponseEntity.badRequest().body(response);
}
/**
* 그 외 모든 예외 처리 (최후의 방어선)
*/
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("Unhandled Exception: ", e);
ErrorResponse response = ErrorResponse.of(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(),
"5000",
"서버 내부 오류가 발생했습니다"
);
return ResponseEntity.internalServerError().body(response);
}
}
처리 흐름
Service에서 예외 던짐
throw new ResourceNotFoundException(ErrorCode.USER_NOT_FOUND);
↓
Controller에서 예외 발생
↓
GlobalExceptionHandler가 예외 catch
↓
예외 타입에 맞는 @ExceptionHandler 메서드 실행
↓
ErrorResponse 생성 후 클라이언트에게 반환
Service 리팩토링
- 기존 코드를 Custom Exception으로 변경했습니다.
UserService
// 변경 전
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new DataIntegrityViolationException("Email is already exists.");
}
// 변경 후
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new DuplicateResourceException(ErrorCode.EMAIL_ALREADY_EXISTS);
}
PostService
// 변경 전
if (!post.getAuthor().getId().equals(userId)) {
throw new IllegalArgumentException("Only author can update the post");
}
// 변경 후
if (!post.getAuthor().getId().equals(userId)) {
throw new AccessDeniedException(ErrorCode.POST_ACCESS_DENIED);
}
CommentService
// 변경 전
throw new EntityNotFoundException("댓글을 찾을 수 없습니다.");
// 변경 후
throw new ResourceNotFoundException(ErrorCode.COMMENT_NOT_FOUND);
테스트 결과
404 Not Found
GET /users/9999
{
"timestamp": "2024-11-22T15:30:00",
"status": 404,
"error": "Not Found",
"code": "4040",
"message": "사용자를 찾을 수 없습니다"
}
409 Conflict
POST /users
Content-Type: application/json
{
"email": "이미존재하는이메일@test.com",
...
}
{
"timestamp": "2024-11-22T15:31:00",
"status": 409,
"error": "Conflict",
"code": "4090",
"message": "이미 존재하는 이메일입니다"
}
403 Forbidden
PUT /posts/1?userId=9999
{
"timestamp": "2024-11-22T15:32:00",
"status": 403,
"error": "Forbidden",
"code": "4030",
"message": "게시글에 대한 권한이 없습니다"
}
느낀 점
@RestControllerAdvice의 편리함
- 예외 처리 로직이 한 곳에 모여있어 관리가 쉬웠습니다.
- 각 Controller에서 try-catch를 사용하지 않아도 되어 코드가 깔끔해졌습니다.
ErrorCode enum의 장점
- 에러 코드를 중앙에서 관리하니 일관성이 유지됩니다.
- HTTP 상태 코드 기반으로 정리하니 직관적입니다.
- 새로운 에러가 필요하면 enum에 추가만 하면 됩니다.
Custom Exception 계층 구조
- BusinessException을 상속받아 확장하니 유지보수가 쉬웠습니다.
- 예외 타입만 보고도 어떤 상황인지 파악할 수 있었습니다.

'백엔드 > 게시판 만들기' 카테고리의 다른 글
| [Spring Boot] 게시판 - 06. 단위 테스트 작성하기 (Mockito & AssertJ) (0) | 2025.11.24 |
|---|---|
| [Spring Boot] 게시판 - 05. Validation 검증 (입력값 유효성 검사) (1) | 2025.11.23 |
| [Spring Boot] 게시판 - 03. 댓글(Comment) API 구현 (대댓글, 소프트 삭제) (0) | 2025.11.21 |
| [Spring Boot] 게시판 - 02. Post API 개발 (페이징 처리, IntelliJ HTTP Client) (0) | 2025.11.20 |
| [Spring Boot] 게시판 - 01. 프로젝트 환경설정 , Entity 설계, 회원 관리 API 개발 (0) | 2025.11.19 |