LY Corporation Tech Blog

LY Corporation과 LY Corporation Group(LINE Plus, LINE Taiwan and LINE Vietnam)의 기술과 개발 문화를 알립니다.

This post is also available in the following languages. Japanese, English

코드 품질 개선 기법 20편: 이례적인 예외 과대 포장

이 글은 2024년 4월 4일에 일본어로 먼저 발행된 기사를 번역한 글입니다.

LY Corporation은 높은 개발 생산성을 유지하기 위해 코드 품질 및 개발 문화 개선에 힘쓰고 있습니다. 이를 위해 다양한 노력을 하고 있는데요. 그중 하나가 Review Committee 활동입니다.

Review Committee에서는 머지된 코드를 다시 리뷰해 리뷰어와 작성자에게 피드백을 주고, 리뷰하면서 얻은 지식과 인사이트를 Weekly Report라는 이름으로 매주 공유하고 있습니다. 이 Weekly Report 중 일반적으로 널리 적용할 수 있는 주제를 골라 블로그에 코드 품질 개선 기법 시리즈를 연재하고 있습니다.

이번에 블로그로 공유할 Weekly Report의 제목은 '이례적인 과대 포장'입니다.

이례적인 예외 과대 포장

Kotlin의 Closeable.use는 인수를 실행한 후에 Closeable.close()를 호출하는 편리한 고차 함수입니다. 다음 코드에서는 두 번째 줄의 람다를 실행한 후 inputStream이 닫힙니다.

file.inputStream.use { stream ->
   // We can use stream here
}

// After `use` execution,
// we don't need to call `close()` for `stream` here.

스코프 포인터(scope pointer)나 자동 참조 카운터(automatic reference counting, ARC) 등이 있는 언어라면 소멸자에서 리소스를 해제해 비슷한 작업을 수행할 수 있습니다. 또한 Java의 경우 AutoCloseable을 상속받아 try-with-resources 문을 사용할 수 있습니다.

이 패턴의 장점 중 하나는 예외를 발생시키거나 비지역 반환(non-local return)을 하더라도 리소스 해제 누락을 방지할 수 있다는 것입니다. 예를 들어 다음 코드에서는 use의 람다 내부에서 Exception이 발생하지만 inputStream.close()는 여전히 호출됩니다.

file.inputStream.use { stream ->
   // We can use stream here
   throw Exception()
}

// Even an exception is thrown,
// we can expect `close` was called.

사용자 정의 인터페이스나 클래스에서도 비슷한 패턴을 구현할 수 있습니다. 다음 Disposable은 사용 후 dispose 호출을 기대하는데요. 이와 같은 사용자 정의 클래스에서도 use라는 확장 함수를 정의하면 dispose 호출 누락을 방지할 수 있습니다(확장 함수 기능이 없는 언어에서도 수신자 대신 인수를 사용해서 동일한 효과를 얻을 수 있습니다).

interface Disposable {
    fun dispose()
}

fun <T : Disposable?, R> T.use(block: (T) -> R): R {
    try {
        return block(this)
    } finally {
        dispose()
    }
}

Disposable을 구현한 후에 'dispose도 예외를 발생시킬 수 있다'는 사실을 알게 되었다고 가정해 봅시다. 즉, use 호출 중에 발생할 수 있는 예외는 block 실행 중과 dispose 호출 중이라는 두 가지 가능성이 있으며, use 호출 한 번으로 두 가지 예외가 모두 발생할 수도 있습니다. 그래서 다음과 같이 DisposableException이라는 예외를 구현해서 use 중에 발생하는 예외를 하나로 통합하도록 구현을 업데이트했습니다.

class DisposableException(
    val exceptionAtBlock: Throwable?,
    val exceptionAtDispose: Throwable?
): Exception()

interface Disposable {
    fun dispose()
}

fun <T : Disposable?, R> T.use(block: (T) -> R): R {
    var exceptionAtBlock: Throwable? = null
    try {
        return block(this)
    } catch (originalException: Throwable) {
        exceptionAtBlock = originalException
        throw DisposableException(exceptionAtBlock, null)
    } finally {
        try {
            this?.dispose()
        } catch(exceptionAtDispose: Throwable) {
            throw DisposableException(
                exceptionAtBlock,
                exceptionAtDispose
          )
        }
    }
}

위 코드에 문제가 있을까요?

예외 중의 예외

위 코드에서는 발생한 예외를 다른 예외로 대체하는데요. 이 변환은 호출자가 기대하는 것과는 다른 변환일 수 있습니다. 예를 들어 다음 SomeDataWriter에서는 write 중에 IOException이 발생할 수 있습니다. 호출자인 someFunction은 해당 IOException을 잡으려고 했지만, 실제로 발생하는 예외는 DisposableException이므로 이 catch는 의도한 대로 작동하지 않습니다. 

class SomeDataWriter : Disposable {
    fun write(someData: SomeData) {
        // write someData
        if (/* for some error case */) {
            throw IOException(...)
        }
    }

    fun dispose() { /* ... */ }
}

fun someFunction(...) {
    try {
        createWriter()
            .use { writer -> writer.write(someData) }
    } catch (exception: IOException) {
        // handle IO Exception
    }
}

위 예제에서는 someFunction 안에 예외 처리와 use가 모두 존재하기 때문에 실수를 비교적 쉽게 발견할 수 있지만, 보조 함수를 만들어 추출한 경우에는 이 실수를 발견하기 더욱 어려울 것입니다.

불필요한 포장 없애기

한 곳에서 여러 예외가 발생할 수 있는 경우에는 다른 예외를 만들어서 감싸는 것보다 Throwable.addSuppressed를 사용해서 '어떤 예외가 더 중요한지'를 명확히 하는 것이 좋습니다. 여기서 어떤 예외가 더 중요한지는 신중히 결정해야 합니다. 예를 들어 다음 코드는 부적절한 구현입니다.

fun <T : Disposable?, R> T.use(block: (T) -> R): R {
    var exceptionAtBlock: Throwable? = null
    try {
        return block(this)
    } catch (originalException: Throwable) {
        exceptionAtBlock = originalException
        throw originalException
    } finally {
        try {
            this?.dispose()
        } catch (exceptionAtDispose: Throwable) {
            if (exceptionAtBlock != null) {
                exceptionAtDispose.addSuppressed(exceptionAtBlock)
            }
            throw exceptionAtDispose
        }
    }
}

위 코드에서는 block에서 IOException이 발생한 후에 dispose에서 또 다른 예외 exceptionAtDispose가 발생하면 최종적으로 던져지는 예외는 exceptionAtDispose가 됩니다. dispose는 어디까지나 보조적인 호출이므로 block에서 발생한 예외를 우선하는 쪽이 오해의 소지가 적은 구현입니다.

수정된 코드는 다음과 같습니다. 이 코드에서는 blockdispose에서 모두 예외가 발생하면 block에서 발생한 예외를 우선합니다(참고로 Kotlin 1.1 이상에서의 Closeable.use 구현도 이와 비슷하게 작동하며, 이와 관련된 자세한 내용은 Closeable?.closeFinally를 참고하세요).

fun <T : Disposable?, R> T.use(block: (T) -> R): R {
    var exceptionAtBlock: Throwable? = null
    try {
        return block(this)
    } catch (originalException: Throwable) {
        exceptionAtBlock = originalException
        throw originalException
    } finally {
        try {
            this?.dispose()
        } catch (exceptionAtDispose: Throwable) {
            if (exceptionAtBlock == null) {
                throw exceptionAtDispose
            } else {
                exceptionAtBlock.addSuppressed(exceptionAtDispose)
            }
        }
    }
}

Java는 예외?

Java와 같이 확인된 예외가 있는 경우 예외를 다른 예외로 감싸더라도 예외를 유형별로 구분할 수 있기 때문에 비교적 안전합니다. 하지만 다음과 같은 상황에서는 주의해야 합니다.

  • 호출자에서 여러 종류의 예외를 처리하고 있으며 예외에 부모-자식 관계가 있는 경우: 예를 들어 IOExceptionException으로 catch하고 있는 경우 IOException을 다른 예외로 바꾸면 Exception으로 잡히며, 이는 컴파일 타임 에러가 아닙니다.
  • 확인되지 않은 예외로 변환한 경우: RuntimeException으로 감싸면 해당 catch/throws가 없어도 코드 컴파일에 성공합니다. 따라서 복구가 불가능한 경우에만 확인된 예외를 RuntimeException으로 감싸는 것이 좋습니다.

한 줄 요약: 예외 처리 중에 예외가 발생하면 어떤 예외를 우선적으로 처리해야 할지 검토한다.

키워드: exception, error handling, wrapper