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한 언어에서, 예외처리로 올바르게 예외를 처리하는 것은 극히 어려우며 주로는 잘못 해결한 문제를 감추는데 훨씬, 훨씬 더 유용하다.