목표: 깔끔하고 일관된 예외 처리 시스템 구축
핵심 아이디어: ErrorCode Enum + BusinessException + ApiResponse + @ControllerAdvice 조합
1단계: ErrorCode Enum 정의
public enum ErrorCode {
// 사용자 관련 (4xx)
USER_NOT_FOUND(404, "U001", "사용자를 찾을 수 없습니다"),
USER_ALREADY_EXISTS(409, "U002", "이미 존재하는 사용자입니다"),
USER_UNAUTHORIZED(401, "U003", "권한이 없습니다"),
// 상품 관련 (4xx)
PRODUCT_NOT_FOUND(404, "P001", "상품을 찾을 수 없습니다"),
PRODUCT_OUT_OF_STOCK(409, "P002", "재고가 부족합니다"),
// 주문 관련 (4xx)
ORDER_NOT_FOUND(404, "O001", "주문을 찾을 수 없습니다"),
ORDER_ALREADY_CANCELLED(409, "O002", "이미 취소된 주문입니다"),
// 입력값 검증 (4xx)
INVALID_INPUT(400, "C001", "잘못된 입력값입니다"),
VALIDATION_FAILED(400, "C002", "유효성 검사에 실패했습니다"),
// 서버 오류 (5xx)
INTERNAL_SERVER_ERROR(500, "S001", "서버 내부 오류가 발생했습니다"),
DATABASE_ERROR(500, "S002", "데이터베이스 오류가 발생했습니다"),
EXTERNAL_API_ERROR(500, "S003", "외부 API 호출에 실패했습니다");
private final int status;
private final String code;
private final String message;
ErrorCode(int status, String code, String message) {
this.status = status;
this.code = code;
this.message = message;
}
public int getStatus() { return status; }
public String getCode() { return code; }
public String getMessage() { return message; }
}
2단계: 공통 예외 클래스 정의
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public BusinessException(ErrorCode errorCode, String customMessage) {
super(customMessage);
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
사용법:
// 서비스에서 예외 발생시키기
public User findUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
}
// 커스텀 메시지와 함께
public void validateUserAge(int age) {
if (age < 0) {
throw new BusinessException(ErrorCode.INVALID_INPUT, "나이는 0 이상이어야 합니다");
}
}
3단계: 통일된 응답 형식 (ApiResponse)
public class ApiResponse<T> {
private boolean success;
private T data;
private String message;
private String errorCode;
private LocalDateTime timestamp;
// 생성자
private ApiResponse(boolean success, T data, String message, String errorCode) {
this.success = success;
this.data = data;
this.message = message;
this.errorCode = errorCode;
this.timestamp = LocalDateTime.now();
}
// 성공 응답 생성
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, data, "요청이 성공했습니다", null);
}
public static <T> ApiResponse<T> success(T data, String message) {
return new ApiResponse<>(true, data, message, null);
}
public static ApiResponse<Void> success() {
return new ApiResponse<>(true, null, "요청이 성공했습니다", null);
}
// 실패 응답 생성
public static <T> ApiResponse<T> failure(String message, String errorCode) {
return new ApiResponse<>(false, null, message, errorCode);
}
public static <T> ApiResponse<T> failure(ErrorCode errorCode) {
return new ApiResponse<>(false, null, errorCode.getMessage(), errorCode.getCode());
}
// Getter 메서드들
public boolean isSuccess() { return success; }
public T getData() { return data; }
public String getMessage() { return message; }
public String getErrorCode() { return errorCode; }
public LocalDateTime getTimestamp() { return timestamp; }
}
4단계: 전역 예외 처리 (@ControllerAdvice)
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 비즈니스 예외 처리
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e) {
log.warn("Business Exception: {}", e.getMessage());
ApiResponse<Void> response = ApiResponse.failure(e.getErrorCode());
return ResponseEntity
.status(e.getErrorCode().getStatus())
.body(response);
}
// 유효성 검사 실패
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException e) {
log.warn("Validation Exception: {}", e.getMessage());
String errorMessage = e.getBindingResult()
.getFieldErrors()
.stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
ApiResponse<Void> response = ApiResponse.failure(errorMessage, ErrorCode.VALIDATION_FAILED.getCode());
return ResponseEntity.badRequest().body(response);
}
// 요청 파라미터 타입 오류
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResponse<Void>> handleTypeMismatchException(MethodArgumentTypeMismatchException e) {
log.warn("Type Mismatch Exception: {}", e.getMessage());
ApiResponse<Void> response = ApiResponse.failure(ErrorCode.INVALID_INPUT);
return ResponseEntity.badRequest().body(response);
}
// 일반적인 예외 (예상치 못한 오류)
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneralException(Exception e) {
log.error("Unexpected Exception: ", e);
ApiResponse<Void> response = ApiResponse.failure(ErrorCode.INTERNAL_SERVER_ERROR);
return ResponseEntity.internalServerError().body(response);
}
}
5단계: 컨트롤러에서 활용
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
// 사용자 목록 조회
@GetMapping
public ApiResponse<List<UserDto>> getUsers() {
List<UserDto> users = userService.findAllUsers();
return ApiResponse.success(users, "사용자 목록을 성공적으로 조회했습니다");
}
// 사용자 단건 조회
@GetMapping("/{id}")
public ApiResponse<UserDto> getUser(@PathVariable Long id) {
UserDto user = userService.findUserById(id); // 없으면 BusinessException 발생
return ApiResponse.success(user);
}
// 사용자 생성
@PostMapping
public ApiResponse<UserDto> createUser(@Valid @RequestBody CreateUserRequest request) {
UserDto createdUser = userService.createUser(request);
return ApiResponse.success(createdUser, "사용자가 성공적으로 생성되었습니다");
}
// 사용자 수정
@PutMapping("/{id}")
public ApiResponse<UserDto> updateUser(@PathVariable Long id, @Valid @RequestBody UpdateUserRequest request) {
UserDto updatedUser = userService.updateUser(id, request);
return ApiResponse.success(updatedUser, "사용자 정보가 성공적으로 수정되었습니다");
}
// 사용자 삭제
@DeleteMapping("/{id}")
public ApiResponse<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return ApiResponse.success();
}
}
6단계: 실제 응답 예시
성공 응답
{
"success": true,
"data": {
"id": 1,
"name": "김철수",
"email": "kim@example.com"
},
"message": "요청이 성공했습니다",
"errorCode": null,
"timestamp": "2024-12-25T14:30:00"
}
실패 응답 (비즈니스 예외)
{
"success": false,
"data": null,
"message": "사용자를 찾을 수 없습니다",
"errorCode": "U001",
"timestamp": "2024-12-25T14:30:00"
}
실패 응답 (유효성 검사)
{
"success": false,
"data": null,
"message": "이름은 필수입니다, 이메일 형식이 올바르지 않습니다",
"errorCode": "C002",
"timestamp": "2024-12-25T14:30:00"
}
장점 정리
- 일관성: 모든 API가 동일한 응답 구조
- 중앙 집중: 에러 코드와 메시지를 한 곳에서 관리
- 유지보수성: 응답 형식 변경시 ApiResponse만 수정
- 클라이언트 친화적: 프론트엔드에서 처리하기 쉬운 구조
- 확장성: 새로운 에러 타입 추가가 용이
- 추적 가능: 에러 코드로 문제 원인 파악 용이
팀프로젝트 팁
- ErrorCode는 한번 정의하면 변경하지 말 것 (API 호환성)
- 에러 메시지는 사용자 친화적으로 작성
- 로깅을 적절히 활용하여 디버깅 지원
- 팀 컨벤션에 맞춰 에러 코드 체계 정립
- 문서화를 통해 팀원들과 공유
'Spring > 이론' 카테고리의 다른 글
Static 메서드, 언제 사용하고 언제 피해야 할까? (0) | 2025.06.16 |
---|---|
Spring Bean 생명주기 - 실무에서 알아야 할 핵심만 (0) | 2025.06.05 |
[트러블슈팅] JPA Cascade와 트랜잭션 전파 - 편의성 vs 안전성 (0) | 2025.06.05 |
[트러블슈팅] JPA 지연로딩과 Proxy - LazyInitializationException 해결하기 (0) | 2025.06.05 |
[트러블슈팅] JPA 상속관계 매핑 3가지 전략 - 실무에서는 언제 쓸까? (0) | 2025.06.04 |