Right now do !

[JAVA] JAVA의 예외처리 - 처리에 대한 다양한 고민

by 지금당장해

프롤로그

 지난 글에서 JAVA의 Throwable 그리고 Exception 클래스들의 계층 관계를 살펴보고 Unchecked, Checked 가 어떻게 구별되는지 왜 구별을 해야 하는지 생각해보는 기회를 갖었다. 이번 글에서는 이렇게 발생한 예외를 어떻게 다루어야 할지를 정리해보도록 하겠다. 일전에 C#의 예외처리에 대한 글을 쓸 때도 언급을 했는데 필자는 예외처리를 위해 try... catch를 남발하는 것을 매우 경계한다. 이는 가독성을 떨어뜨리고 전체적인 로직의 흐름에 방해 요소로 작용하여 유지 보수성을 떨어뜨려 결국 프로그램 품질에 까지 영향을 주는 요소로 작용한다. 프로그램 매니저 역할을 하던 시절에는 단위 모듈 개발자들에게 "그 어디에서도 try... catch를 쓰지 마세요."라고 하고 메인 애플리케이션 혹은 스레드 레벨의 전체 예외 처리를 통해 오류 메시지를 처리하게 한적도 있다. 예를 들어 파일 열기 처리를 할 때 IOException이 발생할 것을 고려하여 try ... catch를 잡는 코드 대신 이를 예측하여 파일이 존재 유무를 판단하는 코드를 넣어 예외 발생을 방지하는 코드를 할 수 있는 것이다. 그러나 이 방법에는 맹점이 있다. 파일을 못 여는 문제는 해당 파일이 존재하지 않아서 예외가 발생하는 경외 외에도 다양한 원인이 있을 수 있기 때문이다. 이 상황에서 프로그램은 사용자에게 오류가 발생한 원인을 알리고 다른 파일을 열던 다시 시도를 하던 여러 선택지를 제시할 수 도 있는데 필자가 했던 획일적인 방식으로 예외를 처리하면 이런 기회를 무시한 체 애플리케이션이나 해당 스레드의 Unhandled Exception 처리기까지 예외가 도달하게 되고 이는 문제가 발생한 지점에서 처리할 수 있는 기회의 박탈을 의미한다.  참 애매모호한 표현인데 너무 잡아도 안되지만 잡을 때는 잡아줘야 한다.

Checked Exception 처리  

 아직도 C#이 메인 프로그램 언어라고 생각하는 필자 입장에서는 Checked Exception의 존재는 상당히 거추장스러운 것이었다. 의미를 정확히 모를 때는 더더욱 그랬다. 예를 들어 같은 IOExcetion의 경우에도 C#의 경우에는 엄밀히 말하면 Unchecked이다 예외가 발생하면 이를 예측하지 못한 개발자 잘못이지 이를 위한 어떤 방지장치도 없다. 그러나 JAVA의 경우에는 그렇지 않다. IOException 같은 Checked 예외는 발생할 가능성이 농후하니 해당 지점에서 이를 예측해서 미리 처리를 하라는 의도가 있다. 이런 언어 설계자의 배려에도 불구하고 개발자가 아래와 같이 구현한다면 어쩔 수 없다. 그냥 안드로메다로(호출자에게) 예외는 날아가고 만다.

File file = new File("~/블라블라경로/test.txt");
try {
    FileReader fr = new FileReader(file);
} catch (FileNotFoundException e) {
    throw e;
}

[코드1 - 호출자에게 throw 하는 Checked Excption]

 

 위 코드는 그래도 호출자에게 알리기라도 한다. 그리고 이런 처리가 필요한 경우도 있다. 하지만 아래 코드와 같은 예외 처리는 반드시 출시 전에 수정해야 한다. 사실 더 나쁜 경우도 있는데 아래 코드에서 e.printStackTrace() 호출 조차도 없는 경우이다. 이런 경우를 업자들끼리 통하는 용어로 예외를 먹었다고 한다. 만약 정말 정말 어떤 이유가 있어 Checked 예외를 꿀꺽해야 하는 상황이 있다면 // Ignore: 간단한 사유를 기입하여 나중에 이 코드를 본 다른 개발자가 당황하지 않도록 해야 한다. 

File file = new File("~/블라블라경로/test.txt");
try {
    FileReader fr = new FileReader(file);
} catch (FileNotFoundException e) {
    e.printStackTrace();
}

[코드2 - 출시 전에 반드시 수정해야 하는 예외 처리]

 

 자 그렇다면 이 상황에서 권장되는 예외처리는 무엇인가? 안타깝게도 딱 부러지는 정답은 없다. 대신 생각해볼 수 있는 경우를 나열해보려 한다. 대화형 프로그램(콘솔이나, GUI 가 존재하는)이고 해당 파일의 경로를 이를 통해 제공받았다면 사용자에게 해당 경로의 파일을 열거나 읽다가 문제가 생겼다는 것을 알리고 다음 지시를 기다리는 방법을 생각해볼 수 있다. 바꾸어 말하면 예외 발생 후 이에 따라 흐름이 분기되는 경우이다. 이 경우에는 호출자에게 throw는 없다. (사실 서두에서도 이야기했지만 아래와 같이 예외로 로직을 유도하지 말고 파일이 존재하는지 확인하는 API를 호출하여 해결하는 편이 가장 좋다. 아래 코드는 예외를 설명해야 하는 차원에서 작성된 코드이니 이점 유의하기 바란다.)

public static void main(String[] args) {
    String command = System.console().readLine("경로를 입력해주세요!");
    while (true) {
        File file = new File("~/블라블라경로/test.txt");
        try {
            FileReader fr = new FileReader(file);
        } catch (FileNotFoundException e) {
            System.console().printf("예외가 발생했고요. 예외는 다음과 같아요. %s", e.getMessage());
            command = System.console().readLine("경로를 다시 입력 하세요. 종료하려면 'exit' 를 입력하세요.");
            if (command.equals("exit")) {
                break;
            }
        }
    }
}

[코드3 - 사용자와 대화를 통한 예외 해결]

 

  다음 생각해볼 수 있는 경우는 해당 파일이 어떤 서비스 (혹은 데몬이라고 불리는) 프로그램의 기동 중 사용되는 설정 파일류라고 생각해보자. 그것이 DBMS 쯤으로 가정을 해보자. 설정 파일을 읽다 어떤 이유던 오류가 발생했다면 해당 서비스의 온전한 기능 수행(서비스 시작)은 불가능하다. 필자라면 이런 경우에 제일 먼저 해당 상황을 로깅을 한다. 로깅의 방법은 다양하다. 심지어 위에서 고쳐야 한다고 했던 e.printStackTrace() 호출 조차도 소극적이긴 하나 로깅에 해당한다. 하지만 이런 영속성이 없는 로깅은 피하기 바란다. 콘솔에 무수히 찍히는 다른 로그 메시지에 가려 내용을 보기 힘들 수도 있기 때문이다. 가급적이면 파일이나 기타 다른 매체로 영속성을 유지시킬 수 있는 Logging 프레임웍의 사용을 권장한다. log4j 혹은 logback 등을 이용하여 로깅을 한다. 두 번째 할 일은 다음과 같다. Checked 예외이긴 하나 설정 파일을 못 읽었으면 서비스를 할 수 없다고 판단해야 한다. 그렇다 서비스를 중단시켜야 한다. 첫 번째 생각해볼 수 있는 방법은 다음 코드와 같이 예외가 잡힌 이 시점에 프로그램을 강제 종료시키는 방법이다.

public void appExitNow() {
    File file = new File("~/conf/config.xml");
    try {
        FileReader fr = new FileReader(file);
    } catch (FileNotFoundException e) {
        // 먼저 로깅을 한다.
        log.error("설정 파일이 해당 위치에 없습니다. 서비스가 종료 됩니다.", e);
        System.exit(-1);
    }
}

[코드4 - 예외 발생 시 로깅을 하고 바로 프로그램 종료]  

 

 얼핏 보면 문제없는 듯하다. 그러나 이 프로그램이 현재 이 코드에 해당하는 객체만 인스턴싱 되어 있고 이 객체 내에는 해제해야 할 자원이 하나도 없을 경우에 그렇다. 지금 가정하고 있는 서비스 프로그램의 기동 과정을 생각해보면 위와 같은 코드가 여러 호출 스택을 가지고 있을 가능성이 높다. 그러니 위 코드와 같이 예외가 검출된 위치에서 대책 없이 프로그램을 종료시켰다가 이를 호출한 상위 컴포넌트가 이 호출 전에 확보한 자원의 해제의 기회를 주지 못하는 문제가 발생할 수 도 있는 것이다. 필자는 이런 경우라면 바로 이 지점에서 exit를 호출하는 것보다는 해당 예외를 호출자에게 던져 호출자에게 자원을 정리할 기회를 주는 것이 맞다고 본다.

public void appExitNow() {
    File file = new File("~/conf/config.xml");
    try {
        FileReader fr = new FileReader(file);
    } catch (FileNotFoundException e) {
        // 1) 먼저 로깅을 한다.
        log.error("설정 파일이 해당 위치에 없습니다. 서비스가 종료 됩니다.", e);
        // 2) 이 객체의 자원을 해제한다.
        // ~ 블라블라(알아서 해제 코드를 작성 하세요.)
        // 3) 호출자에게 서비스를 운영할 수 없는 예외가 발생했음을 알림
        throw new LifeCycleException("설정 파일을 찾을수 없어요", e, Level.Error);
    }
}

[코드5 - 발생한 예외를 로깅하고 호출자에게 커스텀 예외 객체를 만들어 상황을 전달해야 하는 경우]

 

 위 예제 코드의 LifeCycleException은 커스텀 예외 클래스이다. 아파치 톰캣에서 그 이름을 차용하였다. 이 예외를 얻어맞은 호출자는 오류 레벨을 판단하여 처리를 결정할 것이다.  지금 까지 언급한 Checked 예외 처리에 대한 몇 가지 케이스를 정리해보자.

 

  • 잡고 아무것도 안 함 - 비 추천, 적어도 로깅을 하거나 꼭 필요한 경우 사유를 주석으로 남길 것
  • 잡고 그냥 그대로 던지는 경우 - 비 추천, 적어도 로깅이라도 할 것
  • 사용자에게 상황을 알리고 처리를 물어 수행함 - 가능한 경우가 한정적임(대화형 프로그램만 가능)
  • 로깅하고 검출 위치에서 프로그램 바로 종료 - 검토 필요, 해당 클래스가 구조상 최상위가 아니라면 자원 해제 문제가 예상됨
  • 로깅하고 호출자에게 오류 상황을 알릴 수 있는 커스텀 예외 객체 throw - 상위 객체에서 예외 객체의 레벨을 판단하여 처리  

Unchecked Exception 처리

 이전 문서에서도 언급했지만 Error과 RuntimeException 은 예측할 수 없는 상황에서 벌어지고 이를 예측하여 잡아도 대부분 해당 프로세스를 유지할 수 있는 상황이 못된다. 즉 대부분이 프로그램을 종료시켜야 하는 상황에서 발생한 오류라는 것이다. 그것이 잘못된 프로그램이던 H/W나 OS에서 발생한 인프라적인 문제던 결론은 마찬가지다. 그렇다면 응용프로그램 개발자 입장에서 Unchecked Exception에 대비하여 무엇을 준비해야 하는가? 

Unchecked Exception의 검출

 다시 한번 말하지만 RuntimeException 혹은 이를 상속받은 예외 객체 혹은 Error는 특별한 이유가 아니라면 잡지 말아야 한다. 거꾸로 생각해보면 이런 예외는 발생하면 안 되는 오류들이다. 해서 잡아도 할 수 없는 경우가 대부분이다. 그럼 이런 오류는 그냥 두는 것이 능사인가? 그냥 두면 JVM이 잡아 콘솔에 예외 정보를 찍어 최소한의 원인을 알려주고 프로세스가 종료된다. 이런 예외 정보는 최종 사용자가 창을 기록 없이 닫아버리거나 뭘 봤는지 기억하지 못하는 경우가 다반사다. 뭐가 안 된다고 쫒아 갔는데 아무것도 남아 있지 않다. 모든 함수를 try ... catch로 둘러 쌀 수도 없다. 이럴 때 사용할 수 있는 방법이 전역 예외 검출 헨들러를 이용한 Unhandled Exception 처리다.

GlobalUnhandledExceptionHandler globalUnhandledExceptionHandler
    = new GlobalUnhandledExceptionHandler(
    new GlobalUnhandledExceptionListener() {
        @Override
        public void occurredException(Thread t, Throwable e) {
            if ( e instanceof LifecycleException
                && ((LifecycleException)e).getExceptionLevel().getLevel()
                > ExceptionLevel.Error.getLevel() ) {
                System.exit(ABNORMAL_TERMINATION);                        
            }
            else if (e instanceof Error || e instanceof RuntimeException) {                        
                log.error("알수없는 오류가 발생했습니다. 시스템이 종료 됩니다.", e);
                System.exit(ABNORMAL_TERMINATION);
            }
            else {
                log.error(e.getMessage());
            }
        }
    });
Thread.setDefaultUncaughtExceptionHandler(globalUnhandledExceptionHandler);
// 아래 코드를 사용하면 이 Thread 에 한해서 오류를 잡아준다.
//Thread.currentThread().setUncaughtExceptionHandler(globalUnhandledExceptionHandler);

[코드6 - Unhandled Exception의 검출과 처리]

 

막상 사용법은 무척 간단하고 명료하다. GlobalUnhandledExceptionHandler 이벤트 리스너를 선언하여 시스템 전역에서 즉 모든 스레드에서 발생한 Uncaught exception을 잡아주는 함수를 통해 이를 등록하면 된다. 필요에 따라 해당 스레드의 오류만 검출하는 방법도 있다. (주석으로 처리한 가장 아랫 라인코드 참조)

Unchecked Exception의 처리

 try ... catch를 이용하여 무지막지 예외처리하는 방법도 있으나 이는 생략하겠다. 이전 단원에서 언급한 전역 예외 검출 헨들러를 이용하여 검출된 예외를 어떻게 처리해야 하는지를 다뤄 보도록 하겠다. 해당 리스너의 인터페이스 함수는 occurredException(Thread t, Throwable e) 함수를 하나 선언하고 있다. 이 함수의 인자를 주목하자 예외가 발생한 Thread와 예외가 담긴 Throwable이 인자로 전달된다. 이를 분석하여 적절한 처리를 하면 되는데 이 적절한 처리에는 프로그램 종료도 포함이 되어 있다. 필자가 제시한 예제에서는 Thread 정보까지 사용하지 않았으나 실제 복잡한 서비스에서는 어느 스레드에서 난 오류인지에 따라 처리가 달라질 수 있으니 이런 측면에서 활용도를 고민해보자. 필자는 인자로 전달된 Throwable이 서비스 기동시 발생한 오류인지를 파악하기 위해 LifecycleException이라고 하는 커스텀 예외 객체인지 그리고 이 객체가 담고 있는 정보가 심각한지(Error이상) 파악하여 시스템을 비정상 종료시켰다. 로깅은 이미 이 예외를 만들어낸 지점에서 처리했다고 가정한다. 또한 이번 장에서 다루는 Unchecked(Error, RuntimeException)류의 예외는 로깅을 하고 종료하도록 하였다. 필자가 권고한 대로 코드를 작성하였다면 이런 종류의 예외를 그 어디에서도 잡지 않았기 때문에 이 지점에서 로깅은 반드시 필요하다. 

 

에필로그

 2회에 걸쳐 예외 Class들과 예외처리에 대해 다루었다. 현대의 객체지향 언어의 예외처리는 그 자체 만으로는 그리 복잡하지 않다. 그러나 이를 잘못 적용하면 해당 시스템의 전체 구조가 지저분해지고 이에 따라 운영관리에도 영향을 미치는 요소로 작용할 수 있다.  필자가 강조한 2편의 글을 요약하자면 다음과 같다. 1) Unchecked 예외는 가급적 잡지도 상속받지도 않는다. 2) Checked 예외는 잡힌 지점에서 로깅, 판단하여 분기 또는 호출자에게 throw(가급적 다른 형태로 가공하여) 한다. 3) 전역 예외처리 헨들러를 이용하여 Unchecked 예외를 정리할 수 있다. 

 

마지막으로 아무쪼록 이 글이 독자들의 시스템의 예외처리 개선에 도움이 되길 기원한다.

블로그의 정보

지금 당장 해!!!

지금당장해

활동하기