1. 자바 예외 처리
예외 계층
자바는 프로그램 실행 도중 발생할 수 있는 예상치 못한 상황, 즉 예외(Exception)를 처리하기 위한 메커니즘을 제공한다. 이는 프로그램의 안정성과 신뢰성을 높이는 데 중요한 역할을 한다.
- 자바의 예외 처리는 다음 키워드를 사용한다.
- try, catch, finally, throw, throws
그리고 예외를 다루기 위한 예외 처리용 객체들을 제공한다. 예외 계층 그림을 보면서 간단하게 알아보자.
- Object
- 자바에서 기본형을 제외한 모든 것은 객체이며, 예외도 객체이다. 모든 객체의 최상위 부모는 Object이므로 예외의 최상위 부모도 Object이다.
- Throwable
- 최상위 예외이며, 하위에 Exception과 Error가 있다.
- Error
- 메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구가 불가능한 시스템 예외를 말한다. 애플리케이션 개발자는 이 예외를 잡으려고 해서는 안 된다.
- Exception (체크 예외)
- 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외이다.
- Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다. 단, RuntimeException은 예외로 한다.
- 체크 예외는 발생한 예외를 개발자가 명시적으로 처리해야 한다. 그렇지 않으면 컴파일 오류가 발생한다.
- RuntimeException (언체크 예외, 런타임 예외)
- RuntimeException과 그 하위 예외는 모두 컴파일러가 체크하지 않는 언체크 예외이다.
- 언체크 예외는 개발자가 발생한 예외를 명시적으로 처리하지 않아도 된다.
- RuntimeException과 그 하위 예외는 모두 컴파일러가 체크하지 않는 언체크 예외이다.
주의
상위 예외를 catch로 잡으면 그 하위 예외까지 함께 잡는다. 따라서 애플리케이션 로직에서는 Throwable 예외를 잡으면, 앞서 말한 잡으면 안 되는 Error 예외까지 함께 잡을 수 있다. 애플리케이션 로직은 이런 이유로 Exception부터 필요한 예외로 생각하고 잡으면 된다.
예외 기본 규칙
예외에 대해서는 2가지 기본 규칙을 기억하자.
- 예외는 잡아서 처리하거나 자신을 호출한 곳으로 던져야 한다.
- 예외를 잡아서 던질 때, 지정한 예외뿐만 아니라 그 예외의 자식들도 함께 처리할 수 있다.
- ex. Exception을 catch/throws로 잡으면/던지면, 그 하위 예외들도 모두 잡을/던질 수 있다.
a. 예외 처리
예외를 잡아서 처리하면 정상 흐름을 반환하게 된다.
b. 예외 던짐
예외를 처리하지 못하고 계속해서 던져 자바 main() 밖으로 예외를 던지게 되면, 예외 로그를 출력하면서 시스템이 종료된다.
체크 예외
Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다. 단, RuntimeException은 예외로 한다.
- 체크 예외는 잡아서 처리하거나, 자신을 호출한 곳으로 던지도록 선언해야 한다. 그렇지 않으면 컴파일 오류가 발생한다.
a. 예외 클래스 생성 및 예외 발생
예외 클래스를 만들려면 예외를 상속받으면 된다. Exception을 상속받은 예외는 체크 예외가 된다.
- 참고로 RuntimeException을 상속받으면 언체크 예외가 된다. 이런 규칙은 자바 언어에서 문법으로 정한 것이다.
- 예외가 제공하는 기본 기능이 있는데, 그중 생성자를 통해 오류 메세지를 보관하는 기능도 있다.
- ex. super(message)
public class MyCheckedException extends Exception {
public MyCheckedException(String message) {
super(message);
}
}
- super(message)로 전달한 메세지는 Throwable에 있는 detailMessage에 보관된다.
- getMessage()를 통해 조회할 수 있다.
예외도 객체이기 때문에 객체를 먼저 new로 생성하고 예외를 발생(throw)시켜야 한다.
- throws 예외는 발생시킨 예외를 메서드 밖으로 던질 때 사용하는 키워드이다.
- throw와 throws의 차이를 주의하자.
public class Client {
public void call() throws MyCheckedException {
throw new MyCheckedException("ex");
}
}
b. 체크 예외를 잡거나 던지기
체크 예외는 잡아서 처리하거나 던지거나 둘 중 하나를 필수로 선택해야 한다.
- 예외를 잡아서 처리하는 경우
- 예외를 잡아서 처리하려면 try ~ catch (...) 문을 사용해서 예외를 잡으면 된다.
- try 코드 블럭에서 발생하는 예외를 잡아서 catch로 넘긴다.
- 만약 try에서 잡은 예외가 catch의 대상에 없으면 예외를 잡을 수 없다. 이때는 예외를 밖으로 던져야 한다.
- catch에 예외를 지정하면 해당 예외와 그 하위 타입 예외를 모두 잡아준다.
- 예외를 밖으로 던지는 경우
- 체크 예외를 처리할 수 없을 때는 throws 키워드를 사용해서, method() throws 예외와 같이 밖으로 던질 예외를 필수로 지정해줘야 한다.
- 여기서 throws를 지정하지 않으면 컴파일 오류가 발생한다.
- throws에 지정한 타입과 그 하위 타입 예외를 밖으로 던진다.
- 체크 예외를 처리할 수 없을 때는 throws 키워드를 사용해서, method() throws 예외와 같이 밖으로 던질 예외를 필수로 지정해줘야 한다.
public class Service {
Client client = new Client();
// 체크 예외를 잡아서 처리하는 메서드
public void callCatch() {
try {
client.call();
} catch (MyCheckedException e) {
// 예외 처리 로직 ...
}
// 정상 흐름 ...
}
// 체크 예외를 메서드 밖으로 던지는 메서드
public void callThrow() throws MyCheckedException {
client.call();
}
}
참고
예외도 객체이기 때문에 다형성이 적용된다.
c. 장단점
체크 예외는 예외를 잡아서 처리할 수 없을 때, 예외를 밖으로 던지는 throws 예외를 필수로 선언해야 한다. 그렇지 않으면 컴파일 오류가 발생한다. 이것 때문에 장점과 단점이 동시에 존재한다.
- 장점
- 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아주는 안전장치가 된다. 이를 통해 개발자는 어떤 체크 예외가 발생하는지 쉽게 파악할 수 있다.
- 단점
- 실제로는 개발자가 모든 체크 예외를 반드시 잡거나 던지도록 처리해야 하기 때문에 너무 번거로운 일이 된다.
언체크 예외
RuntimeException과 그 하위 예외는 언체크 예외로 분류되며, 말 그대로 컴파일러가 예외를 체크하지 않는다.
- 기본적으론 체크 예외와 동일하지만, 예외를 던지는 throws를 선언하지 않고 생략할 수 있다.
a. 예외 클래스 생성 및 예외 발생
예외를 잡아서 처리하지 않아도 throws 키워드를 생략할 수 있다는 점에서 체크 예외와 다르다. RuntimeException을 상속받은 예외는 언체크 예외가 된다.
public class MyUncheckedException extends RuntimeException {
public MyUncheckedException(String message) {
super(message);
}
}
참고로 언체크 예외도 throws 예외를 선언해도 된다. 물론 생략할 수 있다.
- 언체크 예외는 주로 생략하지만, 중요한 예외일 경우 이렇게 선언해 두면 해당 코드를 호출하는 개발자가 이런 예외가 발생한다는 점을 IDE를 통해 좀 더 편리하게 인지할 수 있다.
- 물론 언체크 예외를 던진다고 해서 체크 예외처럼 컴파일러를 통해 체크할 수 있는 것은 아니다.
public class Client {
// throws 예외 -> 생략 가능
public void call() throws MyUncheckedException {
throw new MyUncheckedException("ex");
}
}
b. 언체크 예외를 잡거나 던지기
언체크 예외는 필수적으로 잡거나 던지지 않아도 된다. 예외를 잡지 않으면 자동으로 밖으로 던진다.
- 예외를 잡아서 처리하는 경우
- 언체크 예외도 필요한 경우 예외를 잡아서 처리할 수 있다.
- 예외를 밖으로 던지는 경우
- 언체크 예외는 체크 예외와는 다르게 throws 예외를 선언하지 않아도 된다.
- 말 그대로 컴파일러가 이런 부분을 체크하지 않기 때문에 언체크 예외라고 부른다.
public class Service {
Client client = new Client();
// 언체크 예외를 잡아서 처리하는 메서드
public void callCatch() {
try {
client.call();
} catch (MyUncheckedException e) {
// 예외 처리 로직
}
// 정상 로직
}
// 언체크 예외를 메서드 밖으로 던지는 메서드
public void callThrow() {
client.call();
}
}
c. 장단점
언체크 예외는 예외를 잡아서 처리할 수 없을 때, 예외를 밖으로 던지는 throws 예외를 생략할 수 있다. 이것 때문에 장점과 단점이 동시에 존재한다.
- 장점
- 신경 쓰고 싶지 않은 언체크 예외를 무시할 수 있다. 체크 예외와는 달리 throws 예외를 생략할 수 있다.
- 단점
- 개발자가 실수로 예외를 누락할 수 있다.
try ~ catch ~ finally
자바는 어떤 경우라도 반드시 호출되는 finally 기능을 제공한다.
- try를 시작하기만 하면, finally 코드 블럭은 어떤 경우라도 반드시 호출된다. 심지어 try와 catch 안에서 잡을 수 없는 예외가 발생해도 finally 코드 블럭은 반드시 호출된다.
- 정리하면 다음과 같다. 즉, finally 코드 블럭이 끝나고 나서 이후에 예외가 밖으로 던져지는 것이다.
- try (정상 흐름) → finally
- 예외 catch → finally
- 예외 던짐 → finally
try {
// 정상 흐름
} catch {
// 예외 흐름
} finally {
// 반드시 호출해야 하는 마무리 흐름
}
try ~ finally
다음과 같이 catch 없이 try ~ finally만 사용할 수도 있다.
- 예외를 직접 잡아서 처리할 일이 없다면 이렇게 사용하면 된다. 이렇게 예외를 밖으로 던지는 경우에도 finally 호출이 보장된다.
try {
// 정상 흐름
} finally {
// 마무리 흐름
}
2. 예외 계층
설명
예외를 계층화해서 다양하게 만들면 더 세밀하게 예외를 처리할 수 있다. 아래 그림처럼 예외를 계층화하면 다음과 같은 장점이 있다.
- 자바에서 예외는 객체이다. 따라서 부모 예외를 잡거나 던지면, 그 하위 예외도 함께 잡거나 던질 수 있다.
- 특정 예외를 잡아서 처리하고 싶다면 하위 예외를 정확하게 명시해 처리하면 된다.
활용
a. 예외마다 처리
catch는 순서대로 작동한다.
- 모든 예외를 잡아서 처리하려면 마지막에 Exception을 두면 된다.
- 주의할 점은 예외가 발생했을 때 catch는 순서대로 실행하므로, 더 세부적인 하위 예외를 먼저 잡아야 한다.
public void sendMessage(String data) {
String address = "https://example.com";
NetworkClient client = new NetworkClient(address);
client.initError(data);
try {
client.connect();
client.send(data);
} catch (ConnectException e) {
// 예외 처리 로직
} catch (NetworkClientException e) {
// 예외 처리 로직
} catch (Exception e) {
// 예외 처리 로직
} finally {
// 마무리 로직
}
}
b. 여러 예외를 묶어서 처리
다음과 같이 |를 사용해서 여러 예외를 한 번에 잡을 수도 있다.
- 이 경우 각 예외들의 공통 부모의 기능만 사용할 수 있다.
public void sendMessage(String data) {
String address = "https://example.com";
NetworkClient client = new NetworkClient(address);
client.initError(data);
try {
client.connect();
client.send(data);
} catch (ConnectException | SendException e) {
// 예외 처리 로직
} finally {
// 마무리 로직
}
}
3. 실무 예외 처리 방안
설명
a. 처리할 수 없는 예외
예를 들어서 상대 네트워크 서버에 문제가 발생해서 통신이 불가능하거나, DB 서버에 문제가 발생해서 접속이 안되면, 애플리케이션에서 연결 오류, DB 접속 실패와 같은 예외가 발생한다.
- 이렇게 시스템 오류 때문에 발생한 예외들은 대부분 예외를 잡아도 해결할 수 있는 것이 거의 없다. 예외를 잡아서 다시 호출해도 같은 오류가 반복될 뿐이다.
- 이런 경우 고객에게는 오류 메시지를 보여주고, 만약 웹이라면 오류 페이지를 보여주면 된다. 그리고 내부 개발자가 문제 상황을 빠르게 인지할 수 있도록, 오류에 대한 로그를 남겨두어야 한다.
b. 체크 예외의 부담
체크 예외는 개발자가 실수로 놓칠 수 있는 예외들을 컴파일러가 체크해 주기 때문에 오래전부터 많이 사용됐다. 그런데 앞서 설명한 것처럼 처리할 수 없는 예외가 많아지고, 프로그램이 복잡해지면서 체크 예외를 사용하는 것이 점점 더 부담스러워졌다.
체크 예외 사용 시나리오
체크 예외를 사용하게 되면 어떤 문제가 발생하는지 가상의 시나리오로 얘기해 보자.
- 실무에서는 수많은 라이브러리를 사용하고, 다양한 외부 시스템과 연동한다. 사용하는 각각의 클래스들이 자신만의 예외를 모두 체크 예외로 전달하게 되면, Service에서 모든 예외를 처리해야 한다. 만약 처리할 수 없다면 밖으로 던져야 한다.
그런데 앞서 설명했듯이 상대 네트워크 서버가 내려갔더나, DB 서버에 문제가 발생한 경우 Service에서 예외를 잡아도 복구할 수 없다. 따라서 예외를 밖으로 던지는 게 더 나은 선택이다.
- 아래 코드처럼 throws로 모든 체크 예외를 하나하나 밖으로 던져야 하며, 라이브러리가 늘어날수록 다뤄야 하는 예외도 많아져서 복잡해진다.
try {
} catch (NetworkException) {...
} catch (DatabaseException) {...
} catch (XxxException) {...}
///
class Service {
void sendMessage(String data) throws NetworkException, DatabaseException, XxxException, ... {
}
}
아래처럼 Exception을 throws해 모든 체크 예외를 잡는 방법을 쓰면 중요한 체크 예외를 다 놓치게 된다. 중간에 중요한 체크 예외가 발생해도 컴파일러는 Exception을 던지기 때문에 문법에 맞다고 판단해서 컴파일 오류가 발생하지 않는다.
- 이렇게 하면 모든 예외를 다 던지기 때문에 체크 예외를 의도한 대로 사용하는 것이 아니다. 따라서 꼭 필요한 경우가 아니면 Exception 자체를 밖으로 던지는 것은 좋지 않은 방법이다.
class Service {
void sendMessage(String data) throws Exception
}
언체크 예외 사용 시나리오
이번에는 Service에서 호출하는 클래스들이 언체크(런타임) 예외를 전달한다고 가정해 보자. 잡아도 복구할 수 없는 예외들이므로 언체크 예외로 선언하면 무시할 수 있다.
- 사용하는 라이브러리가 늘어나서 언체크 예외가 늘어도 본인이 필요한 예외만 잡으면 되고, throws를 늘리지 않아도 된다.
class Service {
void sendMessage(String data) {
}
}
///
try {
} catch (XxxException) {...}
- 예외 공통 처리
- 이렇게 처리할 수 없는 예외들은 중간에 여러 곳에서 나눠 처리하기 보다는, 예외를 공통으로 처리할 수 있는 곳을 만들어서 한 곳에서 해결하면 된다.
- 어차피 해결할 수 없는 예외들이기 때문에 이런 경우 고객에게는 현재 시스템에 문제가 있다는 오류 메시지를 보여주고, 웹이라면 오류 페이지를 보여주면 된다. 그리고 내부 개발자가 지금의 문제 상황을 빠르게 인지할 수 있도록 오류에 대한 로그를 남겨두면 된다.
- printStackTrace() 메서드에 파라미터로 아무것도 넣지 않으면 표준 에러(System.err) 결과 출력으로 넘어간다.
- System.out과 System.err 둘 다 결과가 콘솔에 출력되지만, 서로 다른 흐름을 통해서 출력된다. 따라서 둘을 함께 사용하면 출력 순서가 꼬여서 보일 수 있다.
private static void exceptionHandler(Exception e) {
// 공통 처리
e.printStackTrace(System.out);
// e.printStackTrace(); // 생략 시 기본값 System.err
// 필요하면 예외별로 별도의 추가 처리 가능
if (e instanceof SendException sendEx) {
// 예외 처리 코드
}
}
참고
실무에서는 주로 Slf4j나 logback 같은 별도의 로그 라이브러리를 사용해서 콘솔과 특정 파일에 함께 결과를 출력한다.
try-with-resources
애플리케이션에서 외부 자원을 사용하는 경우 반드시 외부 자원을 해제해야 한다. 따라서 finally 구문을 반드시 사용해야 한다.
- try에서 외부 자원을 사용하고, try가 끝나면 외부 자원을 반납하는 패턴이 반복되면서 자바에서는 Try with resources라는 편의 기능을 자바7에서 도입했다. 이름 그대로 try에서 자원을 함께 사용한다는 뜻이다.
- 이 기능을 사용하려면 먼저 AutoCloseable 인터페이스를 구현해야 한다. 이 인터페이스를 구현하면 Try with resources를 사용할 때, try가 끝나는 시점에 close()가 자동으로 호출된다.
public interface AutoCloseable {
void close() throws Exception;
}
- 그리고 다음과 같이 Try with resources 구문을 사용하면 된다.
try (Resource resource = new Resource()) {
// 리소스를 사용하는 코드
}
예시 코드를 통해 확인해 보자.
- close() 메서드
- AutoCloseable 인터페이스가 제공하는 이 메서드는 try가 끝나면 자동으로 호출된다. 종료 시점에 자원을 반납하는 방법을 여기에 정의하면 된다.
- 이 메서드에서 예외를 던지지는 않으므로 인터페이스의 메서드에 있는 throws Exception은 제거했다.
- Try with resources 구문은 try 괄호 안에 사용할 자원을 명시한다.
- 이 자원은 try 블럭이 끝나면 자동으로 AutoCloseable.close()를 호출해서 자원을 해제한다.
- 여기서 catch 블럭 없이 try 블럭만 있어도 close()는 호출된다.
public class NetworkClient implements AutoCloseable {
private final String address;
...
@Override
public void close() {
disconnect();
}
}
///
public class NetworkService {
public void sendMessage(String data) {
String address = "https://example.com";
try (NetworkClient client = new NetworkClient(address)) {
client.initError(data);
client.connect();
client.send(data);
} catch (Exception e) {
throw e;
}
}
}
장단점은 다음과 같다.
- 리소스 누수 방지
- 모든 리소스가 제대로 닫히도록 보장한다. 실수로 finally 블록을 적지 않거나, finally 블럭 안에서 자원 해제 코드를 누락하는 문제들을 예방할 수 있다.
- 코드 간결성 및 가독성 향상
- 명시적인 close() 호출이 필요 없기 때문에 코드가 더 간결하고 읽기 쉬워진다.
- 스코프 범위 한정
- 예를 들어 리소스로 사용되는 client 변수의 스코프가 try 블럭 안으로 한정된다. 따라서 코드 유지 보수가 더 쉬워진다.
- 조금 더 빠른 자원 해제
- 기존에는 try → catch → finally로 catch 이후에 자원을 반납했다. Try with resources 구문은 try 블럭이 끝나면 즉시 close()를 호출한다.