예외 정보를 무시하는 것은 소프트웨어 유지보수를 포기하는 것과 같다

등록일: 2014. 08. 14

자바 코딩, 이럴 땐 이렇게: PMD로 배우는 올바른 자바 코딩 방법

  • 배병선 지음
  • 426쪽
  • 28,000원
  • 2014년 05월 28일

소프트웨어의 품질을 향상시키는 가장 중요한 항목 중 하나는 예외 처리다. 적절한 예외 처리란 발생 가능한 예외를 최대한 줄이고 문제점을 외부로 노출시키지 않는 것이 아니라 정확한 예외의 유형과 정보를 전달하는 것이다.

사용자 입장에서 어떤 문제점이 발생했을 때 시스템이 이 문제점을 숨기는 것이 아니라 사용자가 어떻게 해야 할지 정확히 알려주는 것이 가장 중요하다. 예를 들어, 사용자가 파일 업로드에 실패했을 때 “파일 업로드 오류[CODE101] -10MB 이하의 이미지만 업로드할 수 있습니다.”라고 문제점에 관한 정확한 정보를 사용자에게 전달하면 사용자는 어떤 문제가 발생했고 어떻게 대처해야 할지 알 수 있다. 혹시나 기타 문의 사항이 있을 때는 제공된 오류 코드를 매뉴얼에서 확인하거나 담당 부서로 문의할 수도 있다.

사용자에게 더 이상의 상세한 정보는 필요하지도 않고, 전달해서도 안 된다. 많은 소프트웨어에서 문제가 발생했을 때 실수로 상세한 정보가 사용자에게 노출되기도 하는데, 이는 보안상 매우 민감하고 위험한 정보를 노출하는 셈이다. 예를 들어, 데이터베이스에 접근하는 시스템에서 자주 실수하는 부분으로 질의문 호출 시 문제가 발생했을 때 해당 질의문과 코드 줄, 문제와 관련된 상세 정보를 표시하곤 하는데, 이는 데이터베이스 구조에 관한 정보를 외부로 노출하고 SQL 인젝션 같은 공격을 당할 여지를 제공한다.

개발자 입장에서 시스템 개발 및 성능 개선과 유지보수 등을 위한 상세한 예외 정보가 때로는 수십 줄의 상세한 주석보다 더욱더 강력할 때가 있다. 주석은 소프트웨어가 어떻게 작동하느냐를 설명하는 것이라면 예외 정보는 소프트웨어의 어느 부분에 문제가 있는지 알려주는 역할을 하기 때문이다. 예외 정보는 질병의 증상과 같은 것이다. 시스템 운영 중에 발생한 긴급한 문제점을 처리할 때는 그 무엇보다도 효과적이다.

실제로 한 공공기관의 신규 ERP 시스템을 시연하던 중에 발생한 상황을 예로 들 수 있다. 당시 빈번한 시스템 요구사항 변경으로 인해 개발 기간이 매우 촉박했고, 이를 뒷받침할 여유 인력도 부족했다. 시연 직전까지도 신규 시스템을 개발 서버 환경에서 막 운영 서버 환경으로 이전만 했을 뿐 전체적인 시스템 점검을 할 시간이 부족했다. 시연을 위해 전체적인 시스템 점검은 불가능했고, 미리 정해진 점검 목록에 따라 최대한 점검하고, 실제 구현 중 발생할 수 있는 돌발 상황에 대비해 모든 담당자가 대기 중이었다.

그러다 시연 직전 마지막 리허설에서 보고서가 업로드되지 않는 문제점이 발견됐고, 시연 화면에는 “업로드 실패[CODE202] – XXX를 확인하세요.”라는 오류가 화면에 나타났다. 그 즉시 대기하고 있던 개발자는 화면상의 오류 메시지와 로그 파일을 비교/검토하고 해당 메시지를 토대로 상세 예외 정보를 검토한 결과 문제가 발생한 보고서 테이블의 컬럼 길이가 설계했던 것보다 짧다는 점을 발견할 수 있었다. 문제의 원인은 데이터베이스를 이전하는 과정에서 보고서 테이블이 최종 버전의 테이블 구조로 이전되지 않았던 것이었다. DBA는 즉시 보고서 테이블 구조를 최종 버전으로 수정했고, 해당 문제는 완벽하게 해결됐다. 문제가 해결되기까지 채 10분이 걸리지 않았고, 시스템 시연은 아무런 문제 없이 끝날 수 있었다.

오류 정보가 정확하지 않았거나 전혀 없었다면 문제가 발생한 위치를 재현하고 소스코드를 따라가며 문제점을 추적하는 방법 외에는 없었을 것이다. 하지만 당시 매우 긴급한 상황에서 정상적으로 이런 방식을 수행할 수 있었을지는 미지수이고, 최악의 경우 많은 시간을 소비하고 미궁에 빠져버릴 수도 있었다. 이처럼 정확한 오류 정보는 소프트웨어 품질 관리에 매우 중요한 역할을 한다.

소프트웨어 품질 측면에서 예외 처리가 중요하다는 사실은 대부분 인정하지만 실제 예외 처리는 현업에서 무시되거나 소홀하게 취급되는 일이 비일비재하다. 많은 프로젝트에서 시간적, 인적, 비용적인 이유로 소프트웨어의 주요 기능을 구현하는 시간을 개발기간으로 산정하고, 예외 처리와 같은 가시적이지 않은 부분은 공수에 포함하지 않기 때문에 실제로는 예외 처리를 위한 기간은 없는 것이나 다름없다. 소프트웨어의 핵심적인 기능 이외의 나머지 기능에 대한 예외 처리는 담당 개발자의 선택에 달렸는데, 결국 어떤 명확한 절차에 의한 예외 처리가 아닌 개발 편의를 위한 가장 단순한 형태로 구현되기 일쑤다.

이처럼 자의적인 예외 처리를 통해 최소한의 정보라도 표출되면 문제 해결의 실마리로 활용할 수 있겠지만, 안타깝게도 그렇지 않고 무시하는 예외 처리가 더 많다. 예를 들어, catch 절을 비워서 예외가 발생해도 어디서도 인지할 수 없도록 예외 정보를 무시하거나 발생한 예외 정보를 호출한 메서드나 객체로 던지듯이 전달해서 명확한 예외 상황의 원인과 의도를 알 수 없게 만들기도 한다. 그리고 오류 처리가 귀찮아서 가장 상위 객체인 Exception으로 모든 오류를 전달받아 instanceof로 처리하고 싶은 오류만 처리하는 것은 모두 잘못된 오류 처리 방식이다. 예제 13.1.1은 이러한 잘못된 오류 처리 방식의 예다.

예제 13.1.1 잘못된 오류 처리의 예

package com.software.debug.problem;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileWriter;
import java.io.IOException;

public class BadExceptionExample {
    /**
     * 발생한 모든 오류를 catch 절로 받아서 무시하는 방식
     * 이 메서드에서 어떠한 오류가 발생했는지는 아무도 알 수 없다.
     */
    public void errorMethod1() {
        try {
            /*
             * 무언가 실행하는 코드
             */
        } catch (Exception e) {
            // 아무런 예외 처리도 하지 않는다.
        }
    }

    /**
     * 발생된 모든 예외를 호출한 메서드나 객체로 전달하고 무시함
     * 정확한 오류 발생 위치와 정보를 알 수 없다.
     * 
     * @throws Exception
     */
    public void errorMethod2(String path, String str) throws Exception {
        File file = new File(path);
        BufferedWriter output = new BufferedWriter(new FileWriter(file));
        output.write(str);
        output.close();
    }

    /**
     * 개발자가 선택한 오류 이외의 모든 오류가 무시된다.
     */
    public void errorMethod3() {
        try {
            /*
             * 뭔가를 실행하는 코드
             */
        } catch (Exception e) {
            if (e instanceof FileNotFoundException) {
                // 오류 처리
            } else if (e instanceof IOException) {
                // 오류 처리
            }
        }
    }
}

문제점 진단

PMD에서는 잘못된 예외 처리를 경고하기 위해 다음과 같은 다양한 룰을 제공한다. 그림 13.1.1은 이러한 룰로 잘못된 오류 처리를 진단한 결과다.

  1. EmptyCatchBlock 룰: 비어 있는 catch 절을 진단
  2. AvoidThrowingRawExceptionTypes 룰: 예외 객체를 최상위 객체로만 처리하는 것을 경고
  3. SignatureDeclareThrowsException 룰: 정확한 오류 정보가 아닌 Exception 객체로 호출한 메서드나 객체로 전달하는 것을 경고.
  4. AvoidCatchingThrowable 룰: 모든 예외를 최상위 예외 클래스와 하나의 catch 절로 처리하는 것을 경고
  5. AvoidInstantiatingObjectsInLoops 룰: 하나의 catch 절 안에서 instanceof를 이용해 원하는 오류만 처리하는 것을 경고
figure13-1-1
그림 13.1.1 잘못된 오류 처리를 진단한 결과

해결 방안

예제 13.1.1과 같이 실행 중에 발생할 수 있는 다양한 증상을 단순히 무시하는 잘못된 예외 처리는 많은 위험성을 내포하고 있다. 이는 질병의 증상이 나타나도 무시하는 것과 다름없다. 발생한 문제를 해결하려면 최소한 오류가 발생한 위치와 정보를 기록해야 한다.

가장 먼저 모든 catch 절은 비어있어서는 안 된다. 예제 13.1.1의 errorMethod1과 같은 방식은 모든 오류 정보를 전달받아 무시하는 방식은 어떠한 오류가 발생해도 무시되어 프로그램이 정상적으로 실행되는 것으로 가장할 수 있다. catch 절은 예제 13.1.2와 같이 최소한 해당 오류가 발생했음을 알릴 수 있게 최소한의 절차를 구현해야 한다.

예제 13.1.2 catch 절에서 발생한 예외를 알릴 수 있도록 기록하는 예

package com.software.design.solution;

public class EmptyExceptionExample {
    public static Logger log = Logger.getLogger(EmptyExceptionExample.class);

    public void errorMethod1() {
        String str = null;

        try {
            System.out.println(str.substring(0, 2));
            // 비어있는 catch 절로 인해 NullPointerException이 무시된다.
        } catch (Exception e) {
            // 최소한 오류가 발생했음을 알릴 수 있는 로그를 출력해야 한다.
            logger.error(e);
        }
    }
}

발생한 예외를 단순히 상위 메서드나 클래스로 전달하는 것 또한 위험한 방식이다. 메서드에서 발생하는 예외는 메서드 내부의 절차에서 발생하는 내부 오류와 외부에서 메서드를 호출할 때 인자를 잘못 전달해서 생기는 등의 외부 호출 오류가 있다. 메서드에서 발생한 모든 문제점을 외부로 전달해버리면 이 문제점의 발생 원인이 정확히 내부의 잘못에서 발생한 문제인지 외부에서 잘못 호출해서 발생한 문제인지 구분하거나 그 원인을 파악하기가 매우 어렵다.

예를 들어, 예제 13.1.1의 errorMethod2와 같이 문자열을 파일에 저장하는 메서드는 저장될 문자열과 저장 경로를 전달받아 해당 위치에 파일을 생성하고 이 파일에 전달된 문자열을 저장하게끔 구현돼 있다. 이 메서드에서 발생할 수 있는 예외로는 내부에서 파일을 생성하고 데이터를 저장하는 과정에서 path가 이미 존재하는 파일을 참조할 때 발생하는 FileAlreadyExistsException 및 파일과 관련된 예외인 IOException과 같은 내부 오류를 비롯해 str이 null일 경우 발생할 수 있는 NullPointerException과 같은 외부 오류가 있다. 하지만 모든 예외를 throws Exception을 통해 이 메서드를 호출한 메서드로 전달해버리면 문제의 원인을 파악하기 매우 까다롭다.

메서드 내부에서 발생해서 자체적으로 해결해야 하는 예외의 경우 메서드 내부에서 catch 절을 통해 해결하고, 외부에서 비롯되는 오류는 외부에서 인지하고 처리할 수 있게 위임해야 한다. 단 메서드의 기능 자체가 해당 메서드를 호출한 메서드와 매우 밀접한 관계를 맺고 있다면 단순히 모든 예외를 해당 메서드를 호출한 메서드로 전달할 수 있다. 예제 13.1.3은 이런 식의 내/외부 예외 처리를 구분한 예다.

예제 13.1.3 내/외부 오류를 분리해서 예외를 처리

package com.software.design.solution;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;

public class FileErrorExample {
    // public static Logger log = Logger.getLogger(FileErrorExample.class);

    public static void main(String[] args) {
        FileErrorExample example = new FileErrorExample();
        try {
            example.errorMethod2("test.txt", "ttt");
        } catch (FileAlreadyExistsException e) {
            // e.printStackTrace();
            logger.error("[FILE002] 파일 생성 오류");
            logger.error(e.getMessage());
        }
    }

    /*
     * FileAlreadyExistsException 파일이 이미 존재한다는 예외는 외부에서 이 메서드를 호출할 때 
     * 중복된 경로를 지정해서 발생한 오류로서 내부 오류가 아닌 외부 오류로 봐아 한다.
     */
    public void errorMethod2(String path, String str)
            throws FileAlreadyExistsException {
        try {
            File file = new File(path);
            Files.createFile(file.toPath());
            BufferedWriter output = new BufferedWriter(new FileWriter(file));
            output.write(str);
            output.close();
        } catch (IOException e) {
            // e.printStackTrace();
            logger.error("[FILE001] 파일 쓰기 오류");
            logger.error(e.getMessage());
        }
    }
}

마지막으로 errorMethod3과 같이 단 하나의 catch 절을 사용해 모든 오류 정보를 전달받고 특정 오류만 instanceof를 이용해 선택적으로 처리할 경우 예측 가능한 오류만 처리하고 그 밖의 모든 예외는 무시된다. 예측 불가능한 예외는 무시되거나 잘못된 오류 절차로 이어질 수 있으며, 특히 외부 오류로 발생한 오류도 예측 불가능한 오류로 분류되어 오류가 발생해도 오류가 발생한 정확한 이유를 알 수 없거나 예외 자체가 무시되어 오류가 발생하지 않은 상태로 가장할 수 있다. 예제 13.1.3와 같이 내부에서 발생할 수 있는 오류와 외부 오류로 분류해서 처리하고, 예측 불가능한 오류는 그대로 발생하게 해서 나중에 문제가 발생했을 때 이를 처리할 수 있게 해야 한다.