안녕하 세요. 커뮤니케이션 앱 LINE의 모바일 클라이언트를 개발하고 있는 Ishikawa입니다.
저희 회사는 높은 개발 생산성을 유지하기 위해 코드 품질 및 개발 문화 개선에 힘쓰고 있습니다. 이를 위해 다양한 노력을 하고 있는데요. 그중 하나가 Review Committee 활동입니다.
Review Committee에서는 머지된 코드를 다시 리뷰해 리뷰어와 작성자에게 피드백을 주고, 리뷰하면서 얻은 지식과 인사이트를 Weekly Report라는 이름으로 매주 공유하고 있습니다. 이 Weekly Report 중 일반적으로 널리 적용할 수 있는 주제를 골라 블로그에 코드 품질 개선 기법 시리즈를 연재하고 있습니다.
이번에 블로그로 공유할 Weekly Report의 제목은 '한 번 엎지른 <error>는 다시 주워 담지 못한다'입니다.
한 번 엎지른 <error>는 다시 주워 담지 못한다
다음은 parseFooValue
라는 함수로 "foo:"
로 시작하는 문자열에서 1~6자리 정수를 조회하는 함수입니다.
/**
* Parses the given "FOO" data string then returns the parsed integer by wrapping with
* [FooParseResult].
*
* The expected format is: "foo:(1-6 digit non-negative integer)".
* For example, "foo:00" and "foo:123456" are valid while "foo : 1", "foo,2", "foo:-1" are not.
*
* This throws [IllegalArgumentException] if the given text format is invalid.
*/
@Throws(IllegalArgumentException::class)
fun parseFooValue(inputText: String): FooParseResult {
val matchResult = FOO_FORMAT_REGEX.matchEntire(inputText)
requireNotNull(matchResult) // Throws if `inputText` format is invalid
val fooIntValue = matchResult.groupValues.getOrNull(1)?.toIntOrNull()
return if (fooIntValue != null) {
FooParseResult.Success(fooIntValue)
} else {
FooParseResult.InvalidRegexError
}
}
/** Result model of parsing "FOO" integer from string value. */
sealed class FooParseResult {
/** A result representing "FOO" data is correctly parsed as an integer. */
class Success(val value: Int) : FooParseResult()
/** An error result representing the parsing regex implementation is incorrect. */
object InvalidRegexError : FooParseResult()
}
private val FOO_FORMAT_REGEX: Regex = """foo:(\d{1,6})""".toRegex()
이 코드의 문제점은 무엇일까요?
엎지른 물 주워 담아 봤자 소용 없다
에러를 전파하는 방법은 호출자가 해당 에러를 어떻게 처리하는지에 따라 달라집니다. 위 예시 코드는 두 가지 에러를 전파하고 있습니다.
- 인수로 입력된 문자열의 형식이 잘못됨
- 정규표현식 구현 실수
일반적으로 '호출자가 제공한 인수는 신뢰할 수 없다'고 생각해야 더 견고한 코드를 작성할 수 있습니다. 해당 인수는 사용자가 입력하거나 외부 시스템에서 제공 받은 것일 수도 있기 때문입니다. '잘못된 인수가 입력되는 것은 흔한 일이다'라고 전제하고 에러 처리를 구현해야 합니다. 한편, 정규 표현식 구현 실수는 parseFooValue
함수에 국한된 에러입니다. 호출자는 이 에러를 신경 쓸 필요가 없으며, '복구할 수 없는' 에러여야 합니다.
위 사실을 염두에 두고 각 에러의 표현 방법을 확인해 봅시다. 인수 형식 에러는 IllegalArgumentException
으로 표현하고 있으며, 정규 표현식 구현 실수는 sealed
클래스의 객체로 표현하고 있습니다. IllegalArgumentException
과 같은 로직 에러는 대부분의 경우 catch
해서는 안됩니다. 로직 자체를 수정해야 합니다. 반면, 정규 표현식 실수는 실제로는 로직 에러이지만 sealed
클래스를 사용해서 호출자가 처리하도록 강제하고 있습니다. 즉, 현재 에러를 처리하는 방법과 에러를 표현하는 방법이 서로 맞지 않습니다.
복구 (불)가능 수준
프로그래밍 언어와 처리 시스템에 따라 다르지만 에러를 표현하는 방법은 매우 다양합니다. 그중 어떤 표 현을 사용할지는 해당 에러가 어느 정도로 복구할 수 있는 것인지를 참고하면 좋습니다.
복구 가능
↑
┃ 0 기본값
┃ 1 단순 도메인 에러
┃ 2 에러값을 포함한 합 타입(sum type)이나 널러블(nullable) 에러값(혹은 다중 반환값)
┃ 3 확인된 예외
┃ 4 확인되지 않은 예외
┃ 5 캐치할 수 없는 에러
↓
복구 불가능
0. 기본값
기본값은 호출자가 에러 발생 여부를 파악하지 않아도 되는 경우에 사용합니다.
- 예:
""
,0
(가산값),1
(계수),Int.MIN_VALUE
,[]
, 널(null) 객체 패턴
// If some error happens, `getUsers` returns empty
val userList = userProvider.getUsers()
userList.forEach { messageSender.send("You are ${it.name}.") }
1. 단순 도메인 에러
단순 도메인 에러는 에러가 발생했다는 사실은 알아야 하지만 에러의 내용까지는 알 필요가 없을 때 사용합니다. 대표적인 예로 조기 반환을 적용하는 경우가 있습니다.
- 예:
null
/nil
/undefined
,Optional
/Maybe
,false
// If some error happens `getUser` returns null.
val user = userProvider.getUser(id)
?: return
dialog.showWithMessage("You are ${user.name}.")
만약 정상적인 상태에서는 반환값이 없는 경우라면 에러 발생 여부를 나타내는 진위값을 반환값으로 자주 사용하며, 이때 대부분의 경우 false
가 에러 발생을 나타냅니다(단, C 언어에서는 0
이 정상 상태를 나타내는 경우가 많다는 점에 유의하세요).
가능하면 타입 안정성(type safe)이 확보된 단순 도메인 에러를 사용해야 합니다(Kotlin의 null
, Scala의 Option.empty
등). 타입 안정성이 확보된 단순 도메인 에러를 사용할 수 있는 경우에는 -1
이나 0xDEADBEEF
와 같은 특수한 에러값을 사용해서는 안 됩니다.
2. 에러값을 포함한 합 타입이나 널러블 에러값(혹은 다중 반환값)
합 타입은 정상 상태와 에러인 상태에 각각 다른 값이 필요할 때 사용합니다. 이는 호출자가 에러 유형에 따라 다르게 처리해야 할 때 유용합니다.
- 예:
Either
,Result
,sealed
클래스, 연관값(associated value), 널러블 에러값
sealed interface Response {
class Success(val value: Value): Response
class Error(val errorType: ErrorType): Response
}
enum class ErrorType { ... }
만약 정상적인 상태에서는 반환값이 필요 없는 경우라면 널러블 에러값을 반환하는 방법도 있습니다. 이 경우에 null
은 정상 상태를 의미합니다. 단, 오해할 수 있는 경우라면 널러블 에러값을 사용하는 것보다 합 타입을 정의하는 것이 좋습니다. 이와 유사한 개념으로 다중 반환값을 채택하는 언어도 있습니다(예: Go, Python).
3. 확인된 예외
이론적으로 확인된 예외는 반환값으로 합 타입을 사용하는 것과 같다고 볼 수 있습니다만, 실제 구현(Java나 Swift의 경우)에서는 합 타입보다 타입을 엄격하게 다루기 어렵습니다. 특히 Java의 Exception
은 스택 정보라는 많은 정보가 포함됩니다. 따라서 현실적으로는 합 타입보다 더 복구하기 어려운 유형에 사용해야 합니다.
- 예: Java의
Exception
, Swift의Error
4. 확인되지 않은 예외
확인되지 않은 예외는 처리 시스템 에러나 로직 에러와 같이 '대부분의 경우 복구 불가능'한 에러에 사용해야 합니다. 위 네 가지 유형과 비교해 봤을 때 호출자가 에러가 발생할 가능성 자체를 간과할 수 있기 때문입니다.
- 예: Java의
RuntimeException
단, 합 타입이나 확인된 예외가 없는 언어에서는 확인되지 않은 예외를 복구 가능한 에러로 사용하는 경우도 있습니다.
5. 캐치할 수 없는 에러
캐치할 수 없는 에러는 확인되지 않은 예외와 거의 같은 방식으로 사용하지만 '빠른 실패(fail fast)'를 더 엄격하게 구현합니다.
- 예: Swift의
fatalError()
엎지른 물은 다시 주워 담지 말자
예제 코드는 다음과 같이 개선할 수 있습니다.
- 잘못된 입력에 대해서는
null
을 반환한다 - 정규 표현식 구현 실수에는 확인되지 않은 예외를 사용한다
- 문자열 그룹 조회는
getOrNull
대신get ([])
을 사용한다 toIntOrNull
대신toInt
를 사용한다
/**
* Returns an integer parsed from the given "FOO" data string.
*
* The expected format is: "foo:(1-6 digit non-negative integer)".
* For example, "foo:00" and "foo:123456" are valid while "foo : 1", "foo,2", "foo:-1" are not.
*
* This returns null if the given text format is invalid.
*/
fun parseFooValue(inputText: String): Int? {
val matchResult = FOO_FORMAT_REGEX.matchEntire(inputText)
?: return null
return matchResult.groupValues[1].toInt()
}
일반적으로 에러 복구 가능 여부는 호출자의 코드와 에러를 처리하는 범위에 따라 달라집니다. 예를 들어, 쿼리 처리 중 에러가 발생하면 '쿼리 처리 로직의 관점에서는 복구가 불가능하지만, 서버 프로세스의 관점에서는 복구 가능'인 경우도 많을 것입니다. 만약 호출자의 코드가 결정되지 않으면 복구 가능 여부를 판단할 수 없는 경우에는 일단 다루기 쉬운 방식으로 에러를 반환한 후 호출자 측에서 다른 에러로 변환하는 것도 고려해야 합니다.
한 줄 요약: 에러가 어느 정도 복구 가능한지에 따라 적절한 에러 표현 방법을 사용한다.
키워드:
recoverable error
,logic error
,exception