oguri's garage

(Java) Stream API에서 Checked Exception 처리하기 본문

개발하다/Java

(Java) Stream API에서 Checked Exception 처리하기

oguri 2025. 10. 7. 23:27

들어가며

Java Stream API를 사용하다 보면 필연적으로 마주치는 문제가 있습니다. 바로 람다 표현식 내에서 Checked Exception을 처리하는 방법입니다. 이 글에서는 왜 이런 문제가 발생하는지, 그리고 어떻게 해결할 수 있는지 알아보겠습니다.

 

 


핵심 요약

  1. Stream API의 람다 표현식에서는 Checked Exception을 직접 던질 수 없다
  2. 그래서 Checked Exception을 RuntimeException으로 "감싸서" 던진다
  3. 감싸서 던진 RuntimeException을 상위에서 다시 원래의 Checked Exception으로 "풀어낸다"

결론: 이런 복잡한 래핑 과정보다는, 애초에 RuntimeException으로 설계하는 것이 더 깔끔하고 효율적하다.



문제 상황: Stream에서 Checked Exception을 던질 수 없다

컴파일 에러가 발생하는 코드

public class MctiAnalysisApiException extends Exception {
    // Checked Exception
}

// ❌ 컴파일 에러 발생!
list.stream()
    .map(item -> {
        throw new MctiAnalysisApiException("에러 발생!");
    })
    .collect(Collectors.toList());

 

위 코드는 컴파일되지 않습니다. 왜 그럴까요?



원인 분석: 함수형 인터페이스의 제약

비유로 이해하기

스트림의 람다는 컨베이어 벨트의 작업자와 같습니다. 각 작업자는 정해진 규칙(함수형 인터페이스)에 따라 일해야 하는데, 이 규칙에는 "갑자기 큰 문제(Checked Exception)가 생겼다고 소리치면 안 된다"는 제약이 있습니다.

함수형 인터페이스의 시그니처

@FunctionalInterface
public interface Function<T, R> {
    R apply(T t);  // Checked Exception을 던지지 않음!
}

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t);  // 마찬가지로 Checked Exception 없음
}

Java 8의 표준 함수형 인터페이스들은 Checked Exception을 던지지 않도록 설계되었습니다. 이는 함수형 프로그래밍의 순수성과 간결성을 위한 의도적인 설계 결정입니다.

Java Exception 계층 구조

Throwable (최상위)
├── Error (시스템 레벨 오류)
└── Exception
    ├── RuntimeException (Unchecked) ✅ 처리 선택적
    └── 기타 Exception들 (Checked) ⚠️ 처리 강제
  • Checked Exception: "이 문제는 호출하는 쪽에서 반드시 대비해야 해" (강제성)
  • Unchecked Exception: "이 문제는 발생할 수도 있지만, 처리는 선택사항이야" (유연성)

 


일반적인 해결 방법: Exception Wrapping

1단계: RuntimeException으로 감싸기

편지를 봉투에 넣는 것처럼, Checked Exception을 RuntimeException이라는 "허용된 봉투"에 넣어서 던집니다.

list.stream()
    .map(item -> {
        try {
            return riskyOperation(item);
        } catch (MctiAnalysisApiException e) {
            // Checked Exception을 RuntimeException으로 감싸기
            throw new RuntimeException(e);
        }
    })
    .collect(Collectors.toList());


2단계: 상위에서 다시 풀어내기

상위 메소드에서는 봉투를 뜯어서 원래 편지를 확인하고, 다시 적절한 형태로 던집니다.

public void processData() throws MctiAnalysisApiException {
    try {
        list.stream()
            .map(item -> {
                try {
                    return riskyOperation(item);
                } catch (MctiAnalysisApiException e) {
                    throw new RuntimeException(e);
                }
            })
            .collect(Collectors.toList());

    } catch (RuntimeException e) {
        // RuntimeException을 풀어서 원래 Exception으로 변환
        if (e.getCause() instanceof MctiAnalysisApiException) {
            throw new MctiAnalysisApiException(
                ExceptionCode.FAILED_TO_PROCESS_DATA
            );
        }
        throw e;
    }
}

 


문제점: Exception Wrapping Hell

이 방식은 작동하지만 여러 문제가 있습니다:

1. 복잡한 변환 과정

원본 Exception 
  → RuntimeException (감싸기)
    → 새로운 Exception (다시 포장)


3번의 불필요한 변환이 발생합니다.


2. 정보 손실 위험

// 원본 에러 코드가 사라질 수 있음
throw new MctiAnalysisApiException(FAILED_TO_DESERIALIZE_JSON);
// 원래는 INVALID_INPUT_DATA 였을 수도...


3. 코드 복잡성 증가

// 스트림 안
try { ... } catch { throw new RuntimeException(...) }

// 상위 메소드
try { ... } catch (RuntimeException e) {
    if (e.getCause() instanceof ...) { ... }
}


읽기 어렵고 유지보수하기 힘든 코드가 됩니다.



권장 해결 방법: RuntimeException으로 설계하기

Before: Checked Exception

// ❌ 복잡한 방식
public class MctiAnalysisApiException extends Exception {
    private final ExceptionCode code;

    public MctiAnalysisApiException(ExceptionCode code) {
        super(code.getMessage());
        this.code = code;
    }
}


After: Unchecked Exception

// ✅ 간단하고 명확한 방식
public class MctiAnalysisApiException extends RuntimeException {
    private final ExceptionCode code;

    public MctiAnalysisApiException(ExceptionCode code) {
        super(code.getMessage());
        this.code = code;
    }
}


개선된 코드

// 스트림에서 직접 던지기 - 간단명료!
list.stream()
    .map(item -> {
        if (isInvalid(item)) {
            throw new MctiAnalysisApiException(
                ExceptionCode.INVALID_INPUT_DATA
            );
        }
        return process(item);
    })
    .collect(Collectors.toList());


장점

  1. 간결성: 불필요한 try-catch와 래핑 제거
  2. 명확성: 예외 발생 지점과 의도가 명확함
  3. 정보 보존: 원본 에러 코드와 스택 트레이스 완벽 보존
  4. 일관성: 프로젝트 전체에서 일관된 예외 처리 패턴

 


실무 적용: Spring Boot에서의 예외 처리

Global Exception Handler

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MctiAnalysisApiException.class)
    public ResponseEntity<ErrorResponse> handleMctiException(
        MctiAnalysisApiException e
    ) {
        return ResponseEntity
            .status(e.getCode().getHttpStatus())
            .body(ErrorResponse.from(e));
    }
}

 

RuntimeException을 상속했기 때문에 Spring의 @ExceptionHandler에서 자연스럽게 처리할 수 있습니다.

Exception Code 설계

public enum ExceptionCode {
    INVALID_INPUT_DATA(HttpStatus.BAD_REQUEST, "유효하지 않은 입력 데이터"),
    FAILED_TO_DESERIALIZE_JSON(HttpStatus.INTERNAL_SERVER_ERROR, "JSON 역직렬화 실패"),
    RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "리소스를 찾을 수 없음");

    private final HttpStatus httpStatus;
    private final String message;

    // constructor, getters...
}

 


결론

Stream API에서 Checked Exception을 처리하는 것은 복잡할 수 있지만, 애초에 RuntimeException으로 설계하면 이 모든 복잡성을 피할 수 있습니다.

핵심은 예외를 언제 강제할 것인가에 대한 판단입니다. 대부분의 비즈니스 로직 예외는 RuntimeException으로 충분하며, Spring Boot 같은 프레임워크와도 더 잘 어울립니다.