스프링 리팩토링 패턴 시리즈 (3/5)

이전 편: 트랜잭션과 데이터 일관성

다음 편: REST API 설계 원칙

예외 처리는 "에러가 나면 try-catch로 잡으면 되지"라는 수준을 넘어, 애플리케이션 전체의 안정성과 디버깅 효율을 결정하는 아키텍처적 결정이다. 잘 설계된 예외 전략은 코드를 읽는 사람에게 "이 메서드에서 무엇이 잘못될 수 있는지"를 명확히 전달한다.

예외 계층 구조 설계

스프링 프로젝트에서 예외를 체계적으로 관리하려면, 도메인별 예외 계층을 만드는 것이 좋다.

graph TD A[RuntimeException] --> B[AppException] B --> C[UserException] B --> D[ChannelException] B --> E[MessageException] C --> C1[UserNotFoundException] C --> C2[UserAlreadyExistsException] D --> D1[ChannelNotFoundException] D --> D2[ChannelModificationNotAllowedException]

공통 부모 예외

@Getter
public abstract class AppException extends RuntimeException {
    private final ErrorCode errorCode;
    private final Map<String, Object> details;

    protected AppException(ErrorCode errorCode, Map<String, Object> details) {
        super(errorCode.getDescription());
        this.errorCode = errorCode;
        this.details = details;
    }

    protected AppException(ErrorCode errorCode) {
        this(errorCode, new HashMap<>());
    }
}
  • 모든 도메인 예외의 부모 역할을 한다.
  • ErrorCode를 통해 HTTP 상태 코드와 에러 메시지를 일관되게 관리한다.
  • details 맵으로 디버깅에 필요한 추가 정보를 전달한다.

ErrorCode Enum

@Getter
@RequiredArgsConstructor
public enum ErrorCode {
    // 4xx
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다"),
    USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 사용자입니다"),
    CHANNEL_NOT_FOUND(HttpStatus.NOT_FOUND, "채널을 찾을 수 없습니다"),
    INVALID_INPUT(HttpStatus.BAD_REQUEST, "잘못된 입력값입니다"),

    // 5xx
    FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다"),
    INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다");

    private final HttpStatus httpStatus;
    private final String description;
}
  • HTTP 상태 코드와 사용자 메시지를 한 곳에서 관리한다.
  • 새로운 에러 유형을 추가할 때 enum에 한 줄만 추가하면 된다.

도메인 예외

public class UserNotFoundException extends AppException {
    public UserNotFoundException(UUID userId) {
        super(ErrorCode.USER_NOT_FOUND, Map.of("userId", userId));
    }
}
  • 생성자에서 필요한 컨텍스트(userId)를 받아 details에 담는다.
  • 사용하는 쪽에서는 throw new UserNotFoundException(userId)만 하면 된다.

@RestControllerAdvice로 중앙 집중 처리

예외를 던지는 것과 HTTP 응답으로 변환하는 것은 분리해야 한다. 서비스 계층은 예외를 던지기만 하고, 컨트롤러 어드바이스가 이를 일관된 응답으로 변환한다.

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(AppException.class)
    public ResponseEntity<ErrorResponse> handleAppException(AppException ex) {
        log.error("[{}] {}", ex.getErrorCode(), ex.getDetails(), ex);
        return ResponseEntity
                .status(ex.getErrorCode().getHttpStatus())
                .body(ErrorResponse.from(ex));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(
            MethodArgumentNotValidException ex) {
        Map<String, String> fieldErrors = ex.getBindingResult()
                .getFieldErrors().stream()
                .collect(Collectors.toMap(
                        FieldError::getField,
                        FieldError::getDefaultMessage,
                        (a, b) -> a));

        return ResponseEntity.badRequest()
                .body(ErrorResponse.validation(fieldErrors));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
        log.error("예상치 못한 예외 발생", ex);
        return ResponseEntity.internalServerError()
                .body(ErrorResponse.internal());
    }
}
  • AppException을 잡는 핸들러 하나면 모든 도메인 예외가 처리된다.
  • MethodArgumentNotValidException은 Bean Validation 실패 시 스프링이 자동으로 던진다.
  • 최하단의 Exception 핸들러는 예상치 못한 예외의 안전망 역할을 한다.

ErrorResponse DTO

public record ErrorResponse(
        Instant timestamp,
        int status,
        String error,
        Map<String, Object> details
) {
    public static ErrorResponse from(AppException ex) {
        return new ErrorResponse(
                Instant.now(),
                ex.getErrorCode().getHttpStatus().value(),
                ex.getErrorCode().getDescription(),
                ex.getDetails()
        );
    }
}

API 소비자에게 일관된 형태의 에러 응답을 제공한다.

IOException 래핑 전략

파일 I/O, 네트워크 통신 등에서 발생하는 IOException을 어떻게 처리할지는 중요한 설계 결정이다.

// 안티패턴: 무의미한 RuntimeException 래핑
try {
    binaryContentStorage.put(saved.getId(), profile.getBytes());
} catch (IOException e) {
    throw new RuntimeException(e);  // 무슨 에러인지 알 수 없다
}

// 모범 사례: 도메인 예외로 래핑
try {
    binaryContentStorage.put(saved.getId(), profile.getBytes());
} catch (IOException e) {
    throw new FileStorageException(saved.getId(), e);
}

RuntimeException으로 래핑하면 세 가지를 잃는다.

  1. 문맥 — 어떤 작업 중 실패했는지 알 수 없다.
  2. 처리 기회GlobalExceptionHandler에서 RuntimeException은 너무 넓은 범위라 적절한 HTTP 상태 코드를 반환하기 어렵다.
  3. 로그 가독성 — 스택트레이스에 RuntimeException만 보이면 원인 파악이 힘들다.

심화 분석

@ExceptionHandler의 우선순위

여러 @ExceptionHandler가 있을 때, 스프링은 가장 구체적인 예외 타입에 매칭되는 핸들러를 선택한다.

graph TD A["throw UserNotFoundException"] --> B{"핸들러 탐색"} B --> C["handleUserNotFound()"] B --> D["handleAppException()"] B --> E["handleException()"] C --> |"매칭!"| F["404 응답"] style C fill:#c8e6c9 style D fill:#fff9c4 style E fill:#ffcdd2

UserNotFoundException이 발생하면 스프링은 UserNotFoundExceptionAppExceptionException 순으로 핸들러를 탐색한다. 가장 구체적인 핸들러가 있으면 그것을 사용한다.

따라서 도메인별로 특수한 처리가 필요한 경우 세분화된 핸들러를 추가하고, 나머지는 상위 예외 핸들러에서 일괄 처리하는 계층적 핸들링이 효과적이다.

예외 vs 비즈니스 로직

예외는 말 그대로 예외적인 상황에만 사용해야 한다. 정상적인 비즈니스 분기에 예외를 쓰면 성능과 가독성 모두 나빠진다.

// 안티패턴: 예외를 흐름 제어에 사용
public boolean isUserExists(String username) {
    try {
        userRepository.findByUsername(username).orElseThrow();
        return true;
    } catch (UserNotFoundException e) {
        return false;
    }
}

// 모범 사례: Optional 활용
public boolean isUserExists(String username) {
    return userRepository.findByUsername(username).isPresent();
}

자주 하는 실수

예외 클래스에 불필요한 필드

public class AppException extends RuntimeException {
    private final ErrorCode errorCode;
    private final Map<String, Object> details;
    Scanner sc = new Scanner(System.in);  // 왜 예외에 Scanner가?
}

예외 클래스는 에러 정보만 담아야 한다. 디버깅 도구, I/O 객체 등은 예외 클래스에 절대 포함하면 안 된다. 특히 Scanner(System.in)은 시스템 리소스를 점유하며, 예외가 생성될 때마다 새 Scanner 인스턴스가 만들어져 리소스 누수를 일으킨다.

catch에서 원본 예외 버리기

// 안티패턴: 원인 예외를 잃어버림
catch (IOException e) {
    throw new RuntimeException("파일 저장 실패");  // e가 사라졌다!
}

// 모범 사례: 원인 예외를 체인으로 보존
catch (IOException e) {
    throw new FileStorageException("파일 저장 실패", e);
}

원본 예외(e)를 체인에 포함시키지 않으면, 로그에서 근본 원인(root cause)을 추적할 수 없다.

NullPointerException 전용 핸들러

@ExceptionHandler(NullPointerException.class)
public ResponseEntity<ErrorResponse> handleNPE(NullPointerException ex) { ... }

NullPointerException코드 버그의 징후이지, 정상적인 비즈니스 예외가 아니다. 전용 핸들러를 만들어 500으로 반환하는 것보다, 코드에서 null 체크를 제대로 하는 것이 올바른 해결책이다. NPE가 발생한다면 Exception 범용 핸들러에서 잡히도록 두고, 로그를 통해 원인을 수정해야 한다.

예외 메시지에 민감 정보 노출

// 위험: 내부 구현 세부사항 노출
throw new AppException("SQL 쿼리 실행 실패: SELECT * FROM users WHERE...");

// 안전: 사용자에게는 일반적인 메시지
throw new AppException(ErrorCode.INTERNAL_ERROR);  // "서버 내부 오류"
// 상세 원인은 로그에만 기록

API 응답에 SQL 쿼리, 스택트레이스, 내부 경로 등이 포함되면 보안 취약점이 된다.