Day 25 Spring

정채림·2026년 2월 13일

에러의 표준화

  • 에러표
@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;

}
  • JSON 형식
@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();
    }
}

이제 서비스에서 에러를 던지면 글로벌 예외 처리기가 처리해주므로 컨트롤러에는 성공 로직만 남길 수 있음(깔끔해짐)

중복체크 API

  • DTO
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);
    }
}
  • Service
    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);
        };
    }
  • Controller
@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));
    }

0개의 댓글