이 글은 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
에서 발생한 예외를 우선하는 쪽이 오해의 소지가 적은 구현입니다.
수정된 코드는 다음과 같습니다. 이 코드에서는 block
과 dispose
에서 모두 예외가 발생하면 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와 같이 확인된 예외가 있는 경우 예외를 다른 예외로 감싸더라도 예외를 유형별로 구분할 수 있기 때문에 비교적 안전합니다. 하지만 다음과 같은 상황에서는 주의해야 합니다.
- 호출자에서 여러 종류의 예외를 처리하고 있으며 예외에 부모-자식 관계가 있는 경우: 예를 들어
IOException
과Exception
으로catch
하고 있는 경우IOException
을 다른 예외로 바꾸면Exception
으로 잡히며, 이는 컴파일 타임 에러가 아닙니다. - 확인되지 않은 예외로 변환한 경우:
RuntimeException
으로 감싸면 해당catch
/throws
가 없어도 코드 컴파일에 성공합니다. 따라서 복구가 불가능한 경우에만 확인된 예외를RuntimeException
으로 감싸는 것이 좋습니다.
한 줄 요약: 예외 처리 중에 예외가 발생하면 어떤 예외를 우선적으로 처리해야 할지 검토한다.
키워드:
exception
,error handling
,wrapper