| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- 헥사고날 아키텍처
- constructor
- OpenSearch
- UserLand
- 3계층 아키텍처
- builder-pattern
- 가용영역
- 다짐글
- ReverseNested
- 레벨1
- 코딩테스트
- axios
- DevOps
- React
- design-pattern
- Level2
- 글쓰기세미나
- 프로그래머스
- object-creation
- 포트앤어댑터 아키텍처
- QueryDSL
- static-factory-method
- 회고
- 클린 아키텍처
- 글또10기
- HashMap
- 글또
- 코엑스그랜드볼룸
- SpringBoot
- 클라우드아키텍처
- Today
- Total
oguri's garage
(Java) Stream API에서 Checked Exception 처리하기 본문
들어가며
Java Stream API를 사용하다 보면 필연적으로 마주치는 문제가 있습니다. 바로 람다 표현식 내에서 Checked Exception을 처리하는 방법입니다. 이 글에서는 왜 이런 문제가 발생하는지, 그리고 어떻게 해결할 수 있는지 알아보겠습니다.
핵심 요약
- Stream API의 람다 표현식에서는 Checked Exception을 직접 던질 수 없다
- 그래서 Checked Exception을 RuntimeException으로 "감싸서" 던진다
- 감싸서 던진 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());
장점
- 간결성: 불필요한 try-catch와 래핑 제거
- 명확성: 예외 발생 지점과 의도가 명확함
- 정보 보존: 원본 에러 코드와 스택 트레이스 완벽 보존
- 일관성: 프로젝트 전체에서 일관된 예외 처리 패턴
실무 적용: 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 같은 프레임워크와도 더 잘 어울립니다.
'개발하다 > Java' 카테고리의 다른 글
| Inner Class에 Static을 붙여야 하는 이유 (메모리 관점) (1) | 2025.10.29 |
|---|---|
| Java 문자열 조합, String.format()이 가독성에 좋은 이유 (0) | 2025.10.19 |
| (Java) Record - Lombok Builder vs Record 비교 (0) | 2025.10.05 |
| `null` 대신 `Optional.empty`를 return 해야 하는 이유 (0) | 2025.09.30 |
| Java 객체 생성 패턴 비교 및 선택 가이드 (0) | 2025.09.28 |