에러 처리
에러 처리 (Error Handling)
OCaml에서 에러는 여러 방식으로 처리할 수 있어요. 이 문서는 사용 가능한 대부분의 수단을 소개해요. 다만 OCaml 5에서 도입된 effect handler로 에러를 처리하는 방법은 아직 다루지 않았어요. 이 주제는 Yaron Minsky와 Anil Madhavapeddy의 Real World OCaml 책의 Error Handling 장에도 다뤄져 있어요(참고 자료 참조).
출처: OCaml 공식 문서
본문
특수 값으로서의 에러 (Errors as Special Values)
그렇게 하지 마세요.
일부 언어, 가장 대표적으로 C는 특정 값을 에러로 취급해요. 예를 들어 유닉스 시스템에서 man 2 read에는 다음과 같은 내용이 있어요.
read - 파일 디스크립터에서 읽기
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);[...]
에러 시 -1이 반환되고, 에러를 나타내도록
errno가 설정된다.
이런 스타일로 훌륭한 소프트웨어가 작성되기도 했어요. 하지만 예상된 반환 값과 에러를 나타내는 값을 구분할 수 없으므로, 프로그래머의 규율 외에는 에러가 무시되지 않도록 보장할 방법이 없어요. 이것은 많은 버그의 원인이었고, 어떤 것들은 끔찍한 결과를 초래했죠. 이것은 OCaml에서 에러를 다루는 올바른 방법이 아니에요.
OCaml에서 에러를 무시하는 것을 불가능하게 만드는 주요 방법 세 가지가 있어요.
- 예외(Exceptions)
option값result값
이것들을 사용하세요. 데이터 안에 에러를 인코딩하지 마세요.
예외는 제어 흐름 수준에서 에러를 다루는 수단을 제공하는 반면, option과 **result**는 에러를 정상 반환 값과 구별되게 해요.
이 문서의 나머지는 에러 처리에 대한 접근법들을 소개하고 비교할게요.
예외 (Exceptions)
역사적으로 OCaml에서 에러를 다루는 첫 번째 방법은 예외예요. 표준 라이브러리는 예외에 크게 의존해요.
예외의 가장 큰 문제는 타입에 나타나지 않는다는 거예요. 실제로 List.find나 String.sub가 예외를 던져 실패할 수 있는 함수라는 걸 보려면 문서를 읽어야 해요.
하지만 예외는 효율적인 기계 코드로 컴파일된다는 큰 장점이 있어요. 자주 백트래킹할 것 같은 시행착오 접근법을 구현할 때, 예외를 사용해 좋은 성능을 얻을 수 있어요.
예외는 확장 가능한 합 타입인 exn 타입에 속해요.
# exception Foo of string;;
exception Foo of string
# let i_will_fail () = raise (Foo "Oh no!");;
val i_will_fail : unit -> 'a = <fun>
# i_will_fail ();;
Exception: Foo "Oh no!".
여기서 exn 타입에 variant Foo를 추가하고, 이 예외를 던지는 함수를 만들었어요. 이제 예외를 어떻게 처리할까요? 구문은 try ... with ...예요.
# try i_will_fail () with Foo _ -> ();;
- : unit = ()
미리 정의된 예외 (Predefined Exceptions)
표준 라이브러리는 여러 예외를 미리 정의해요. Stdlib을 참고하세요. 몇 가지 예시예요.
# 1 / 0;;
Exception: Division_by_zero.
# List.find (fun x -> x mod 2 = 0) [1; 3; 5];;
Exception: Not_found.
# String.sub "Hello world!" 3 (-2);;
Exception: Invalid_argument "String.sub / Bytes.sub".
# let rec loop x = x :: loop x;;
val loop : 'a -> 'a list = <fun>
# loop 42;;
Stack overflow during evaluation (looping recursion?).
마지막 것은 예외처럼 보이지 않지만, 실제로는 예외예요.
# try loop 42 with Stack_overflow -> [];;
- : int list = []
표준 라이브러리의 미리 정의된 예외 중에서, 다음 예외들은 사용자 작성 함수가 던지도록 의도된 것이에요.
Exit는break문처럼 반복을 종료하는 데 쓸 수 있어요.Not_found는 만족할 만한 것을 찾을 수 없어 검색이 실패했을 때 던져야 해요.Invalid_argument는 매개변수를 받아들일 수 없을 때 던져야 해요.Failure는 결과를 만들 수 없을 때 던져야 해요.
표준 라이브러리는 문자열 매개변수로 Invalid_argument와 Failure를 던지는 함수를 제공해요.
val invalid_arg : string -> 'a
(** @raise Invalid_argument *)
val failwith : string -> 'a
(** @raise Failure *)
예외를 던지는 함수를 노출하는 소프트웨어 컴포넌트를 구현할 때는 설계 결정을 해야 해요.
- 기존 예외를 사용하기
- 사용자 정의 예외 던지기
둘 다 말이 될 수 있고 일반적인 규칙은 없어요. 표준 라이브러리 예외를 사용한다면, 의도된 조건에서 던져야 해요. 그렇지 않으면 핸들러가 처리하는 데 문제가 생겨요. 사용자 정의 예외를 사용하면 클라이언트 코드가 전용 catch 조건을 포함하도록 강제돼요. 클라이언트 수준에서 반드시 처리해야 하는 에러에는 이것이 바람직할 수 있어요.
Fun.protect 사용하기 (Using Fun.protect)
예외 처리는 정상 제어 흐름을 중단시키기 때문에, 엄격히 순서가 정해지고 결합된 과정을 요구하는 어떤 작업을 복잡하게 만들 수 있어요. 이런 시나리오를 위해 표준 라이브러리의 Fun 모듈은 다음을 제공해요.
val protect : finally:(unit -> unit) -> (unit -> 'a) -> 'a
이 함수는 계산이 성공하든 실패하든 항상 계산 후에 해야 하는 일이 있을 때 사용하도록 되어 있어요. 라벨이 없는 함수 인자가 먼저 호출되고, 그다음 라벨 인자인 finally로 전달된 함수가 호출돼요. 그러면 protect는 라벨 없는 인자와 같은 값을 반환하거나, 그 함수가 던진 것과 같은 예외를 던져요.
protect에 전달된 함수는 오직 ()만 매개변수로 받는 다는 점을 주의하세요. 이것이 protect를 쓸 수 있는 경우를 제한하지는 않아요. 어떤 계산 f x도 함수 fun () -> f x로 감쌀 수 있으니까요. 항상 그렇듯이, 함수의 몸통은 함수가 호출될 때까지 평가되지 않아요.
finally 함수는 어떤 부작용을 수행하기만 할 것으로 예상되며, 스스로 예외를 던지면 안 돼요. finally가 예외 e를 던지면, protect는 그 예외가 보호된 함수에서 온 것이 아님을 분명히 하기 위해 감싼 Finally_raised e를 던져요.
텍스트 파일의 첫 번째 n 줄을 읽으려는 함수(유닉스 명령 head처럼)로 보여드릴게요. 파일에 n 줄보다 적은 줄이 있으면 함수는 End_of_file을 던져야 해요. 어느 경우든 파일 디스크립터는 나중에 닫혀야 해요. Fun.protect를 사용한 가능한 구현이에요.
# let head_channel chan =
let rec loop acc n = match input_line chan with
| line when n > 0 -> loop (line :: acc) (n - 1)
| _ -> List.rev acc in
loop [];;
val head_channel : in_channel -> int -> string list = <fun>
# let head_file filename n =
let ic = open_in filename in
let finally () = close_in ic in
let work () = head_channel ic n in
Fun.protect ~finally work;;
val head_file : string -> int -> string list = <fun>
head_file이 호출되면 파일 디스크립터를 열고 finally와 work를 정의한 다음, Fun.protect ~finally work가 두 계산을 순서대로 수행해요. work () 그리고 나서 finally ()이고, work ()와 같은 결과를 가져요. 값을 반환하든 예외를 던지든 말이죠. 어느 쪽이든 파일 디스크립터는 사용 후 닫혀요.
비동기 예외 (Asynchronous Exceptions)
일부 예외는 프로그램이 시도한 무언가가 실패해서 생기는 게 아니라, 오히려 외부 요인이 실행을 방해해서 생겨요. 그런 예외를 비동기(asynchronous)라고 불러요. 여기에는 다음이 포함돼요.
Out_of_memoryStack_overflowSys.Break
마지막 것은 사용자가 대화형 실행을 중단할 때 던져져요. 프로그램 로직과 느슨하게 또는 전혀 관련이 없기 때문에, 비동기 예외가 던져진 위치를 추적하는 것은 대부분 의미가 없어요. 어디일 수 있으니까요. 애플리케이션이 그런 예외를 잡아야 하는지, 어떻게 해야 하는지 결정하는 것은 이 튜토리얼의 범위를 벗어나요. 관심 있는 독자는 Guillaume Munch-Maccagnoni의 A Guide to recover from interrupts를 참고할 수 있어요.
문서화 (Documentation)
예외를 던질 수 있는 함수는 이렇게 문서화해야 해요.
val foo : a -> b
(** [foo] does this and that, here is how it works, etc.
@raise Invalid_argument if [a] doesn't satisfy ...
@raise Sys_error if filesystem is not happy
*)
스택 트레이스 (Stack Traces)
잡히지 않은 예외가 프로그램을 크래시시킬 때 스택 트레이스를 얻으려면, 디버그 모드로 프로그램을 컴파일해야 해요. (ocamlc를 호출할 때 -g, ocamlbuild를 호출할 때 -tag 'debug'.) 그러면:
OCAMLRUNPARAM=b ./myprogram [args]
그리고 스택 트레이스를 얻을 수 있어요. 대안으로 프로그램 안에서 이렇게 호출할 수도 있어요.
let () = Printexc.record_backtrace true
출력 (Printing)
예외를 출력하려면 Printexc 모듈이 유용해요. 예를 들어 아래의 notify_user : (unit -> 'a) -> 'a 함수는 함수를 호출하고, 실패하면 stderr에 예외를 출력해요. 스택 트레이스가 활성화되어 있으면 이 함수도 그것을 표시해요.
let notify_user f =
try f () with e ->
let msg = Printexc.to_string e
and stack = Printexc.get_backtrace () in
Printf.eprintf "there was an error: %s%s\n" msg stack;
raise e
OCaml은 내장 예외를 출력하는 법을 알지만, 자신의 예외를 출력하는 법도 알려줄 수 있어요.
exception Foo of int
let () =
Printexc.register_printer
(function
| Foo i -> Some (Printf.sprintf "Foo(%d)" i)
| _ -> None (* for other exceptions *)
)
각 프린터는 자신이 아는 예외를 처리해 Some <출력된 예외>를 반환하고, 그 외에는 None을 반환해야 해요(다른 프린터가 처리하게 두기).
런타임 크래시 (Runtime Crashes)
OCaml은 매우 안전한 언어지만, 런타임에 복구할 수 없는 에러를 촉발하는 것은 가능해요.
던져지지 않는 예외 (Exceptions Not Raised)
컴파일러와 런타임은 의미 있는 예외를 던지기 위해 최선을 다해요. 하지만 일부 에러 조건은 감지되지 않은 채 남아 세그멘테이션 폴트를 초래할 수 있어요. 특히 신뢰할 수 없는 Out_of_memory가 그 경우예요. Stack_overflow도 한때 그랬어요.
하지만 스택 오버플로를 잡는 것은 유닉스 계열 시스템과 Windows 모두에서 까다롭기 때문에, 현재 OCaml 구현은 때때로 버그가 있는 최선의 노력(best effort)이에요.
Xavier Leroy, 2021년 10월
이후로는 개선됐어요. 이제 연결된 C 코드만이 감지되지 않은 스택 오버플로를 촉발할 수 있어요.
본질적으로 안전하지 않은 함수 (Inherently Unsafe Functions)
일부 OCaml 함수는 본질적으로 안전하지 않아요. 주의해서 사용하세요. 이렇게는 말고요.
> echo "fst Marshal.(from_string (to_string 0 []) 0)" | ocaml -stdin
Segmentation fault (core dumped)
언어 버그 (Language Bugs)
크래시가 다음에서 오는 게 아니라면:
- 네이티브 코드 컴파일러의 한계
Marshal과Obj모듈에서 찾을 수 있는 본질적으로 안전하지 않은 함수
언어 버그일 수 있어요. 그런 일이 생겨요. 의심될 때 할 일이에요.
- 크래시가 두 컴파일러(바이트코드와 네이티브) 모두에 영향을 주는지 확인
- 크래시를 촉발하는 것 외에 아무것도 하지 않는 자족적이고 최소한의 개념 증명 코드 작성
- GitHub의 OCaml Bug Tracker에 이슈 등록
그런 버그의 예시: https://github.com/ocaml/ocaml/issues/7241
안전한 함수 vs 안전하지 않은 함수 (Safe vs. Unsafe Functions)
잡히지 않은 예외는 런타임 크래시를 일으켜요. 그래서 다음과 같은 용어를 쓰는 경향이 있어요.
- 예외를 던지는 함수: 안전하지 않음(Unsafe)
- 데이터로 에러를 처리하는 함수: 안전함(Safe)
그런 안전한 에러 처리 함수를 작성하는 주요 방법은 option(다음 절)이나 result(그다음 절) 값을 사용하는 거예요. 이런 타입으로 데이터에 에러를 처리하면 에러 값과 예외의 문제를 피할 수 있지만, 매 단계마다 담긴 값을 추출해야 해서 보일러플레이트 코드가 생기고 런타임 비용이 올 수 있어요.
에러에 option 타입 사용하기 (Using the option Type for Errors)
option 모듈은 예외의 첫 번째 대안을 제공해요. 'a option 데이터 타입은 타입 'a의 데이터(예: Some 42는 int option 타입)를 나타내거나, 어떤 에러로 인한 데이터 부재를 None으로 나타내요.
**option**을 사용하면 예외를 던지는 대신 None을 반환하는 함수를 작성할 수 있어요. 그런 함수 두 예시예요.
# let div_opt m n =
try Some (m / n) with
Division_by_zero -> None;;
val div_opt : int -> int -> int option = <fun>
# let find_opt p l =
try Some (List.find p l) with
Not_found -> None;;
val find_opt : ('a -> bool) -> 'a list -> 'a option = <fun>
이 함수들을 시험해 볼 수 있어요.
# 1 / 0;;
Exception: Division_by_zero.
# div_opt 42 2;;
- : int option = Some 21
# div_opt 42 0;;
- : int option = None
# List.find (fun x -> x mod 2 = 0) [1; 3; 5];;
Exception: Not_found.
# find_opt (fun x -> x mod 2 = 0) [1; 3; 4; 5];;
- : int option = Some 4
# find_opt (fun x -> x mod 2 = 0) [1; 3; 5];;
- : int option = None
요즘은 함수가 버그가 아닌 경우(즉 assert false가 아니라, 네트워크 실패, 키 부재 등)에 실패할 수 있을 때, 예외를 던지기보다 'a option이나 ('a, 'b) result(다음 절) 같은 타입을 반환하는 것이 좋은 관행으로 여겨져요.
명명 규칙 (Naming Conventions)
같은 기본 동작을 하면서 하나는 예외를 던지고 다른 하나는 옵션을 반환하는 함수 쌍의 이름을 붙이는 데 두 가지 규칙이 있어요. 위 예시에서는 표준 라이브러리의 규칙을 사용했어요. 예외를 던지는 대신 옵션을 반환하는 함수 버전의 이름에 _opt 접미사를 붙이는 거예요.
val find: ('a -> bool) -> 'a list -> 'a
(** @raise Not_found *)
val find_opt: ('a -> bool) -> 'a list -> 'a option
이것은 표준 라이브러리의 List 모듈에서 가져온 것이에요.
하지만 일부 프로젝트는 예외의 사용을 피하거나 줄이는 경향이 있어요. 그런 맥락에서는 반대 규칙이 비교적 흔해요. 예외를 던지는 함수 버전에 _exn 접미사를 붙이는 거예요. 같은 함수들을 사용하면 이런 명세가 돼요.
val find_exn: ('a -> bool) -> 'a list -> 'a
(** @raise Not_found *)
val find: ('a -> bool) -> 'a list -> 'a option
옵션을 반환하는 함수 조합하기 (Composing Functions Returning Options)
div_opt 함수는 예외를 던질 수 없어요. 하지만 int 타입의 결과를 반환하지 않으므로 int 자리에 사용할 수 없어요. OCaml이 정수를 실수로 승격하지 않는 것과 같은 방식으로, int option을 int로 또는 그 반대로 자동 변환하지 않아요.
# 21 + Some 21;;
Error: This expression has type 'a option
but an expression was expected of type int
옵션 값을 다른 값과 결합하려면 변환 함수가 필요해요. 옵션에 담긴 데이터를 추출하는 option 모듈이 제공하는 함수들이에요.
val get : 'a t -> 'a
val value : 'a t -> default:'a -> 'a
val fold : none:'a -> some:('b -> 'a) -> 'b t -> 'a
get은 내용을 반환하거나, None에 적용되면 Invalid_argument를 던져요. value는 내용을 반환하거나, None에 적용되면 default 인자를 반환해요. fold는 옵션의 내용에 적용된 some 인자를 반환하거나, None에 적용되면 none 인자를 반환해요.
한 가지 참고로, value는 fold를 사용해 구현할 수 있다는 걸 관찰하세요.
# let value ~default = Option.fold ~none:default ~some:Fun.id;;
val value : default:'a -> 'a option -> 'a = <fun>
# Option.value ~default:() None = value ~default:() None;;
- : bool = true
# Option.value ~default:() (Some ()) = value ~default:() (Some ());;
- : bool = true
옵션 값에 패턴 매칭을 수행하는 것도 가능해요.
match opt with
| None -> ... (* Something *)
| Some x -> ... (* Something else *)
하지만 이런 표현식들을 이어 붙이면 깊은 중첩이 생기고, 종종 좋지 않은 것으로 여겨져요.
들여쓰기가 3단계 이상 필요하다면, 어차피 끝난 거다. 프로그램을 고쳐야 한다.
Linux 커널 스타일 가이드
그걸 피하는 권장 방법은 다음 하위 절에서 설명했듯이 옵션 값의 내용에 접근하려는 시도를 자제하거나 미루는 거예요.
Option.map과 Option.bind 사용하기 (Using Option.map and Option.bind)
예시로 시작할게요. 문자열로 제공된 이메일 주소의 호스트네임 부분을 반환하는 함수를 작성해야 한다고 상상해 보세요. 예를 들어 문자열 "[email protected]"가 주어지면 문자열 "courrier"를 반환해야 해요. (그런 설계에 반대할 만한 입장이 있을 수도 있지만, 이건 그냥 예시일 뿐이에요.)
예외를 사용한 의심스럽지만 간단한 구현이에요.
# let host email =
let fqdn_pos = String.index email '@' + 1 in
let fqdn_len = String.length email - fqdn_pos in
let fqdn = String.sub email fqdn_pos fqdn_len in
try
let host_len = String.index fqdn '.' in
String.sub fqdn 0 host_len
with Not_found ->
if fqdn <> "" then fqdn else raise Not_found;;
val host : string -> string = <fun>
String.index에 대한 첫 번째 호출이 실패하면 이 함수는 Not_found를 던져 실패할 수 있어요. 그 경우는 입력 문자열에 @ 문자가 없어서 이메일 주소가 아니라는 뜻일 때 생길 수 있어요. 하지만 String.index에 대한 두 번째 호출이 실패하면, 즉 점 문자가 없다는 뜻이면, 전체 정규화 도메인 이름(FQDN)을 폴백으로 반환할 수 있어요. 그런데 그것이 빈 문자열만 아니면요.
일반적으로 String.sub가 Invalid_argument를 던질 수 있다는 점을 주의하세요. 다행히 fqdn을 계산할 때는 그럴 수 없어요. 최악의 경우 @ 문자가 마지막에 있고, 그때 fqdn_pos가 범위에서 하나 벗어나지만 fqdn_len이 0이며, 그 매개변수 조합은 예외 대신 빈 문자열을 줘요.
같은 로직을 사용하지만 예외 대신 **option**을 사용한 동등한 함수예요.
# let host_opt email =
match String.index_opt email '@' with
| Some at_pos -> begin
let fqdn_pos = at_pos + 1 in
let fqdn_len = String.length email - fqdn_pos in
let fqdn = String.sub email fqdn_pos fqdn_len in
match String.index_opt fqdn '.' with
| Some host_len -> Some (String.sub fqdn 0 host_len)
| None -> if fqdn <> "" then Some fqdn else None
end
| None -> None;;
val host_opt : string -> string option = <fun>
안전하기는 하지만 가독성이 좋아지진 않았어요. 어떤 사람들은 더 나빠졌다고 주장할 수도 있어요.
이 코드를 어떻게 개선하는지 보여주기 전에, Option.map과 Option.bind이 어떻게 동작하는지 설명해야 해요. 타입들이에요.
val map : ('a -> 'b) -> 'a option -> 'b option
val bind : 'a option -> ('a -> 'b option) -> 'b option
Option.map은 옵션 매개변수가 None이 아니라면 함수 f를 그 옵션 매개변수에 적용해요.
let map f = function
| Some x -> Some (f x)
| None -> None
f가 무언가에 적용될 수 있으면 그 결과는 새 옵션으로 다시 감싸져요. f에 공급할 것이 없으면 None이 전달돼요.
인자 순서를 고려하지 않으면, Option.bind은 f가 옵션을 반환한다고 가정한다는 점만 빼고 거의 정확히 같아요. 그래서 그 결과를 다시 감쌀 필요가 없어요. 이미 옵션 값이니까요.
let bind opt f = match opt with
| Some x -> f x
| None -> None
bind는 map에 대해 매개변수 순서를 뒤집었는데, 이는 그것을 "custom let"을 만드는 수단을 제공하는 OCaml의 인기 있는 확장인 바인딩 연산자로 사용할 수 있게 해줘요. 이렇게 쓰는 거예요.
# let ( let* ) = Option.bind;;
val ( let* ) : 'a option -> ('a -> 'b option) -> 'b option = <fun>
이 메커니즘들을 사용해 host_opt를 다시 쓰는 가능한 방법이에요.
# let host_opt email =
let* fqdn_pos = Option.map (( + ) 1) (String.index_opt email '@') in
let fqdn_len = String.length email - fqdn_pos in
let fqdn = String.sub email fqdn_pos fqdn_len in
String.index_opt fqdn '.'
|> Option.map (fun host_len -> String.sub fqdn 0 host_len)
|> function None when fqdn <> "" -> Some fqdn | opt -> opt;;
val host_opt : string -> string option = <fun>
이 버전은 옵션에 대한 연산을 어떻게 사용하고 결합하는지 보여주기 위해 골랐어요. 이해 가능성과 견고성 사이의 균형을 이룰 수 있게 해주죠. 몇 가지 관찰이에요.
원래 host 함수(예외 사용)와 마찬가지로.
String함수들(index_opt,length,sub)에 대한 호출이 같고 같은 순서예요.- 같은 지역 이름들이 같은 타입으로 사용돼요.
- 남은 들여쓰기나 패턴 매칭이 없어요.
1번 줄:
=의 오른쪽:Option.map은String.index_opt의 결과가 실패하지 않았다면 거기에 1을 더하는 것을 허용해요.=의 왼쪽:let*문법은 나머지 코드 전체(2번 줄부터 끝까지)를fqdn_pos를 매개변수로 받는 익명 함수의 몸통으로 바꾸고, 함수( let* )가fqdn_pos와 그 익명 함수로 호출돼요.
2번, 3번 줄: 원래와 같음.
4번 줄: try나 match가 제거됨.
5번 줄: 이전 단계가 실패하지 않았다면 String.sub가 적용되고, 그렇지 않으면 에러가 전달됨.
6번 줄: 앞에서 아무것도 찾지 못했고, 비어 있지 않으면 폴백으로 fqdn이 반환됨.
catch 문으로 에러를 처리하는 데 익숙하다면 후자 스타일에 익숙해지는 데 시간이 걸려요. 핵심 아이디어는 옵션 값을 직접 들여다보는 것을 피하거나 미루는 거예요. 대신 map과 bind 같은 임시 pipes를 사용해 전달하는 거죠. Erik Meijer는 그 스타일을 "행복한 경로 따르기(following the happy path)"라고 불러요. 시각적으로 C에서 흔히 발견되는 "early return" 패턴과도 닮았어요.
option 타입의 한계 중 하나는 반환 값을 갖지 못하게 한 이유를 기록하지 않는다는 거예요. None은 조용해서 무엇이 잘못됐는지 아무 말도 하지 않아요. 그런 이유로 옵션 값을 반환하는 함수는 None을 반환할 수 있는 상황을 문서화해야 해요. 그런 문서는 @raise를 사용하는 예외에 요구되는 것과 비슷할 거예요. 다음 절에서 설명할 result 타입은 그 공백을 채우기 위한 것이에요. 옵션 값처럼 데이터로 에러를 관리하면서, 예외처럼 에러에 대한 정보도 제공하는 거죠.
에러에 result 타입 사용하기 (Using the result Type for Errors)
표준 라이브러리의 result 모듈은 다음 타입을 정의해요.
type ('a, 'b) result =
| Ok of 'a
| Error of 'b
값 Ok x는 계산이 성공해 x를 만들었다는 뜻이고, 값 Error e는 실패했으며 e가 그 과정에서 수집된 에러 정보를 나타낸다는 뜻이에요. 다른 합 타입처럼 패턴 매칭을 사용해 두 경우를 처리할 수 있어요. 하지만 map과 bind을 사용하는 것이 더 편리할 수 있고, 어쩌면 **option**에서보다 더 그럴 수 있어요.
Result.map을 보기 전에 List.map과 Option.map을 바뀐 관점에서 생각해 보죠. 두 함수 모두 각각 []나 None에 적용되면 항등처럼 동작해요. 그 매개변수들이 데이터를 담지 않기 때문에 그게 유일한 가능성이에요. Error 생성자가 있는 **result**와는 다르죠. 그래도 Result.map은 비슷하게 구현돼요. Error에서도 항등처럼 동작해요.
타입이에요.
val map : ('a -> 'b) -> ('a, 'c) result -> ('b, 'c) result
이렇게 쓰여요.
let map f = function
| Ok x -> Ok (f x)
| Error e -> Error e
result 모듈에는 map 함수 두 개가 있어요. 방금 본 것과, 같은 로직으로 Error에 적용되는 또 다른 것이에요.
타입이에요.
val map_error : ('c -> 'd) -> ('a, 'c) result -> ('a, 'd) result
이렇게 쓰여요.
let map_error f = function
| Ok x -> Ok x
| Error e -> Error (f e)
Result.bind에도 같은 추론이 적용돼요. 다만 bind_error는 없어요. 이 함수들을 사용해, Anil Madhavapeddy의 OCaml YAML 라이브러리를 사용하는 가상의 코드 예시예요.
let file_opt = File.read_opt path in
let file_res = Option.to_result ~none:(`Msg "File not found") file_opt in begin
let* yaml = Yaml.of_string file_res in
let* found_opt = Yaml.Util.find key yaml in
let* found = Option.to_result ~none:(`Msg (key ^ ", key not found")) found_opt in
found
end |> Result.map_error (Printf.sprintf "%s, error: %s: " path)
관련된 함수들의 타입들이에요.
val File.read_opt : string -> string option
val Yaml.of_string : string -> (Yaml.value, [`Msg of string]) result
val Yaml.Util.find : string -> Yaml.value -> (Yaml.value option, [`Msg of string]) result
val Option.to_result : none:'e -> 'a option -> ('a, 'e) result
File.read_opt는 파일을 열고 내용을 읽어 옵션에 담긴 문자열로 반환하고, 뭔가 잘못되면None을 반환한다고 가정돼 있어요.Yaml.of_string은 문자열을 파싱해 임시 OCaml 타입으로 바꿔요.Yaml.find는 Yaml 트리에서 키를 재귀적으로 검색해요. 찾으면 해당 데이터를 옵션에 담아 반환해요.Option.to_result는 **option**을 **result**로 변환해요.
마지막으로 let*는 Result.bind를 뜻해요.
Yaml 모듈의 두 함수가 모두 result 데이터를 반환하므로, 그 타입을 처음부터 끝까지 처리하는 파이프를 쓰기가 더 쉬워요. 그래서 Option.to_result를 사용해야 해요. **result**를 만드는 단계는 bind로 이어 붙여야 하고, 그렇지 않은 단계는 어떤 map 함수로 값을 다시 **result**로 감싸 이어 붙여야 해요.
result 모듈의 map 함수들은 데이터나 에러를 처리하게 해주지만, 사용하는 루틴은 실패하면 안 돼요. Result.map은 Ok를 Error로 만들지 않고 Result.map_error는 Error를 Ok로 만들지 않으니까요. 반면 Result.bind에 전달된 함수는 실패해도 돼요. 앞서 말했듯이 Result.bind_error는 없어요. 그 부재를 이해하는 한 가지 방법은 그 타입을 생각해 보는 거예요. 그것은 이렇게 될 거예요.
val Result.bind_error : ('a, 'e) result -> ('e -> ('a, 'f) result) -> ('a, 'f) result
그러면:
Result.map_error f (Ok x) = Ok x
그리고 둘 중 하나:
Result.map_error f (Error e) = Ok yResult.map_error f (Error e) = Error e'
이것은 에러가 유효한 데이터로 다시 바뀌거나 다른 에러로 바뀐다는 뜻이에요. 에러에서 회복하는 것과 거의 비슷해요. 하지만 회복이 실패할 때는 실패의 초기 원인을 보존하는 것이 나을 수 있어요. 그 동작은 다음 함수를 정의해 얻을 수 있어요.
# let recover f = Result.(fold ~ok:ok ~error:(fun (e : 'e) -> Option.to_result ~none:e (f e)));;
val recover : ('e -> 'a option) -> ('a, 'e) result -> ('a, 'e) result = <fun>
어떤 종류의 데이터든 result Error로 감쌀 수 있지만, 그 생성자로 실제 에러를 담는 것이 권장돼요. 예를 들어:
exn: 그런 경우 result 타입은 예외를 명시적으로 만들 뿐이에요.string: 에러 case가 무엇이 실패했는지 나타내는 메시지인 경우.string Lazy.t: 출력이 필요할 때만 평가되는 더 정교한 형태의 에러 메시지.- 어떤 다형적 variant: 가능한 에러마다 case 하나. 매우 정확하지만(각 에러를 명시적으로 다룰 수 있고 타입에 나타나서), 다형적 variant의 사용이 때로 코드를 읽기 어렵게 만들어요.
어떤 사람들은 result 타입과 Either.t가 동형이라고 말해요. 구체적으로, 완전히 중립적인 리팩터링처럼 하나를 다른 하나로 항상 대체할 수 있다는 뜻이에요. **result**와 Either.t 타입의 값은 서로 변환될 수 있고, 두 변환을 어느 순서로든 연달아 적용하면 시작 값으로 돌아와요. 하지만 그렇다고 **result**를 Either.t 자리에 써야 한다거나 그 반대라는 뜻은 아니에요. 이름 짓기는 중요해요. Phil Karlton의 유명한 말이 그것을 말해주죠.
컴퓨터 과학에는 어려운 일이 딱 두 가지 있다: 캐시 무효화와 이름 짓기.
에러 처리는 필연적으로 코드를 복잡하게 만들어, 예외적인 조건에서 잘못 동작하거나 실패하는 단순한 코드보다 읽고 이해하기 어렵게 해요. 올바른 도구, 데이터, 함수가 명확함을 최소한으로 잃고 올바른 동작을 보장하게 도와줘요. 그것들을 사용하세요.
이항 연산자로서의 bind (bind as a Binary Operator)
Option.bind이나 Result.bind을 사용할 때, 그것들은 종종 let* 같은 사용자 정의 바인딩 연산자로 별칭된다. 하지만 이항 연산자로 사용하는 것도 가능한데, 보통 >>=로 써요. 이렇게 bind를 사용하는 것은 다른 함수형 프로그래밍 언어, 특히 Haskell에서 극도로 인기 있기 때문에 자세히 설명해야 해요.
a와 b가 유효한 OCaml 표현식이라고 가정하면, 다음 세 코드 조각은 기능적으로 동일해요.
bind a (fun x -> b)
let* x = a in b
a >>= fun x -> b
무의미해 보일 수 있어요. 말이 되게 하려면 bind 호출이 여러 번 이어 붙은 표현식을 봐야 해요. 다음 세 개도 동등해요.
bind a (fun x -> bind b (fun y -> c))
let* x = a in
let* y = b in
c
a >>= fun x -> b >>= fun y -> c
세 경우 모두 x와 y 변수가 c에 나타날 수 있어요. 첫 번째 형태는 괄호를 많이 사용해 그리 편리하지 않아요. 두 번째는 일반적인 지역 정의와 닮아서 자주 선호돼요. 세 번째는 >>=가 바로 그 경우에 괄호를 피하려고 오른쪽으로 결합하지만, 길을 잃기 쉽기 때문에 읽기 더 어려워요. 그래도 이름 붙은 함수를 사용할 때는 매력이 있어요. 예전 유닉스 파이프를 조금 닮았죠.
a >>= f >>= g
이것이 다음보다 더 낫게 보여요.
let* x = a in
let* y = f x in
g y
x >>= f라고 쓰는 것은 메서드와 수신자(receiver)가 있는 함수형으로 오염된 프로그래밍 언어(Kotlin, Scala, Go, Rust, Swift, 심지어 현대 Java)에서 발견되는 것과 아주 비슷해요. 거기서는 x.bind(f)처럼 보일 거예요.
이전 절 끝에 제시된 것과 같은 코드를 이항 연산자로 Result.bind을 사용해 다시 쓴 거예요.
File.read_opt path
|> Option.to_result ~none:(`Msg "File not found")
>>= Yaml.of_string
>>= Yaml.Util.find key
>>= Option.to_result ~none:(`Msg (key ^ ", key not found"))
|> Result.map_error (Printf.sprintf "%s, error: %s: " path)
참고로 이 스타일을 무점성 프로그래밍(tacit programming)이라고 불러요. >>=와 |> 연산자의 결합 우선순위 덕분에 괄호로 묶인 표현식이 한 줄을 넘지 않아요.
OCaml은 엄격한 타입 규율을 가지지, 엄격한 스타일 규율을 가지지 않아요. 그래서 올바른 스타일을 고르는 것은 작성자의 결정에 달려 있어요. 그것은 에러 처리에도 적용돼요. 그러니 의식적으로 스타일을 고르세요. 이런 문제에 대한 자세한 내용은 OCaml Programming Guidelines을 참고하세요.
에러 사이의 변환 (Conversions Between Errors)
**option**이나 **result**에서 예외 던지기 (Throwing Exceptions From option or result)
이것은 다음 함수들을 사용해 해요.
**option**에서 Invalid_argument 예외로는 Option.get 함수를 사용해요.
val get : 'a option -> 'a
**result**에서 Invalid_argument 예외로는 Result.get_ok과 Result.get_error 함수를 사용해요.
val get_ok : ('a, 'e) result -> 'a
val get_error : ('a, 'e) result -> 'e
다른 예외를 던지려면 패턴 매칭과 raise를 사용해야 해요.
**option**과 result 사이의 변환 (Conversion Between option and result)
이것은 다음 함수들을 사용해 해요.
**option**에서 **result**로는 Option.to_result 함수를 사용해요.
val to_result : none:'e -> 'a option -> ('a, 'e) result
**result**에서 **option**으로는 Result.to_option 함수를 사용해요.
val to_option : ('a, 'e) result -> 'a option
예외를 **option**이나 **result**로 바꾸기 (Turning Exceptions into option or result)
표준 라이브러리는 그런 함수를 제공하지 않아요. 이것은 **try ... with**나 match ... exception 문을 사용해 해야 해요. 예를 들어 예외를 던지는 대신 **option**을 반환하는 Stdlib.input_line 버전을 만드는 방법이에요.
let input_line_opt ic = try Some (input_line ic) with End_of_file -> None
**result**도 같은데, Error 생성자에 어떤 데이터를 제공해야 한다는 점만 달라요.
어떤 사람들은 이것을 고차 일반 함수로 바꾸고 싶어할 수 있어요.
# let catch f x = try Some (f x) with _ -> None;;
val catch : ('a -> 'b) -> 'a -> 'b option = <fun>
단언 (Assertions)
내장 assert 명령은 표현식을 인자로 받고, 제공된 표현식이 false로 평가되면 Assert_failure 예외를 던져요. 이 예외를 잡지 않는다고 가정하면(이 예외를 잡는 것은 특히 초보자에게 현명하지 않을 수 있어요), 프로그램이 멈추고 에러가 발생한 소스 파일과 줄 번호를 출력해요. 예시:
# assert (Sys.os_type = "Win32");;
Exception: Assert_failure ("//toplevel//", 1, 0).
물론 Win32에서 이걸 실행하면 에러를 던지지 않아요.
assert false를 쓰면 그냥 프로그램이 멈춰요. 이 관용구는 때때로 죽은 코드를 나타내는 데 사용돼요. (종종 타입 검사나 패턴 매칭 완전성을 위해) 반드시 작성해야 하지만 런타임에는 도달할 수 없는 프로그램 부분을 가리키는 거죠.
단언은 실행 가능한 주석으로 이해해야 해요. 디버깅 중이거나 실행이 어떤 종류의 진전도 절대적으로 막는 진짜 예외적인 상황이 아니면 실패해서는 안 돼요.
실행이 처리할 수 없는 조건에 도달하면, failwith "error message"를 호출해 Failure를 던지는 것이 옳아요. 단언은 그런 경우를 다루기 위한 것이 아니에요. 예를 들어 다음 코드에서:
match Sys.os_type with
| "Unix" | "Cygwin" -> (* code omitted *)
| "Win32" -> (* code omitted *)
| "MacOS" -> (* code omitted *)
| _ -> failwith "this system is not supported"
failwith를 사용하는 것이 옳아요. 다른 운영체제는 지원되지 않지만 존재하기는 하죠. 이중 예시예요.
function x when true -> () | _ -> assert false
여기서 failwith를 사용하는 것은 옳지 않아요. 두 번째 코드 경로가 실행되려면 시스템이 손상되거나 컴파일러가 버그가 있어야 하니까요. 언어 의미론의 파손은 예외적인 상황에 해당해요. 치명적인 거죠!
맺는 말 (Concluding Remarks)
에러를 제대로 처리하는 것은 복잡한 문제예요. 그것은 횡단 관심사(cross-cutting concern)로, 애플리케이션의 모든 부분에 닿으며 전용 모듈에 격리할 수 없어요. 다른 여러 주류 언어와 달리 OCaml은 예외적인 사건을 처리하는 여러 메커니즘을 제공하며, 모두 좋은 런타임 성능과 코드 이해 가능성을 가져요. 그것들을 제대로 사용하려면 약간의 학습과 연습이 필요해요. 이후에는 항상 약간의 생각이 필요한데, 이는 이로운 일이에요. 올바른 에러 관리는 결코 간과해서는 안 되니까요. 어떤 에러 처리 메커니즘도 항상 다른 것보다 더 낫지는 않아요. 그리고 어떤 것을 쓸지는 취향의 문제라기보다 맥락에 맞추는 문제여야 해요. 하지만 자기 주장이 강한 OCaml 코드도 괜찮으니, 균형의 문제예요.
외부 자료 (External Resources)
- "The OCaml Manual, The Core Language" 1장 6절의 "Exceptions", 2022년 12월
- OCaml 라이브러리의 "option 모듈"
- OCaml 라이브러리의 "result 모듈"
- Yaron Minsky와 Anil Madhavapeddy, "Real World OCaml" 7부의 "Error Handling", 2판, Cambridge University Press, 2022년 10월
- Marcello Seri, "Add "finally" function to Pervasives", GitHub PR, ocaml/ocaml/pull/1855
- Guillaume Munch-Maccagnoni, "A guide to recover from interrupts", memprof-limits 문서의 일부
더 알아보기
- OCaml 공식 문서 - Error Handling
- OCaml Manual - Exceptions — 예외에 대한 공식 매뉴얼
- option 모듈, result 모듈
- Real World OCaml의 Error Handling 장