JAVA의 가장 기본이 되는 내용

예외 처리 3 (예외 처리 도입)

cwchoiit 2024. 4. 23. 14:00
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을

www.inflearn.com

 

예외 처리 1 에서 다룬 프로그램에서 이런 문제가 있었다.

  • 정상 흐름과 예외 흐름이 섞여 있기 때문에 코드를 한눈에 이해하기 어렵다. 쉽게 이야기해서 가장 중요한 정상 흐름이 한눈에 들어오지 않는다.
  • 심지어 예외 흐름이 더 많은 코드 분량을 차지한다.

이 문제를 점진적으로 해결해보자.

 

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

 

예외도 객체이다. 따라서 필요한 필드와 메서드를 가질 수 있다.

  • 오류 코드(errorCode): 이전에는 오류 코드를 반환값으로 리턴해서 어떤 오류가 발생했는지 구분했다. 여기서는 어떤 종류의 오류가 발생했는지 구분하기 위해 예외 안에 오류 코드를 보관한다.
  • 오류 메시지(message): 오류 메시지에는 어떤 오류가 발생했는지 개발자가 보고 이해할 수 있는 설명을 담아둔다. 오류 메시지는 상위 클래스인 Throwable에서 기본으로 제공하는 기능을 사용한다.  

NetworkClientV2

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 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;
        }
    }
}
  • 기존의 코드와 대부분 같지만, 오류가 발생했을 때 오류 코드를 반환하는 게 아니라 예외를 던진다.
  • 예외를 던지기 때문에 따로 반환값이 필요없다. 반환값을 void로 변경했다.
  • 이전에는 반환값을 통해 성공, 실패 여부를 확인했지만 이제 예외 처리 덕분에 메서드가 정상 종료되면 성공이고 예외가 던져지면 예외를 통해 실패를 확인할 수 있다.
  • 오류가 발생하면 예외 객체를 만들고 거기에 오류 코드와 오류 메시지를 담아둔다. 그리고 만든 예외 객체를 throw를 통해 던진다.

NetworkServiceV2_1

package exception.ex2;


public class NetworkServiceV2_1 {

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

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

예외를 별도로 처리하지 않고 throws를 통해 밖으로 던진다.

 

MainV2

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.println("전송할 문자: ");
            String input = scanner.nextLine();
            if (input.equals("exit")) {
                break;
            }

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

실행결과:

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

전송할 문자: 
error1
Exception in thread "main" exception.ex2.NetworkClientExceptionV2: https://example.com 서버 연결 실패
	at exception.ex2.NetworkClientV2.connect(NetworkClientV2.java:15)
	at exception.ex2.NetworkServiceV2_1.sendMessage(NetworkServiceV2_1.java:11)
	at exception.ex2.MainV2.main(MainV2.java:18)

 

"error1"을 사용자가 입력하면 외부 서버와 연결에 실패한다. 그리고 그 예외를 모든 곳에서 잡지 않았기 때문에 결과적으로 main() 밖으로 예외가 던져진다. 이렇게 되면 예외를 추적할 수 있는 스택 트레이스를 출력하고 프로그램을 종료한다.

 

남은 문제

  • 예외 처리를 도입했지만 예외가 복구되지 않는다. 그래서 예외가 발생하면 프로그램이 종료된다.
  • 사용 후 반드시 disconnect()를 호출해서 연결을 해제해야 한다.

 

예외 복구하기

이번엔 예외를 잡아서 예외 흐름을 정상 흐름으로 복구해보자.

 

NetworkServiceV2_2

package exception.ex2;


public class NetworkServiceV2_2 {

    public void sendMessage(String data) {
        String address = "https://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 예외를 잡았다. 여기서는 예외를 잡으면 오류 코드와 예외 메시지를 출력한다. 예외를 잡아서 처리했기 때문에 이후에는 정상 흐름으로 복귀한다. 여기서는 리턴을 사용해서 sendMessage()를 정상적으로 빠져나간다.

 

MainV2

package exception.ex2;

import java.util.Scanner;

public class MainV2 {
    public static void main(String[] args) throws NetworkClientExceptionV2 {
        NetworkServiceV2_2 networkService = new NetworkServiceV2_2();

        Scanner scanner = new Scanner(System.in);

        while (true) {
            System.out.println("전송할 문자: ");
            String input = scanner.nextLine();
            if (input.equals("exit")) {
                break;
            }

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

실행결과:

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

전송할 문자: 
error2
https://example.com 서버 연결 성공
[오류] 코드: sendError, 메시지: https://example.com 서버에 데이터 전송 실패: error2

전송할 문자: 
error1
[오류] 코드: connectError, 메시지: https://example.com 서버 연결 실패

전송할 문자: 
exit
프로그램을 정상 종료합니다.

 

이제 예외를 잡아서 처리했기 때문에 예외가 복구되고 프로그램도 계속 수행할 수 있다.

 

남은 문제

  • 예외 처리를 했지만 정상 흐름과 예외 흐름이 섞여 있어 코드를 읽기 어렵다.
  • 사용 후 반드시 disconnect()를 호출해서 연결을 해제해야 한다.

 

정상, 예외 흐름 분리하기

NetworkServiceV2_3

package exception.ex2;


public class NetworkServiceV2_3 {

    public void sendMessage(String data) {
        String address = "https://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 블럭에서 해결한다.

이렇게 하면 정상 흐름은 try 블럭에 들어가고, 예외 흐름은 catch 블럭으로 명확하게 분리할 수 있다.

 

실행결과:

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

전송할 문자: 
error1
[오류] 코드: connectError, 메시지: https://example.com 서버 연결 실패

전송할 문자: 
error2
https://example.com 서버 연결 성공
[오류] 코드: sendError, 메시지: https://example.com 서버에 데이터 전송 실패: error2

전송할 문자: 
exit
프로그램을 정상 종료합니다.

 

실행 결과를 보면, 에러가 발생하면 다음 라인을 진행하지 않고 바로 catch로 빠지기 때문에 disconnect()를 호출하지 않는다.

 

 

남은 문제

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

 

리소스 반환 정상적으로 해보기

현재 구조에서 disconnect()를 항상 호출하려면 다음과 같이 생각해 볼 수 있다.

NetworkServiceV2_4

package exception.ex2;


public class NetworkServiceV2_4 {

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

 

이 코드를 보면, 예외 처리가 끝난 다음에 정상 흐름의 마지막에 client.disconnect()를 호출했다. 

문제없이 동작할 것 같다.

실행결과:

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

전송할 문자: 
error1
[오류] 코드: connectError, 메시지: https://example.com 서버 연결 실패
https://example.com 서버 연결 해제

전송할 문자: 
error2
https://example.com 서버 연결 성공
[오류] 코드: sendError, 메시지: https://example.com 서버에 데이터 전송 실패: error2
https://example.com 서버 연결 해제

전송할 문자: 
exit
프로그램을 정상 종료합니다.

 

정말 문제없이 동작하고 있다. 에러가 발생해도 연결 해제가 항상 실행된다.

그러나, 이 방법에는 큰 문제가 있다. 바로 catch에서 잡을 수 없는 예외가 발생했을 때이다.

 

잠깐만 connect()를 아래처럼 에러 발생 시 NetworkClientExceptionV2가 아니라 RuntimeException으로 변경해보자.

public void connect() throws NetworkClientExceptionV2 {
    if (connectError) {
        // throw new NetworkClientExceptionV2("connectError", address + " 서버 연결 실패");
        throw new RuntimeException("Ex");
    }

    System.out.println(address + " 서버 연결 성공");
}

 

이렇게 변경하면 기존 코드로는 catch에서 connect() 호출 시 에러가 발생하면 잡을 수 없게 된다.

실행결과:

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

전송할 문자: 
error1
Exception in thread "main" java.lang.RuntimeException: Ex
	at exception.ex2.NetworkClientV2.connect(NetworkClientV2.java:16)
	at exception.ex2.NetworkServiceV2_4.sendMessage(NetworkServiceV2_4.java:12)
	at exception.ex2.MainV2.main(MainV2.java:18)

 

catch에서 잡기로 했던 NetworkClientExceptionV2가 아닌 다른 에러가 발생하면 잡을 수 없어 예외가 밖으로 던져지게 된다. 그러면 그 하단에 있는 모든 라인은 무시된다. 따라서, client.disconnect() 호출은 무시가 된다.

 

finally 등장

자바에서 저런 문제를 해결하기 위해 어떤 경우라도 반드시 호출되는 finally 기능을 제공한다.

 

NetworkServiceV2_5

package exception.ex2;


public class NetworkServiceV2_5 {

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

    }
}

 

finally 키워드는 어떤 상황에서도 마지막에 반드시 실행되는 구문이다. 그래서 catch에서 잡을 수 없는 에러가 발생해서 밖으로 던져질 때에도 이 finally를 한 번 거친 후에 밖으로 던져진다.

 

다음과 같이 catch가 없이 try - finally 만 사용해도 된다.

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

그대신 이 경우 예외는 언체크 예외이거나, 체크 예외인 경우엔 throws 예외로 밖으로 던진다고 선언해야 한다. 체크 예외는 반드시 잡거나 던져야 하므로.

 

정리

자바 예외 처리는 try - catch - finally 구조를 사용해서 쉽게 처리할 수 있다. 덕분에 다음과 같은 이점이 있다.

  • 정상 흐름과 예외 흐름을 분리해서, 코드를 읽기 쉽게 만든다.
  • 사용한 자원을 항상 반환할 수 있도록 보장해준다.

 

근데, 이 모든게 체크 예외를 사용하면서 발생하는 번거로움이다. 결국 실무에서는 체크 예외보다 언체크 예외를 더 많이 사용한다. 왜냐하면 실무에선 수십개의 라이브러리를 사용하는데 그 모든 라이브러리에서 쏟아지는 모든 예외를 다 다루고 싶지 않을 것이다. 그리고 결정적으로 그 예외 중 대다수는 복구할 수 있는 예외가 아니라 복구할 수 없는 예외이다. 그런데도 불구하고 체크 예외이기 때문에 throws에 던질 대상을 일일이 명시해야 한다.

 

그래서 결국, 정말 무조건 체크해야 하는 예외나 해결이 가능한 예외만 잡아 처리하고 그 외에 것들은 신경 쓰지 않는 것이 더 나은 선택일 수 있다. 그러한 방법을 사용해보자.

 

언체크 예외 사용 시나리오

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

NetworkException, DatabaseException은 잡아도 복구할 수 없다. 언체크 예외이므로 이런 경우 무시하면 된다.

 

언체크 예외를 던지는 예시

class Service {
    void sendMessage(String data) {...}
}
  • 언체크 예외이므로 throws를 선언하지 않아도 된다.
  • 사용하는 라이브러리가 늘어나서 언체크 예외가 늘어도 본인이 필요한 예외만 잡으면 되고, throws를 늘리지 않아도 된다.

일부 언체크 예외를 잡아서 처리하는 예시

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

필요하다면 언체크 예외를 직접 잡아서 처리하면 된다.

 

이렇게 언체크 예외로 기본을 무시한다고 큰 틀을 잡고 모든 언체크 예외를 공통으로 처리하는 부분이 있으면 된다.

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

 

이런 시나리오를 토대로 지금까지 다뤘던 코드에 적용해보자.

 

 

 

모든것을 언체크 예외로 변경한다. 우선 NetworkClientException도 두 개로 분류할 수 있다. ConnectException, SendException. connect() 호출 시 발생 가능성이 있는 에러를 ConnectException으로 처리하고 send() 호출 시 발생 가능성이 있는 에러를 SendException으로 처리해서 더 세분화하자.

 

NetworkClientExceptionV4

package exception.ex4.exception;

public class NetworkClientExceptionV4 extends RuntimeException {

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

 

ConnectExceptionV4

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

 

SendExceptionV4

package exception.ex4.exception;

public class SendExceptionV4 extends NetworkClientExceptionV4 {
    private final String data;

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

    public String getData() {
        return data;
    }
}

 

ConnectExceptionV4, SendExceptionV4는 모두 NetworkClientExceptionV4를 상속받는다. 그리고 각 예외 클래스는 본인한테 필요한 필드(address, data)를 가지고 있다. 

 

NetworkClientV4

package exception.ex4;

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

public class NetworkClientV4 {

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

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

    public void connect() {
        if (connectError) {
            throw new ConnectExceptionV4(address, "서버 연결 실패");
        }

        System.out.println(address + " 서버 연결 성공");
    }

    public void send(String data) {
        if (sendError) {
            // throw new RuntimeException("ex");
            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;
        }
    }
}

connect(), send() 모두 throws를 사용할 필요가 없다.

 

NetworkServiceV4

package exception.ex4;

public class NetworkServiceV4 {

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

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

 

  • NetworkServiceV4는 발생하는 예외인 ConnectExceptionV4, SendExceptionV4를 잡아도 복구할 수 없다. 따라서 예외를 밖으로 던진다. 근데 언체크 예외이므로 던지는 걸 throws 선언없이 자동으로 해준다.
  • 개발자 입장에선 네트워크 문제가 발생하는데 그 문제가 발생한다고 뭘 할 수 있겠는가? 따라서 해당 예외들을 생각하지 않는 것이 더 나은 선택일 수 있다. 해결할 수 없는 예외들은 다른 곳에서 공통으로 처리된다.
  • 이런 방식 덕분에 NetworkServiceV4는 해결할 수 없는 예외보단 본인 스스로의 코드에 더 집중할 수 있다. 따라서 코드가 깔끔해진다.

MainV4

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.println("전송할 문자: ");
            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);
        // e.printStackTrace();

        //필요하면 예외 별로 별도의 추가 처리 가능
        if (e instanceof SendExceptionV4 sendExceptionV4) {
            System.out.println("[전송 오류] 전송 데이터: " + sendExceptionV4.getData());
        }
    }
}

 

공통 예외 처리

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

여기에 예외를 공통으로 처리하는 부분이 존재한다.

  • Exception을 잡아서 지금까지 해결하지 못한 모든 예외를 여기서 공통으로 처리한다. Exception을 잡으면 필요한 모든 예외를 잡을 수 있다.
// 공통 예외 처리
private static void exceptionHandler(Exception e) {
    System.out.println("사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다.");

    System.out.println("==개발자용 디버깅 메시지==");
    e.printStackTrace(System.out);
    // e.printStackTrace();

    //필요하면 예외 별로 별도의 추가 처리 가능
    if (e instanceof SendExceptionV4 sendExceptionV4) {
        System.out.println("[전송 오류] 전송 데이터: " + sendExceptionV4.getData());
    }
}
  • 복구할 수 없는 예외가 발생하면 사용자에게는 시스템 내 알 수 없는 문제가 발생했다고 알린다. (디테일한 내용을 알릴 필요가 없다. 알려서도 안된다)
  • 개발자는 빨리 문제를 찾고 디버깅 할 수 있도록 오류 메시지를 남겨두어야 한다.
  • 예외도 객체이므로 필요하면 instanceof와 같이 예외 객체의 타입을 확인해서 별도의 추가 처리를 할 수 있다.
참고로, 실무에서는 System.out 이런걸 사용하는 게 아니고 Slf4J, logback 같은 별도의 로그 라이브러리를 사용한다.

 

 

정리하자면

1. 대부분의 에러는 어차피 개발자가 복구할 수 없다.

2. 복구할 수 없는 에러를 체크 예외로 던져서 일일이 처리하는 건 예외 지옥에 빠질것이다.

3. 그래서 정말 처리해야 하는 예외만 체크 예외로 하고 나머지는 언체크 예외로 해서 본연의 기능에 충실할 수 있도록 코드를 작성한다. 이는 코드 가독성을 높이고 불필요한 라인을 제거할 수 있다.

4. 실행 중 발생한 언체크 예외를 공통으로 처리하는 부분에서 사용자에겐 문제가 발생했다고 알려주고 개발자는 그 문제를 로그를 통해 빠르게 알아내고 디버깅해서 문제를 빠르게 해결하면 된다.

 

728x90
반응형
LIST