Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

12장: 에러 처리 전략 (Error Handling Strategies)

프로그램에서 실패는 피할 수 없습니다. 파일이 없을 수 있고, 네트워크가 끊길 수 있고, 사용자가 잘못된 입력을 줄 수 있습니다. 문제는 “실패가 발생하는가?“가 아니라 “실패를 어떻게 표현하고 처리하는가?“입니다.

11장에서 예외(Exception)를 배웠습니다. 예외는 강력하지만, 함수의 시그니처만 봐서는 그 함수가 실패할 수 있는지 알 수 없다는 근본적인 한계가 있습니다. FunLang는 예외 외에도 OptionResult라는 두 가지 타입 기반 접근법을 제공합니다. 이 세 가지 중 어떤 것을 선택하느냐에 따라 코드의 안전성, 합성 가능성, 가독성이 크게 달라집니다.

이 장은 같은 문제를 세 가지 방식으로 풀어보고, 각각의 장단점을 비교한 뒤, 실전에서의 선택 기준을 제시합니다. 함수형 프로그래밍 커뮤니티에서 왜 “예외보다 타입을 써라“라고 말하는지, 그 이유를 직접 확인하게 됩니다.

세 가지 접근법 비교

같은 문제를 세 가지 방식으로 풀어보겠습니다: 리스트에서 조건을 만족하는 첫 번째 원소를 찾는 함수입니다.

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")

resultBindresultMap으로 에러가 자동 전파됩니다:

  • parseInt "abc"Error "invalid: abc"resultBindsafeDivide를 건너뛰고 에러 전파
  • 에러가 발생한 정확한 지점의 메시지가 보존됩니다

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/ResultTCO 호환
최상위 레벨 에러 복구Exception + try-with프로그램 크래시 방지

일반 원칙:

  • 기본값은 Option 또는 Result를 사용하세요
  • Exception은 마지막 수단으로만 사용하세요
  • 타입이 실패 가능성을 말해주게 하세요 — 호출자가 놓치지 않도록