예외 처리2 - 실습

박병욱·2025년 7월 1일

Java

목록 보기
21/38
post-thumbnail

🛫 예외 처리 도입

앞서 만든 프로그램은 반환 값을 사용해서 예외를 처리했기 때문에 여러 문제가 발생했다. 정상 흐름과 예외 처리 로직이 뒤섞여 코드를 한눈에 파악하기 어려웠고, 가장 중요한 정상 흐름 로직보다 예외를 처리하는 코드가 훨씬 많았다.

 

자바 예외 처리를 도입하면서 위 문제들을 단계적으로 개선해보자. 먼저 NetworkClientExceptionV2라는 체크 예외를 만들었다.

package exception.ex2;

public class NetworkClientExceptionV2 extends Exception {

    private String errorCode;

	// 예외 안에 오류 코드 보관
    public NetworkClientExceptionV2(String errorCode, String message) {
        super(message);
        this.errorCode = errorCode;
    }

    public String getErrorCode() {
        return errorCode;
    }
}

앞서 말했다시피 예외도 객체라고 했다. 따라서 필요한 필드와 메서드를 가질 수 있다. 이전에는 오류 코드를 반환 값으로 받아 어떤 오류가 발생한 것인지 구분한 반면, 지금은 어떤 종류의 오류가 발생했는지 구분하기 위해 예외 안에 오류 코드를 보관하도록 했다. 그리고 오류 메시지(message)에 어떤 오류가 발생했는지 개발자가 보고 이해할 수 있도록 설명을 담아뒀다. 참고로 오류 메시지는 상위 클래스인 Throwable에서 기본으로 제공하는 기능을 사용한다.

 

package exception.ex2;

public class NetworkClientV2 {

    private final String address;
    public boolean connectError;
    public boolean sendError;

    public NetworkClientV2(String address) {
        this.address = address;
    }

	// 오류 코드를 반환하지 않고 예외를 던짐
    public void connect() throws NetworkClientExceptionV2 {
        if (connectError) {
            throw new NetworkClientExceptionV2("connectError...", address + " 서버 연결 실패!");
        }

        System.out.println(address + " 서버가 성공적으로 연결되었습니다.");
    }

	// 오류 코드를 반환하지 않고 예외를 던짐
    public void send(String data) throws NetworkClientExceptionV2 {
        if (sendError) {
            throw new NetworkClientExceptionV2("sendError...", address + " 서버에 데이터 전송 실패: " + 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;
        }
    }
}

위의 코드와 같이, 오류가 발생했을 때 오류 코드를 리턴하는 것이 아니라 바로 예외를 던지도록 처리했다. 이전에는 반환 값으로 코드가 성공적으로 수행됐는지 아닌지를 확인해야 했지만, 지금은 예외 처리 덕분에 메서드가 정상 종료된다면 성공인 것이고, 예외가 던져지면 예외를 통해 실패했다는 것을 알 수 있는 것이다.

 

정리하자면, 예외가 발생하면 예외 객체를 만들고 그곳에 오류 메시지와 오류 코드를 담아두는 것이다. 그리고 그 예외 객체를 throw를 통해 던지는 것이다.

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();
    }
}

그래서 위의 코드를 보다시피, throws NetworkClientExceptionV2를 반드시 붙여줘야 한다. NetworkClientExceptionV2라는 체크 예외를 던지는데 해결할 수 있다면 잡아서 처리하거나, 밖으로 던져야 하는 것이다.

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("프로그램을 정상적으로 종료합니다.");
    }
}

throws NetworkClientExceptionV2를 붙여줘야 컴파일 오류가 발생하지 않는다. 아까 NetworkServiceV2_1에서 예외가 터지면 밖으로 던지도록 했는데, 그럼 main()에서 다시 잡거나 던져야 한다. 일단 다 던져보자.

 

<정상 실행 결과>

 

<예외 발생 결과>

보다시피, “error1” 이 입력되면 연결 실패가 발생한다. 현재 모든 곳에서 예외를 잡아서 처리하지 않았기 때문에 main() 밖으로 던져진 상황이다. main() 메서드 밖으로 던져지면 예외 메시지와 예외를 추적할 수 있는 스택 트레이스를 출력하고 프로그램이 종료된다.

이번엔 “error2”가 발생했을 경우다. 마찬가지로 그 어느 곳에서도 예외를 잡아서 처리하지 않았기 때문에 main() 밖으로 던져졌다.

 

이렇게 예외 처리를 도입하면서 정상 흐름이 아주 깔끔하게 보이지만, 어차피 예외가 발생하는 경우에 밖으로 다 던지고 있기 때문에 여전히 부족하다. 그리고 사용 후에 disconnect() 메서드를 호출해서 연결을 해제해야 하지만 그러지도 못하고 있는 상황이다.


🧑🏻‍🔧 예외 복구

이제 폭탄을 발견하면 폭탄을 해제하도록 처리해보자.

package exception.ex2;

public class NetworkServiceV2_2 {

    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();
    }
}

예외가 발생할 가능성이 있는 connect() 메서드와 send() 메서드에 try-catch를 사용해서 NetworkClientExceptionV2 예외를 잡도록 처리했다. 예외를 잡으면 오류 메시지를 출력하고, return을 사용해서 sendMessage() 메서드를 정상적으로 빠져 나가도록 했다.

 

전송할 데이터: hello
http://example.com 서버가 성공적으로 연결되었습니다.
http://example.com 서버에 데이터 전송: hello
http://example.com 서버 연결이 해제되었습니다.

전송할 데이터: error1
[오류] 코드: connectError..., 메시지: http://example.com 서버 연결 실패!

전송할 데이터: error2
http://example.com 서버가 성공적으로 연결되었습니다.
[오류] 코드: sendError..., 메시지: http://example.com 서버에 데이터 전송 실패: error2

전송할 데이터: exit
프로그램을 정상적으로 종료합니다.

상황을 정리해보면, 예외를 잡아서 처리하도록 했다. 예외가 발생해도 다시 정상 흐름으로 복귀할 수 있고, 프로그램도 계속 수행할 수 있다. 하지만, 여전히 정상 흐름과 예외 처리 로직이 혼잡되어 있기 때문에 코드가 길어질 경우, 한눈에 파악하기 어려울 가능성이 높다. 그리고 마지막에 disconnect()도 호출되지 않고 있다.


♻️ 정상, 예외 흐름 분리

try-catch를 제대로 활용해서 정상 흐름과 예외 처리 로직이 섞여 있는 문제를 해결해보도록 하자.

package exception.ex2;

public class NetworkServiceV2_3 {

    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());
        }
    }
}

정상 흐름을 모두 try문 안으로 때려 박았다. 그리고 예외 부분은 모두 catch문에서 해결하도록 했다. 정상 흐름과 예외 흐름이 명확히 분리된 것 같다.

 

전송할 데이터: hello
http://example.com 서버가 성공적으로 연결되었습니다.
http://example.com 서버에 데이터 전송: hello
http://example.com 서버 연결이 해제되었습니다.

전송할 데이터: error1
[오류] 코드: connectError..., 메시지: http://example.com 서버 연결 실패!

전송할 데이터: error2
http://example.com 서버가 성공적으로 연결되었습니다.
[오류] 코드: sendError..., 메시지: http://example.com 서버에 데이터 전송 실패: error2

전송할 데이터: exit
프로그램을 정상적으로 종료합니다.

 

하지만… 여전히 성공적으로 로직이 수행되고 나서 disconnect() 메서드가 호출되지 않고 있다. 알다시피 외부 연결과 같은 자바 외부의 자원은 자동으로 해제되지 않는다고 했다. 따라서 자원을 사용한 후에는 반드시 연결을 해제해서 반납해야 하는 것이다. 그럼 만약 예외가 발생했을 때도 disconnect()를 반드시 호출하려면 어떻게 해야 할까?


⏎ 리소스 반환 문제

아래와 같이 생각해보자.

package exception.ex2;

public class NetworkServiceV2_4 {

    public void sendMessage(String data) {

        String address = "http://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());
        }

        // 정상 흐름의 마지막에 disconnect()를 호출한다면...?
        client.disconnect();
    }
}

/*
전송할 데이터: hello
http://example.com 서버가 성공적으로 연결되었습니다.
http://example.com 서버에 데이터 전송: hello
http://example.com 서버 연결이 해제되었습니다.

전송할 데이터: error1
[오류] 코드: connectError..., 메시지: http://example.com 서버 연결 실패!
http://example.com 서버 연결이 해제되었습니다.

전송할 데이터: error2
http://example.com 서버가 성공적으로 연결되었습니다.
[오류] 코드: sendError..., 메시지: http://example.com 서버에 데이터 전송 실패: error2
http://example.com 서버 연결이 해제되었습니다.

전송할 데이터: exit
프로그램을 정상적으로 종료합니다.
*/

코드를 실행해보면, 오류가 발생해도 서버 연결이 정상적으로 해제되는 것처럼 보인다. 결론부터 말하면, 이런 식으로 처리하는 것은 매우 치명적이다. 바로 catch문이 잡을 수 없는 예외가 발생한다면 처리할 수 없기 때문이다. 확인해보기 위해 잠깐 RuntimeException이 터지도록 해보자.

위 이미지와 같이 catch에서 NetworkClientExceptionV2는 잡을 수 있지만 새로 등장한 알 수 없는 예외(RuntimeException)는 잡을 수 없다.

 

처리 흐름을 자세하게 분석해보자면,

  • error1: client.connect()에서 NetworkClientExceptionV2 예외가 발생했기 때문에 바로 catch 문으로 이동해서 정상 흐름을 이어가는 것을 볼 수 있다.

  • error2: client.send()에서 생판 처음 보는 RuntimeException이라는 언체크 예외가 터지면 지금 예외를 잡지도 않고 컴파일러가 체크해주지 않고, 냅다 밖으로 집어 던진다. 따라서 client.disconnect()는 호출되지 않는 문제가 발생하는 것이다.

 

사용 후에 반드시 disconnect() 메서드를 호출하는 것은 그리 쉽지 않다. 왜냐하면 정상적인 상황과 예외 상황, 그리고 알 수 없는 예외가 밖으로 던져지는 상황을 모두 고려해야 하기 때문이다. 새로운 해결책이 필요해보인다.


🔚 finally

자바에서는 그 어떤 경우라도 반드시 호출되는 finally라는 기능을 제공한다. 기본적인 구조는 아래와 같다.

try {
	// 정상 흐름
} catch {
	// 예외 흐름
} finally {
	// 반드시 호출해야 하는 마무리 흐름
}

try를 시작하기만 하면, finally 코드 블록은 어떤 경우라도 반드시 호출한다. 심지어 try, catch 안에서 잡을 수 없는 예외가 발생해도 finally는 반드시 호출된다. 따라서 finally 블록은 주로 try에서 사용한 자원을 해제할 때 사용된다.

 

package exception.ex2;

public class NetworkServiceV2_5 {

    public void sendMessage(String data) {

        String address = "http://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, catch 문 안에서 처리할 수 없는 예외가 발생해도 finally는 반드시 호출되는 것을 볼 수 있다. 처리할 수 없는 예외(RuntimeException)가 발생했다고 가정하고 확인해보자.

예외가 밖으로 던져져도 서버 연결이 정상적으로 해제되는 것을 볼 수 있다. 위와 같이 잡을 수 없는 예외가 발생한다해도 finally가 먼저 호출되고 예외를 밖으로 던진다. 아주 좋다…

 

🤔 참고 사항

catch 문 없이 그냥 try-finally만 사용할 수도 있다. 예외를 직접 잡아서 처리할 일이 없다면 이런 식으로 사용해도 무방하다. 여전히 예외가 밖으로 던져진다고 해도 finally 호출이 보장된다.

 

최종적으로 정리하도록 하자.

자바 예외 처리는 try-catch-finally 구조를 사용해서 쉽게 처리 가능하다. 따라서 정상 흐름과 예외 흐름을 분리해서, 코드를 한눈에 파악할 수 있고, 사용한 자원을 항상 반환할 수 있도록 보장한다.


🏁 예외 계층 - 시작

예외를 계층화해서 다양하게 만든다면, 더 알차게 예외를 처리할 수 있다.

위와 같은 구조로 예외를 처리해보자. NetworkClient에서 발생하는 모든 예외는 NetworkClientExceptionV3의 자식이다. 연결 실패 시 발생하는 예외인 ConnectExceptionV3가 터지면 내부 연결에 실패한 서버의 주소(address)를 담도록 하고, 전송 실패 시 발생하는 예외인 SendExceptionV3가 터지면 내부에 전송을 실패한 데이터(sendData)를 담도록 했다.

 

이런 식으로 예외를 계층화화면…

  • NetworkClientExceptionV3 예외를 잡으면 그 하위인 ConnectExceptionV3, SendExceptionV3 까지 싹 잡아낼 수 있다.

  • 특정 예외를 잡아서 처리하고 싶다면 ConnectExceptionV3, SendExceptionV3와 같은 하위 예외를 잡아서 처리할 수 있다.

 

package exception.ex3.exception;

public class NetworkClientExceptionV3 extends Exception {

    public NetworkClientExceptionV3(String message) {
        super(message);
    }
}
package exception.ex3.exception;

public class ConnectExceptionV3 extends NetworkClientExceptionV3 {

    // 연결에 실패한 서버의 주소
    private final String address;

    public ConnectExceptionV3(String address, String message) {
        super(message);
        this.address = address;
    }

    public String getAddress() {
        return address;
    }
}
package exception.ex3.exception;

public class SendExceptionV3 extends NetworkClientExceptionV3 {

    // 내부에 전송을 실패한 데이터
    private final String sendData;

    public SendExceptionV3(String sendData, String message) {
        super(message);
        this.sendData = sendData;
    }

    public String getSendData() {
        return sendData;
    }
}
package exception.ex3;

import exception.ex3.exception.ConnectExceptionV3;
import exception.ex3.exception.SendExceptionV3;

public class NetworkClientV3 {

    private final String address;
    public boolean connectError;
    public boolean sendError;

    public NetworkClientV3(String address) {
        this.address = address;
    }

	// 연결 관련 오류가 발생하면 ConnectExceptionV3 예외를 던진다.
    public void connect() throws ConnectExceptionV3 {
        if (connectError) {
            throw new ConnectExceptionV3(address, address + " 서버 연결 실패!");
        }

        System.out.println(address + " 서버가 성공적으로 연결되었습니다.");
    }
    
	// 전송 관련 오류가 발생하면 SendExceptionV3 예외를 던진다.
    public void send(String data) throws SendExceptionV3 {
        if (sendError) {
            throw new SendExceptionV3(data, address + " 서버에 데이터 전송 실패: " + 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;
        }
    }
}
package exception.ex3;

import exception.ex3.exception.ConnectExceptionV3;
import exception.ex3.exception.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() 
            + ", 메시지: " + e.getMessage());
        } catch (SendExceptionV3 e) {
            System.out.println("[전송 오류] 전송 데이터: " + e.getSendData() 
            + ", 메시지: " + e.getMessage());
        } finally {
            client.disconnect();
        }
    }

}

위처럼 예외 클래스를 각각의 예외 상황에 맞춰서 만들면, 각 필요에 맞는 예외를 잡아서 처리할 수 있다. 보다시피 e.getAddress(), e.getSendData()와 같이 각각의 예외 클래스가 가지는 고유의 기능을 활용할 수 있다.

 

package exception.ex3;

import java.util.Scanner;

public class MainV3 {
    public static void main(String[] args) {

        NetworkServiceV3_1 networkService = new NetworkServiceV3_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("프로그램을 정상적으로 종료합니다.");
    }
}

/*
전송할 데이터: hello
http://example.com 서버가 성공적으로 연결되었습니다.
http://example.com 서버에 데이터 전송: hello
http://example.com 서버 연결이 해제되었습니다.

전송할 데이터: error1
[연결 오류] 주소: http://example.com, 메시지: http://example.com 서버 연결 실패!
http://example.com 서버 연결이 해제되었습니다.

전송할 데이터: error2
http://example.com 서버가 성공적으로 연결되었습니다.
[전송 오류] 전송 데이터: error2, 메시지: http://example.com 서버에 데이터 전송 실패: error2
http://example.com 서버 연결이 해제되었습니다.

전송할 데이터: exit
프로그램을 정상적으로 종료합니다.
*/

출력 결과를 확인해보면, 연결 오류(ConnectExceptionV3)와 전송 오류(SendExceptionV3)가 발생한 각각의 경우에 대해 출력된 오류 메시지가 다른 것을 확인할 수 있다.


🖋️ 예외 계층 - 활용

만약 NetworkClientV3가 수많은 예외를 발생시킨다고 한다면 어떨까? 그럼 모든 예외를 일일이 다 잡아서 처리해야 할까? 당연히 그렇지 않을 것이다. 일단 연결 오류가 아주 심각한 오류라고 가정해보자. 그리고 나머지 예외는 단순하게 출력해도 된다고 해보자.

package exception.ex3;

import exception.ex3.exception.ConnectExceptionV3;
import exception.ex3.exception.NetworkClientExceptionV3;

public class NetworkServiceV3_2 {

    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() + ", 메시지: " + e.getMessage());
        } catch (NetworkClientExceptionV3 e) {
            System.out.println("[네트워크 오류] 메시지: " + e.getMessage());
        } catch (Exception e) {
            System.out.println("[알 수 없는 오류] 메시지: " + e.getMessage());
        } finally {
            client.disconnect();
        }
    }

}

NetworkClientExceptionV3SendExceptionV3의 부모이다. 알다시피 부모는 자식을 담을 수 있으므로 NetworkClientExceptionV3을 잡으면 SendExceptionV3도 잡을 수 있는 것이다. 근데 참고로, 만약 예외 처리하는 부분의 순서를 바꾸면 될까? 결론은 처리하는 순서는 예외가 발생했을 때 catch를 순서대로 실행하므로 더 디테일한 자식을 먼저 잡아야 한다.

알 수 없는 오류도 아래처럼 처리 가능하다.

번외로 여러 예외를 한번에 잡을 수 있는 방법도 있다.

try {
  client.connect();
  client.send(data);
} catch (ConnectExceptionV3 | SendExceptionV3 e) {
  System.out.println("[연결 또는 전송 오류] 주소: , 메시지: " + e.getMessage());
} finally {
  client.disconnect();
}

참고로 이 경우에는 각 예외들의 공통 부모 기능만 사용 가능하다. 여기서는 NetworkClientExceptionV3의 기능만 사용할 수 있다.

 

정리하자면, 예외를 계층화하고 다양하게 구성한다면 더 세밀한 동작들을 깔끔하게 처리할 수 있다. 그리고 특정 분류의 공통 예외들도 한번에 catch로 잡아서 처리할 수 있다.


🎭 실무 예외 처리 방안

예외 중에서도 처리할 수 없는 예외가 있다. 가령, 애플리케이션에서 연결 오류, DB 접속 실패와 같은 시스템 오류 때문에 발생한 예외들은 대부분 예외를 잡는다 해도 해결할 수 있는 것이 거의 없다고 봐야 한다. 설사 예외를 잡는다 하더라도 다시 호출을 시도해서 같은 오류가 반복될 것이다. 이런 경우에는, “현재 시스템에 문제가 있습니다.” 라는 오류 메시지를 보여주고, 만약 웹이라면 오류 페이지를 보여주면 된다. 그리고 개발자가 문제 상황을 빠르게 인지할 수 있도록, 오류 로그를 남겨야 한다.

원래 개발자는 체크 예외를 많이 사용해왔지만, 위와 같이 처리할 수 없는 예외가 많아지면서, 또 프로그램이 점점 복잡해지면서 체크 예외를 사용하는 것이 점점 부담스러워지기 시작했다.

 

일단 체크 예외를 사용했을 때, 어떤 문제가 발생하는지 상황적으로 알아보자. 아래의 가상 시나리오를 참고하자.

✅ 체크 예외 사용 시나리오

  • 실무에서는 수많은 라이브러리를 사용하고, 또 다양한 외부 시스템과 연동한다.
  • 사용하는 각각의 클래스들이 자신만의 예외를 모두 체크 예외로 만들어서 전달한다고 가정하겠다.

이 경우, Service는 호출하는 곳에서 던지는 체크 예외들을 처리해야 한다. 만약 처리할 수 없다면 밖으로 내다 던져야 한다.

 

모든 체크 예외를 잡아서 처리한다면…

try {
} catch (NetworkException) {...}
} catch (DatabaseException) {...}
} catch (XxxException) {...}

하지만, 위에서 말했다시피 시스템 오류가 발생한 경우 Service에서 예외를 잡아도 복구할 수 없다. 어차피 본인이 처리할 수 없는 예외들이기 때문에 밖으로 던지는 것이 더 현명한 것이다.

 

class Service {
		void sendMessage(String data) throws NetworkException, 
		DatabaseException, ... {
					...
		}
}

위와 같은 식으로 모든 체크 예외를 하나씩 다 밖으로 던져야 한다. 라이브러리가 늘어날수록 다뤄야 하는 예외들도 많아질 것이다. 근데 이걸 일일이 다 throws ~ 어쩌구 하면서 다 붙인다? 말도 안 되는 소리다. 그리고 심지어 중간에 어떤 클래스가 끼어든다고 해보자.

이 경우, Facade 클래스에서도 이런 예외들을 복구할 수 없다. Facade 클래스도 예외를 밖으로 던져야 한다. 결국 중간에 하나 더 낑겨넣어서 상황을 더 복잡하게 만든 꼴이다. 아래처럼 throws로 발견한 모든 예외를 다 밖으로 던지는 것이다.

class Facade {
	void send() throws NetworkException, DatabaseException, ...
}

class Service {
	void sendMessage(String data) throws NetworkException, DatabaseException, 
		...
}

 

이렇게 개발자는 처리할 수도 없는 수많은 체크 예외들에 포위된다. 계속 던지는 코드만 복붙하다가 그게 1개, 2개도 아니어서 결국… 하지 말아야 할 짓을 하고 만다.

// 어차피 처리할 수 없는 예외들... 부모 타입으로다가 싹 다 쳐내면 안 되나?
class Facade {
		void send() throws Exception
}

class Service {
		void sendMessage(String data) throws Exception
}

위와 같이, Exception은 애플리케이션에서 일반적으로 다루는 모든 예외의 부모이기 때문에 모든 예외를 다 던질 수 있다. 이렇게 NetworkException, DatabaseException도 함께 던지게 된다. 그리고 이후에 예외가 추가되더라도 throws Exception은 변경하지 않고 그대로 유지할 수 있다. 깔끔하게 예외를 처리하는 것처럼 보이지만, 아주 치명적인 문제가 있다.

 

🤔 throws Exception의 문제

말했다시피 Exception으로 처리하면 모든 예외 사항들을 밖으로 던진다. 그러면 다른 체크 예외를 체크할 수 있는 기능이 무효화되고, 중요한 체크 예외를 다 놓치게 된다. 중간에 중요한 체크 예외가 발생해도 컴파일러는 Exception을 던지고 있기 때문에 문법에 맞다고 판단해서 컴파일 오류를 발생시키지 않는다. 이렇게 되면 모든 예외를 던지기 때문에 체크 예외를 의도대로 사용하는 것이 아니다. 따라서 꼭 필요한 경우가 아니면, Exception 자체를 밖으로 던지는 것은 자제하도록 하자.

 

지금까지의 체크 예외를 사용할 때의 문제점을 정리해보면, 예외를 잡아서 복구할 수 있는 예외보다 복구할 수 없는 예외가 많다는 점과 처리할 수 없는 체크 예외들의 경우에 throws에 던질 대상을 일일이 명시해야 한다는 점이다. 개발자는 이런 자질구레한 정성을 쏟고 싶지 않을 것이다. 본인이 해결할 수 있는 예외만 잡아서 처리하도록 하고, 해결할 수 없는 예외는 신경쓰지 않는 것이 더 나을 것이다.

 

❌ 언체크(런타임) 예외 사용 시나리오

이번에는 Service에서 호출하는 클래스들이 언체크(런타임) 예외를 전달한다고 해보자.

Service에서 가끔 중요한 예외는 내가 처리하고, 내가 해결할 수 없는 건 그냥 두면 알아서 밖으로 나갈 것이다. 나간 예외들은 하나로 뭉쳐서 “공통 예외 처리” 로 처리하는 것이다.

 

예시를 한번 보자.

class Service {
		void sendMessage(String data) {
				... 
		}
}

언체크 예외이므로 throws를 선언하지 않아도 되고, 사용하는 라이브러리가 늘어나서 언체크 예외가 늘어난다 하더라도 내가 필요한 예외만 잡으면 되기 때문에 throws를 늘리지 않아도 된다.

 

아무튼 이처럼 처리할 수 없는 예외들만 모아서 공통으로 처리할 수 있는 만드는 것이 더 낫다. 어차피 해결할 수 없는 예외들의 경우에 고객에게 현재 시스템에 문제가 있다고 오류 메시지만 보여주고, 내부 개발자들이 빠르게 오류를 인지할 수 있도록 오류 로그만 남겨주면 된다는 소리다.

 

그럼 본격적으로 언체크 예외로 만들고, 또 해결할 수 없는 예외들을 공통으로 처리해보도록 하자.

일단 NetworkClientExceptionV4는 언체크 예외인 RuntimeException을 상속받고 있으므로 이제 NetworkClientExceptionV4와 자식(ConnectExceptionV4, SendExceptionV4)은 모두 언체크(런타임) 예외가 된다.

package exception.ex4.exception;

public class NetworkClientExceptionV4 extends RuntimeException {

    public NetworkClientExceptionV4(String message) {
        super(message);
    }
}
package exception.ex4.exception;

public class ConnectExceptionV4 extends NetworkClientExceptionV4 {

    private final String address;

    public ConnectExceptionV4(String address, String message) {
        super(message);
        this.address = address;
    }

    public String getAddress() {
        return address;
    }
}
package exception.ex4.exception;

public class SendExceptionV4 extends NetworkClientExceptionV4 {
    
    private final String sendData;

    public SendExceptionV4(String sendData, String message) {
        super(message);
        this.sendData = sendData;
    }

    public String getSendData() {
        return sendData;
    }
}
package exception.ex4;

import exception.ex4.exception.ConnectExceptionV4;
import exception.ex4.exception.SendExceptionV4;

public class NetworkClientV4 {

    private final String address;
    public boolean connectError;
    public boolean sendError;

    public NetworkClientV4(String address) {
        this.address = address;
    }

	// 언체크 예외이므로 throws를 사용하지 않는다.
    public void connect() {
        if (connectError) {
            throw new ConnectExceptionV4(address, address + " 서버 연결 실패!");
        }

        System.out.println(address + " 서버가 성공적으로 연결되었습니다.");
    }

	// 언체크 예외이므로 throws를 사용하지 않는다.
    public void send(String data) {
        if (sendError) {
            throw new SendExceptionV4(data, address + " 서버에 데이터 전송 실패: " + data);
//            throw new RuntimeException("ex");
        }

        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;
        }
    }
}
package exception.ex4;

public class NetworkServiceV4 {

    public void sendMessage(String data) {

        String address = "http://example.com";

        NetworkClientV4 client = new NetworkClientV4(address);
        client.initError(data);

        try {
            client.connect();
            client.send(data);
        } finally {
            client.disconnect();
        }
    }

}

NetworkServiceV4는 발생하는 예외인 ConnectExceptionV4, SendExceptionV4를 잡아도 해당 오류들을 복구할 수 없으므로 예외를 밖으로 던진다. 사실 NetworkServiceV4 개발자 입장에서는 해당 예외들을 복구할 수 없다. 따라서 해당 예외들을 생각하지 않는 것이 더 나은 선택일 수 있다. 해결할 수 없는 예외들은 아래 MainV4에서 공통으로 처리하듯이 하면 된다. 이런 방식으로 NetworkServiceV4는 해결할 수 없는 예외보다는 본인 스스로의 코드에 더 집중할 수 있다.

package exception.ex4;

import exception.ex4.exception.SendExceptionV4;

import java.util.Scanner;

public class MainV4 {
    public static void main(String[] args) {

        NetworkServiceV4 networkService = new NetworkServiceV4();

        Scanner scanner = new Scanner(System.in);
        
        while (true) {
            System.out.print("전송할 데이터: ");
            String input = scanner.nextLine();
            if (input.equals("exit")) {
                break;
            }

            try {
                networkService.sendMessage(input);
            } catch (Exception e) {  // 모든 예외를 잡아서 처리
                exceptionHandler(e);
            }

            System.out.println();
        }

        System.out.println("프로그램을 정상적으로 종료합니다.");
    }

    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());
        }
    }
}

/*
전송할 데이터: error1
http://example.com 서버 연결이 해제되었습니다.
사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다!
==개발자용 디버깅 메시지==
exception.ex4.exception.ConnectExceptionV4: http://example.com 서버 연결 실패!
	at exception.ex4.NetworkClientV4.connect(NetworkClientV4.java:18)
	at exception.ex4.NetworkServiceV4.sendMessage(NetworkServiceV4.java:13)
	at exception.ex4.MainV4.main(MainV4.java:21)

전송할 데이터: error2
http://example.com 서버가 성공적으로 연결되었습니다.
http://example.com 서버 연결이 해제되었습니다.
사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다!
==개발자용 디버깅 메시지==
exception.ex4.exception.SendExceptionV4: http://example.com 서버에 데이터 전송 실패: error2
	at exception.ex4.NetworkClientV4.send(NetworkClientV4.java:26)
	at exception.ex4.NetworkServiceV4.sendMessage(NetworkServiceV4.java:14)
	at exception.ex4.MainV4.main(MainV4.java:21)
[전송 오류] 전송 데이터: error2

전송할 데이터:
*/

 

공통 예외 처리 부분을 조금 더 분석해보자면,

try {
    networkService.sendMessage(input);
} catch (Exception e) {  // 모든 예외를 잡아서 처리
    exceptionHandler(e);
}

Exception으로 잡아서 해결할 수 없었던 예외들을 여기서 공통으로 처리하는 것이다. 그리고 예외도 객체이므로 공통 처리 메서드인 exceptionHandler(e)에 예외 객체를 전달한다.

 

해결할 수 없는 예외가 발생하면 exceptionHandler()를 통해 사용자에게 시스템 내에 알 수 없는 문제가 발생했다고 알리는 것이 좋다. 예외도 객체이므로 필요하다면 instanceof와 같이 예외 객체의 타입을 확인해서 별도의 추가 처리를 할 수도 있다.


🔨 try-with-resources

앞서 말했듯이 외부 자원을 사용하고 나면 반드시 해제해야 한다. 따라서 finally문을 사용해야 한다고 했었다. 이런 패턴이 반복되면서 자바 7에서는 try-with-resources라는 편의 기능을 제공하기 시작했다. 이건 이름 그대로 try에서 외부 자원을 함께 사용한다는 뜻이다.

 

이 기능을 사용하려면 먼저 AutoCloseable 인터페이스를 구현해야 한다.

package java.lang;

public interface AutoCloseable {
		void close() throws Exception;
}

이 인터페이스를 구현하면 try-with-resources를 사용할 때, try가 끝나는 시점에 close()를 자동으로 호출된다. 그리고 나서 try-with-resources 구문을 사용하면 된다. 실제 구현 코드를 만들어보자.

package exception.ex4;

import exception.ex4.exception.ConnectExceptionV4;
import exception.ex4.exception.SendExceptionV4;

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() {
        if (connectError) {
            throw new ConnectExceptionV4(address, address + " 서버 연결 실패!");
        }

        System.out.println(address + " 서버가 성공적으로 연결되었습니다.");
    }

    public void send(String data) {
        if (sendError) {
            throw new SendExceptionV4(data, address + " 서버에 데이터 전송 실패: " + data);
//            throw new RuntimeException("ex");
        }

        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;
        }
    }

    @Override
    public void close() {
        System.out.println("NetworkClientV5.close");
        disconnect();
    }
}

implements AutoCloseable를 통해 AutoCloseable 인터페이스를 구현했다. 그럼 close() 메서드를 통해 try가 끝나면 자동으로 호출된다. 따라서 종료 시점에 자원을 반납하는 방법으로 여기에 정의하면 된다.

package exception.ex4;

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 괄호 안에 사용할 자원(NetworkClientV5 client = new NetworkClientV5(address)))을 명시한다. 이 자원은 try 블록이 끝나서 나갈 때 AutoCloseable.close()를 호출해서 자원을 해제한다. 여기서 catch 블록은 단순히 발생한 예외를 잡아서 예외 메시지를 출력하고, 잡은 예외를 throw를 사용해서 다시 밖으로 던진다.

 

package exception.ex4;

import exception.ex4.exception.SendExceptionV4;

import java.util.Scanner;

public class MainV4 {
    public static void main(String[] args) {

//        NetworkServiceV4 networkService = new NetworkServiceV4();
        NetworkServiceV5 networkService = new NetworkServiceV5();

        Scanner scanner = new Scanner(System.in);
        while (true) {
            System.out.print("전송할 데이터: ");
            String input = scanner.nextLine();
            if (input.equals("exit")) {
                break;
            }

            try {
                networkService.sendMessage(input);
            } catch (Exception e) {
                exceptionHandler(e);
            }

            System.out.println();
        }

        System.out.println("프로그램을 정상적으로 종료합니다.");
    }

    // 예외에 대한 정보를 받아서 어떻게 처리할 것인가? (공통 예외 처리)
    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());
        }
    }
}

/*
전송할 데이터: hello
http://example.com 서버가 성공적으로 연결되었습니다.
http://example.com 서버에 데이터 전송: hello
NetworkClientV5.close
http://example.com 서버 연결이 해제되었습니다.

전송할 데이터: error1
NetworkClientV5.close
http://example.com 서버 연결이 해제되었습니다.
[예외 확인]: http://example.com 서버 연결 실패!
사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다!
==개발자용 디버깅 메시지==
exception.ex4.exception.ConnectExceptionV4: http://example.com 서버 연결 실패!
	at exception.ex4.NetworkClientV5.connect(NetworkClientV5.java:18)
	at exception.ex4.NetworkServiceV5.sendMessage(NetworkServiceV5.java:11)
	at exception.ex4.MainV4.main(MainV4.java:22)

전송할 데이터: error2
http://example.com 서버가 성공적으로 연결되었습니다.
NetworkClientV5.close
http://example.com 서버 연결이 해제되었습니다.
[예외 확인]: http://example.com 서버에 데이터 전송 실패: error2
사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다!
==개발자용 디버깅 메시지==
exception.ex4.exception.SendExceptionV4: http://example.com 서버에 데이터 전송 실패: error2
	at exception.ex4.NetworkClientV5.send(NetworkClientV5.java:26)
	at exception.ex4.NetworkServiceV5.sendMessage(NetworkServiceV5.java:12)
	at exception.ex4.MainV4.main(MainV4.java:22)
[전송 오류] 전송 데이터: error2

전송할 데이터: exit
프로그램을 정상적으로 종료합니다.
*/

 

Try with resources의 장점을 정리하자.

  • 리소스 누수 방지: 모든 리소스가 제대로 닫히도록 보장한다. 실수로 finally 블록을 적지 않거나, finally 블록 안에서 자원 해제 코드를 누락하는 문제들을 예방할 수 있다.
  • 코드 간결성 및 가독성 향상: 명시적인 close() 호출이 필요 없기 때문에 코드가 더 간결하고 파악하기 쉽다.
  • 스코프 범위 한정: 리소스로 사용되는 client 변수의 스코프가 try 블록 안으로 한정된다. 따라서 유지보수가 더 쉬워진다.
  • 빠른 자원 해제: 기존에는 try-catch-finallycatch 이후에 자원을 반납했다. 하지만, Try with resources 구분은 try 블록이 끝나면 즉시 close()를 호출한다.
profile
도메인을 이해하는 백엔드 개발자(feat. OOP)

0개의 댓글