@Getter
@RequiredArgsConstructor
public enum ErrorCode {
// Member 관련 에러 (M으로 시작)
DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "M001", "이미 존재하는 이메일입니다."),
DUPLICATE_USERNAME(HttpStatus.BAD_REQUEST, "M002", "이미 존재하는 사용자 이름입니다."),
DUPLICATE_PHONE(HttpStatus.BAD_REQUEST, "M003", "이미 존재하는 전화번호입니다."),
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M004", "회원을 찾을 수 없습니다."),
INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "M005", "비밀번호가 일치하지 않습니다."),
// 공통 에러 (C로 시작)
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "잘못된 입력값입니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C002", "서버 내부 오류가 발생했습니다.");
private final HttpStatus status;
private final String code;
private final String message;
}
@Getter
@Builder
public class ErrorResponse {
private final LocalDateTime timestamp; // 언제?
private final int status; // HTTP 상태 코드 (400, 404 등)
private final String error; // 에러 이름 (BAD_REQUEST)
private final String code; // 우리가 정의한 코드 (M001)
private final String message; // 친절한 메시지
private final String path; // 요청 경로 (/api/auth/signup)
}
@Getter
public class MemberException extends RuntimeException {
private final ErrorCode errorCode;
public MemberException(ErrorCode errorCode) {
super(errorCode.getMessage()); // 부모(RuntimeException)에게 메시지 전달
this.errorCode = errorCode;
}
}
@Slf4j
@RestControllerAdvice // 👈 모든 컨트롤러의 예외를 감시하는 어노테이션
public class GlobalExceptionHandler {
/**
* 1. 비즈니스 로직 예외 처리 (우리가 예상한 에러)
* MemberService 등에서 던진 MemberException을 잡습니다.
*/
@ExceptionHandler(MemberException.class)
public ResponseEntity<ErrorResponse> handleMemberException(MemberException e, HttpServletRequest request) {
log.warn("MemberException : {}", e.getMessage());
ErrorCode errorCode = e.getErrorCode();
return ResponseEntity
.status(errorCode.getStatus())
.body(buildErrorResponse(errorCode, e.getMessage(), request.getRequestURI()));
}
/**
* 2. 유효성 검사 실패 처리 (@Valid)
* DTO의 @NotBlank, @Pattern 조건 등을 만족하지 못했을 때 발생합니다.
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException e, HttpServletRequest request) {
log.warn("ValidationException : {}", e.getMessage());
// 여러 에러 중 첫 번째 에러 메시지만 가져와서 보여줍니다.
String errorMessage = e.getBindingResult().getFieldError().getDefaultMessage();
return ResponseEntity
.badRequest()
.body(buildErrorResponse(ErrorCode.INVALID_INPUT_VALUE, errorMessage, request.getRequestURI()));
}
/**
* 3. 예상치 못한 시스템 에러 (최후의 보루)
* NullPointerException 등 우리가 예상하지 못한 에러가 발생했을 때 500 응답을 줍니다.
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e, HttpServletRequest request) {
log.error("Exception : {}", e.getMessage()); // 개발자는 로그로 원인을 확인하고
// 클라이언트에게는 "서버 오류"라고만 알려줍니다 (보안상 상세 내용 숨김)
return ResponseEntity
.internalServerError()
.body(buildErrorResponse(ErrorCode.INTERNAL_SERVER_ERROR, "알 수 없는 에러가 발생했습니다.", request.getRequestURI()));
}
// ErrorResponse 생성 편의 메서드
private ErrorResponse buildErrorResponse(ErrorCode errorCode, String message, String path) {
return ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(errorCode.getStatus().value())
.error(errorCode.getStatus().name())
.code(errorCode.getCode())
.message(message)
.path(path)
.build();
}
}
이제 서비스에서 에러를 던지면 글로벌 예외 처리기가 처리해주므로 컨트롤러에는 성공 로직만 남길 수 있음(깔끔해짐)
import lombok.Builder;
@Builder
public record DuplicateCheckResponse(
boolean available,
String message
) {
// 사용 가능할 때 호출
public static DuplicateCheckResponse available(String message) {
return new DuplicateCheckResponse(true, message);
}
// 이미 사용 중일 때 호출
public static DuplicateCheckResponse unavailable(String message) {
return new DuplicateCheckResponse(false, message);
}
}
public boolean checkDuplicate(String type, String value) {
return switch (type) {
case "username" -> !memberRepository.existsByUsername(value);
case "email" -> !memberRepository.existsByEmail(value);
case "phone" -> !memberRepository.existsByPhone(value);
default -> throw new MemberException(ErrorCode.INVALID_INPUT_VALUE);
};
}
@GetMapping("/check-duplicate")
public ResponseEntity<ApiResponse<DuplicateCheckResponse>> checkDuplicate(
@RequestParam String type,
@RequestParam String value
) {
// 1. 서비스 로직 호출 (사용 가능한가?)
boolean isAvailable = memberService.checkDuplicate(type, value);
// 2. 메시지 생성
String message = isAvailable ? "사용 가능한 " + type + "입니다." : "이미 사용 중인 " + type + "입니다.";
// 3. 상황에 맞는 DTO 생성 (정적 팩토리 메서드 활용)
DuplicateCheckResponse response = isAvailable ?
DuplicateCheckResponse.available(message) :
DuplicateCheckResponse.unavailable(message);
// 4. 공통 응답 박스(ApiResponse)에 담아 반환
return ResponseEntity.ok(ApiResponse.success(response));
}