2014년 10월 8일 수요일

어째서 에러리턴을 예외처리보다 선호하는가.

대체로 아래 링크들에서 하는 이야기들의 재탕이다.
http://blogs.msdn.com/b/oldnewthing/archive/2005/01/14/352949.aspx
http://blogs.msdn.com/b/oldnewthing/archive/2004/04/22/118161.aspx

아래 링크에서는 Joel Spolsky의 코멘트 부분 역시 근본적으로 같은 지적.
http://nedbatchelder.com/text/exceptions-vs-status.html

내 코멘트를 아주 간략하게 덧붙이자면, 예외처리는 "방금 벌어진 일이 무엇인가?"가 아니라 "무슨 종류의 문제가 생겼는가?"를 기반으로 동작하도록 되어 있는데, 이것은 declarative한 코드에 어울리지만 예외처리라는 기능이 그 자체로 코드를 declarative하게 만들 능력은 없으므로, Imperative한 언어에서는 에러리턴이 맞는 방식이라는 것이다.

일전에 트위터 타임라인에서 "어째서 exception이 더 깔끔한가?" 라는 주제로 글이 올라왔었는데, 솔직하게 말하자면 그 예제 자체가 엄밀한 예외처리가 아니었고, 어째서 내가 예외처리를 싫어하는가를 단적으로 보여주는 예였다. 글을 찾기 귀찮아서 거의 equivalent한 유사코드를 써보자면,

try {
    file_object = file_open("file_name"); // 파일을 연다. 예외 발생 가능성 있음.
    file_object.read(BUFFER_SIZE, buffer); // 파일을 읽는다. 예외 발생 가능성 있음.
    // 읽은 내용으로 뭔가를 한다.
    file_object.close();
} catch (Exception e) {
    handle_file_exception(e);
}

이와 대비되는 에러코드 리턴의 유사코드를 써보자면,

open_error = file_open("file_name", file_object_pointer);
if (open_error != NULL) {
    handle_open_error();  // 이 구문만을 보자면 별로 할 일 없음.
    return;
}

read_error = file_object_pointer->file_read(BUFFER_SIZE, buffer);
if (read_error != NULL) {
    handle_read_error();  // file_close() 동작 포함.
    return;
}

// 읽은 내용으로 뭔가를 한다.
file_object_pointer->file_close();

이런 형태가 될 것이다. 따라서 전자가 깔끔하다는 논리인데,

문제는 어차피 handle_open_error()와 handle_read_error()는 다를 수밖에 없다는 점이다. 위의 사례를 가지고 말한다면 handle_open_error()에서는 그냥 나가면 되지만 handle_read_error에서는 이미 파일은 열린 상태이므로 file_close()가 되어야 한다. 저 handle_file_exception() 내부는 에러리턴 방식의 코드보다 훨씬, 훨씬, 훨씬 더 fugly 하게 된다는 점이다.

func handle_file_exception(e) {
    if (e instanceof FileOpenExeption) {
        handle_open_error();
    } else if (e instanceof FileReadExeption) {
        handle_file_error();
    }
}

...

쳐다 보기도 싫다. -_-

...

instanceof가 둘이나 들어갔다. 그나마도 런타임에 오브젝트에 대한 힌트를 얻을 수 있는 언어라고 가정할 때 이야기다.

여전히 handle_open_error()와 handle_file_error()는 필요하다. 이것이 exception 방식의 깔끔함인가? -_-

조엘이 지적한 같은 문제의 다른 측면도 추가해보자. 예외처리 방식은 어느 라인에서 try 블럭을 빠져나오는 것인지 명시적으로 보이지 않는다. 그리고 그것은 위의 func handle_file_exception(e);의 같은 추함의 다른 측면을 가리키는 것이다.

finally 도 생각보다 무력하다. finally절에 들어갈 코드 역시 코드가 어디까지 진행되었느냐에 따라서 다르게 동작해야 한다. db를 열다가 실패했을 때, db까지는 열렸는데 쿼리를 하다가 실패했을 때, 쿠리까지는 성공했는데 다른 뭔가를 하다가 실패했을 때, 그때마다 해야 할 일이 다르다. 진짜로 무조건 실행되는 구문이라면 겨우 두세줄 이하이거나, 아니면 finally 구문 안에서 코드 진행을 유추할 수 있는 흔적들을 찾아서 if-else로 씨름할텐데 역시 추하기는 마찬가지.

한편 위의 일견 깔끔해보이는 코드를 가지고 exception을 비난하는 것은 부당하다. 예외처리 코드로서도 잘못되었기 때문이다. 잘못 짠 예외처리 구문을 가지고 예외처리를 비난하는 것은 부당하지 않은가. 자바의 경우 catch(Exception e){} 구문은 DO NOT 목록의 대표적인 아이템이다. exception의 방식으로 위의 문제를 다룬다면,

try {
    file_object = file_open("file_name"); // 파일을 연다.
    file_object.read(BUFFER_SIZE, buffer); // 파일을 읽는다.
    // 읽은 내용으로 뭔가를 한다.
    file_object.close();
} catch (FileOpenException e) {
    handle_file_open_exception(e); // 별로 하는 일 없음.
} catch (FileReadException e) {
    handle_file_read_exception(e); // 파일 클로즈 포함해야 함.
}

이 코드 자체는 맞는 코드라고 할 수 있을 것이다. 그러나 여전히 코드 진행 도중에 점프가 일어나는 문제는 그대로이다. 열기 단계에서 문제가 일어났을 때, 읽기 단계에서 문제가 일어났을 때, 코드가 위에서 아래로 라인단위로 진행되지 못하고 goto가 발생한다. 위의 코드는 코드 흐름만을 보이기 위해서, read를 버퍼 사이즈만큼 한 번만 읽고 말 정도로 각 단계가 매우 짧게 축약되어 있다는 점을 상기하자. 위로 다시 올라가지 않는 goto는 경우에 따라서 조심스럽게 쓸 수도 있다고 생각하는 쪽이지만 이 경우에 에러리턴 방식에서는 필요 없었던 점프가 등장해서 얻게 된 실익이 무엇인가? 올바른 에러처리에 오면, 이미 에러리턴 방식보다 코드가 짧다고도 할 수 없다.

상황을 좀 더 복잡하게 만들어보자. 동일한 예외가 다른 두 지점에서 발생할 수 있는 상황을 가정해보자.

  1. 파일1을 특정 지점까지 읽는다.
  2. 거기까지 읽은 내용에 기반해서 파일2를 읽는다.
  3. 파일2에 써 있는 내용을 기반으로 파일 1을 계속 읽을지 결정한다.
try {
    file_object1 = file_open("file_name1");  // 문제1
    read_count = file_object1.read(BUFFER_SIZE, buffer); // 문제2
    file_name2 = extract_filename_from_buffer(buffer, read_count);
    file_object2 = file_open(file_name2"); // 문제3
    read_count = file_object2.read(BUFFER_SIZE, buffer); // 문제4
    file_object2.close();
    if (should_read_more(buffer, read_count)) {
        read_count = file_object1.read(BUFFER_SIZE, buffer); // 문제5
        // 추가로 읽은 내용으로 뭔가를 한다.
    }
    file_object1.close();
} catch (FileOpenException e) {
    // 지점 1, 3에서 발생한 문제 해결
} catch (FileReadException e) {
    // 지점 2, 4, 5에서 발생한 문제 해결
}

위의 경우에 FileOpenException의 캐치 구문은 문제 1, 3을 해결해야 하고, FileReadException의 캐치 구문은 문제 2, 4, 5를 해결해야 한다.

문제1 발생시 - 할 일 없음.
문제 3 발생시 - 파일1을 닫아줘야 함.

문제2 발생시 - 파일1을 닫아줘야 함.
문제4 발생시 - 파일1, 2를 닫아줘야 함.
문제5 발생시 - 파일1, 2를 닫아줘야 함.

이 지경이 되면, 각각의 catch 블락은 어떤 상황인지를 파악하느라 if-else에 무슨 객체가 널인지 아닌지 등등을 체크하느라 헬이다. 그것도 문제가 일어난 코드와 점프로 멀찍이 떨어져서. 저런 문제를 피하기 위해서 당장 떠오르는 방법은.. try 구문의 중첩이나, 아니면 위의 "올바른 예외처리" 형태처럼 될 수 있도록 try 블락 하나에서 파일을 하나만 여닫는 것이다. 그러면 자주 여닫게 될 것이다. fd가 로컬 파일이 아니라 TCP 통신이거나 한 상태라면 이 옵션은 불가능할 것이다.

수미상관으로, 예외처리가 과연 유용할까? 유용할 수 있다고 생각한다. 고도로 declarative 하게 짜여진 코드라면 그럴 수 있다. 그러나 imperative한 언어에서, 예외처리로 올바르게 예외를 처리하는 것은 극히 어려우며 주로는 잘못 해결한 문제를 감추는데 훨씬, 훨씬 더 유용하다.

댓글 2개:

  1. Java로 작성한 다음 코드는 어떨까요?

    물론 foo()가 반환값을 가지거나 더 정교한 작업을 해야 한다면 코드는 복잡해지겠지만 말이죠.

    - - -

    package com.example;

    import java.io.FileInputStream;
    import java.io.IOException;
    import java.io.InputStream;

    public abstract class Foo {

    static final int BUFFER_SIZE = 0;

    void foo() throws IOException {

    InputStream file_object1 = null;
    InputStream file_object2 = null;

    int read_count;
    byte[] buffer = new byte[BUFFER_SIZE];
    String file_name2;

    try {
    file_object1 = new FileInputStream("file_name1"); // 문제1
    read_count = file_object1.read(buffer); // 문제2
    file_name2 = extract_filename_from_buffer(buffer, read_count);
    file_object2 = new FileInputStream(file_name2); // 문제3
    read_count = file_object2.read(buffer); // 문제4
    file_object2.close();
    if (should_read_more(buffer, read_count)) {
    read_count = file_object1.read(buffer); // 문제5
    // 추가로 읽은 내용으로 뭔가를 한다.
    }
    } finally {
    if (file_object1 != null)
    file_object1.close();
    if (file_object2 != null)
    file_object2.close();
    }

    }

    abstract String extract_filename_from_buffer(byte[] buffer, int read_count);

    abstract boolean should_read_more(byte[] buffer, int read_count);

    }

    답글삭제
    답글
    1. 저도 차라리 finally 블락을 이용하는 것이 훨씬 낫다고 생각합니다. :-) 작성하신 코드가 최선이거나, 그에 가까울 것 같고요.

      하지만 finally 블락이 아주 엄밀하게 exception 이라고 보기는 힘들지 않나 싶네요.

      사실은 에러 리턴 선호에서 Go를 염두에 두고 있었는데, 이 글에서는 언급하지 않은 C/C++/Java의 에러리턴 방식의 별로인 점들을 개선하는 점이 있습니다.

      output parameter를 강요한다는 점과 finally의 장점인데, output parameter를 강요한다는 점은 리턴값을 복수로 돌려줄 수 있는 방식으로 해결했고, finally에 대응되는 defer라는 키워드가 있습니다.

      삭제