자바 예외 이해
- 김영한님의 스프링 DB 1편 강의를 통해 자바 예외의 계층 구조와 체크/언체크 예외의 차이를 이해하고, 실무에서 효과적인 예외 처리 전략을 정리함
예외 계층 구조
자바 예외 계층도

- Object
- 자바의 모든 객체의 최상위 부모
- 예외도 객체이므로 Object를 상속
- Throwable
- 예외의 최상위 부모
- Error와 Exception의 상위 클래스
- Error
- 메모리 부족이나 시스템 레벨의 심각한 오류
- 애플리케이션에서 잡으려고 시도하면 안 됨
- 복구 불가능한 시스템 예외
- Exception
- 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외
- 컴파일러가 체크하는 체크 예외
- RuntimeException을 제외한 모든 Exception은 체크 예외
- RuntimeException
- 컴파일러가 체크하지 않는 언체크 예외
- 런타임 예외와 그 하위 예외를 모두 언체크 예외라고 함
예외 상속 규칙
-
Exception을 상속받으면 체크 예외1 2 3 4 5 6
// 체크 예외 정의 class MyCheckedException extends Exception { public MyCheckedException(String message) { super(message); } }
-
RuntimeException을 상속받으면 언체크 예외1 2 3 4 5 6
// 언체크 예외 정의 class MyUncheckedException extends RuntimeException { public MyUncheckedException(String message) { super(message); } }
예외 기본 규칙
기본 규칙
- 예외는 잡아서 처리하거나 던져야함
- 예외를 잡거나 던지지 않으면 컴파일 오류 발생
- 처리
try-catch로 예외 잡기
- 던지기
throws로 예외 던지기
- 예외를 던질 때 지정한 예외와 그 하위 예외까지 함께 던져짐
Exception을 catch → 하위 예외까지 모두 잡을 수 있음Exception을 throws → 하위 예외까지 모두 던질 수 있음
예외 처리 흐름

예외를 처리하지 못하는 경우

- 예외를 처리하지 못하고 계속 던지면 최종적으로
main()밖으로 예외가 던져짐 - 예외 정보와 스택 트레이스를 출력하고 시스템 종료
- 웹 애플리케이션의 경우 오류 페이지 표시
체크 예외
체크 예외 특징
Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외- 단,
RuntimeException은 예외 (언체크 예외) - 체크 예외는 잡아서 처리하거나, 던지거나 둘 중 하나를 필수로 선택해야 함
체크 예외 처리 방식

-
예외 잡아서 처리
1 2 3 4 5 6 7 8
public void callCatch() { try { repository.call(); } catch (MyCheckedException e) { // 예외 처리 로직 log.info("예외 처리, message={}", e.getMessage(), e); } }
-
예외 던지기
1 2 3
public void callThrow() throws MyCheckedException { repository.call(); }
체크 예외 장단점
- 장점
- 개발자가 실수로 예외를 누락하지 않도록 컴파일러가 체크
- 실수로 예외를 누락하면 컴파일 오류로 확인 가능
- 단점
- 개발자가 모든 체크 예외를 반드시 잡거나 던지도록 처리해야 함
- 너무 번거롭고 실용적이지 않음
언체크 예외
언체크 예외 특징
RuntimeException과 그 하위 예외는 언체크 예외로 분류- 컴파일러가 예외를 체크하지 않음
- 체크 예외와 달리
throws를 선언하지 않아도 됨
언체크 예외 처리 방식

-
예외 잡아서 처리
1 2 3 4 5 6 7
public void callCatch() { try { repository.call(); } catch (MyUncheckedException e) { log.info("예외 처리, message={}", e.getMessage(), e); } }
-
throws 생략 가능
1 2 3 4 5 6 7 8 9
// throws 선언 없이도 자동으로 던짐 public void callThrow() { repository.call(); } // 명시적 선언도 가능 (IDE 힌트용) public void callThrow() throws MyUncheckedException { repository.call(); }
언체크 예외 장단점
- 장점
- 신경쓰고 싶지 않은 예외를 무시할 수 있음
- 체크 예외처럼 예외를 강제로 의존하지 않아도 됨
- 단점
- 개발자가 실수로 예외를 누락할 수 있음
체크 예외와 언체크 예외 비교
비교 표
| 구분 | 체크 예외 | 언체크 예외 |
|---|---|---|
| 상속 | Exception (RuntimeException 제외) | RuntimeException |
| 컴파일러 체크 | 체크함 | 체크 안함 |
| 처리 강제 | 필수 (catch 또는 throws) | 선택 (생략 가능) |
| throws 선언 | 필수 | 선택 |
| 장점 | 예외 누락 방지 | 코드 간결, 의존성 감소 |
| 단점 | 번거로움, 의존성 증가 | 예외 누락 가능 |
체크 예외의 문제점
복구 불가능한 예외
- 대부분의 예외는 복구가 불가능함
SQLException- DB 문제
- 개발자가 SQL 수정 필요
ConnectException- 네트워크 문제
- 시스템 관리자 개입 필요
- 공통 예외 처리 방법
- 오류 로그 남기기
- 개발자에게 알림 (메일, 슬랙 등)
- 사용자에게 일반적인 오류 메시지 표시
의존 관계 문제

- 문제점
- Service와 Controller가 JDBC 기술에 의존하게 됨
- JDBC에서 JPA로 기술 변경 시 모든 코드 수정 필요
- OCP, DI 원칙 위배
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// 불필요한 의존성 class Controller { // SQLException에 의존하게 됨 public void request() throws SQLException, ConnectException { service.logic(); } } class Service { // SQLException에 의존하게 됨 public void logic() throws SQLException, ConnectException { repository.call(); networkClient.call(); } }
throws Exception의 위험성
1
2
3
void method() throws Exception {
}
- 모든 예외를 던짐 → 체크 예외 기능 무효화
- 중요한 예외를 놓칠 수 있음
언체크 예외 활용
기본 원칙
- 기본적으로 언체크(런타임) 예외를 사용
- 체크 예외는 비즈니스 로직상 의도적으로 던지는 예외에만 사용
해결 방안

예외 전환
1
2
3
4
5
6
7
8
9
10
11
12
// 런타임 예외 정의
class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException(Throwable cause) {
super(cause); // 기존 예외 포함
}
}
class RuntimeConnectException extends RuntimeException {
public RuntimeConnectException(String message) {
super(message);
}
}
1
2
3
4
5
6
7
8
9
10
// Repository: 체크 예외 -> 런타임 예외 전환
class Repository {
public void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException(e); // 전환
}
}
}
1
2
3
4
5
6
7
8
9
10
// Service: throws 선언 불필요
class Service {
Repository repository = new Repository();
NetworkClient networkClient = new NetworkClient();
public void logic() {
repository.call();
networkClient.call();
}
}
1
2
3
4
5
6
7
8
// Controller: throws 선언 불필요
class Controller {
Service service = new Service();
public void request() {
service.logic();
}
}
런타임 예외 장점
- 의존성 제거
- Service/Controller가 예외 타입을 몰라도 됨
- 코드 간결
- throws 선언 불필요
- 기술 변경 유연
- 공통 처리만 수정하면 됨
예외 포함과 스택 트레이스
예외 포함의 중요성

올바른 예외 전환
1
2
3
4
5
6
7
public void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException(e); // 기존 예외(e) 포함
}
}
1
2
3
4
5
6
7
8
hello.jdbc.exception.basic.UncheckedAppTest$RuntimeSQLException:
java.sql.SQLException: ex
at UncheckedAppTest$Repository.call(UncheckedAppTest.java:61)
at UncheckedAppTest$Service.logic(UncheckedAppTest.java:45)
at UncheckedAppTest$Controller.request(UncheckedAppTest.java:35)
Caused by: java.sql.SQLException: ex // 원본 예외 확인 가능
at UncheckedAppTest$Repository.runSQL(UncheckedAppTest.java:66)
at UncheckedAppTest$Repository.call(UncheckedAppTest.java:59)
잘못된 예외 전환
1
2
3
4
5
6
7
public void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException(); // 기존 예외(e) 제외
}
}
1
2
3
hello.jdbc.exception.basic.UncheckedAppTest$RuntimeSQLException: null
at UncheckedAppTest$Repository.call(UncheckedAppTest.java:61)
at UncheckedAppTest$Service.logic(UncheckedAppTest.java:45)
- 원본 SQLException 정보 손실
- DB 오류 원인 파악 불가능
올바른 로그 출력
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
void printEx() {
Controller controller = new Controller();
try {
controller.request();
} catch (Exception e) {
// 마지막 파라미터로 예외 전달
log.info("예외 발생, message={}", e.getMessage(), e);
// 예외만 전달
log.info("ex", e);
// System.out 사용 지양
// e.printStackTrace();
}
}
런타임 예외 문서화
문서화의 필요성
- 런타임 예외는 컴파일러가 체크하지 않으므로 문서화가 중요함
JavaDoc 주석
1
2
3
4
5
6
7
8
/**
* Make an instance managed and persistent.
* @param entity entity instance
* @throws EntityExistsException if the entity already exists.
* @throws IllegalArgumentException if the instance is not an entity
* @throws TransactionRequiredException if there is no transaction
*/
public void persist(Object entity);
코드에 명시
1
2
3
4
5
6
/**
* Issue a single SQL execute, typically a DDL statement.
* @param sql static SQL to execute
* @throws DataAccessException if there is any problem
*/
void execute(String sql) throws DataAccessException;
- 효과
- IDE에서 예외 확인 가능
- 개발자가 중요한 예외 인지
- 처리 필요 시 catch 가능
예외 선택 가이드
기준

안티 패턴
1
2
// throws Exception 사용
void method() throws Exception { }
1
2
3
4
5
// 예외 무시
try {
} catch (Exception e) {
// 아무것도 하지 않음
}
1
2
// 예외 전환 시 원본 예외 미포함
throw new CustomException(); // e를 포함하지 않음
1
2
// System.out으로 예외 출력
e.printStackTrace(); // 로그 사용할 것
요약 정리
- 자바 예외는
Object→Throwable→Error/Exception→RuntimeException계층 구조를 가짐 - 체크 예외는
Exception을 상속하며 컴파일러가 처리를 강제하고, 언체크 예외는RuntimeException을 상속하며 처리가 선택적임 - 체크 예외의 문제점은 복구 불가능한 예외가 많고 불필요한 의존성을 만든다는 것임
- 실무에서는 런타임 예외를 기본으로 사용하고 비즈니스 예외만 선택적으로 체크 예외를 사용함
- 예외 전환 시 반드시 기존 예외를 포함하여 스택 트레이스를 유지해야 함
- 런타임 예외는 컴파일러가 체크하지 않으므로 JavaDoc과
throws로 문서화가 필수임 - 최종적으로 시스템 예외는 런타임 예외로 전환하고, 원본 예외를 항상 포함하며, 공통 예외 처리(ControllerAdvice)를 활용해야 함
연습 문제
-
자바 예외 계층 구조에서 애플리케이션 로직이 일반적으로 처리하지 않아야 하는 가장 심각한 오류 유형은 무엇일까요?
a. Error
- Error는 복구 불가능한 심각한 시스템 문제
- 애플리케이션 로직에서 잡는 것은 일반적으로 권장되지 않음
- Exception 하위의 예외들만 주로 처리
-
자바의 체크 예외(Checked Exception)와 언체크 예외(Unchecked Exception)의 가장 근본적인 차이점은 무엇일까요?
a. 컴파일러가 처리/선언 의무를 검사하는지 여부
- 컴파일러가 예외 처리 의무를 검사하는지가 핵심 기준
- 체크 예외는 컴파일러가 강제, 언체크 예외는 선택적
-
메서드에서 체크 예외를 발생시킬 수 있는 경우, 해당 메서드를 호출하는 쪽에서 컴파일 오류 없이 진행하기 위한 필수적인 처리 방법은 무엇일까요?
a. try-catch 블록으로 예외를 잡거나 throws 키워드로 선언하여 던진다.
- 체크 예외는 컴파일러가 처리를 강제
- 호출하는 곳에서는 반드시 예외를 직접 잡거나 던진다고 선언해야 함
-
일반적인 애플리케이션 개발에서 데이터베이스 접근 오류(SQLException)나 네트워크 연결 오류(ConnectException)와 같이 시스템 레벨의, 대부분 복구 불가능한 예외를 처리할 때 더 선호되는 예외 유형과 그 이유는 무엇일까요?
a. 언체크 예외 - 호출 체인에 불필요한 의존성을 만들지 않기 때문
- 시스템 레벨의 복구 불가능한 오류는 언체크 예외로 전환
- 호출 체인에 기술 의존성을 만들지 않고 공통으로 처리하는 것이 유연함
-
한 예외를 잡아서 새로운 예외로 변환하여 다시 던질 때, 디버깅 시 원인(Root Cause)을 쉽게 파악하기 위해 가장 중요한 실천 방법은 무엇일까요?
a. 새로운 예외 생성 시 이전 예외를 ‘cause’ 인자로 전달한다.
- 새로운 예외를 만들 때 이전 예외를 ‘cause’로 전달
- 스택 트레이스에서 ‘Caused by’를 통해 원인 예외 확인 가능
- 근본 원인 추적에 필수적