[JAVA] 예외 처리를 어떻게 해야할까? - (1)

kindof

·

2021. 6. 17. 16:46

1. 예외 처리

Try/Catch문을 사용하는 가장 큰 목적은 예외 처리(Exception Handling)를 하는 데 있습니다. 예외 처리란 두 가지 방식으로 수행할 수 있는데, 첫번째가 전통적인 방식인 조건문을 이용하는 것이고 두번째 방법이 try-catch문을 이용하는 것입니다.

 

하지만, 제 생각으로는 예외 처리를 IF문으로 한다는 것은 최대한 "지양"해야 하는 방향이라고 생각합니다.

 

그 이유는 아래와 같으며, 글을 풀어나가며 구체적으로 설명하려고 합니다.

 

첫째, try-catch문에서는 예외가 발생했을 때 그 즉시 해당 블록이 종료되고 catch 블록으로 제어가 이동합니다.

 

하지만 if-else로 에러를 처리할 경우에는 에러가 발생한 객체에 대하여 수명이 유지되기 때문에 에러를 처리하는 동안에도 에러를 발생한 객체를 참조하는 코드가 정상적으로 컴파일되어 위험한 상황이 발생할 수 있죠. 

 

둘째, try/catch문은 예외가 발생했을 때 "문제가 발생할 당시의 파라미터", "해당 예외에 대한 설명" 등을 전달해줌으로써 정보 전달의 기능도 할 수 있습니다.

 

2. try/catch문

try/catch의 기본 문법은 잘 알고 계시리라 생각하지만, 한 번 짚고 넘어가겠습니다.

try{
    예외가 생길 가능성이 있는 코드 작성
}catch(예외 발생 클래스명e){
    예외처리 코드
}finally...

 

try/catch구문은 기본적으로 try{} 구문 안에 예외가 생길 가능성이 있는 코드를 작성하고, 만약 여기서 예외가 터진다면 무엇을 할 지 catch{} 블록 안에 정의합니다. 그리고 예외 발생 여부와 관계없이 항상 수행할 동작을 finally{} 블록 안에 쓰죠.

 

간단한 예제 코드를 보면서 예외 처리 방법을 살펴보겠습니다.

 public class Math{
     public static void main(String[] args){
         int num1, num2;
         Scanner sc = new Scanner(System.in); # import 생략
         num1 = sc.nextInt();
         num2 = sc.nextInt();

         try{
             System.out.println(num1/num2);
         }catch (Exception e){
             System.out.println("0으로 나눌 수 없습니다.");

         }
     }
 }

위 코드에서 사용자가 num2에 0을 대입했을 때는 "0으로 나눌 수 없습니다."라는 메시지가 출력됩니다. 하지만 try-catch문이 없을 경우에는 ArithmeticException이라는 이름의 에러가 발생하게 되죠.

 

하지만 위 코드는 ArithmeticException을 명시해주는 대신에 모든 Exception을 싸잡아서 가장 상위 클래스인 Exception으로 제어하고 있습니다. 좋지 않은 예외처리라고 생각합니다.

 

아래 코드처럼 직접적으로 어떤 예외가 발생했는지 Exception의 클래스명을 알고 있다면 각각의 에러에 대해 예외 처리를 할 수 있겠죠.

 

만약 Exception 클래스명을 모르거나 개발자가 의도한 예외라면 사용자 정의 예외로 처리한다면 더 좋을 것 같습니다.

 

그러면 되면 여러 에러가 발생할 가능성을 가지고 각각의 에러에 대한 예외처리 구문을 작성해 줄 수 있게 되고, 코드를 작성한 사람이나 다른 사람이 코드를 보았을 때 한번에 파악하기도 쉬워집니다.

 public class Math{
     public static void main(String[] args){
         int num1, num2;
         Scanner sc = new Scanner(System.in); // import 생략
         num1 = sc.nextInt();
         num2 = sc.nextInt();

         try{
             System.out.println(num1/num2);
         }catch (ArithmeticException e){ // <- 이런 식으로 
             System.out.println("0으로 나눌 수 없습니다.");
         }
     }
 }

 

방금 했던 이야기에 대해 조금 더 살을 붙여보겠습니다.

 

3. try/catch문의 디테일(1) - 예외에 의미를 제공하자

예외를 던질 때는 전후 상황을 충분히 덧붙여야 합니다. 그래야 오류가 발생한 원인과 위치를 찾기가 쉬워지죠.

 

자바는 모든 예외에 호출 스택을 제공합니다. 하지만 실패한 코드의 의도를 파악하려면 호출 스택만으로는 부족합니다.

 

오류 메시지에 정보를 담아 예외와 함께 던져야 합니다. 위의 1번에서 이야기한 것처럼 실패한 연산 이름과 실패 유형도 언급하는 것이 좋다는 것이죠.

 

아래 예시는 은행 계좌에 대한 클래스를 작성한 것입니다. 출금(withdraw)메서드에서 잔고(balance)와 출금액을 비교해서 잔고가 부족하면 BalanceInsufficientExceptioin을 발생시킵니다.

package except;

public class Account {
    private long balance;

    public Account() {}

    public long getBalance() {
        return balance;
    }

    public void setBalance(long balance) {
        this.balance = balance;
    }

    public void deposit(int money) {
        balance += money;
    }

    public void withdraw(int money) throws BalanceInsufficientException {         // 사용자 정의 예외 처리
        if (balance < money) {
            throw new BalanceInsufficientException("잔고 부족: " + (money - balance) + " 모자람");
        }
        balance -= money;
    }
}

 

BalanceInsuficientException은 사용자 정의 예외로, 다음과 같이 정의되어 있습니다.

package except;

public class BalanceInsufficientException extends Exception {
    /**
     * 
     */
    private static final long serialVersionUID = 1L;
    public BalanceInsufficientException() {}
    public BalanceInsufficientException(String msg) {        // 생성자 오버로딩을 통해 정보 전달
        super(msg);
    }
}

 

이제 위의 Account 클래스의 withdraw 메서드와 BalaceInsufficientException이라는 사용자 정의 예외가 사용된 결과를 보겠습니다.

package except;
public class AccountExam {

    public static void main(String[] args) {
        Account act = new Account();

        act.deposit(25000);
        System.out.println("예금액: " + act.getBalance());

        try {
            act.withdraw(50000);
        } catch (BalanceInsufficientException e) {
            String msg = e.getMessage();
            System.out.println(msg);
            System.out.println();
            e.printStackTrace();
        }
    }
}

예외 발생

어떤가요? 그냥 예외 호출 스택만 툭 띄우는 것보다 예외의 내용과 사용자 메시지를 던져주는 게 훨씬 좋지 않나요?🧐

 

 

4. try/catch문의 디테일(2) - 호출자를 고려해 예외 클래스를 정의하자

예외를 분류하는 방법은 수 없이 많습니다.

 

예외가 발생한 위치로 분류할 수도 있고, 예외가 발생한 컴포넌트로 분류할 수도 있습니다.  아니면 예외의 유형으로도 분류가 가능하죠.

 

하지만 애플리케이션에서 예외를 정의할 때 프로그래머에게 가장 중요한 관심사는 예외를 잡아내는 방법이 되어야 합니다.

 

아래 코드는 예외를 잘 분류한 코드일까요?

ACMPort port = new ACMPort(12);

try{
  port.open();
} catch (DeviceResponseException e){
    reportPortError(e);
    logger.log("Device response exception", e);
} catch (ATM1212UnlockedException e){
    reportPortError(e);
    logger.log("Unlock exception", e);
} catch (GMXError e){
    reportPortError(e);
    logger.log("Device response exception", e);
} finally{
    ...
}

위 경우는 예외에 대응하는 방식이 예외 유형과 무관하게 거의 똑같습니다. 그래서 코드를 간결하게 고치기가 아주 쉽습니다. 호출하는 라이브러리 API를 감싸면서 예외 유형 하나를 반환하는 것이죠.

 

LocalPort port = new LocalPort(12);
try{
  port.open();
} catch (PortDeviceFailure e) {
  reportError(e);
  logger.log(e.getMessage(), e);
} finally{
  ...
}

여기서 LocalPort 클래스는 단순히 ACMEPort 클래스가 던지는 예외를 잡아 변환하는 Wrapper 클래스일 뿐입니다.

 

public class LocalPort{
  private ACMEPort innerport;

  public LocalPort(int portNumber){
    innerPort = new ACMEPort(portNumber);
  }

  public void open(){
    try{
      innerport.open();
    } catch (DeviceResponseEexception e){
      throw new PortDeviceFailure(e);
    } catch (ATM1212UnlockedException e){
      throw new PortDeviceFailure(e);
    } catch (GMXError e){
      throw new PortDeviceFailure(e);
    }
  }
  ...
}

Wrapper 클래스를 예외 처리에 사용하면 코드가 간결해지고 정확히 어떤 예외를 처리하려고 하는지 중복없이 깔끔하게 전달할 수 있습니다.

 

또한 외부 API를 감싸면 외부 라이브러리와 프로그램 사이에서 의존성이 크게 줄어듭니다.

 

나중에 다른 라이브러리로 갈아타도 유지 보수가 쉬워지는 것이죠.

 

 

5. 예외 처리의 중요성

프로그램에서 예외는 발생할 수밖에 없고, 특히 예외로 인해 서버 프로그램이 작동을 멈춘다면 문제가 커집니다.

 

적어도 발생한 예외가 어떤 예외이며, 이것이 무시해도 될 수준인지 아니면 반드시 처리를 해야하는 것인지 그것도 아니면 시스템을 종료시켜야 하는지에 대해 생각해봐야하죠.

 

이후에 포스팅에서 예외 처리에 대한 또 다른 내용을 다뤄보도록 하겠습니다.

 

감사합니다.