multi-catch — 여러 예외를 한 번에 처리하기
Java multi-catch 완전 분석 — catch (A | B e) 구문, implicitly final 변수, LUB 타입 추론, catch 블록 순서와 예외 계층 주의사항, multi-catch 활용 패턴
지난 글에서 try-with-resources로 자원을 안전하게 해제하는 방법을 살펴봤다. 이번에는 Java 7에서 함께 도입된 multi-catch 구문을 다룬다. 여러 예외를 동일하게 처리해야 할 때 발생하는 코드 중복을 깔끔하게 제거해준다.
multi-catch가 해결하는 문제
서로 다른 두 예외를 같은 방식으로 처리하고 싶을 때 Java 7 이전에는 catch 블록을 여러 번 써야 했다.
// Java 7 이전 — 동일한 처리 코드 중복
try {
process();
} catch (IOException e) {
log.error("처리 실패", e);
throw new ServiceException(e);
} catch (SQLException e) {
log.error("처리 실패", e); // 완전 동일!
throw new ServiceException(e);
}
이 패턴의 문제는 단순 중복이 아니다. 한쪽을 수정할 때 다른 쪽을 빠뜨리는 실수가 실제로 발생한다.
multi-catch 기본 구문
파이프(|)로 예외 타입을 구분해 하나의 catch 블록에서 처리한다.
try {
process();
} catch (IOException | SQLException e) {
log.error("처리 실패", e);
throw new ServiceException(e);
}
컴파일러는 이를 두 개의 개별 catch 블록으로 변환하지 않는다. 바이트코드 수준에서 INSTANCEOF 분기가 하나로 합쳐지므로 성능 차이도 없다.
implicitly final — 변수 재할당 금지
multi-catch에서 잡힌 변수 e는 implicitly final이다. 타입이 런타임에 확정되지 않으므로 재할당을 허용하면 타입 안전성이 깨진다.
catch (IOException | SQLException e) {
e = new IOException("대체"); // 컴파일 에러!
// e의 타입은 IOException과 SQLException의 LUB
// 재할당하면 타입 불변성 보장 불가
}
반면 일반 단일 catch 블록에서는 재할당이 허용된다(권장하지는 않지만).
LUB — 최소 공통 상위 타입 추론
multi-catch 변수의 정적 타입은 LUB(Least Upper Bound), 즉 열거된 예외 타입들의 최소 공통 상위 타입으로 추론된다.
// IOException과 SQLException의 LUB는 Exception
catch (IOException | SQLException e) {
// e의 컴파일 타임 타입: Exception
// (둘 다 Exception을 직접 상속)
Throwable t = e; // OK
Exception ex = e; // OK
IOException io = e; // 컴파일 에러! (타입 캐스팅 필요)
}
공통 인터페이스가 있다면 그것도 LUB 계산에 포함된다.
interface Retryable { boolean isRetryable(); }
class NetException extends Exception implements Retryable { ... }
class TimeoutException extends Exception implements Retryable { ... }
catch (NetException | TimeoutException e) {
// LUB: Exception & Retryable
if (e.isRetryable()) retry(); // 인터페이스 메서드 호출 가능!
}
상위 타입 포함 시 컴파일 에러
multi-catch에서 한 타입이 다른 타입의 상위 타입이면 컴파일 에러다.
// 컴파일 에러: IOException은 Exception의 하위 타입
catch (Exception | IOException e) { } // 'IOException' already caught by 'Exception'
이미 상위 타입이 잡으므로 하위 타입 선언이 의미 없다고 컴파일러가 판단한다.
catch 블록 순서 — 구체적인 것을 먼저
multi-catch를 사용하더라도 여러 catch 블록을 쓸 때는 예외 계층을 고려해야 한다.
try {
openFile();
} catch (FileNotFoundException e) {
// 가장 구체적 — 먼저
System.err.println("파일 없음: " + e.getFileName());
} catch (IOException | NetworkException e) {
// 중간 수준
log.warn("IO/네트워크 오류", e);
} catch (Exception e) {
// 가장 넓은 타입 — 마지막
log.error("예상치 못한 오류", e);
}
상위 타입 catch가 먼저 나오면 하위 타입 catch는 도달 불가(unreachable)가 되어 컴파일 에러다.
try-with-resources와 결합
두 기능은 자연스럽게 결합된다.
try (
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)
) {
ps.executeUpdate();
} catch (SQLException | IllegalStateException e) {
log.error("DB 작업 실패", e);
throw new DataAccessException(e);
}
close() 호출은 자동 처리되고, 발생 가능한 여러 예외는 multi-catch로 통합 처리한다.
언제 multi-catch를 쓰고 언제 쓰지 말아야 하나
적합한 경우
- 예외 타입은 다르지만 복구 전략이 동일할 때
- 예외를 래핑해서 다시 던지는 패턴(
throw new ServiceException(e))
피해야 하는 경우
- 예외별로 다른 복구 로직이 필요할 때 (억지로 합치면 가독성 저하)
- 예외 타입별로 다른 로그 메시지나 메트릭이 필요할 때
// 잘못된 사용 — 처리가 실제로 다른데 억지로 합침
catch (FileNotFoundException | PermissionException e) {
if (e instanceof FileNotFoundException fnfe) {
createDefaultFile(fnfe.getFileName()); // FileNotFoundException 전용
} else {
requestPermission(); // PermissionException 전용
}
}
// → 이럴 거면 개별 catch 블록으로 분리하는 것이 더 명확하다
지난 글: try-with-resources — 자원 자동 해제의 모든 것
다음 글: 커스텀 예외 — 도메인에 맞는 예외 클래스 설계
읽어주셔서 감사합니다. 😊