실습을 위해 커스텀 Exception 클래스를 만들어보자.
package exception.ex2;
public class NetworkClientException2 extends Exception{
private String errorCode;
public NetworkClientException2(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
커스텀 예외 클래스 NetworkClientException2는 Exception을 상속하며, 멤버 변수로 errorCode를 가진다. 이 클래스는 errorCode를 반환하는 getErrorCode() 메서드와, errorCode를 생성하고 관련된 에러 메시지를 생성하는 생성자를 제공한다. 또한, super(message)를 통해 NetworkClientException2에서 발생한 메시지는 상위 클래스인 Exception의 detailMessage에 저장된다. 이는 NetworkClientException2 -> Exception -> Throwable 계층 구조를 통해 에러 메시지가 전달되는 방식이다.
package exception.ex2;
public class NetworkClientV2 {
private final String address;
private boolean connectError;
private boolean sendError;
public NetworkClientV2(String address) {
this.address = address;
}
public void connect() throws NetworkClientException2{
if (connectError) {
throw new NetworkClientException2("connectError", address + "서버 연결 실패");
}
System.out.println(address + " 서버 연결 성공");
}
public void send(String data) throws NetworkClientException2 {
if (sendError) {
throw new NetworkClientException2("sendError", "서버에 데이터 전송 실패 : "+ data);
}
System.out.println(address + " 서버에 데이터 전송: "+ data);
}
public void disconnect() {
System.out.println(address + " 서버 연결 해제");
}
public void initError(String data) {
if (data.contains("error1")) {
connectError = true;
}
if (data.contains("error2")) {
sendError = true;
}
}
}
NetworkClientV2는 요청자 역할을 수행한다. 이 요청자는 connect()와 send() 메서드를 수행할 수 있으며, connect()는 send()의 필요조건이다.
connect()와 send()는 생성자로 초기화되지 않은 boolean 멤버 변수인 connectError와 sendError를 가진다. 이 두 에러는 initError() 메서드를 통해 변경될 수 있다. initError()는 입력된 스트링 데이터 안에 error1이나 error2가 존재할 경우 작동하며, 해당 에러를 처리한다.
send()와 connect() 메서드는 initError()에 의해 connectError와 sendError가 true로 변경되고, 호출될 경우 throw new NetworkClientException2()로 예외 객체를 발생시킨다. 이 예외는 catch되지 않으며, send() 또는 connect()를 호출한 곳으로 던져진다. NetworkClientException2는 체크 예외이므로, 예외를 던질 때 throws를 사용하여 적절한 예외 클래스를 선언해야 한다.
NetworkClientV2는 요청 단에서의 기능을 제공하며, 기능 실패 시 예외를 발생시킨다.
package exception.ex2;
public class NetworkServiceV2_1 {
public void sendMessage(String data) throws NetworkClientExceptionV2 {
String address = "http://example.com";
NetworkClientV2 client = new NetworkClientV2(address);
client.initError(data);
client.connect();
client.send(data);
client.disconnect();
}
}
NetworkServiceV2_1은 클라이언트의 기능을 이용하여 기능 묶음을 수행하는 클래스이다. 이 클래스에는 하나의 기능 묶음 메서드인 sendMessage()가 존재한다. sendMessage()는 주소를 입력받아 연결 시도, 메시지 전송 시도, 그리고 연결 해제의 세 가지 동작을 순차적으로 수행한다.
먼저, 연결할 address를 생성자로 전달하고, NetworkClientV2 클래스의 인스턴스를 생성한 후, initError()를 통해 sendMessage()에 전달된 data에 포함된 글자에 대해 클라이언트의 boolean 멤버 변수를 설정한다. 그 후 connect, send, disconnect 메서드를 수행한다.
이 메서드(sendMessage())는 예외 발생 가능성이 있는 client.connect()와 client.send()를 포함하고 있으므로, 예외 처리가 필요하다. throws NetworkClientExceptionV2를 선언하여 예외가 발생하면, 해당 예외는 sendMessage()를 호출한 곳으로 던져지게 된다.
package exception.ex2;
import java.util.Scanner;
public class MainV2 {
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("프로그램을 정상 종료합니다.");
}
}
MainV2는 최상위 클래스이다. NetworkServiceV2_1(기능 묶음 수행 클래스)의 인스턴스를 생성하고, Scanner를 생성하여 콘솔로부터 입력을 받는다. while 루프는 input이 "exit"일 때만 종료된다. 사용자가 입력한 문자열을 scanner.nextLine()을 통해 input에 저장하고, 만약 "exit"이면 루프를 종료하고, 그렇지 않으면 networkService.sendMessage(input)을 실행한다.
sendMessage(input) 메서드는 initError(input)을 호출하며, 이 메서드는 input에 "error1"이나 "error2"가 포함되면 boolean 멤버 변수를 true로 변경한다. true로 설정되면, send()나 connect()에서 예외 객체를 발생시킨다.
따라서, MainV2 클래스에서 sendMessage()로 전달된 예외를 받을 가능성이 있다. sendMessage()는 예외를 처리하지 않고 모두 던져버리므로, MainV2 클래스에서도 예외를 던지고 있다. 이는 sendMessage()에서 발생한 예외가 MainV2에서 처리되지 않고 다시 던져지는 구조임을 의미한다.
만약 MainV2를 실행하고 error1을 출력한다면,
다음과 같은 결과가 나타난다. 이것은 스택 트레이스이며 예외를 추적하는 역할을 한다. MainV2에서도 예외를 던졌기 때문에 이러한 스택트레이스를 남기고 프로그램을 종료한다. 스택트레이스를 해석하면 다음과 같다.
결국 main() -> sendMessage() -> connect()로 추적이 가능하다. 즉 connect에서 처음 예외 객체가 생성된 것이다.
만약 기능묶음 수행클래스에서 예외를 던지지 않고 try-catch등을 이용해 NetworkClientException2를 처리한다면 이런 스택트레이스는 생성되지 않고 정상적으로 프로그래밍이 종료될 것이다.
정상흐름과 예외흐름은 정말로 분리되었는가? 구조를 깊이있게 구성했다보니 정말 잘 분리되었는지 헷갈릴 수 있다. 하지만 기능을 수행하는 클래스에서는 분명 코드가 깔끔해졌다.

connect에서 예외가 발생한다면, sendMessage의 로직에서 예외를 catch하지 않으면, send와 disconnect는 무시된 채 sendMessage를 호출한 곳으로 예외가 바로 전파된다.
만약 connect까지 정상적으로 수행되었고, send에서 예외가 발생했다면 disconnect는 수행되지 않는다.
여기서 중요한 점은, connect가 수행되었으므로 요청한 주소로 연결은 유지된 채, disconnect()가 호출되지 않았다는 것이다.
따라서, disconnect()는 예외가 발생하든, send가 잘 수행되든 항상 호출을 시도해야 한다. 만약 connect에서 예외가 발생하더라도 disconnect()가 호출되는 데 문제가 없다. (연결이 되지 않았다면, 단순히 연결을 해제하지 않으면 되므로).
finally를 통해 예외처리와 상관없이(밖으로 던지던지 처리하던지) 수행될 것을 넣어줄 수 있다.
try {
//정상 흐름
} catch {
//예외 흐름
} finally {
//반드시 호출해야 하는 마무리 흐름
}
즉 try에서 사용한 자원을 해제할 때 주로 사용된다.

만약 다음과 같은 계층을 만들어본다고 가정하자. NetworkClientExceptionV3를 상속받는 수 많은 자식 예외 클래스들이 존재한다고 가정해보자.
try {
client.connect();
client.send(data);
} catch (ConnectExceptionV3 e) {
System.out.println("[연결 오류] 주소: " + e.getAddress() + ", 메시지: " + e.getMessage());
} catch (SendExceptionV3 e) {
System.out.println("[전송 오류] 전송 데이터: " + e.getSendData() + ", 메시 지: " + e.getMessage());
} finally {
client.disconnect();
}
그렇다면 위와 같은 효과를 누릴 수 있다. 만약 부모인 NetworkClientExceptionV3 없이 100가지의 개별 예외가 존재한다면, 이를 처리하기 위해 catch문을 100개 작성해야 할 것이다. 그러나 다형성을 활용하면, 예를 들어 중요하지 않은 예외들을 한꺼번에 처리할 수 있다. 이렇게 하면 코드가 간결해지고, 예외 처리 로직을 효율적으로 관리할 수 있게 된다.
전송 오류가 중요하다고 가정한다면, 위와 같이 개별 예외를 따로 처리해주면 된다. 중요한 예외는 특정하게 다루고, 덜 중요한 예외는 부모 예외 클래스나 다형성을 활용하여 한 번에 처리할 수 있다. 이렇게 하면 중요한 예외는 명확하게 구분하여 처리할 수 있으며, 코드의 가독성도 유지할 수 있다.
아주 예전에는 체크 예외를 많이 사용했다. 당시에는 현재처럼 다양한 라이브러리와 복잡한 시스템이 존재하지 않았고, 프로그램의 규모도 작았기에 발생할 수 있는 예외도 상대적으로 적었다. 그때는 체크 예외를 통해 상세하게 예외를 처리할 여유가 있었으나, 현재는 너무 많은 외부 연결과 라이브러리가 존재하고, 실제로 대부분의 예외는 처리할 수 없는 예외가 많다. 예를 들어, 외부 서버와 통신할 때 서버가 닫혀 있으면, 우리 측에서 그 예외를 처리할 수 없다. 대부분의 예외가 이런 형태이다.
모든 예외를 체크하려면 throws에 선언해야 할 예외들이 넘쳐날 것이다. 그래서 현재는 대부분의 예외가 런타임(언체크) 예외로 처리되고 있다.
throws Exception하면 안 됨?안 된다.
Exception은 최상위 예외 클래스이다. 즉,throws Exception을 선언하면 코드는 짧아지고 예외를 던질 때 수정할 필요가 없어지지만, 특정 예외를 처리하려고 예외를 생성해도,throws Exception으로 인해 모든 예외가 던져지게 된다. 이런 방식은 코드에서 세부적인 예외 처리가 어려워지므로 바람직하지 않다.
따라서,throws를 사용하지 않고 언체크 예외를 주로 사용하고, 만약 잡을 수 있는 예외가 있다면catch등을 통해 처리하는 것이 좋다. 대부분의 경우 예외를 열어두고, 특정한 예외만 잡도록 한다.
예를 들어, 특정한 체크 예외인neededProcessException을 생성했다고 하자. 만약throws Exception으로 선언되어 있다면, 컴파일러는 경고를 주지 않는다. 하지만throws otherException만 적혀 있을 때는throws otherException, neededProcessException처럼 명시적으로 선언해야만 던질 수 있고, 그렇지 않으면 컴파일러가 경고를 줄 것이다. 반면,throws Exception을 사용하면 이러한 경고를 받을 수 없다.
예외는 한 군데로 몰아넣는다. 예를 들어보자.
//공통 예외 처리
private static void exceptionHandler(Exception e) {
//공통 처리
System.out.println("사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다.");
System.out.println("==개발자용 디버깅 메시지==");
e.printStackTrace(System.out); // 스택 트레이스 출력
//e.printStackTrace();
// System.err에 스택 트레이스 출력
//필요하면 예외 별로 별도의 추가 처리 가능
if (e instanceof SendExceptionV4 sendEx) {
System.out.println("[전송 오류] 전송 데이터: " + sendEx.getSendData());
}
}
위에서 생성한 Main에 exceptionHandler를 추가하여 catch를 통해 모든 error를 이 메서드로 넘겨준다. 그렇게 되면 사용자에게는 러프하게 에러메세지를 보내줄 수 있고 백엔드에는 개발자용 로그들을 남겨줄 수 있다. 사용자에게 상세한 예외정보를 주지않으면서, 개발자들은 이것을 보고 효과적으로 대응할 수 있도록 로그를 남겨줄 수 있다. 실제로 위와 같은 코드로 진행되지 않고 이런 것들을 위한 라이브러리가 사용된다.
finally를 사용하지 않고 try로만 finally처럼 운용할 수 있는 try-with-resources가 java7부터 도입되었다.
이를 사용하기 위해서는 AutoCloseable 인터페이스를 구현해야한다.
// in Client클래스
@Override
public void close() {
System.out.println("NetworkClientV5.close");
disconnect();
}
AutoCloseable를 구현하는 클래스는 Client클래스이다. Client클래스는 close()메서드를 오버라이딩을 통해 구현하면서 try문이 끝날 때 disconnect()를 하고 만들어진 객체가 close되는 것이다.
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 ()에 객체를 생성한다. 그리고 try문이 끝나면 client 객체도 사라진다. client가 가진 메서드인 close()에 의해 삭제되며, close()메서드 안에 존재하는 disconnect()는 수행된다.
즉 try 괄호 안에 자원이 할당되고 try가 끝나면 자원이 해제되는 것이다.
리소스 누수 방지: 모든 리소스가 제대로 닫히도록 보장한다. 실수로 finally 블록을 적지 않거나, finally 블럭 안에서 자원 해제 코드를 누락하는 문제들을 예방할 수 있다.코드 간결성 및 가독성 향상: 명시적인 close() 호출이 필요 없어 코드가 더 간결하고 읽기 쉬워진다.스코프 범위 한정: 예를 들어 리소스로 사용되는 client 변수의 스코프가 try 블럭 안으로 한정된다. 따라서 코드 유지보수가 더 쉬워진다.조금 더 빠른 자원 해제: 기존에는 try catch finally로 catch 이후에 자원을 반납했다. Try with resources 구분은 try 블럭이 끝나면 즉시 close() 를 호출한다.현재 활용되는 대부분의 예외는 언체크 예외이며, 필요한 경우에 이를 잡아서 처리하고(드문 상황), 그렇지 않으면 자연스럽게 던지도록 둔다(대부분). 던져지는 예외들은 특정한 한 곳에 모이게 설계하여 공통으로 처리하도록 한다.