2014년 6월 9일 월요일

C# using vs. Java try-with-resources

C/C++1와 달리 C#이나 자바는 가비지 컬렉션이 있어서 사용한 메모리를 일일히 수동으로 반환해야 하는—지옥에 가까운—번거로움 없이도 프로그램을 쉽게 짤 수 있다2. 그렇지만 파일이나 네트워크 같은 외부 자원은 가비지 컬렉션 대상이 아니기 때문에 여전히 사용후 수동 반환 과정이 꼭 필요하다. 예를 들어 아래와 같이 말이다:

var fs = new FileStream("SomeFile", FileMode.Open, FileAccess.Read);
// ...
fs.Close();

그런데 이 코드는 문제가 있다. 파일을 열거나 입출력하는 과정에서 예외가 발생할 수 있기 때문이다. 그래서 대부분의 경우 아래처럼 try 블럭으로 전체를 감싸 주어야 한다:

FileStream fs = null;
try {
    fs = new FileStream("README", FileMode.Open, FileAccess.Read);
    // ...
} catch (...) {
    // ...
} finally {
    if (fs != null)
        fs.Close();
}

전체를 try-catch-finally로 감싸 놓으니 예외 처리와 자원 반환이 확실히 되어 좋긴 한데, 위에선 null을 대입하고 아래에선 다시 null 체크를 하는 등 너저분한 느낌이 든다. 그냥

try {
    var fs = new FileStream("README", FileMode.Open, FileAccess.Read);
    // ...
} catch (...) {
    // ...
} finally {
    fs.Close();
}

처럼 짜면 깔끔할 텐데 말이다. 그렇지만 실제로 이렇게 짜면 컴파일 에러가 난다. fs 변수가 try 블럭 안에서만 유효하기 때문이다. 그래서 try 블럭을 쓸 때는 항상 이전의 너저분한 코드처럼 짤 수 밖에 없다.

C# using

C#에서는 using문을 이용해서 이런 종류의 코드를 간단히 짤 수 있게 해준다. 위의 코드를 using을 써서 다시 짜면

try {
    using (var fs = new FileStream("README", FileMode.Open, FileAccess.Read)) {
        // ...
    }
} catch (...) {
    // ...
}

처럼 되는데, 이것을 풀어 쓰면

try {
    FileStream fs = new FileStream("README", FileMode.Open, FileAccess.Read);
    try {
        // ...
    } finally {
        fs.Dispose();
    }
} catch (...) {
    // ...
}

처럼 된다. C# 컴파일러는 IDisposable을 구현하는 타입에 대해 finally 절에서 Dispose()를 호출하는 코드를 자동 생성한다. 그래서 실행 흐름이 using 블럭을 벗어나는 순간 fs.Dispose()가 자동 호출되고, 이 Dispose() 안에서 fs.Close()가 호출되어 파일 스트림이 닫히는 원리다. 아울러 finally 절에서 null 체크를 할 필요도 없는 것도 눈여겨 볼 점이다. 내부 try 블럭 밖에서 실행되는 new FileStream(...)에서 예외가 발생하면 finally 절이 아예 실행되지 않기 때문이다.

Java try-with-resources

한편 자바 7에서는 C# using과 매우 비슷한 try-with-resources란 구문이 새로 생겼다. 이것을 이용하면

OutputStreamWriter writer = null;
try {
    writer = new OutputStreamWriter(new FileOutputStream(fileName), "utf-8");
    // ...
} catch (UnsupportedEncodingException ex) {
    // ...
} finally {
    if (writer != null) {
        try {
            writer.close();
        } catch (...) {
        }
    }
}

처럼 지저분한 코드가

try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(fileName), "utf-8")) {
    // ...
} catch (UnsupportedEncodingException ex) {
    // ...
}

처럼 깔끔해진다. 이 경우 자바 컴파일러는 AutoCloseable 또는 Closeable 인터페이스를 구현한 타입에 대해 finally 절에서 close()를 호출하는 코드를 생성한다.

그리고 C# using과 달리 자바의 try-with-resources는 catch 블럭과도 함께 쓰일 수 있다. 그 결과 자바 쪽이 더 깔끔하게 되었다.

미묘한 차이

그런데 문제는 굉장히 흡사해 보이는 이 두 구문이 실제로는 미묘하게 다르다는 점이다. 아래의 두 코드를 비교해 보자:

자바 버전:

public static void main(String[] args) {
    try (Foo f = new Foo()) {
        throw new Exception("try");
    } catch (Exception ex) {
        System.err.println(ex.getMessage());
    }
}

static class Foo implements AutoCloseable {
    @Override
    public void close() throws Exception {
        throw new Exception("Foo");
    }
}

C# 버전:

static void Main() {
    try {
        using (var foo = new Foo()) {
            throw new Exception("try");
        }
    } catch (Exception ex) {
        Console.WriteLine(ex.Message);
    }
}

class Foo : IDisposable {
    public void Dispose() {
        throw new Exception("Foo");
    }
}

두 언어간 사소한 문법적 차이 몇개를 제외하면 거의 동일한 코드다. 그래서 결과도 같을 것 같은데…실제로 실행해 보면 자바는

try

C#은

Foo

가 된다.

이렇게 차이가 나는 이유는 C#의 경우 항상 마지막에 발생한 예외를 밖의 catch 절에서 잡는 반면 자바는 내부 try 블럭과 생략된 finally 절에서 모두 예외가 발생할 경우 먼저 발생한 전자를 밖의 catch 절에서 잡기 때문이다. 버려진(?) 후자의 예외를 특별히 suppressed exception이라고 하고, Throwable.getSuppressed()를 호출해서 알아낼 수 있다. 그래서 위의 자바 코드는 옛날 스타일로 풀어 쓰면

public static void main(String[] args) {
    Throwable[] suppressedExceptions = null;
    try {
        Foo f = new Foo();
        try {
            throw new Exception("try block");
        } finally {
            try {
                f.close();
            } catch (Exception ex) {
                suppressedExceptions = new Throwable[1];
                suppressedExceptions[0] = ex;
            }
        }
    } catch (Exception ex) {
        if (suppressedExceptions != null)
            ex.setSuppressed(suppressedExceptions);
        System.err.println(ex.getMessage());
    }
}

static class Foo implements AutoCloseable {
    @Override
    public void close() throws Exception {
        throw new Exception("Foo");
    }
}

처럼 된다3. try-with-resources가 중첩된 상태에서 예외가 여러 개 발생할 경우 코드가 한층 더 복잡해질 것이다. 물론 이 모든 것은 컴파일러가 알아서 만들어 주므로 사람이 신경 쓸 필요는 없다.


  1. 현대적인 C++의 메모리 관리 기법은 C와는 사뭇 달라 메모리 릭 가능성이 크게 줄었지만 여전히 문제는 남아 있다.
  2. C#/자바에서도 메모리 릭이 발생할 수 있지만 일반적인 경우가 아니므로 여기서는 논외로 한다.
  3. 개념상으로 이렇다는 얘기. 실제로 setSuppressed() 메쏘드는 없다.

댓글 없음:

댓글 쓰기

댓글을 입력하세요. 링크를 걸려면 <a href="">..</a> 태그를 쓰면 됩니다. <b>와 <i> 태그도 사용 가능합니다.

게시한지 14일이 지난 글에는 댓글이 등록되지 않습니다. 날짜를 반드시 확인해 주세요.