12장: 에러 처리 전략 (Error Handling Strategies)
프로그램에서 실패는 피할 수 없습니다. 파일이 없을 수 있고, 네트워크가 끊길 수 있고, 사용자가 잘못된 입력을 줄 수 있습니다. 문제는 “실패가 발생하는가?“가 아니라 “실패를 어떻게 표현하고 처리하는가?“입니다.
11장에서 예외(Exception)를 배웠습니다. 예외는 강력하지만, 함수의 시그니처만 봐서는 그 함수가 실패할 수 있는지 알 수 없다는 근본적인 한계가 있습니다. FunLang는 예외 외에도 Option과 Result라는 두 가지 타입 기반 접근법을 제공합니다. 이 세 가지 중 어떤 것을 선택하느냐에 따라 코드의 안전성, 합성 가능성, 가독성이 크게 달라집니다.
이 장은 같은 문제를 세 가지 방식으로 풀어보고, 각각의 장단점을 비교한 뒤, 실전에서의 선택 기준을 제시합니다. 함수형 프로그래밍 커뮤니티에서 왜 “예외보다 타입을 써라“라고 말하는지, 그 이유를 직접 확인하게 됩니다.
세 가지 접근법 비교
같은 문제를 세 가지 방식으로 풀어보겠습니다: 리스트에서 조건을 만족하는 첫 번째 원소를 찾는 함수입니다.
Exception 방식
$ cat find_exc.l3
exception NotFound
// 조건을 만족하는 첫 번째 원소를 찾고, 없으면 예외 발생
let rec find pred = fun xs ->
match xs with
| [] -> raise NotFound
| h :: t -> if pred h then h else find pred t
let result = try find (fun x -> x > 3) [1; 2; 3; 4; 5] with | NotFound -> 0 - 1
$ fn find_exc.l3
4
Option 방식
$ cat find_opt.l3
// 조건을 만족하는 첫 번째 원소를 Option으로 반환
let rec find pred = fun xs ->
match xs with
| [] -> None
| h :: t -> if pred h then Some h else find pred t
let result = optionDefault (0 - 1) (find (fun x -> x > 3) [1; 2; 3; 4; 5])
$ fn find_opt.l3
4
Result 방식
$ cat find_res.l3
// 조건을 만족하는 첫 번째 원소를 Result로 반환
let rec find pred = fun xs ->
match xs with
| [] -> Error "not found"
| h :: t -> if pred h then Ok h else find pred t
let result = resultDefault (0 - 1) (find (fun x -> x > 3) [1; 2; 3; 4; 5])
$ fn find_res.l3
4
세 가지 모두 같은 결과를 반환합니다. 그러나 코드의 의미와 안전성에서 큰 차이가 있습니다.
왜 Option/Result를 권장하는가
1. 타입 시스템이 실패를 표현한다
Exception은 타입 시그니처에 나타나지 않습니다:
// Exception: 타입만 보면 실패 가능성을 알 수 없음
// findIdx : ('a -> bool) -> 'a list -> int -> int
// ^^^
// 실패 시 예외를 던지지만, 타입에는 int만 보임!
// Option: 타입이 실패 가능성을 명시
// findIdx : ('a -> bool) -> 'a list -> int -> Option<int>
// ^^^^^^^^^^^
// None이 올 수 있다는 것을 호출자가 알 수 있음
// Result: 타입이 실패 이유까지 명시
// findIdx : ('a -> bool) -> 'a list -> int -> Result<int, string>
// ^^^^^^^^^^^^^^^^^^^^
// 에러 메시지가 포함될 수 있음을 호출자가 알 수 있음
Option이나 Result를 사용하면, 호출자가 반드시 실패 경우를 처리해야 합니다.
패턴 매칭에서 None이나 Error를 처리하지 않으면 소진 경고가 나옵니다.
Exception은 호출자가 try-with를 잊으면 프로그램이 크래시합니다.
2. 합성(composition)이 자연스럽다
Exception은 합성이 어렵습니다. 여러 실패 가능한 연산을 순서대로 실행하려면
중첩된 try-with가 필요합니다:
$ cat chain_exc.l3
exception ParseError of string
exception DivError of string
let parseInt s =
match s with
| "42" -> 42
| "0" -> 0
| _ -> raise (ParseError ("invalid: " + s))
let safeDivide a = fun b -> if b = 0 then raise (DivError "div/0") else a / b
let compute input = try
let n = parseInt input
try
safeDivide 100 n
with
| DivError msg -> 0 - 1
with
| ParseError msg -> 0 - 2
let r1 = compute "42"
let r2 = compute "0"
let r3 = compute "abc"
let result = (r1, r2, r3)
$ fn chain_exc.l3
(2, -1, -2)
Result를 사용하면 파이프라인으로 합성할 수 있습니다:
$ cat chain_res.l3
let parseInt s =
match s with
| "42" -> Ok 42
| "0" -> Ok 0
| _ -> Error ("invalid: " + s)
let safeDivide a = fun b -> if b = 0 then Error "div/0" else Ok (a / b)
let compute input = parseInt input |> resultBind (safeDivide 100) |> resultMap (fun x -> x + 1)
let r1 = compute "42"
let r2 = compute "0"
let r3 = compute "abc"
let result = (r1, r2, r3)
$ fn chain_res.l3
(Ok 3, Error "div/0", Error "invalid: abc")
resultBind와 resultMap으로 에러가 자동 전파됩니다:
parseInt "abc"→Error "invalid: abc"→resultBind가safeDivide를 건너뛰고 에러 전파- 에러가 발생한 정확한 지점의 메시지가 보존됩니다
3. 예외는 제어 흐름을 깨뜨린다
Exception은 비지역적(non-local) 제어 흐름입니다. raise는 현재 함수를
즉시 벗어나 가장 가까운 try-with까지 스택을 풀어올립니다. 이로 인해:
- 어디서 예외가 발생했는지 추적이 어렵습니다
- 중간 정리(cleanup) 코드가 실행되지 않을 수 있습니다
- 꼬리 호출 최적화(TCO)가
try본문에서 동작하지 않습니다
Option/Result는 일반적인 값입니다. 함수가 None이나 Error를 반환하면,
호출자는 평범한 패턴 매칭으로 처리합니다. 제어 흐름이 예측 가능합니다.
4. TCO와 함께 사용할 수 있다
try 본문은 꼬리 위치가 아닙니다 (예외 핸들러가 스택 프레임을 필요로 하기 때문).
따라서 try 안에서 깊은 재귀를 하면 스택 오버플로우가 발생할 수 있습니다.
Option/Result를 사용하면 재귀 함수가 try 없이 동작하므로 TCO가 적용됩니다:
$ cat tco_result.l3
let rec searchList pred = fun xs -> fun i ->
match xs with
| [] -> Error "not found"
| h :: t -> if pred h then Ok i else searchList pred t (i + 1)
let result = searchList (fun x -> x = 999999) [1..1000000] 0
$ fn tco_result.l3
Ok 999998
100만 개의 리스트를 검색해도 스택 오버플로우 없이 동작합니다.
Option vs Result: 언제 어떤 것을 쓰는가
Option — 실패 이유가 중요하지 않을 때
“값이 있거나 없거나“만 구분하면 될 때 Option을 사용합니다:
$ cat option_use.l3
let safeHead xs =
match xs with
| [] -> None
| h :: _ -> Some h
let safeDivide a = fun b -> if b = 0 then None else Some (a / b)
let result = Some [10; 20; 30] |> optionBind safeHead |> optionBind (safeDivide 100) |> optionDefault 0
$ fn option_use.l3
10
적합한 경우:
- 리스트에서 원소 찾기 (
find) - 맵에서 키 검색
- 파싱이 성공하거나 실패하거나 (이유 불필요)
- null 대체 (nullable 값)
<|> 연산자를 사용하면 fallback 체인을 더 간결하게 작성할 수 있습니다:
$ cat fallback.l3
let safeHead xs =
match xs with
| [] -> None
| h :: _ -> Some h
let safeDivide a = fun b -> if b = 0 then None else Some (a / b)
let result = safeDivide 10 0 <|> safeDivide 10 2 <|> Some 0
$ fn fallback.l3
Some 5
<|>는 첫 번째 Some 값을 반환하고, 모두 None이면 마지막 값을 반환합니다.
Result — 실패 이유가 중요할 때
에러 메시지나 에러 코드를 보존해야 할 때 Result를 사용합니다:
$ cat result_use.l3
let validateAge age = if age < 0 then Error "age cannot be negative" else if age > 150 then Error "age too large" else Ok age
let validateName name = if String.length name = 0 then Error "name cannot be empty" else Ok name
let validate name = fun age -> validateName name |> resultBind (fun _ -> validateAge age |> resultMap (fun a -> name + " (" + to_string a + ")"))
let r1 = validate "Alice" 30
let r2 = validate "" 25
let r3 = validate "Bob" (0 - 5)
let result = (r1, r2, r3)
$ fn result_use.l3
(Ok "Alice (30)", Error "name cannot be empty", Error "age cannot be negative")
적합한 경우:
- 사용자 입력 검증
- 파일/네트워크 작업 (에러 메시지 필요)
- 여러 단계의 처리 파이프라인
- API 응답 (에러 코드 + 메시지)
Exception — 정말 예외적인 상황에만
Exception은 프로그래밍 오류나 복구 불가능한 상황에 사용합니다:
$ cat exception_use.l3
exception InternalError of string
let rec processAll xs =
match xs with
| [] -> 0
| h :: t ->
if h < 0 then raise (InternalError "unexpected negative value")
else h + processAll t
let result = processAll [1; 2; 3; 4; 5]
$ fn exception_use.l3
15
적합한 경우:
- 프로그래밍 오류 (불변 조건 위반)
- 복구 불가능한 시스템 오류
- 방어적 프로그래밍 (도달할 수 없는 코드에 대한 안전장치)
Prelude 함수 요약
-- Option 함수
optionMap : ('a -> 'b) -> Option<'a> -> Option<'b>
optionBind : ('a -> Option<'b>) -> Option<'a> -> Option<'b>
optionDefault : 'a -> Option<'a> -> 'a
optionDefaultValue: 'a -> Option<'a> -> 'a -- optionDefault와 동일
optionFilter : ('a -> bool) -> Option<'a> -> Option<'a>
optionIter : ('a -> unit) -> Option<'a> -> unit
isSome : Option<'a> -> bool
isNone : Option<'a> -> bool
-- Result 함수
resultMap : ('a -> 'b) -> Result<'a, 'e> -> Result<'b, 'e>
resultBind : ('a -> Result<'b, 'e>) -> Result<'a, 'e> -> Result<'b, 'e>
resultMapError : ('e -> 'f) -> Result<'a, 'e> -> Result<'a, 'f>
resultDefault : 'a -> Result<'a, 'e> -> 'a
resultDefaultValue: 'a -> Result<'a, 'e> -> 'a -- resultDefault와 동일
resultIter : ('a -> unit) -> Result<'a, 'e> -> unit
resultToOption : Result<'a, 'e> -> Option<'a>
isOk : Result<'a, 'e> -> bool
isError : Result<'a, 'e> -> bool
권장 가이드라인
| 상황 | 권장 방식 | 이유 |
|---|---|---|
| 값이 있을 수도 없을 수도 있는 경우 | Option | 간단, 가벼움 |
| 실패 이유를 알아야 하는 경우 | Result | 에러 메시지 보존 |
| 여러 연산을 순서대로 합성 | Result + |> | 파이프라인 합성 |
| 프로그래밍 오류/불변 조건 위반 | Exception | 즉시 중단, 디버깅 |
| 깊은 재귀 내부 에러 처리 | Option/Result | TCO 호환 |
| 최상위 레벨 에러 복구 | Exception + try-with | 프로그램 크래시 방지 |
일반 원칙:
- 기본값은 Option 또는 Result를 사용하세요
- Exception은 마지막 수단으로만 사용하세요
- 타입이 실패 가능성을 말해주게 하세요 — 호출자가 놓치지 않도록