// 서비스 코드
throw new RuntimeException("게시글을 찾을 수 없습니다: " + id);
throw new RuntimeException("비밀번호가 일치하지 않습니다");
throw new RuntimeException("본인이 작성한 글만 수정할 수 있습니다");
throw new RuntimeException("이미 존재하는 사용자 이름입니다");
전부 RuntimeException입니다. Spring은 이걸 다 500으로 처리합니다. 하지만 각 에러의 성격이 다릅니다:
게시글을 찾을 수 없습니다 → 404 Not Found
비밀번호가 일치하지 않습니다 → 401 Unauthorized
본인 글만 수정 가능 → 403 Forbidden
이미 존재하는 사용자 → 409 Conflict
제목이 비어있음 → 400 Bad Request
이걸 구분하려면 커스텀 예외 클래스가 필요합니다.
package com.myname.board.exception;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
// 400 Bad Request
INVALID_INPUT("잘못된 입력값입니다", HttpStatus.BAD_REQUEST),
// 401 Unauthorized
INVALID_PASSWORD("비밀번호가 일치하지 않습니다", HttpStatus.UNAUTHORIZED),
INVALID_TOKEN("유효하지 않은 토큰입니다", HttpStatus.UNAUTHORIZED),
// 403 Forbidden
ACCESS_DENIED("접근 권한이 없습니다", HttpStatus.FORBIDDEN),
// 404 Not Found
POST_NOT_FOUND("게시글을 찾을 수 없습니다", HttpStatus.NOT_FOUND),
COMMENT_NOT_FOUND("댓글을 찾을 수 없습니다", HttpStatus.NOT_FOUND),
USER_NOT_FOUND("사용자를 찾을 수 없습니다", HttpStatus.NOT_FOUND),
// 409 Conflict
DUPLICATE_USERNAME("이미 존재하는 사용자 이름입니다", HttpStatus.CONFLICT),
DUPLICATE_EMAIL("이미 존재하는 이메일입니다", HttpStatus.CONFLICT);
private final String message;
private final HttpStatus httpStatus;
}
에러 코드를 Enum으로 관리하면 에러 메시지가 흩어지지 않고 한 곳에 모입니다. 새 에러가 필요하면 여기에 추가만 하면 됩니다.
package com.myname.board.exception;
import lombok.Getter;
@Getter
public class CustomException extends RuntimeException {
private final ErrorCode errorCode;
public CustomException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public CustomException(ErrorCode errorCode, String detail) {
super(errorCode.getMessage() + ": " + detail);
this.errorCode = errorCode;
}
}
RuntimeException을 상속하므로 @Transactional에서 자동 롤백됩니다. ErrorCode를 가지고 있어서, 예외 처리기가 적절한 HTTP 상태 코드를 반환할 수 있습니다.
모든 Controller에서 발생하는 예외를 한 곳에서 처리합니다. 이전에 각 메서드마다 try-catch를 쓰는 게 왜 나쁜지 배웠습니다. @RestControllerAdvice가 그 문제를 해결합니다.
package com.myname.board.exception;
import com.myname.board.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 1. 커스텀 예외 처리
@ExceptionHandler(CustomException.class)
public ResponseEntity<ApiResponse<Void>> handleCustomException(CustomException e) {
log.warn("CustomException: {}", e.getMessage());
return ResponseEntity
.status(e.getErrorCode().getHttpStatus())
.body(ApiResponse.error(e.getMessage()));
}
// 2. 유효성 검증 예외 처리 (@Valid 실패 시)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidationException(
MethodArgumentNotValidException e) {
Map<String, String> errors = new HashMap<>();
e.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
log.warn("Validation failed: {}", errors);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error("입력값 검증에 실패했습니다"));
}
// 3. 그 외 모든 예외 (예상 못한 에러)
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
log.error("Unexpected error: ", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("서버 내부 오류가 발생했습니다"));
}
}
각 @ExceptionHandler가 하는 일:
handleCustomException: 우리가 만든 CustomException을 처리합니다. ErrorCode에 정의된 HTTP 상태 코드와 메시지를 반환합니다.
handleValidationException: @Valid 검증이 실패하면 Spring이 MethodArgumentNotValidException을 던집니다. 어떤 필드가 어떤 이유로 실패했는지 추출해서 반환합니다.