[JAVA] 현명하게 예외처리하기

예외 처리의 중요성

대부분의 언어에는 예외 처리(혹은 오류 처리)라는 것이 존재한다. 예외 처리는 프로그래밍 시, 아주 중요한 작업이다. 시스템의 문제 여부를 빠르게 확인하고 어떠한 문제인지 파악하는데 도움을 받을 수 있기 때문이다. 또한 어플리케이션이 다시 정상적으로 동작할 수 있도록 도움을 줄 수도 있다.

예외의 종류

JVM 계열 언어(Java, Kotlin, Scala 등)에서는 Checked Exception, Unchecked Exception 두 가지 종류의 예외를 지원한다. 이 둘은 각각, 다음과 같은 특징을 지니고 있다.

Checked Exception

  • 예외 처리가 필수다.
  • 개발자가 예측하기 쉽다.
  • 코드 수준의 문제 보다는, 주로 외부적인 요인(파일/경로 없음, 입출력 도중 연결 끊김 등)에 의해 발생한다.

Unchecked Exception

  • 예외 처리를 강제하지 않는다.
  • 개발자가 예측하기 어렵다.
  • 주로 코드 수준에서 발생하는 문제다. 일반적으로 개발자의 부주의 함에 의해 발생하는 경우가 많다.

이 글에서는 예외 처리가 강제적인 Checked Exception 위주로 다루어 볼 예정이다.

예외 처리 방법

예외를 처리하는 방법들은 다음과 같다.

try/catch 구문으로 예외 처리

가장 기본적인 예외처리로 try 구문으로 예외가 발생할 수 있는 코드를 감싸고, 예외가 발생할 경우 catch 구문으로 이동해 처리하도록 하는 것이 try/catch 구문의 역할이다.

try {
    OutputStream out = new FileOutputStream("a.txt");
} catch (FileNotFoundException e) {
    log.warn("존재하지 않는 파일입니다.");
}

catch 구문을 사용하면, 예외가 발생한 로직을 다른 방식으로 처리할 수 있도록 유도할 수 있다.

throws 구문으로 예외 던지기

메소드 내부에서 try/catch 구문을 사용하기 여의치 않거나, 모아서 예외를 처리하고 싶다면 throws를 이용해 해당 메소드를 호출한 곳에서 예외를 처리하도록 책임을 전가할 수 있다.

public void readFile() {
    try {
        read();
    } catch (FileNotFoundException e) {
        log.warn("존재하지 않는 파일입니다.");
    } 
}

private void read() throws FileNotFoundException {
    OutputStream out = new FileOutputStream("a.txt");
}

대신 throws를 사용한 경우, 잊지말고 호출한 위치에서 예외처리를 하도록 해야한다. (물론 요즘 IDE들은 잊지 말고 예외 처리 해야한다고 다 알려 준다...)

예외 전환

하나의 메소드에서 발생하는 예외가 너무 많거나 발생한 예외의 의미가 명확하지 않을 경우, 이를 공통으로 감쌀 수 있는 예외 혹은 명확한 의미를 지닌 예외로 원래의 예외를 감싸 예외를 전환할 수 있다. 아래는 IOException이 발생한 경우, 좀 더 명확한 의미의 예외인 FileNotFoundException으로 전환하여 발생시키는 코드다.

public void readFile() {
    try {
        OutputStream out = new FileOutputStream("a.txt");
    } catch (IOException e) {
    
        /*
         * FileNotFoundException은 IOException의 하위 예외다.
         * 따라서 실제로는 이렇게 작성하지 않으며, catch (FileNotFoundException e)를 사용하는게 맞다.
         * 여기서는 예시를 위해, 이렇게 작성했음을 알린다.
         */
        throw new FileNotFoundException("존재하지 않는 파일입니다.");
    }
}

물론 이렇게 예외를 전환 했다면, try/catch 혹은 throws 구문을 이용해 다시 예외 처리를 해주어야 한다.

무시

무시는 대단히 특수한 케이스다. 실제로 예외를 무시하는 것은 매우 좋지 않은 프로그래밍 습관이다. 자세한 사항은 나쁜 예외 처리에서 다루도록 하겠다.

try {
    OutputStream out = new FileOutputStream("a.txt");
} catch (FileNotFoundException e) {
    // 무시는 매우 특수한 케이스임을 잊지않도록 다시한번 강조한다!        
}

나쁜 예외 처리

예외를 catch하고 아무것도 하지 않기

try/catch 구문을 이용해 예외를 잡아놓고 아무것도 하지 않으면 예외를 catch할 이유가 없다. 예외를 catch 했다면 예외 상황을 파악하기 위한 로그라도 남기는게 좋다.

// BAD
try {
    OutputStream out = new FileOutputStream("a.txt");
} catch (FileNotFoundException e) {}

// GOOD
try {
    OutputStream out = new FileOutputStream("a.txt");
} catch (FileNotFoundException e) {
    log.error(e.getMessage());
}

다만 위에서 얘기했듯이 예외도 있다. 로직상 예외가 절대 발생하지 않는다고 확신할 수 있을 경우에는 catch절을 그냥 비워둔 채로 놔둬도 상관없다.

try {

    /*
     * getBytes() 메소드는 매개변수가 지원되지 않는 인코딩일 경우 예외가 발생한다.
     * 하지만 "UTF-8"은 지원되는 인코딩 방식이므로 절대 예외가 발생하지 않는다. 
     */
    byte[] bytes = "String".getBytes("UTF-8");
    
        // 이 경우에는 e 혹은 exception 대신 ignored를 쓰는게 좋다.
} catch (UnsupportedEncodingException ignored) {}
위의 로직에서는 절대로 UnsupportedEncodingException이 발생할 수 없다.

일반적으로 catch 절의 인수명은 관습적으로 e 혹은 exception을 사용하지만,  SornarLint에서 위와 같은 경우에는 ignored라는 인수명을 사용 하라고 경고를 띄운다.

무작정 throws하고 보기

초보 개발자의 경우, 간혹 메소드 내부에서 try/catch 처리를 하기 귀찮다는 이유로 무작정 호출한 메소드로 throws하는 경우가 많다. 물론, 어딘가에서 try/catch 처리를 한다면 문제는 없다. 문제는 최종적으로 가장 처음에 호출된 메소드에서도 그대로 throws 하는 경우다. 물론 이렇게 하더라도 최종적으로는 JVM에서 처리 되므로 당장 동작에는 문제가 없다. 그러나 이는 스스로 예외에 대한 정보를 얻는 것과 이를 능동적으로 처리할 기회를 날려버리는 것이다. 따라서 상황을 고려하지 않는 throws 남발은 하지 않는것이 좋다.

추상화 된 예외 클래스 사용하지 않기

여기서 말하는 추상화 된 예외 클래스는 Exception.class를 의미하는데, 추가로 하나 더 꼽자면 RuntimeException.class도 포함할 수 있다. Java에서 지원하거나 혹은 직접 작성해서 사용하는 예외는 모두 이 둘 중 하나의 예외를 상속받아서 구현해야만 한다.

☝️
간혹 다른 예외 클래스를 상속받아 구현된 것들도 볼 수 있는데, 이런 예외들도 상속된 예외 클래스를 파고 들면, 끝에는 Exception.class가 나오게 된다.

throws 혹은 catch문에서 예외를 던지거나 잡을 때, 다음과 같이 작성하는 경우가 있다.

public void method() throws Exception {
    ....
}

혹은

try {
    ...
} catch (Exception e) {
    ...
}
throws Exception, catch (Exception e)를 사용하면 모든 예외를 한번에 필터링 할 수 있다.

위와 같이 작성하면 작성해야 할 코드량은 줄어들지만, 예외 별로 다양하게 로직을 작성할 수 없게되며 어떤 예외가 발생했는지 일일이 예외 스택을 추적해야하는 번거로움이 생기게 된다. 따라서 좀 더 명시적인 예외를 사용하는 것이 더 좋다.

// BAD
try {
    ...
} catch (Exception e) {
    // 로직상 발생하는 예외 별로 다르게 처리할 수 없다!
}

// GOOD
try {
    ...
} catch (FileNotFoundException e) {
    // 로직 1
} catch (UnsupportedDataTypeException e) {
    // 로직 2
}

e.printStackTrace() 사용 지양

catch절에서 잡은 예외의 메소드인 printStackTrace()를 사용하는 경우가 많은데, 이 메소드는 사용하지 않는 것이 좋다. 이 메소드는 다음과 같은 단점을 지니고 있다.

  1. System.error를 이용한 출력이므로 비용이 비싸며 제어하기 어렵다.
  2. 출력이 어디로 가는지 WAS별로 다르기 때문에 기록 파악이 힘들다. (Tomcat의 경우 기본적으로 catalina.out에 기록된다.)
  3. 상태의 단계(info, warning, error 등) 별로 관리하기 힘들다.

따라서 printStackTrace()를 사용하는 것 보다는 Throwable을 지원하는 log 라이브러리(log4j2, logback 등)를 쓰는게 좋다.

// BAD
try {
    ...
} catch (FileNotFoundException e) {
    e.printStackTrace();
}

// GOOD
try {
    ...
} catch (FileNotFoundException e) {
    log.error("파일이 존재하지 않습니다.", e);
}

예외 처리를 위한 팁

예외에 의미 제공하기

대부분의 예외는 메시지를 담을 수 있는 매개변수를 지원한다. 이 메시지에는 현재 예외 상황에 대한 다양한 정보를 담을 수 있다. 예외 상황 발생 시, 빠르게 상황 파악을 하기 위해서는 이 메시지에 최대한 많은 정보를 담는 것이 좋다.

// GOOD
public void method(String a) {
    if (a == null) {
        throw new IllegalArgumentException("인수 a는 null일 수 없습니다.");
    }
}

Unchecked Exception를 선호하자

Checked Exception의 장점은 명확하다. 예외 처리를 강제하기 때문에 예외 상황으로 부터 복구가 가능하고, 새로운 흐름으로 유도할 수 있다. 즉, 어플리케이션을 좀 더 안정적으로 만들 수 있다. 하지만 개발자 입장에서 볼 때, Checked Exception은 장점에 비해 단점이 더 크다. 가장 대표적으로 개방-폐쇄 원칙을 어기는 코드를 만들기 쉬우므로 개발자가 리팩토링을 시도하기 어렵게 만든다. 다음의 코드를 보자.

public void firstMethod() {
    try {
        secondMethod();
    } catch (FileNotFoundException e) {
        log.error("존재하지 않는 파일입니다.");
    }
}

public void secondMethod() throws FileNotFoundException {
    thirdMethod();
}

public void thirdMethod() throws FileNotFoundException {
    OutputStream out = new FileOutputStream("a.txt");
}

이 때 만약 thirdMethod()에서 변경이 일어나 FileNotFoundException이 아닌 다른 예외를 발생시켜야 하거나 제거해야한다면, firstMethod(), secondMethod(), thirdMethod() 세 곳에서 코드 변경이 연쇄적으로 일어나게 될 것이다. 이러한 이유 때문에 Unchecked Exception을 사용하는 것이 비용적으로 좀 더 이득이다. 또한, 강제는 아니더라도 Unchecked Exception도 마찬가지로 try/catch 구문으로 잡을 수 있으므로 필요에 따라 별도의 흐름으로 유도하는 것이 가능하다.

플래그, 오류코드 보다는 예외 발생시키기

플래그, 오류 코드를 사용하는 것은 대표적인 안티 패턴이다. 옛날이야 예외를 지원하지 않는 언어가 많았기 때문에 플래그 혹은 오류코드를 이용해 오류 상황을 제어하는 경우가 많았다. 하지만 예외가 지원되는 지금은 굳이 플래그나 오류 코드를 사용할 필요가 없다. 아래의 코드는 클린 코드라는 책에서 가져온 코드다.

public void sendShutDown() {
    DeviceHandle handle = getHandle(DEV1);
    
    // 디바이스 상태 점검
    if (handle != DeviceHandle.INVALID) {
    
        // 레코드 필드에 디바이스 상태를 저장
        retrieveDeviceRecord(handle);
        
        // 디바이스가 일시정지 상태가 아니라면 종료한다
        if (record.getStatus() != DEVICE_SUSPENDED) {
            pauseDevice(handle);
            clearDeviceWorkQueue(handle);
            closeDevice(handle);
        } else {
            log.info("Device suspended. Unable to shut down");
        }
    } else {
        log.info("Invalide handle for: " + DEV1.toString());
    }
    ...
}

위와 같이 디바이스의 상태를 나타내는 오류 코드를 사용할 경우, 호출자의 코드가 복잡해지는 것을 볼 수 있다.

public void sendShutDown() {
    try {
        tryToShutDown();
    } catch (DeviceShutDownException e) {
        log.info(e.getMessage());
    }
}

private void tryToShutDown() throws DeviceShutDownException {

    // 모든 것을 이 메소드에서 처리하다, 예외가 발생하면 호출부로 예외를 던진다
    DeviceHandle handle = getHandle(DEV1);
    DeviceRecord record = retrieveDeviceRecord(handle);
    
    pauseDevice(handle);
    clearDeviceWorkQueue(handle);
    closeDevice(handle);
}

private DeviceHandle getHandle(DeviceId id) {
    ...
    throw new DeviceShutDownException("Invalid handle for: " + id.toString());
    ...
}

위의 코드를 보면 로직의 세부 내용을 별도의 메소드로 분리한다. 분리된 메소드에서 오류가 발생할 경우 예외를 발생 시키면 호출부가 훨씬 간단해지는 것을 볼 수 있다.


정리하며

예외 처리는 아주 귀찮고 어려운 일이다. 그래서 이번 기회에 누군가에게 도움이 되길 바라며 예외와 관련해 긴 글을 한번 적어보았다. 다음 번에는 새로운 주제를 통해 돌아오도록 하겠다.


참고한 문헌 및 글

  1. 클린 코드 - 로버트 C. 마틴 지음 (박재호, 이해영 옮김)