공부/Java

자바 예외처리 복습하기

Stair 2024. 9. 22. 17:09
반응형

예외처리가 필요한 이유를 알아보기 위해 다음과 같은 간단한 예제 프로그램을 만들었다.

 

이 코드는 사용자의 입력을 받고, 입력 받은 문자를 외부 서버에 전송하는 프로그램이다.

(실제 통신을 하고있다고 가정한다)

 

NetworkClient : 외부 서버와 연결하고, 데이터를 전송하고, 연결을 종료하는 기능을 제공한다.

NetworkService : NetworkClient를 사용해서 데이터를 전송한다. NetworkClient를 사용하려면, 연결, 전송, 연결 종료와 같은 복잡한 흐름을 제어해야 하는데, 이런 부분을 NetworkService가 담당한다.

Main : 사용자의 입력을 받는다.

 

--> 전체 흐름 : Main을 통해 사용자의 입력을 받으면, 사용자의 입력을 NetworkService에 전달한다. NetworkService는 NetworkClient를 사용해서 외부 서버에 연결하고, 데이터를 전송하고, 전송이 완료되면 연결을 종료한다.

public class NetworkClientV0 {

    private final String address;

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

    public String connect(){
        System.out.println(address + "서버 연결 성공");
        return "success";
    }

    public String send(String data){
        System.out.println(address + "서버에 데이터 전송: " + data);
        return "success";
    }

    public void disconnect(){
        System.out.println(address + " 서버 연결 해제");
    }
}

 

String address : 접속할 외부 서버 주소

connect() : 외부 서버에 연결한다.

send(String data) : 연결한 외부 서버에 데이터를 전송한다.

disconnect() : 외부 서버와 연결을 해제한다.

 

public class NetworkService {

    public void sendMessage(String data){
        String address = "https://www.example.com";
        NetworkClientV0 client = new NetworkClientV0(address);

        client.connect();
        client.send(data);
        client.disconnect();
    }
}

NetworkClient 사용 로직을 처리한다.

NetworkClient를 생성하고, 이때 접속할 외부 서버 주소도 함께 전달한다.

NetworkClient를 사용하는데 필요한 connect(), send(data), disconnect()순으로 호출을 진행한다.

 

다음은 main메서드 이다.

public class MainV0 {

    public static void main(String[] args) {
        NetworkService networkService = new NetworkService();
        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("프로그램을 정상 종료합니다.");
    }
}

 

전송할 문자를 Scanner를 통해 입력받고

send를 통해 문자를 전달한다.

 

 

 

위 코드에는 여러가지 문제점이 있다.

1. 외부 서버와 연결에 실패한다.(네트워크 DB 오류 등등)

2. 데이터 전송에 문제가 발생한다.

 

물론 실제 외부 서버와 통신을 하는것은 아니지만 이런 오류가 발생한다고 가정한다.

 

 

오류 상황을 시뮬레이션 하기 위해 사용자의 입력 값을 활용하자

연결 실패 : 사용자가 입력하는 문자에"error1"이 있으면 연결에 실패한다. 오류 코드는 connectError 이다

전송 실패 : 사용자가 입력하는 문자에"error2"가 있으면 데이터 전송에 실패한다. 오류 코드는 sendError 이다

public class NetworkClientV1 {

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

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

    public String connect(){
        if (connectError){
            System.out.println(address + "서버 연결 실패");
            return "connectError";
        }
        System.out.println(address + "서버 연결 성공");
        return "success";
    }

    public String send(String data){
        if(sendError){
            System.out.println(address + "서버에 데이터 전송 실패 : " + data);
            return "sendError";
        }
        System.out.println(address + "서버에 데이터 전송: " + data);
        return "success";
    }

    public void disconnect(){
        System.out.println(address + " 서버 연결 해제");
    }

    public void initError(String data){
        if (data.contains("error1")){
            connectError = true;
        }
        if (data.contains("error2")){
            sendError = true;
        }
    }
}

NetworkClientV1에는 connectError, sendError필드가 있 다.

connectError : 이 필드의 값이 true가 되면 연결에 실패하고, connectError 오류 코드를 반환한다.

sendError : 이 필드의 값이 true 가 되면 데이터 전송에 실패한다. 그리고 sendError 코드를 반환한다.

문제가 없으면 둘다 success 코드를 반환한다.

 

initError(String data)

이 메서드를 통해 connectError,sendError필드의 값을 true를 설정할 수 있다.

public class NetworkServiceV1_1 {

    public void sendMessage(String data){
        String address = "https://www.example.com";
        NetworkClientV1 client = new NetworkClientV1(address);
        client.initError(data);

        client.connect();
        client.send(data);
        client.disconnect();
    }
}

Service에 client.initError(data)메서드를 호출하였다.

 

public class MainV1 {

    public static void main(String[] args) {
        NetworkServiceV1_1 networkService = new NetworkServiceV1_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문을 돌려 보았다.

 

전송할 문자: hi
https://www.example.com서버 연결 성공
https://www.example.com서버에 데이터 전송: hi
https://www.example.com 서버 연결 해제

전송할 문자: error1
https://www.example.com서버 연결 실패
https://www.example.com서버에 데이터 전송: error1
https://www.example.com 서버 연결 해제

전송할 문자: error2
https://www.example.com서버 연결 성공
https://www.example.com서버에 데이터 전송 실패 : error2
https://www.example.com 서버 연결 해제

 

 

그런데 한가지 문제가 발생했다. error1이 전송되어 서버가 연결이 실패가 되었음에도 error1이라는 데이터가 전송되고 서버가 연결 해제되었다.

+ 또한 오류가 발생했을 때 어떤 오류가 발생했는지 자세한 내역을 남겨 디버깅에 도움이 되기에 오류 내역을 남기고 싶어졌다.

 

위 문제들을 해결해보자.

public class NetworkServiceV1_2 {

    public void sendMessage(String data){
        String address = "https://www.example.com";
        NetworkClientV1 client = new NetworkClientV1(address);
        client.initError(data);

        String connectResult = client.connect();
        if (isError(connectResult)){
            System.out.println("[네트워크 오류 발생] 오류 코드 : " + connectResult);
            return;
        }
        String sendResult = client.send(data);
        if (isError(sendResult)){
            System.out.println("[네트워크 오류 발생] 오류 코드 : " + sendResult);
            return;
        }
        client.disconnect();
    }

    private static boolean isError(String connectResult) {
        return !connectResult.equals("success");
    }
}

connect에서 return하는 문자열을 받아 오류가 맞는지 아닌지 확인하여 오류라면 메세지로그를 남기도록 하였다.

 

전송할 문자: hello
https://www.example.com서버 연결 성공
https://www.example.com 서버에 데이터 전송: hello
https://www.example.com 서버 연결 해제

전송할 문자: error1
https://www.example.com서버 연결 실패
[네트워크 오류 발생] 오류 코드 : connectError

전송할 문자: error2
https://www.example.com서버 연결 성공
https://www.example.com서버에 데이터 전송 실패 : error2
[네트워크 오류 발생] 오류 코드 : sendError

 

앞서 있었던 문제들을 해결하였다.

그런데 error2을 보면 연결에 성공을 하였고 데이터 전송에만 실패를 하였지만 서버 연결 해제가 안된다.

 

사용 후에니느 반드시 disconnect()를 호출해서 연결을 해제해야 한다.

error2를 보면 데이터 전송에 실패하는 경우, 연결이 해제되지 않는다. 계속 이렇게 두면 네트워크 자원이 고갈될 수 있다.

 

참고 : 자바의 경우 GC가 있기 때문에 JVM 메모리에 있는 인스턴스는 자동으로 해제할 수 있지만 자바 외부 연결과 같은 자바 외부의 자원은 자동으로 해제가 되지 않는다. 따라서 외부 자원을 사용한 후에는 연결을 해제하여 외부 자원을 반드시 반납해야 한다.

 

public class NetworkServiceV1_3 {

    public void sendMessage(String data){
        String address = "https://www.example.com";
        NetworkClientV1 client = new NetworkClientV1(address);
        client.initError(data);

        String connectResult = client.connect();
        if (isError(connectResult)){
            System.out.println("[네트워크 오류 발생] 오류 코드 : " + connectResult);
        } else {
            String sendResult = client.send(data);
            if (isError(sendResult)){
                System.out.println("[네트워크 오류 발생] 오류 코드 : " + sendResult);
            }
        }
        client.disconnect();
    }

    private static boolean isError(String connectResult) {
        return !connectResult.equals("success");
    }
}

if문의 코드를 살짝 변경하여 실패하던 성공하면 무조건 disconnect()메서드를 호출할 수 있도록 코드를 구성하였다.

 

전송할 문자: hello
https://www.example.com서버 연결 성공
https://www.example.com 서버에 데이터 전송: hello
https://www.example.com 서버 연결 해제

전송할 문자: error1
https://www.example.com서버 연결 실패
[네트워크 오류 발생] 오류 코드 : connectError
https://www.example.com 서버 연결 해제

전송할 문자: error2
https://www.example.com서버 연결 성공
https://www.example.com서버에 데이터 전송 실패 : error2
[네트워크 오류 발생] 오류 코드 : sendError
https://www.example.com 서버 연결 해제

 

connect()가 실패한 경우 send()를 보내면 안됨 ->해결

사용 후에는 반드시 disconnect()를 호출해서 연결을 해제해야 함 -> 해결

 

 

그런데 반환 값을 예외를 처리하는 networkServiceV1_2, networkServiceV1_3을 보면 정상 흐름과 예외를 처리하는 흐름이 섞여있어 코드의 가독성이 매우 떨어진다. 심지어 예외 흐름을 처리하는 부분이 더 많다.

 

어떻게하면 정상 흐름과 예외 흐름을 분리할 수 있을까? 지금과 같이 반환 값을 사용해서 예외 상황을 처리하는 방식으로는 해결할 수 없는 것은 확실하다.

이런 문제를 해결하기 위해 예외 처리 매커니즘이 등장한다.

 

 

 

자바는 프로그램 실행 중에 발생할 수 있는 예상치 못한 상황, 즉 예외(exception)을 처리하기 위한 메커니즘을 제공한다.
이는 프로그램의 안정성과 신뢰성을 높이는 데 중요한 역할을 한다.

 

자바의 예외 처리는 다음 키워드를 사용한다

try, catch, finally, throw, throws

 

그리고 예외를 다루기 위한 예외 처리용 객체들을 제공한다.

Object : 자바에서 기본형을 제외한 모든 것은 객체이다. 따라서 예외도 객체이다. 모든 객체의 최상위 부모는 Object 이므로 예외의 최상위 부모도 Object이다.

Throwable :  최상의 예외이다. 하위에 exception 과 Error가 있다

Error : 메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구가 불가능한 시스템 예외이다. 개발자는 이 예외를 잡으려고 해서는 안된다.

Exception : 체크 예외

- 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외이다.

- Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다. 단 RuntimeException은 예외로 한다.

RuntimeException : 언체크 예외(런타임 예외)

- 컴파일러가 체크하지 않는 언체크 예외이다.

- RuntimeException과 그 자식 예외는 모두 언체크 예외이다.

 

 

체크 예외는 발생한 예외를 개발자가 명시적으로 처리해야 한다. 그렇지 않으면 컴파일 오류가 발생한다.

언체크 예외는 개발자가 발생한 예외를 명시적으로 처리하지 않아도 된다.

 

주의 : 상속 관계에서 부모 타입은 자식을 담을 수 있다. 이 개념이 예외 처리에도 적용되는데, 상위 예외를 잡으면 하위 예외 전부 잡을 수 있다. 그러나 Throwable 예외를 잡으면 안된다. Error 예외도 함께 잡아지기 때문이다. 그렇기에 Exception에서부터 실질적으로 예외를 잡도록 하자.

 

 

자바 예외 처리는 폭탄 돌리기와 같다. 예외가 발생하면 잡아서 처리하거나, 처리할 수 없으면 밖으로 던져야한다.

1. Main은 Service를 호출한다

2. Service는 Client를 호출한다.

3. Client에서 예외가 발생했다.

4. Client에서 예외를 처리하지 못하고 밖으로 던진다. 여기서 Client의 밖은 Client를 호출한 Service를 뜻한다.

5. Serivce에 예외가 전달된다. Service에서 예외를 처리했다. 이후에는 애플리케이션 로직이 정상 흐름으로 동작한다.

6. 정상 흐름을 반환한다.

 

예외 기본 규칙 2가지

1. 예외는 잡아서 처리하거나 밖으로 던져야한다.

2. 예외를 잡거나 던질 때 지정한 예외뿐만 아니라 그 예외의 자식들도 함께 처리할 수 있다.

-Exception을 catch, throws로 잡거나 던지면 그 하위 예외들도 모두 잡히거나 던져진다.

 

 

 


예외를 계속 처리하지 못하면 어떻게 될까?

1. 예외는 잡아서 처리하거나 밖으로 던져야 한다.

2. 계속 던져지계 되면 결국 main 밖으로 예외가 던져지게 되고 예외 로그를 출력하면서 시스템이 종료된다.

 

 

 

체크 예외

Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다. 단 RuntimeException은 제외한다.

체크예외는 잡아서 처리하거나 밖으로 던져야한다. 그렇지 않으면 컴파일 오류가 발생한다.

//Exception을 상속 받은 예외는 체크 예외가 된다.
public class MyCheckedException extends Exception {

    public MyCheckedException(String message) {
        super(message);
    }
}

예외 클래스를 만들려면 예외를 상속 받으면 된다.

Exception을 상속 받은 예외는 체크 예외가 된다.

 

public class Client {
    
    public void call() throws MyCheckedException{
        //문제 상황 
        throw new MyCheckedException("ex");
    }
}

 

throw 예외 라고 하면 새로운 예외를 발생시킬 수 있다. 예외도 객체이기 때문에 객체를 먼저 new로 생성하고 예외를 발생시켜야 한다.

throws 예외는 발생시킨 예외를 메서드 밖으로 던질 때 사용하는 키워드 이다.

throw, throws의 차이에 주의하자.

public class Service {

    Client client = new Client();


    //예외를 잡아서 처리하는 코드
    public void callCatch(){
        try{
            client.call();
        } catch (MyCheckedException e){
            System.out.println("예외 처리, message=" + e.getMessage());
        }

        System.out.println("정상 흐름");

    }


    //체크 예외를 밖으로 던지는 코드
    //체크 예외는 예외를 잡지 않고 밖으로 던지려면 throws 예외를 메서드에 필수로 선언해야 한다.
    public void catThrow() throws MyCheckedException {
        client.call();
    }
}

 

예외를 잡아서 처리하려면

try ~ catch()를 사용해서 예외를 잡으면 된다.

try 코드 블럭에서 발생하는 예외를 잡아서 catch로 넘긴다.

여기서는 MyCheckedException을 잡아서 처리했다.

 

catch는 해당 타입과 그 하위타입 모두를 잡을 수 있다. 다시 한번 부모 자식간의 관계를 확인하자

 

체크 예외는 예외를 잡아서 처리할 수 없을 때, 예외를 밖으로 던지는 throws를 필수로 선언해야 한다. 그렇지 않으면 컴파일 오류가 발생한다. 이것 때문에 단점과 장점이 동시에 존재한다.

장점 : 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아주는 훌륭한 안전장치이다.

단점 : 개발자가 모든 체크 예외를 반드시 잡거나 던지도록 처리해야 하기 때문에 너무 번거롭다.

 

 

 

언체크 예외

RuntimeException과 그 하위 예외는 언체크 예외로 분류된다.

언체크 예외는 말 그대로 컴파일러가 예외를 체크하지 않는다는 뜻이다.

언체크 예외는 체크 예외와 기본적으로 동일하다. 차이가있다면 예외를 던지는 throws를 선언하지 않고 생략할 수 있다. 생략한 경우 자동으로 예외를 던진다.

 

체크예외 VS 언체크 예외

체크 예외 : 예외를 잡아서 처리하지 않으면 항상 throws 키워드를 사용해서 던지는 예외를 선언해야 한다.

언체크 예외 : 예외를 잡아서 처리하지 않아도 throws 키워드를 생략할 수 있다.

 

//RuntimeException을 상속받은 예외는 언체크 예외가 된다.
public class MyUncheckedException extends  RuntimeException{
    
    public MyUncheckedException(String message) {
        super(message);
    }
}
public class Client {

    public void call(){
        throw new MyUncheckedException("ex");
    }
}
//unChecked 예외는 예외를 잡거나, 던지지 않아도 된다.
//예외를 잡지 않으면 자동으로 밖으로 던진다.
public class Service {

    Client client = new Client();

    public void callCatch(){

        try{
            client.call();
        } catch (MyUncheckedException e){
            System.out.println("예외 처리, message= " + e.getMessage());
        }

        System.out.println("정상 로직");

    }


    //예외를 안잡아도 자연스럽게 상위로 넘어간다.
    //체크 예외와 다르게 throws 예외 선언을 하지 않아도 된다.
    public void callThrow(){
        client.call();
    }
}
public class UncheckedCatchMain {

    public static void main(String[] args) {
        Service service = new Service();
        service.callCatch();
        System.out.println("정상 종료");

        System.out.println();
        service.callThrow();
    }
}

 

참고로 언체크 예외도 throws 예외를 선언해도 된다.

주로 생략하지만 중요한 예외의 경우 생략하지 않고 적는 경우도 있다.

 

 

언체크 예외의 장단점

1. 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다. 체크 예외의 경우 처리할 수 없는 예외를 밖으로 던지려면 항상 throws를 선언해야 하지만, 언체크 예외는 이 부분을 생략할 수 있다.

2. 언체크 예외는 개발자가 실수로 예외를 누락할 수 있다. 반면 체크 예외는 컴파일러를 통해 예외 누락을 잡아준다.

 

정리

체크 예외와 언체크 예외의 차이는 예외를 처리할 수 없을 때 예외를 밖으로 던지는 부분을 필수로 선언해야 하는가 아닌가에 있다.

 

 

 

이제 앞서 발생한 정상흐름 예외흐름이 꼬인 문제를 예외처리를 통해 분리해 보자.

 

앞서 만든 프로그램의 문제는 다음과 같다

1. 정상 흐름과 예외 흐름이 섞여있기 때문에 코드를 한눈에 이해하기 어렵다.

2. 예외 흐름이 더 많은 코드 분량을 차지한다.

 

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 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);
//            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;
        }
    }
}
public class NetworkServiceV2_5 {

    public void sendMessage(String data) {
        String address = "https://www.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();
        }





    }
}
public class MainV2 {

    public static void main(String[] args) throws NetworkClientExceptionV2 {
        NetworkServiceV2_5 networkService = new NetworkServiceV2_5();
        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("프로그램을 정상 종료합니다.");
    }
}

 

자바 예외 처리는 try ~catch~finally 구조를 사용해서 처리할 수 있다.

finally를 사용하면 예외를 밖으로 던지는 경우에도 finally의 호출을 보장하며 정상적으로 disconnect()메서드를 호출하게 된다.

 

 

 

 

예외를 단순히 오류 코드로 분류하는 것이 아니라, 예외를 계층화해서 다양하게 만들면 더 세밀하게 예외를 처리할 수 있다.

예외를 계층화하면 다음과 같은 장점이 있다.

1. 자바에서 예외는 객체이다. 따라서 부모 예외를 잡거나 던지면, 자식 예외도 함께 잡거나 던질 수 있다.

2. 특정 예외를 잡아서 처리하고 싶으면 하위 예외를 잡아서 처리하면 된다.

public class NetworkClientExceptionV3 extends Exception{

    public NetworkClientExceptionV3(String message) {
        super(message);
    }
}
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;
    }
}
public class SendExceptionV3 extends NetworkClientExceptionV3{

    private final String sendData;

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

    public String getSendData() {
        return sendData;
    }
}

 

예외를 세분화 해서 상황에 맞는 예외를 따로 만들어 두었다.

 

 

 

public class NetworkClientV3 {

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

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

    public void connect() throws ConnectExceptionV3 {
        if (connectError){
            throw new ConnectExceptionV3(address,
                    address + " 서버 연결 실패");
        }
        System.out.println(address + " 서버 연결 성공");
    }

    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;
        }
    }
}
public class NetworkServiceV3_1 {

    public void sendMessage(String data) {
        String address = "https://www.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();
        }





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

 

 

이번에는 예외를 잡아서 처리할 때 예외 계층을 활용해보자

 NetworkClientV3에서 수많은 예외를 발생한다고 가정해보자. 이런 경우 모든 예외를 하나하나 다 잡아서 처리하는 것은 상당히 번거로울 것이다. 그래서 예외처리를 다음과 같이 구성해보자.

1. 연결 오류는 중요하다. ConnectExceptionV3가 발생하면 메시지를 명확하게 남기자

2. NetworkClientV3을 사용하면서 발생하는 나머지 예외는 단순하게 출력하자.

3. 그 외에 예외가 발생하면 알 수 없는 오류로 퉁쳐보자

 

public class NetworkServiceV3_2 {

    public void sendMessage(String data) {
        String address = "https://www.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();
        }
    }
}

try ~ catch ~ finally를 계층적으로 나누어서 코딩하였다. 내려갈수록 큰 범위의 예외를 잡는다.

catch는 순서대로 매칭하여 오류를 잡기 때문에 connectException에 대한 오류는 맨 처음에, sendException같은 오류는 두번째에서 RuntimeException같은 오류는 맨 마지막 Exception에서 오류를 처리하게 된다.

 

 

 

 

실무 예외 처리 방안

처리할 수 없는 예외 : 예를 들어 상대 네트워크 서버에 문제가 발생해서 통신이 불가능하건, 데이터베이스 서버에 문제가 발생해서 접속이 안되면, 애플리케이션에서 연결 오류, 데이터베이스 접속 실패와 같은 예외가 발생한다.

이렇게 시스템 오류 때문에 발생한 예외들은 대부분 예외를 잡아도 해결할 수 있는 것이 거의 없다. 예외를 잡아서 다시 호출을 시도해도 같은 오류가 반복될 뿐이다.

이런 경우 고객에게는 " 현재 시스템에 문제가 있습니다" 라는 오류 메시지를 보여주고, 만약 웹이라면 오류 페이지를 보여주면 된다. 그리고 내부 개발자가 문제 상황을 빠르게 인지할 수 있도록,, 오류에 대한 로그를 남겨두어야 한다.

 

체크 예외의 부담 : 체크 예외는 개발자가 실수로 놓칠 수 있는 예외들을 컴파일러가 체크해주기 때문에 오래전부터 많이 사용되었다. 그런데 앞서 설명한 것처럼 처리할 수 없는 예외가 많아지고, 또 프로그램이 복잡해지면서 체크 예외를 사용하는 것이 부담스러워졌다.

 

예외가 많아지면 많아질수록 본인이 다룰 수 없는 수 많은 체크 예외 지옥에 빠지게 된다. 결국 Exception으로 던지는 최악의 수를 두게 된다.

 

Exception은 애플리케이션에서 일반적으로 다루는 모든 예외의 부모이다. 따라서 이렇게 한 줄만 넣으면 모든 예외를 다 던질 수 있다. 이렇게 하면  Exception은 물론이고 그 하위 타입인 NetworkException, DatabaseException 도 함께 던지게 된다. 코드가 깔끔해지는 것 같지만 치명적인 문제가 있다.

 

Exception은 최상위 타입이므로 모든 체크 예외를 다 밖으로 던져버리는 문제가 발생한다.

결과적으로 체크 예외의 최상위 타입인  Exception을 던지면 다른 체크 예외를 체크할 수 있는 기능이 무효화 되고, 중요한 체크 예외를 다 놓치게 된다. 중간에 중요한 체크 예외가 발생해도 컴파일러는  Exception을 던지기 떄문에 문법에 맞다고 판단해서 컴파일 오류가 발생하지 않는다.

 

 

지금까지 알아본 체크 예외를 사용할때 발생하는 문제들은 다음과 같다

1. 처리할 수 없는 예외 : 예외를 잡아서 복구할 수 있는 예외보다 복구할 수 없는 예외가 더 많다

2. 체크 예외의 부담 : 처리할 수 없는 예외는 밖으로 던져야 한다. 체크 예외이므로 throws에 던질 대상을 일일히 명시해야 한다.

 

그래서 런타임 에러(언체크 예외)를 사용한다.

사용하는 라이브러리가 늘어나서 언체크 예외가 늘어도 본인이 필요한 예외만 잡으면 되고, throws를 늘리지 않아도 된다.

 

 

예외 공통 처리

이렇게 처리할 수 없는 예외들은 중간에 여러곳에서 나누어 처리하기 보다는 예외를 공통으로 처리할 수 있는 곳을 만들어서 한곳에서 해결하면 된다.

어짜피 해결할 수 없는 예외들이기 때문이다. 그리고 내부 개발자가 지금의 문제 상황을 빠르게 인지할 수  있도록, 오류에 대한 로그를 남겨두면 된다. 이런 부분은 공통 처리가 가능하다.

public class NetworkClientExceptionV4 extends RuntimeException{

    public NetworkClientExceptionV4(String message) {
        super(message);
    }
}
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;
    }
}
public class SendExceptionV4 extends NetworkClientExceptionV4 {

    private final String sendData;

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

    public String getSendData() {
        return sendData;
    }
}
public class NetworkServiceV4 {

    public void sendMessage(String data) {
        String address = "https://www.example.com";
        NetworkClientV4 client = new NetworkClientV4(address);
        client.initError(data);

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

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

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

    public void connect() throws ConnectExceptionV4 {
        if (connectError){
            throw new ConnectExceptionV4(address,
                    address + " 서버 연결 실패");
        }
        System.out.println(address + " 서버 연결 성공");
    }

    public void send(String data) throws SendExceptionV4 {
        if(sendError){
            throw new SendExceptionV4(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;
        }
    }
}
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());
        }

    }
}

 

 

exceptionHandler() : 해결할 수 없는 예외가 발생하면 사용자에게는 시스템 내에 알 수 없는 문제가 발생했다고 알린다. 개발자는 빨리 문제를 찾고 디버깅할 수 있도록 오류 메시지를 남겨두어야 한다. 예외도 객체이므로 필요하면 instanceof와 같이 예외 객체의 타입을 확인해서 별도의 추가 처리를 할 수 있다.

 

e.printStackTrace() : 예외 메시지와 스택 트레이스를 출력할 수 있다. 이 기능을 사용하면 예외가 발생한 지점을 역으로 추적할 수 있다.

 

 

 

try-with-resources

애플리케이션에서 외부 자원을 사용해야 하는 경우 반드시 외부 자원을 해제해야 한다.

try에서 외부 자원을 사용하고 try가 끝나면 외부 자원을 반납하는 패턴이 반복되면서 자바에서는 Try with resources라는 편의 기능을 도입했다. 이름 그대로 try에서 자원을 함께 사용한다는 것이다. 여기서 자원은 try가 끝나면 반드시 종료해서 반납해야 하는 외부 자원을 뜻한다.

 

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

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() throws ConnectExceptionV4 {
        if (connectError){
            throw new ConnectExceptionV4(address,
                    address + " 서버 연결 실패");
        }
        System.out.println(address + " 서버 연결 성공");
    }

    public void send(String data) throws SendExceptionV4 {
        if(sendError){
            throw new SendExceptionV4(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;
        }
    }

    @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 = "https://www.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.close9)를 호출해서 자원을 해제한다.

참고로  catch가 없이 try만 있어도 close()는 호출된다.

여기서 catch 블럭은 단순히 발생한 예외를 잡아서 예외 메시지를 출력하고 잡은 예외를  throw를 통해서 밖으로 던지기만 한다.

 

Try with resources 장점

1. 리소스 누수 방지 : 모든 리소스가 제대로 닫히도록 보장한다. finally블록을 적지 않거나 자원 해제 코드를 누락하는 문제들을 예방할 수 있다.

2. 코드 간결성 및 가독성 향상 : 명시적인  close()호출이 필요 없어 코드가 더 간결하고 읽기 쉬워진다.

3. 스코프 범위 한정 : 예를 들어 리소스로 사용되는 client변수의 스코프가 try 블럭 안으로 한정된다.

4. 조금 더 빠른 자원 해제 : 기존에는 try -> catch -> finally로 catch 이후 자원을 반납했지만 Try with resources 구문은 try 블럭이 끝나면 즉시 close()를 호출한다.

 

 

 

반응형

'공부 > Java' 카테고리의 다른 글

자바 제네릭(Generic) 2편  (0) 2024.09.24
자바 제네릭(Generic) 1편  (1) 2024.09.23
자바 지역클래스 복습하기  (0) 2024.09.21
자바 중첩 클래스, 내부 클래스 복습하기  (0) 2024.09.20
자바 열거형 ENUM 복습하기  (0) 2024.09.20