[Java] 에러(Error)와 예외 클래스(Exception)
"모든 예외를 catch로 잡으면 안전하다"는 착각
코드가 어디선가 터질까 불안해서, 넓은 범위의 catch(Exception e)로 모든 것을 감싸버리는 것입니다. 이렇게 하면 컴파일 에러는 사라지고 프로그램이 중단되지 않으니 "안전하다"고 느낍니다. 하지만 이것은 화재경보기를 꺼놓고 "조용하니 안전하다"고 말하는 것과 같습니다.
자바는 예외 처리를 우연에 맡기지 않습니다. Throwable 계층 구조를 통해 "이 문제는 복구할 수 있는가, 없는가"를 설계 레벨에서 분류해 놓았습니다. 이 분류를 이해하면, 어떤 예외를 잡아야 하고 어떤 예외는 잡지 말아야 하는지가 명확해집니다.
1. Throwable 계층 구조: 자바 예외 시스템의 전체 지도
자바에서 프로그램 실행 중 발생하는 모든 문제는 Throwable 클래스의 하위 계층으로 표현됩니다. 이 계층 구조를 먼저 머리에 잡아야 이후의 모든 설명이 하나로 연결됩니다.
Throwable 아래에는 두 개의 큰 가지가 있습니다. 하나는 Error, 다른 하나는 Exception입니다.
그리고 Exception은 다시 Checked Exception과 Unchecked Exception(RuntimeException)으로 나뉩니다.
단순히 "에러 종류가 많으니 분류하자"는 차원이 아니라, 개발자에게 명확한 행동 지침을 주기 위해 이 구조를 만들었습니다. 각 가지가 전달하는 메시지는 아래와 같습니다.
- Error → "너는 이것을 처리할 수 없다. 시도하지 마라."
- Checked Exception → "이것은 일어날 수 있는 일이다. 반드시 대비하라."
- Unchecked Exception → "이것은 네 코드의 실수다. 코드를 고쳐라."
이 세 가지 원칙을 기준으로 각 영역을 구체적으로 살펴보겠습니다.
2. Error: 개발자의 영역 밖에 있는 문제
Error는 JVM 자체가 정상적으로 동작할 수 없는 상황을 나타냅니다. 애플리케이션 코드에서 복구하거나 우회할 수 있는 수준의 문제가 아닙니다.
2-1. 대표적인 Error 유형
OutOfMemoryError는 JVM의 힙 메모리가 완전히 소진되었을 때 발생합니다. 새 객체를 생성할 메모리 자체가 없으므로, "메모리를 확보하는 복구 코드"를 실행할 메모리조차 없을 수 있습니다.
StackOverflowError는 메서드 호출이 너무 깊어져 호출 스택의 한계를 초과할 때 발생합니다. 대부분 재귀 함수의 종료 조건이 잘못되었을 때 나타납니다.
📄 StackOverflowError 발생 예시
// 종료 조건이 없는 재귀 → StackOverflowError
public int factorial(int n) {
// ❌ n == 0 또는 n == 1일 때 멈춰야 하지만, 종료 조건이 없음
return n * factorial(n - 1);
// n = 5 → 4 → 3 → 2 → 1 → 0 → -1 → -2 → ... 무한 호출
}
2-2. Error를 catch하면 안 되는 이유
기술적으로는 catch(Error e) 또는 catch(Throwable t)로 Error를 잡을 수 있습니다. 하지만 잡아서 할 수 있는 일이 없습니다. OutOfMemoryError를 catch한 뒤 "복구 로직"을 실행하려 해도, 그 복구 로직 자체를 실행할 메모리가 없을 가능성이 높습니다.
Error는 예방의 영역입니다. JVM 옵션 튜닝, 메모리 누수 분석, 재귀 깊이 제한 등 코드 실행 전에 환경을 올바르게 구성하는 것이 올바른 대응입니다.
3. Checked Exception: "이 상황에 반드시 대비하라"
Checked Exception은 RuntimeException을 상속하지 않는 모든 Exception 하위 클래스를 의미합니다. 컴파일러가 이 예외의 처리를 강제합니다. try-catch로 잡거나, throws로 호출자에게 위임하지 않으면 컴파일 자체가 되지 않습니다.
3-1. 왜 컴파일러가 강제하는가
Checked Exception이 표현하는 상황은 프로그래머의 실수가 아니라, 외부 환경에 의해 충분히 일어날 수 있는 일입니다.
- 파일이 존재하지 않을 수 있습니다 → FileNotFoundException
- 네트워크 연결이 끊길 수 있습니다 → IOException
- DB 쿼리가 실패할 수 있습니다 → SQLException
이런 상황은 코드가 완벽해도 발생합니다. 따라서 자바는 "이 상황을 무시하면 안 된다"는 것을 컴파일 타임에 강제하는 방법을 선택한 것입니다.
📄 Checked Exception — 컴파일러 강제의 의미
// 이 코드는 컴파일되지 않습니다
public String readFile(String path) {
// ❌ FileNotFoundException은 Checked Exception
// try-catch 또는 throws 없이는 컴파일 에러
FileReader reader = new FileReader(path);
// ...
}
📄 방법 1: try-catch로 직접 처리
public String readFile(String path) {
try {
FileReader reader = new FileReader(path);
// 파일 읽기 로직
} catch (FileNotFoundException e) {
// ✅ 대체 동작: 기본 파일 사용, 사용자에게 알림 등
return getDefaultContent();
}
}
📄 방법 2: throws로 호출자에게 위임
public String readFile(String path) throws FileNotFoundException {
FileReader reader = new FileReader(path);
// ✅ 이 메서드를 호출하는 쪽이 예외를 처리해야 함
// ...
}
3-2. Checked Exception의 처리 원칙
"처리"라는 것은 단순히 catch 블록을 작성하는 것이 아닙니다. 비즈니스적으로 의미 있는 대응을 하는 것이 진정한 처리입니다.
- 재시도할 수 있는가 → 재시도 로직 실행
- 대안이 있는가 → 기본값 반환, 대체 경로 사용
- 사용자에게 알려야 하는가 → 적절한 메시지 전달
- 이 계층에서 대응할 수 없는가 → throws로 상위 계층에 위임
가장 나쁜 처리는 catch 블록을 비워두는 것입니다. 이것은 화재경보를 듣고 아무것도 하지 않는 것과 같습니다.
4. Unchecked Exception: "네 코드에 실수가 있다"
Unchecked Exception은 RuntimeException을 상속하는 모든 예외입니다. 컴파일러가 처리를 강제하지 않습니다. 이 예외들은 대부분 프로그래머의 논리적 실수에서 발생하며, catch로 잡는 것이 아니라 코드를 수정하여 예방하는 것이 올바른 대응입니다.
4-1. 대표적인 Unchecked Exception
NullPointerException은 null 참조에 대해 메서드를 호출하거나 필드에 접근할 때 발생합니다. 자바에서 가장 빈번하게 마주치는 예외입니다.
IllegalArgumentException은 메서드에 전달된 인자 값이 유효하지 않을 때 발생합니다. 예를 들어 나이에 음수를 넣거나, 이름에 빈 문자열을 넣는 경우입니다.
IndexOutOfBoundsException은 배열이나 리스트의 유효 범위를 벗어난 인덱스에 접근할 때 발생합니다.
📄 나쁜 예시 — NullPointerException을 try-catch로 잡기
public String getUserCity(User user) {
try {
return user.getAddress().getCity();
} catch (NullPointerException e) {
// ❌ 문제를 숨기는 것일 뿐
// user가 null? getAddress()가 null? getCity()가 null? 알 수 없음
return "Unknown";
}
}
📄 좋은 예시 — null 가능성을 사전에 검증하여 예방
public String getUserCity(User user) {
if (user == null) {
return "Unknown";
}
Address address = user.getAddress();
if (address == null) {
return "Unknown";
}
// ✅ 정확히 어느 지점이 null인지 파악 가능
return address.getCity();
}
NullPointerException을 catch로 잡으면, 어디서 null이 발생했는지 알 수 없게 됩니다. 좋은 예시처럼 사전 검증을 하면 정확히 어느 지점이 null인지 파악할 수 있고, 디버깅이 훨씬 쉬워집니다.
4-2. Unchecked Exception은 왜 컴파일러가 강제하지 않는가
모든 예외를 컴파일러가 강제하면 더 안전하지 않을까요? NullPointerException이 발생할 수 있는 모든 곳에 try-catch를 강제한다고 상상해 보세요. 거의 모든 줄에 try-catch가 필요해지고, 코드는 읽을 수 없을 정도로 복잡해집니다.
Unchecked Exception은 코드의 논리적 결함을 나타냅니다. 올바른 코드라면 발생하지 않아야 할 예외입니다.
따라서 컴파일러가 처리를 강제하는 대신, 개발자가 코드 자체를 올바르게 작성하여 예방하도록 설계된 것입니다.
5. 세 가지 분류 종합 비교
지금까지 설명한 Error, Checked Exception, Unchecked Exception의 핵심 특성을 하나의 표로 정리하겠습니다.
| 구분 | Error | Checked Exception | Unchecked Exception |
| 상위 클래스 | Throwable → Error | Throwable → Exception | Throwable → Exception → RuntimeException |
| 발생 원인 | JVM/시스템 레벨 문제 | 외부 환경 (파일, 네트워크, DB) | 프로그래머의 논리적 실수 |
| 컴파일러 강제 | 없음 | try-catch 또는 throws 강제 | 없음 |
| 올바른 대응 | 예방 (환경 설정, 설계) | 비즈니스 대응 (재시도, 대안, 위임) | 코드 수정으로 예방 |
| catch 여부 | ❌ 잡지 않음 | ✅ 의미 있는 처리와 함께 잡음 | ⚠️ 원칙적으로 잡지 않고 예방 |
| 대표 예시 | OutOfMemoryError, StackOverflowError | IOException, SQLException | NullPointerException, IllegalArgumentException |
6. 예외 처리의 실전 안티패턴과 올바른 패턴
이론을 이해했으니, 실무에서 자주 발생하는 잘못된 예외 처리 패턴과 올바른 대안을 비교해 보겠습니다.
6-1. 안티패턴: 빈 catch 블록
📄 나쁜 예시 — 예외를 삼키는 빈 catch
public void processOrder(Order order) {
try {
paymentService.charge(order);
inventoryService.deduct(order);
} catch (Exception e) {
// ❌ 아무것도 하지 않음 — "예외 삼키기(Swallowing)"
// 결제가 실패했는데 재고가 차감될 수 있음
// 로그도 없으니 문제가 발생한 사실조차 모름
}
}
📄 좋은 예시 — 의미 있는 예외 처리
public void processOrder(Order order) {
try {
paymentService.charge(order);
inventoryService.deduct(order);
} catch (PaymentFailedException e) {
// ✅ 구체적인 예외를 잡아서 비즈니스 대응
log.error("결제 실패: orderId={}", order.getId(), e);
notifyUser("결제에 실패했습니다. 다시 시도해 주세요.");
throw new OrderProcessingException("결제 실패", e);
} catch (InventoryException e) {
// ✅ 각 예외별로 다른 대응
log.error("재고 차감 실패: orderId={}", order.getId(), e);
paymentService.refund(order); // 결제 취소 (보상 처리)
throw new OrderProcessingException("재고 부족", e);
}
}
두 코드의 차이는 명확합니다. 나쁜 예시는 모든 예외를 뭉뚱그려 삼키면서 문제를 감춥니다. 좋은 예시는 예외 유형별로 구분하여 적절한 비즈니스 대응을 수행합니다. 결제 실패와 재고 부족은 전혀 다른 문제이므로, 서로 다른 처리가 필요합니다.
6-2. 안티패턴: 과도하게 넓은 catch
📄 나쁜 예시 — catch(Exception e)로 모든 것을 잡기
public User findUser(long id) {
try {
return userRepository.findById(id);
} catch (Exception e) {
// ❌ NullPointerException도, SQLException도, 심지어
// 프로그래머의 실수도 여기서 숨겨짐
return null;
}
}
이 코드의 위험은 Unchecked Exception(프로그래머 실수)까지 삼켜버린다는 것입니다. 예를 들어 findById 내부에서 null 처리 실수로 NullPointerException이 발생해도, catch가 이를 조용히 삼키고 null을 반환합니다. 버그가 있는데 버그의 증거가 사라지는 셈입니다.
📄 좋은 예시 — 필요한 예외만 정확히 잡기
public User findUser(long id) {
try {
return userRepository.findById(id);
} catch (DataAccessException e) {
// ✅ DB 관련 예외만 잡아서 처리
log.warn("사용자 조회 실패: id={}", id, e);
throw new UserNotFoundException("사용자를 찾을 수 없습니다", e);
}
// NullPointerException 등 프로그래머 실수는 잡지 않음
// → 그대로 터져서 빠르게 발견됨
}
6-3. 안티패턴: 예외 정보 유실
📄 나쁜 예시 — 원인 예외를 버리고 새 예외만 던지기
try {
externalApi.call();
} catch (IOException e) {
// ❌ 원래 예외(e)의 스택 트레이스를 포함하지 않음
// 로그에서 "외부 API 호출 실패"만 보이고, 진짜 원인을 추적할 수 없음
throw new ServiceException("외부 API 호출 실패");
}
📄 좋은 예시 — 원인 예외를 체이닝
try {
externalApi.call();
} catch (IOException e) {
// ✅ 원인 예외를 두 번째 인자로 전달 (Exception Chaining)
// 스택 트레이스에 "Caused by: IOException..."이 포함됨
throw new ServiceException("외부 API 호출 실패", e);
}
new ServiceException("메시지", e)에서 두 번째 인자 e가 원인 예외(Cause)입니다. 이것을 포함하지 않으면, 로그에서 최초 원인을 추적할 수 없게 됩니다. 예외를 감싸서 다시 던질 때는 반드시 원인 예외를 체이닝하세요.
7. 예외 전파(Exception Propagation)의 이해
예외가 발생하면, 해당 메서드에서 처리되지 않을 경우 호출 스택을 따라 위로 전달됩니다.
이 메커니즘을 이해하면 "어디서 잡을 것인가"를 판단할 수 있습니다.
7-1. 전파 흐름
methodC()에서 예외 발생
↓ catch 없음 → 전파
methodB()
↓ catch 없음 → 전파
methodA()
↓ catch 블록에서 처리됨! → 전파 중단
main()
예외는 자신을 처리할 수 있는 catch를 만날 때까지 호출 스택을 거슬러 올라갑니다. 어디에서도 잡히지 않으면 최종적으로 JVM이 프로그램을 종료합니다.
여기서 Checked와 Unchecked의 전파 방식에 중요한 차이가 있습니다. Checked Exception은 전파 경로의 모든 메서드에 throws 선언이 있어야 합니다. 반면 Unchecked Exception은 throws 선언 없이도 자유롭게 전파됩니다. 이 차이가 Checked Exception이 때로는 번거롭게 느껴지는 이유이기도 합니다.
7-2. "어디서 잡을 것인가"의 원칙
예외를 어디서 잡을지는 "이 계층에서 이 예외에 대해 의미 있는 대응을 할 수 있는가?"로 결정합니다.
- DB 접근 계층: SQL 관련 예외를 잡아서 비즈니스 의미의 예외로 변환
- 서비스 계층: 비즈니스 로직 예외를 잡아서 재시도, 보상 트랜잭션 등 처리
- 컨트롤러/API 계층: 최종적으로 사용자에게 적절한 에러 응답을 반환
아래 계층에서 대응할 수 없는 예외는 throws로 위임하고, 의미 있는 대응이 가능한 계층에서 잡는 것이 올바른 전략입니다.
8. 실무에서 자주 만나는 예외 가이드
마지막으로, 실무에서 빈번히 마주치는 예외들의 원인과 대응 전략을 정리합니다.
| 예외 | 분류 | 주요 원인 | 대응 전략 |
| NullPointerException | Unchecked | null 참조에 메서드/필드 접근 | null 사전 검증, Optional 활용 |
| IllegalArgumentException | Unchecked | 메서드에 유효하지 않은 인자 전달 | 메서드 진입부에 입력값 검증 추가 |
| ClassCastException | Unchecked | 호환되지 않는 타입으로 캐스팅 | instanceof 확인 후 캐스팅 |
| ArrayIndexOutOfBoundsException | Unchecked | 배열 범위 밖의 인덱스 접근 | 인덱스 범위 사전 확인 |
| IOException | Checked | 파일/네트워크 I/O 실패 | 재시도, 기본값 반환, 사용자 알림 |
| SQLException | Checked | DB 쿼리 실패, 연결 끊김 | 재시도, 트랜잭션 롤백 |
| OutOfMemoryError | Error | 힙 메모리 소진 | JVM -Xmx 튜닝, 메모리 누수 분석 |
| StackOverflowError | Error | 과도한 재귀 호출 | 재귀 종료 조건 확인, 반복문 대체 |
9. 결론: 예외의 종류가 곧 대응 전략을 결정합니다
자바의 예외 계층 구조는 단순한 분류 체계가 아닙니다. "이 문제를 어떻게 다뤄야 하는가"를 설계 레벨에서 알려주는 가이드입니다. Error는 애플리케이션이 복구할 수 없는 JVM 레벨의 문제이므로 환경 설정과 설계로 예방해야 합니다. Checked Exception은 외부 환경에 의해 발생하는 예측 가능한 문제이므로, 컴파일러의 강제에 따라 비즈니스적으로 의미 있는 처리를 해야 합니다. Unchecked Exception은 프로그래머의 논리적 실수이므로, catch로 잡는 것이 아니라 코드 자체를 수정하여 예방해야 합니다.
이 세 가지 원칙만 기억하면, "이 예외를 어떻게 처리할까"라는 질문에 대한 답이 자연스럽게 따라옵니다.
- Error는 JVM 레벨 문제입니다. catch하지 말고, 환경 설정과 설계로 예방하세요.
- Checked Exception은 외부 환경에 의한 예측 가능한 문제입니다. 컴파일러의 강제에 따라 비즈니스적으로 의미 있는 대응을 하세요.
- Unchecked Exception은 프로그래머의 논리적 실수입니다. catch로 숨기지 말고, 코드를 수정하여 예방하세요.
- 빈 catch 블록, catch(Exception e) 남용, 원인 예외 유실은 실무에서 가장 위험한 세 가지 안티패턴입니다.
- 예외를 감싸서 다시 던질 때는 반드시 원인 예외(cause)를 포함하세요.