예외 처리 - 실전
#Java
정리
이전 챕터에서 해결하지 못했던 문제들을 예외 처리를 이용하여 해결해보자.
예외 클래스
public class NetworkClientExceptionV2 extends Exception {
private String errorCode;
public NetworkClientExceptionV2(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
어떤 종류의 오류가 발생했는지 구분하기 위해 예외 안에 오류 코드를 보관하도록 설계한다.
클라이언트
public void connect() throws NetworkClientExceptionV2 {
if (connectError) {
throw new NetworkClientExceptionV2("connectError", address + " 서버 연결 실패");
}
System.out.println(address + " 서버 연결 성공");
}
- 기존과는 다르게 오류가 발생했을 때 오류 코드를 반환하는 것이 아니라 예외를 던진다.
- 따라서 반환 값을 사용하지 않아도 된다. 여기서는 반환 값을
void
로 처리한다. - 예외 처리 덕분에 메서드가 정상 종료되면 성공이고, 예외가 던져지면 예외를 통해 실패를 확인할 수 있다.
- 오류가 발생하면, 예외 객체를 만들고 거기에 오류 코드와 오류 메시지를 담아둔다. 그리고 만든 예외 객체를
throw
를 통해 던진다.
서비스 계층
서비스 계층에서는 예외를 잡지 않고 throws
를 통해 밖으로 던진다.
메인
public static void main(String[] args) throws NetworkClientExceptionV2 {
NetworkServiceV2_1 networkService = new NetworkServiceV2_1();
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.print("전송할 문자: ");
String input = scanner.nextLine();
if (input.equals("exit")) {
break;
}
networkService.sendMessage(input);
System.out.println();
}
System.out.println("프로그램을 정상 종료합니다.");
}
- 예외 로직이 따로 없다. 모든 곳에서 발생한 예외를 잡지 않았기 때문에 결과적으로
main()
밖으로 예외가 던져진다. main()
밖으로 예외가 던져지면 예외 메시지와 예외를 추적할 수 있는 스택 트레이스를 출력하고 프로그램을 종료한다.
예외가 복구되도록 복구
서비스 계층에서 오류를 잡도록 수정
public void sendMessage(String data) {
String address = "http://example.com";
NetworkClientV2 client = new NetworkClientV2(address);
client.initError(data);
try {
client.connect();
} catch (NetworkClientExceptionV2 e) {
System.out.println("[오류] 코드: " + e.getErrorCode() + ", 메시지: " + e.getMessage());
return;
}
try {
client.send(data);
} catch (NetworkClientExceptionV2 e) {
System.out.println("[오류] 코드: " + e.getErrorCode() + ", 메시지: " + e.getMessage());
return;
}
client.disconnect();
}
- 예외를 잡아서 처리했기 때문에 이후에는 정상 흐름으로 복귀한다. 여기서는
return
을 사용해서sendMessage()
메서드를 정상적으로 빠져나간다.
남은 문제
- 예외 처리를 했지만 정상 흐름과 예외 흐름이 섞여 있어서 코드를 읽기 어렵다.
- 사용 후에는 반드시
disconnect()
를 호출해서 연결을 해제해야 한다.
흐름 분리
public void sendMessage(String data) {
String address = "http://example.com";
NetworkClientV2 client = new NetworkClientV2(address);
client.initError(data);
try {
client.connect();
client.send(data);
client.disconnect(); // 예외 발생시 무시
} catch (NetworkClientExceptionV2 e) {
System.out.println("[오류] 코드: " + e.getErrorCode() + ", 메시지: " + e.getMessage());
}
}
정상 흐름과 예외 흐름은 명확히 분리되었다.
남은 문제
- 사용 후에는 반드시
disconnect()
를 호출해서 연결을 해제해야 한다.
앞서 이야기했듯이 외부 연결과 같은 자바 외부의 자원은 자동으로 해제가 되지 않는다. 따라서 외부 자원을 사용한 후에는 연결을 해제해서 외부 자원을 반드시 반납해야 한다.
예외가 발생해도 disconnect()
를 반드시 호출해서 연결을 해제하고 자원을 반납하려면 어떻게 해야할까?
접근: catch문 뒤에다가 disconnect()를 두면 예외 처리 후 반드시 처리되는 거 아니에요?
catch에서 잡을 수 없는 오류가 발생한 경우 client.disconnect()
가 호출되지 않는다.
finally
자바는 어떤 경우라도 반드시 호출되는 finally
기능을 제공한다.
try {
// 정상 흐름
} catch {
// 예외 흐름
} finally {
// 반드시 호출해야 하는 마무리 흐름
}
try ~ catch ~ finally
구조는 정상 흐름, 예외 흐름, 마무리 흐름을 제공한다.- 여기서
try
를 시작하기만 하면,finally
코드 블럭은 어떤 경우라도 반드시 호출된다. - 심지어
try
,catch
안에서 잡을 수 없는 예외가 발생해도finally
는 반드시 호출된다.
케이스 정리
- 정상 흐름 →
finally
- 예외 →
catch
→finally
- 예외 던짐 →
finally
finally
코드 블럭이 끝나고 나서 이후에 예외가 밖으로 던져짐
finally
블럭은 반드시 호출된다. 따라서 주로 try
에서 사용한 자원을 해제할 때 주로 사용한다.
public void sendMessage(String data) {
String address = "https://example.com";
NetworkClientV2 client = new NetworkClientV2(address);
client.initError(data);
try {
client.connect();
client.send(data);
} catch (NetworkClientExceptionV2 e) {
System.out.println("[오류] 코드: " + e.getErrorCode() + ", 메시지: " + e.getMessage());
} finally {
client.disconnect();
}
}
try ~ finallycatch
없이 try ~ finally
만 사용할 수도 있다.
예외 계층
예외를 단순히 오류 코드로 분류하는 것이 아니라, 예외를 계층화해서 다양하게 만들면 더 세밀하게 예외를 처리할 수 있다. 위 예시의 경우 연결, 전송 오류로 분리하고, 두 오류를 네트워크 오류로 추상화하여 계층화시킬 수 있다.
이렇게 예외를 계층화하면 다음과 같은 장점이 있다.
- 자바에서 예외는 객체이다. 따라서 부모 예외를 잡거나 던지면, 자식 예외도 함께 잡거나 던질 수 있다.
- 특정 예외를 잡아서 처리하고 싶으면
ConnectExceptionV3
,SendExceptionV3
와 같은 하위 예외를 잡아서 처리하면 된다.
public class NetworkServiceV3_1 {
public void sendMessage(String data) {
String address = "http://example.com";
NetworkClientV3 client = new NetworkClientV3(address);
client.initError(data); // 추가
try {
client.connect();
client.send(data);
} catch (ConnectExceptionV3 e) {
System.out.println("[연결 오류] 주소: " + e.getAddress());
} catch (SendExceptionV3 e) {
System.out.println("[전송 오류] 전송 데이터: " + e.getSendData() + ", 메시지: " + e.getMessage());
} finally {
client.disconnect();
}
}
}
- 예외 클래스를 각각의 예외 상황에 맞추어 만들면, 각 필요에 맞는 예외를 잡아서 처리할 수 있다.
- 예를 들면
e.getAddress()
,e.getSendData()
와 같이 각각의 예외 클래스가 가지는 고유의 기능을 활용할 수 있다.
예외 계층 활용
NetworkClientV3
에서 수 많은 예외를 발생한다고 가정해보자. 이런 경우 모든 예외를 하나하나 다 잡아서 처리하는 것은 상당히 번거로울 것이다.
예외 계층을 사용하면 일부 중요한 예외만 따로 처리하고, 나머지 오류는 공통적으로 처리할 수 있다.
아래 예시에선 연결 오류만 따로 처리하고 나머지는 공통적으로 처리하는 예시이다.
public void sendMessage(String data) {
String address = "http://example.com";
NetworkClientV3 client = new NetworkClientV3(address);
client.initError(data); // 추가
try {
client.connect();
client.send(data);
} catch (ConnectExceptionV3 e) {
System.out.println("[연결 오류] 주소: " + e.getAddress());
} catch (NetworkClientExceptionV3 e) {
System.out.println("[네트워크 오류] 메시지: " + e.getMessage());
} catch (Exception e) {
System.out.println("[알 수 없는 오류] 메시지: " + e.getMessage());
} finally {
client.disconnect();
}
}
주의할 점은 예외가 발생했을 때 catch
를 순서대로 실행하므로, 더 디테일한 자식을 먼저 잡아야 한다.
예외 한 번에 잡기
다음과 같이 |
를 사용해서 여러 예외를 한번에 잡을 수 있다.
try {
client.connect();
client.send(data);
} catch (ConnectExceptionV3 | SendExceptionV3 e) {
System.out.println("[연결 또는 전송 오류] 주소: , 메시지: " + e.getMessage());
} finally {
client.disconnect();
}
참고로 이 경우 각 예외들의 공통 부모의 기능만 사용할 수 있다. 여기서는 NetworkClientExceptionV3
의 기능만 사용할 수 있다.
실무 예외 처리 방안
시스템 오류 때문에 발생한 예외들은 대부분 예외를 잡아도 해결할 수 있는 것이 거의 없다.
예외를 잡아서 다시 호출을 시도해도 같은 오류가 반복될 뿐이다.
이런 경우 고객에게는 "현재 시스템에 문제가 있습니다."라는 오류 메시지를 보여주고, 만약 웹이라면 오류 페이지를 보여주면 된다. 내부 개발자가 문제 상황을 빠르게 인지할 수 있도록, 오류에 대한 로그를 남겨두거나 중요한 오류의 경우 알림을 보내줘야 한다.
체크 예외의 부담
체크 예외는 개발자가 실수로 놓칠 수 있는 예외들을 컴파일러가 체크해주기 때문에 오래전부터 많이 사용되었다.
그런데 앞서 설명한 것처럼 처리할 수 없는 예외가 많아지고, 또 프로그램이 점점 복잡해지면서 체크 예외를 사용하는 것이 점점 더 부담스러워졌다.
체크 예외 부담 - 시나리오
- 상대 네트워크 서버가 내려갔거나, 데이터베이스 서버에 문제가 발생한 경우
Service
에서 예외를 잡아도 복구할 수 없다. 예외를 던진다. - 수 많은 예외가 있는데, 모든 체크 예외를 다 던져줘야 한다.
- 계층이 더 많다면 중간에 모든 클래스에서 예외를 계속 밖으로 던지는 지저분한 코드가 더 많이 만들어진다.
throws Exception
개발자는 본인이 다룰 수 없는 수 많은 체크 예외 지옥에 빠지게 된다. throws Exception
을 사용하는 최악의 수를 두게 된다.
Exception
은 애플리케이션에서 일반적으로 다루는 모든 예외의 부모이다. 따라서 이렇게 한 줄만 넣으면 모든 예외를 다 던질 수 있다. 체크 예외의 최상위 타입인 Exception
을 던지게 되면 다른 체크 예외를 체크할 수 있는 기능이 무효화되고, 중요한 체크 예외를 다 놓치게 된다. 모든 예외를 다 던지기 때문에 체크 예외를 의도한 대로 사용하는 것이 아니다. 꼭 필요한 경우가 아니라면 이렇게 Exception
자체를 밖으로 던지는 것은 좋지 않은 방법이다.
언체크 예외를 사용한다면?
NetworkException
, DatabaseException
은 잡아도 복구할 수 없다. 언체크 예외이므로 이런 경우 무시하면 된다.
언체크 예외를 던지는 예시
class Service {
void sendMessage(String data) {
...
}
}
- 언체크 예외이므로
throws
를 선언하지 않아도 된다. - 사용하는 라이브러리가 늘어나서 언체크 예외가 늘어도 본인이 필요한 예외만 잡으면 되고,
throws
를 늘리지 않아도 된다.
예외 공통 처리
이렇게 처리할 수 없는 예외들은 중간에 여러곳에서 나누어 처리하기 보다는 예외를 공통으로 처리할 수 있는 곳을 만들어서 한 곳에서 해결하면 된다.
어차피 해결할 수 없는 예외들이기 때문에 이런 경우 고객에게는 "현재 시스템에 문제가 있습니다."라고 오류 메시지를 보여주고, 만약 웹이라면 오류 페이지를 보여주면 된다.
그리고 내부 개발자가 지금의 문제 상황을 빠르게 인지할 수 있도록, 오류에 대한 로그를 남겨두면 된다.
이런 부분은 공통 처리가 가능하다. (or 오류 사항을 개발자의 email, slack 이런데로 보내도록)
// 공통 예외 처리
private static void exceptionHandler(Exception e) {
// 공통 처리
System.out.println("사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다.");
System.out.println("==개발자용 디버깅 메시지==");
e.printStackTrace(System.out);
if (e instanceof SendExceptionV4 sendEx) {
System.out.println("[전송 오류] 전송데이터: " + sendEx.getSendData());
}
}
Exception
을 잡아서 지금까지 해결하지 못한 모든 예외를 여기서 공통으로 처리한다.Exception
을 잡으면 필요한 모든 예외를 잡을 수 있다.- 예외도 객체이므로 공통 처리 메서드인
exceptionHandler(e)
에 예외 객체를 전달한다.
exceptionHandler()
- 해결할 수 없는 예외가 발생하면 사용자에게는 시스템 내에 알 수 없는 문제가 발생했다고 알리는 것이 좋다.
- 사용자가 디테일한 오류 코드나 오류 상황까지 모두 이해할 필요는 없다. 예를 들어서 사용자는 데이터베이스 연결이 안되서 오류가 발생한 것인지, 네트워크에 문제가 있어서 오류가 발생한 것인지 알 필요는 없다.
- 개발자는 빨리 문제를 찾고 디버깅 할 수 있도록 오류 메시지를 남겨두어야 한다.
- 예외도 객체이므로 필요하면
instanceof
와 같이 예외 객체의 타입을 확인해서 별도의 추가 처리를 할 수 있다.
e.printStackTrace()
- 예외 메시지와 스택 트레이스를 출력할 수 있다.
- 이 기능을 사용하면 예외가 발생한 지점을 역으로 추적할 수 있다.
- 참고로 예제에서는
e.printStackTrace(System.out)
을 사용해서 표준 출력으로 보냈다. e.printStackTrace()
를 사용하면System.err
이라는 표준 오류에 결과를 출력한다.- IDE에서는
System.err
로 출력하면 출력 결과를 빨간색으로 보여준다. - 일반적으로 이 방법을 사용한다.
- IDE에서는
참고:
System.out
,System.err
둘다 결국 콘솔에 출력되지만, 서로 다른 흐름을 통해서 출력된다. 따라서 둘을 함께 사용하면 출력 순서를 보장하지 않는다. 출력 순서가 꼬여서 보일 수 있다.
참고: 실무에서는
System.out
이나System.err
을 통해 콘솔에 무언가를 출력하기 보다는, 주로 Slf4J, logback 같은 별도의 로그 라이브러리를 사용해서 콘솔과 특정 파일에 함께 결과를 출력한다. 그런데e.printStackTrace()
를 직접 호출하면 결과가 콘솔에만 출력된다. 이렇게 되면 서버에서 로그를 확인하기 어렵다. 서버에서는 파일로 로그를 확인해야 한다. 따라서 콘솔에 바로 결과를 출력하는e.printStackTrace()
는 잘 사용하지 않는다. 대신에 로그 라이브러리를 통해서 예외 스택 트레이스를 출력한다. 지금은 로그 라이브러리라는 것이 있다는 정도만 알아두자. 학습 단계에서는e.printStackTrace()
를 적극 사용해도 괜찮다.
try-with-resource
try
에서 외부 자원을 사용하고, try
가 끝나면 외부 자원을 반납하는 패턴이 반복되면서 자바에서는Try with resources
라는 편의 기능을 자바 7에서 도입했다. 이름 그대로 try
에서 자원을 함께 사용한다는 뜻이다. 여기서 자원은 try
가 끝나면 반드시 종료해서 반납해야 하는 외부 자원을 뜻한다.
이 기능을 사용하려면 먼저 AutoCloseable
인터페이스를 구현해야 한다.
package java.lang;
public interface AutoCloseable {
void close() throws Exception;
}
이 인터페이스를 구현하면 Try with resources
를 사용할 때 try
가 끝나는 시점에 close()
가 자동으로 호출된다.
그리고 다음과 같이 Try with resources
구문을 사용하면 된다.
try (Resource resource = new Resource()) {
// 리소스를 사용하는 코드
}
public class NetworkClientV5 implements AutoCloseable {
private final String address;
public boolean connectError;
public boolean sendError;
public NetworkClientV5(String address) {
this.address = address;
}
public void connect() {}
public void send(String data) {}
public void disconnect() {}
@Override
public void close() {
System.out.println("NetworkClientV5.close");
disconnect();
}
}
implements AutoCloseable
을 통해AutoCloseable
을 구현한다.close()
:AutoCloseable
인터페이스가 제공하는 이 메서드는try
가 끝나면 자동으로 호출된다. 종료 시점에 자원을 반납하는 방법을 여기에 정의하면 된다. 참고로 이 메서드에서 예외를 던지지는 않으므로 인터페이스의 메서드에 있는throws Exception
은 제거했다.
public class NetworkServiceV5 {
public void sendMessage(String data) {
String address = "http://example.com";
try (NetworkClientV5 client = new NetworkClientV5(address)) {
client.initError(data);
client.connect();
client.send(data);
} catch (Exception e) {
System.out.println("[예외 확인]: " + e.getMessage());
throw e;
}
}
}
Try with resources
구문은try
괄호 안에 사용할 자원을 명시한다.- 이 자원은
try
블럭이 끝나면 자동으로AutoCloseable.close()
를 호출해서 자원을 해제한다. - 참고로 여기서
catch
블럭 없이try
블럭만 있어도close()
는 호출된다. - 여기서
catch
블럭은 단순히 발생한 예외를 잡아서 예외 메시지를 출력하고, 잡은 예외를throw
를 사용해서 다시 밖으로 던진다.
Try with resources 장점
- 리소스 누수 방지
- 코드 간결성 및 가독성 향상
- 스코프 범위 한정
- 조금 더 빠른 자원 해제
프레임워크에서는?
체크 예외의 문제점 때문에 최근 라이브러리들은 대부분 런타임 예외를 기본으로 제공한다. 가장 유명한 스프링이나 JPA 같은 기술들도 대부분 언체크(런타임) 예외를 사용한다.
런타임 예외도 필요하면 잡을 수 있기 때문에 필요한 경우에는 잡아서 처리하고, 그렇지 않으면 자연스럽게 던지도록 둔다. 그리고 처리할 수 없는 예외는 예외를 공통으로 처리하는 부분을 만들어서 해결하면 된다.
'Programming Language > Java' 카테고리의 다른 글
[Java] 28. 제네릭 - Generic(2) (0) | 2025.01.13 |
---|---|
[Java] 27. 제네릭 - Generic(1) (0) | 2025.01.13 |
[Java] 25. 예외 처리 - 이론 (0) | 2025.01.13 |
[Java] 24. 중첩 클래스, 내부 클래스(2) (0) | 2025.01.13 |
[Java] 23. 중첩 클래스, 내부 클래스(1) (0) | 2025.01.13 |