Java 예외 처리하기
Exceptions in Java
시스템 운영 중 발생할 가능성이 있는 다양한 오류 상황들을 대비한 처리 방안 구성은 매우 중요한 이슈입니다. 컴파일러는 문법적 오류를 효과적으로 감지하고 제거할 수 있지만, 런타임 에러에 대해서는 아직 완전한 해결책을 제공하지 않습니다. 이에 따라, 개발자들이 코드를 작성하는 과정에서 발생 가능한 예외 상황들을 세심하게 고려하고 주의해야 합니다.
Error vs. Exception
Java는 애플리케이션의 실행 도중 발생 가능한 프로그램의 문제점을 에러(Error)와 예외(Exception)의 두 가지 유형으로 구분합니다.
- 에러(Error): 시스템 수준의 심각한 문제를 나타냅니다. 이는 JVM의 시스템 오류나 메모리 부족과 같이 대부분 개발자의 제어 범위를 벗어나는 문제에 의해 발생합니다. 따라서 개발자가 직접 처리할 수 없으며,
try catch
를 사용하여 처리를 시도하는 것은 적합하지 않습니다. 오류가 발생하는 경우, 일반적으로 애플리케이션을 종료하거나 필요한 복구 조치를 취해야 합니다. - 예외(Exception): 애플리케이션 실행 중에 발생하는 예외적인 상황을 나타냅니다. 이는 파일 열기 시 해당 파일이 존재하지 않는다든지, 네트워크 연결이 끊어지는 상황 등을 포함하며, 개발자가 직접 적절하게 처리할 수 있습니다.
Java에서는 이 시스템 에러와 개발자가 처리할 수 있는 예외를 다루는 두 가지 주요 카테고리를 Throwable
이라는 상위 클래스를 통해 구분합니다. 이 상위 클래스는 두 개의 주요 하위 클래스인 Error
와 Exception
으로 구성된 계층구조를 가지고 있습니다. 이 구조는 애플리케이션 실행 도중에 발생하는 문제를 명확하게 분류하고 적절하게 대응할 수 있는 기능을 제공합니다.
Object
└── Throwable
├── Error
│ ├── VirtualMachineError
│ │ ├── OutOfMemoryError
│ │ └── StackOverflowError
│ └── ...
└── Exception
├── RuntimeException
│ ├── IndexOutOfBoundsException
│ │ └── ArrayIndexOutOfBoundsException
│ ├── NullPointerException
│ └── ...
├────── IOException
│ ├── FileNotFoundException
│ └── ...
├────── SQLException
│ ├── SQLSyntaxErrorException
│ └── ...
└────── ...
Checked vs. Unchecked Exceptions
Exception
클래스 계층은 예외의 처리 방식을 기준으로 체크 예외(Checked Exception)와 언체크 예외(Unchecked Exception)로 더욱 세분화됩니다.
Exception
├── RuntimeException
│ ├── IndexOutOfBoundsException
│ │ └── ArrayIndexOutOfBoundsException
│ ├── NullPointerException
│ └── ...
├────── IOException
│ ├── FileNotFoundException
│ └── ...
├────── SQLException
│ ├── SQLSyntaxErrorException
│ └── ...
└────── ...
Checked Exception
체크 예외(Checked Exceptions)는 컴파일 과정에서 검증되는 예외 유형입니다. 이 종류의 예외는 주로 외부 시스템과 통신하면서 발생하는 메소드에서 주로 발생하곤 합니다. 특히, 파일 입출력, 데이터베이스 연결, 네트워크 연결 등의 작업들에서 발생합니다.
체크 예외는 그 이름에서 알 수 있듯이, 핸들링하지 않으면 컴파일 에러를 유발합니다. 이 때문에, 예외를 처리하거나 throws
를 사용해 해당 메서드의 선언 부분에서 예외를 던져 처리해야 합니다.
체크 예외의 큰 단점 하나는 이에 대한 의존성이 늘어나는 것입니다. 예외처리를 위해 추가로 관련 객체에서 예외 핸들링이 필요하거나, 상위 계층으로 예외를 지속적으로 전달해야 합니다.
static class Controller {
...
public void request() throws SQLException, ConnectException {
service.logic();
}
}
static class Service {
...
public void logic() throws SQLException, ConnectException {
repository.call();
networkClient.call();
}
}
static class Repository {
public void call() throws SQLException {
throw new SQLException("ex");
}
}
static class NetworkClient {
public void call() throws ConnectException {
throw new ConnectException("연결 실패");
}
}
- 위 예시에서,
NetworkClient
에서 발생한ConnectException
은 이 예외를 처리하기 위해 호출자 클래스 모두가 예외 처리를 구현해야 합니다. 그렇지 않으면 컴파일 및 빌드 에러를 일으킵니다.
대부분의 SQLException
같은 예외들은 서비스 레이어에서 처리하는 것이 불가능합니다. 대신 시스템 로그를 남기거나, 사용자에게 서비스 오류를 알리는 것이 최선의 대응 방안이 될 수 있습니다.😮💨
이처럼 애플리케이션 레벨에서 처리할 수 없는 예외들은 대부분 공통 예외 처리 영역에서 처리하게 됩니다. 체크 예외의 경우, 발생 지점까지 많은 흔적을 남기게 됩니다.
그래서 최근에는 체크 예외보다는 언체크 예외를 더 선호하는 편입니다.
Unchecked Exception
언체크 예외(Unchecked Exceptions)는 런타임 도중에 발생하는 예외로, 주로 프로그래밍적 실수에서 일어나는 문제들에 대응합니다. 이러한 예외는 컴파일 시점에서는 검증되지 않으며, 이에 따라 예외 처리를 강제로 요구하지 않습니다. 이런 특성 때문에 이들은 컴파일러가 예외 처리를 확인하지 않는 Unchecked(언체크) 예외라 불립니다.
언체크 예외 계층 구조의 최상위 클래스는 RuntimeException
입니다. 그래서 언체크 예외 보다 런타임 예외라는 명칭이 흔히 사용되며 그 의미가 명확하게 인지됩니다.
런타임 예외를 사용하게 되면 컴파일러가 예외를 강제로 처리하지 않기 때문에, 예외 핸들링을 언급하지 않아도 애플리케이션이 빌드하고 실행하는 데 문제가 없습니다. 하지만, 이로 인해 예외 처리를 누락할 가능성이 있으므로 다양한 테스트 코드를 주행하는 것이 바람직합니다.
static class Controller {
...
public void request() {
service.logic();
}
}
static class Service {
...
public void logic() {
repository.call();
networkClient.call();
}
}
static class Repository {
...
public void call() {
throw new RuntimeSQLException("ex");
}
}
static class NetworkClient {
public void call() {
throw new RuntimeConnectException("연결 실패");
}
}
NetworkClient
클래스에서는RuntimeConnectException
을 던지지만, 호출한 클래스에서는 예외 처리를 하지 않아도 컴파일 및 빌드 에러가 발생하지 않습니다.
런타임 예외는 적절하게 활용하면, 프로그램에서 처리할 수 없는 예외 상황들을 한 군데에서 효율적으로 관리할 수 있게 됩니다. 그러나 언체크 예외의 특성상 예외 처리를 빼먹을 수 있는 위험이 있어, 이를 방지하기 위해 적절한 문서화가 필수적입니다.
그리고, 예외를 발생시키는 메서드에 throws
키워드를 명시함으로써, 다른 개발자들이 어떤 예외가 발생할 수 있는지 쉽게 파악할 수 있게 하는 것이 좋습니다. 이러한 방법은 여전히 런타임 예외로 처리하면서도, IntelliJ IDEA의 코드 미리보기 기능 등을 활용하여 예외 발생 가능성을 쉽게 파악할 수 있게 도와줍니다.
Java 언어를 처음 설계할 당시에는 견고한 어플리케이션을 위해 체크 예외에 더 가치를 두는 의견이 강했습니다. 그러나, 애플리케이션 규모가 급격히 커지고 다양한 라이브러리가 생성되면서 예외 처리와 관련한 이슈들이 나타나기 시작했습니다.
그래서 현재는 런타임 예외을 기본으로 사용하고, 반드시 필요한 경우에만 체크 예외를 사용하도록 권장하고 있습니다. 예를 들어 JPA 라이브러리도 RuntimeException
을 활용하고 있습니다.
public class PersistenceException extends RuntimeException {...}
추가적으로, 시스템 레벨에서 발생하여 개발자가 처리할 수 없는 Java의 Error
계층 역시 언체크 방식을 따르고 있습니다.
Spring Transactional Rollback Strategies
Spring Framework에서 @Transactional
이 적용된 범위 내에서의 예외 처리 기본 전략은 다음과 같습니다:
- 체크 예외(Checked Exception) 발생시 ⇢ 트랜잭션 커밋
- 런타임 예외(Unchecked Exception) 발생시 ⇢ 트랜잭션 롤백
이러한 동작 방식은 @Transactional
어노테이션에서 제공하는 rollbackFor
와 noRollbackFor
속성을 통해 상황에 따라 개발자가 적절하게 제어할 수 있습니다.
예를 들어, rollbackFor = Exception.class
로 설정하면, 런타임 예외뿐만 아니라 체크 예외에 대해서도 트랜잭션을 롤백하게 됩니다. 이는 트랜잭션 내에서 모든 예외가 발생했을 때 롤백이 수행된다는 것을 의미합니다.
@Transactional(rollbackFor = Exception.class)
public void someMethod() {...}
반대로, noRollbackFor
속성을 사용하면 특정 예외가 발생했을 때 롤백을 수행하지 않도록 설정할 수 있습니다. 즉, 아래와 같이 설정하면 SomeBusinessException
이 발생해도 롤백을 수행하지 않습니다.
@Transactional(noRollbackFor = SomeBusinessException.class)
public void someMethod() {...}
이런 방식으로, Spring의 @Transactional
을 이용하면 Java 예외가 발생할 때 트랜잭션 범위 내의 동작을 세밀하게 조절할 수 있습니다. 이를 통해 각 비즈니스 로직에 대해 적절한 예외 처리와 트랜잭션 관리를 구현할 수 있습니다.
- Java
- Spring