Lowent 매뉴얼←↑→

17 실패를 설계하기

먼저 알아야 할 것

11장 답을 담는 타입 · 실패를 말하는 세 길
14장 계약 · requires 는 부르는 쪽, errors 는 op 의 책임
16장 권한 · 시작점만 보면 바깥과 닿는 자리를 안다

돌아보기

11장은 result·option·panic 을 가르는 물음을 무엇이라 했는가?

답. “부르는 쪽이 무엇을 할 수 있는가” 였다. 다른 길을 시도할 수 있으면 result, 그냥 없는 것이면 option, 부르는 쪽이 이미 약속을 어긴 것이라 고칠 수 없으면 멈춘다. 이 장은 그 물음을 op 하나가 아니라 프로그램의 층 전체에 적용한다.

이 장의 필요성과 맥락

문법을 다 알아도 실패를 어디서 무엇으로 말할지는 저절로 정해지지 않는다. 같은 “잘못된 포트 번호” 가 설정 파일을 읽는 층에서는 result 여야 하고, 이미 검사를 마친 안쪽 층에서는 계약이어야 한다. 이 선택을 틀리면 검사가 여러 겹으로 되풀이되거나, 반대로 아무 층도 검사하지 않는다. 제4부의 마지막 장으로, 계약·오류·권한을 함께 써서 실패의 자리를 정하는 법을 정리한다.

이 장이 끝나면

바깥에서 온 값과 안쪽의 불변식을 가르고, 경계에서는 result 로, 안쪽에서는 계약으로 말하는 설계를 익힌다. 오류 열거형을 어떻게 나누고 errors 절에 무엇을 적을지, 층을 올라가며 try 로 넘길지 그 자리에서 다룰지 정하는 기준을 알게 된다. option 으로 정보를 버려도 되는 자리와 panic 을 써도 되는 자리도 보게 된다.

이 장에서 답할 질문

  1. errors not_digit . 처럼 조건 없이 적은 오류와 errors empty eq (len s) 0 . 처럼 조건을 붙인 오류는 무엇이 다른가?

17.1 경계와 안쪽#

프로그램에는 바깥에서 값이 들어오는 자리가 있다. 프로그램 인자, 파일 내용, 네트워크에서 온 바이트, 사용자의 입력이다. 그 값에는 아무 약속도 없다. 반대로 한 번 검사를 통과한 값이 흘러 다니는 안쪽이 있다.

설계의 첫 원칙은 이 둘을 가르는 것이다.

자리값의 성격말하는 법
경계(파싱·입력)무엇이든 올 수 있다result 와 errors
안쪽(계산)이미 검사를 통과했다requires·range·newtype
어느 층도 다룰 수 없음있어서는 안 되는 상태panic

표 17.1 — 실패를 말하는 자리

examples/ch17/parse.low

module parse .
rem run: parse_u16 [52,50]
rem run: parse_u16 []
rem run: parse_u16 [52,120]
rem run: parse_u16 [55,48,48,48,48]
rem run: port_or_default [56,48]
rem run: port_or_default [120]

enum parse_error do
  empty .
  not_digit .
  too_big .
end

fn is_digit input c u8 . output bool .
do
  return and (ge c 48) (le c 57) .
end

rem 바깥에서 온 바이트 — 무엇이든 올 수 있으므로 실패는 값이다
fn parse_u16 input s slice u8 . output result u16 parse_error .
  errors empty eq (len s) 0 .
  errors not_digit .
  errors too_big .
do
  guard gt (len s) 0 . else return error empty .
  var acc u64 be 0 .
  for c s do
    guard is_digit c . else return error not_digit .
    set acc (add (mul acc 10) (widen u64 (sub c 48))) .
    guard le acc 65535 . else return error too_big .
  end
  return ok (narrow u16 acc) .
end

rem 이 층은 «왜» 가 필요 없다 — 없으면 기본값을 쓴다
fn port_or_default input s slice u8 . output u16 .
do
  let r result u16 parse_error be parse_u16 s .
  guard not (is_error r) . else return 8080 .
  return ok_value r .
end

실행 결과

$ lowentc --run parse_u16 parse.low [52,50]
parse_u16([52,50]) = ok 42
  arg0 (written) = [52,50]
$ lowentc --run parse_u16 parse.low []
parse_u16([]) = err empty
  arg0 (written) = []
$ lowentc --run parse_u16 parse.low [52,120]
parse_u16([52,120]) = err not_digit
  arg0 (written) = [52,120]
$ lowentc --run parse_u16 parse.low [55,48,48,48,48]
parse_u16([55,48,48,48,48]) = err too_big
  arg0 (written) = [55,48,48,48,48]
$ lowentc --run port_or_default parse.low [56,48]
port_or_default([56,48]) = 80
  arg0 (written) = [56,48]
$ lowentc --run port_or_default parse.low [120]
port_or_default([120]) = 8080
  arg0 (written) = [120]

parse_u16 은 경계다. 바이트는 비어 있을 수도, 숫자가 아닐 수도, 너무 클 수도 있다. 셋 모두 고칠 수 있는 실패이므로 result 로 돌려준다. 계약으로 “숫자만 주시오” 라고 요구하는 것은 틀린 설계다 — 부르는 쪽은 바이트를 받자마자 이 op 에 넘기므로, 요구를 지킬 방법이 없다.

값이 어디서 와서 어디로 가는지 그리면 이렇다.

 바깥 (약속 없음)         경계                 안쪽 (이미 걸렀다)
 인자 · 파일 · 바이트 ──▶ parse_u16 ── ok ──────▶ slot_of …
                          result + errors      requires · range · newtype
                             │ error
                             ▼
                          부르는 층이 무엇을 할지 고른다

문. errors not_digit . 처럼 조건 없이 적은 오류와 errors empty eq (len s) 0 . 처럼 조건을 붙인 오류는 무엇이 다른가?

답. 조건을 붙이면 “이 조건일 때 정확히 이 오류가 난다” 는 나가는 쪽의 계약이 된다. 조건이 참인데 정상으로 돌아오면 계약 위반이다. 부르는 쪽은 그 조건만 피하면 그 오류를 다루지 않아도 된다고 믿을 수 있다. 조건을 계약의 식으로 적을 수 없는 오류(모든 바이트가 숫자인가)는 조건 없이 적어 “날 수 있다” 만 약속한다. 적을 수 있는 조건은 적는 편이 부르는 쪽에 더 많은 것을 알려 준다.

17.2 오류 열거형을 나누는 기준#

오류 갈래는 부르는 쪽이 다르게 행동할 경우마다 하나씩 둔다. empty·not_digit·too_big 을 가른 것은 화면에 다른 안내를 보여 줄 수 있기 때문이다. 부르는 쪽이 모두 같은 일을 한다면 갈래를 합친다. 갈래가 많을수록 match 로 다루는 쪽의 짐이 는다.

반대로 한 op 이 서로 관계없는 층의 오류를 모두 담는 거대한 열거형은 피한다. 파일 오류와 파싱 오류와 설정 오류가 한 열거형에 섞이면, 머리의 errors 절이 그 op 이 실제로 낼 수 있는 것보다 넓은 그림을 그리게 되고, 일어날 수 없는 오류 선언은 번역이 거절한다(14장).

17.3 층을 올라가며#

실패를 받은 층은 둘 중 하나를 한다. 넘기거나, 그 자리에서 다루거나.

examples/ch17/layers.low

module layers .
rem run: main

enum config_error do
  bad_port .
  port_zero .
end

rem 안쪽 불변식 — 부르는 쪽이 이미 걸렀어야 하는 것은 계약이다
fn slot_of input port u16 . input slots u64 . output u64 .
  requires gt slots 0 .
do
  return mod (widen u64 port) slots .
end

rem 바깥 입력 — 틀릴 수 있는 것은 result 다
fn check_port input port u16 . output result u16 config_error .
  errors port_zero eq port 0 .
do
  guard ne port 0 . else return error port_zero .
  return ok port .
end

fn pick_slot input port u16 . output result u64 config_error .
  errors port_zero eq port 0 .
do
  let p u16 be try check_port port .
  return ok (slot_of p 16) .
end

proc main input out cap io . output u8 . effects io .
do
  let r result u64 config_error be pick_slot 8080 .
  guard not (is_error r) . else do
    let n u64 be write_out out 1 "bad port\n" .
    return 1 .
  end
  let m u64 be write_out out 1 "slot ok\n" .
  return narrow u8 (ok_value r) .
end

실행 결과

$ lowentc --run main layers.low
slot ok
main() = 0

port 가 0 일 때 실패가 올라가는 길을 그리면 이렇다. 내려갈 때는 부르고, 올라올 때는 넘기거나 다룬다.

 main ─────────────────────────  다룬다: "bad port" 를 쓰고 1 을 돌려준다
  │  ▲ error port_zero
  ▼  │
 pick_slot ────────────────────  넘긴다: try 가 그대로 위로
  │  ▲ error port_zero      │
  ▼  │                      ▼ 성공한 뒤에만
 check_port                 slot_of
 (경계: result)             (안쪽: requires gt slots 0)

규칙으로 정리하면, 넘길 수 있는 가장 가까운 층이 다룰 수 있으면 넘기지 말고, 다룰 수 없으면 넘긴다. try 는 넘기는 비용을 한 낱말로 줄였지만, 넘긴다는 사실은 부르는 쪽의 errors 절에 남는다.

흔한 오해. 모든 op 이 result 를 돌려주면 가장 안전하다

안쪽까지 result 를 돌려주면 모든 호출에 try 가 붙고, 모든 머리에 errors 가 붙는다. 그런데 안쪽에서는 그 실패가 일어날 수 없다 — 경계에서 이미 걸렀기 때문이다. 일어날 수 없는 실패를 적으면 다루는 코드가 생기고, 그 코드는 영영 돌지 않는다. 안쪽의 불변식은 계약과 타입(range·newtype)으로 적는다. 그러면 검사가 경계에 한 번 남고, 안쪽에서는 사실로 쓰여 오히려 지워진다.

17.4 정보를 버려도 되는 자리#

port_or_default 는 실패의 이유가 필요 없다. 무엇이 틀렸든 기본 포트 8080 을 쓴다. 이런 층에서는 result 를 묻고 버리거나, try … else_none 으로 option 으로 바꾸어 value_or 를 쓴다. 정보를 버리는 것은 그 층이 결정할 일이다. 아래 층이 미리 option 으로 버려 두면, 이유가 필요한 위 층이 되찾을 길이 없다. 그러니 버리는 것은 가능한 한 위에서 한다.

17.5 panic 을 써도 되는 자리#

panic 은 복구할 수 없는 상태를 만났을 때 쓴다. 판단 기준은 “이 상태를 어느 층이 다룰 수 있는가” 다.

반대로 사용자의 입력이 틀린 것, 파일이 없는 것, 연결이 끊긴 것은 panic 할 일이 아니다. 어느 층인가 다룰 수 있다. 그리고 panic 은 효과이므로, 쓰는 순간 그 op 과 부르는 모든 op 의 머리에 effects panic 이 번진다 (15장). 그 비용이 panic 을 아껴 쓰게 만든다.

실제 사례. HTTP 요청 파서의 알맹이는 거절이다

표준 라이브러리의 http 모듈은 HTTP/1.1 요청을 읽는다. 그 문서가 스스로를 소개하는 말이 “알맹이는 거절이다” 이다. 요청 밀반입(request smuggling) 같은 공격은 서버와 프록시가 애매한 요청을 서로 다르게 받아들이는 자리에서 산다. 그래서 파서는 애매한 입력을 최대한 받아 주지 않고 이름 붙은 오류로 돌려준다. 경계의 op 이 result 로 무엇을 거절하는지 분명하게 말하면, 안쪽은 받아들인 요청의 모양을 믿을 수 있다(36장).

17.6 흔한 실수#

반례. 경계에서 틀린 입력에 panic 한다

examples/ch17/mistake_panicinput.low

module mistake_panicinput .
rem run: parse_or_die [52,50]
rem trap: parse_or_die [52,120]

rem ✘ 사용자가 잘못 친 한 글자에 프로그램 전체가 멈춘다
proc parse_or_die input s slice u8 . output u64 . effects panic .
do
  var acc u64 be 0 .
  for c s do
    if or (lt c 48) (gt c 57) . do panic "not a digit" . end
    set acc (add (mul acc 10) (widen u64 (sub c 48))) .
    if gt acc 65535 . do panic "too big" . end
  end
  return acc .
end

실행 결과

$ lowentc --run parse_or_die mistake_panicinput.low [52,50]
parse_or_die([52,50]) = 42
  arg0 (written) = [52,50]
$ lowentc --run parse_or_die mistake_panicinput.low [52,120]
== ir diagnostics (1) ==
0:0 E-VM-PANIC: the program called `panic` — this is an unrecoverable stop, and it is NOT a contract violation (the code chose to stop, it did not break a promise)

사용자가 4x 를 쳤을 뿐인데 프로그램 전체가 멈춘다. 틀린 입력은 흔히 일어나는 일이고 어느 층인가 다룰 수 있다 — 다시 묻거나, 기본값을 쓰거나, 안내를 보여 줄 수 있다. panic 은 그 선택지를 모두 없앤다. 게다가 effects panic 이 부르는 쪽 모두로 번진다. 이 장 첫머리의 parse_u16 처럼 실패를 result 로 돌려주고, 무엇을 할지는 부르는 층이 정하게 한다.

반례. 아래 층에서 이유를 미리 버린다

examples/ch17/mistake_dropwhy.low

module mistake_dropwhy .
rem run: explain []
rem run: explain [120]

enum parse_error do
  empty .
  not_digit .
end

fn parse_digit input s slice u8 . output result u8 parse_error .
  errors empty eq (len s) 0 .
  errors not_digit .
do
  guard gt (len s) 0 . else return error empty .
  let c u8 be index s 0 .
  guard and (ge c 48) (le c 57) . else return error not_digit .
  return ok (sub c 48) .
end

rem ✘ 아래 층이 미리 이유를 버렸다
fn digit_or_none input s slice u8 . output option u8 .
do
  return try (parse_digit s) else_none .
end

rem 위 층은 빈 입력과 숫자 아닌 입력을 다르게 안내하고 싶지만 둘 다 0 만 본다
fn explain input s slice u8 . output u8 .
do
  let d option u8 be digit_or_none s .
  guard is_some d . else return 0 .
  return 9 .
end

실행 결과

$ lowentc --run explain mistake_dropwhy.low []
explain([]) = 0
  arg0 (written) = []
$ lowentc --run explain mistake_dropwhy.low [120]
explain([120]) = 0
  arg0 (written) = [120]

digit_or_none 이 else_none 으로 오류를 option 으로 바꾸는 순간 “왜” 가 사라진다. 위 층의 explain 은 빈 입력과 숫자 아닌 입력에 다른 안내를 하고 싶지만 둘 다 0 만 본다. 되찾을 길이 없다. 이유는 끝까지 들고 올라와, 버려도 되는지 아는 층에서 버린다.

examples/ch17/dropwhy_fixed.low

module dropwhy_fixed .
rem run: explain []
rem run: explain [120]
rem run: explain [55]

enum parse_error do
  empty .
  not_digit .
end

fn parse_digit input s slice u8 . output result u8 parse_error .
  errors empty eq (len s) 0 .
  errors not_digit .
do
  guard gt (len s) 0 . else return error empty .
  let c u8 be index s 0 .
  guard and (ge c 48) (le c 57) . else return error not_digit .
  return ok (sub c 48) .
end

rem 이유를 끝까지 들고 올라와, 필요한 층에서 가른다
fn explain input s slice u8 . output u8 .
do
  match parse_digit s do
    case ok v . return 9 .
    case error e . do
      rem 오류 값에 이름을 묶고, 그 열거형을 한 번 더 가른다
      match e do
        case empty . return 1 .
        case not_digit . return 2 .
      end
    end
  end
end

실행 결과

$ lowentc --run explain dropwhy_fixed.low []
explain([]) = 1
  arg0 (written) = []
$ lowentc --run explain dropwhy_fixed.low [120]
explain([120]) = 2
  arg0 (written) = [120]
$ lowentc --run explain dropwhy_fixed.low [55]
explain([55]) = 9
  arg0 (written) = [55]

오류 갈래는 case error <갈래이름> 으로 곧바로 가른다.

examples/ch17/errvariant.low

module errvariant .
rem run: explain []
rem run: explain [120]
rem run: explain [53]

enum parse_error do
  empty .
  not_digit .
end

fn parse_digit input s slice u8 . output result u8 parse_error .
  errors empty eq (len s) 0 .
  errors not_digit .
do
  guard gt (len s) 0 . else return error empty .
  let c u8 be index s 0 .
  guard and (ge c 48) (le c 57) . else return error not_digit .
  return ok (sub c 48) .
end

fn explain input s slice u8 . output u8 .
do
  rem `case error <갈래>` 의 이름이 선언된 갈래면 그 갈래만 받는다
  match parse_digit s do
    case ok v . return 9 .
    case error empty . return 1 .
    case error not_digit . return 2 .
  end
end

실행 결과

$ lowentc --run explain errvariant.low []
explain([]) = 1
  arg0 (written) = []
$ lowentc --run explain errvariant.low [120]
explain([120]) = 2
  arg0 (written) = [120]
$ lowentc --run explain errvariant.low [53]
explain([53]) = 9
  arg0 (written) = [53]

case error 뒤의 이름이 선언된 갈래면 그 갈래만 받는다. 선언된 갈래가 아니면 오류 값 전체를 그 이름에 묶는다(case error e) — 이름 하나로 두 가지 뜻을 쓰는 셈이지만, 가르는 규칙은 match 의 다른 자리와 같다: 맨 이름이 갈래면 갈래다(7장). 갈래마다 적으면 망라 검사가 갈래 하나가 빠진 것을 잡는다. 이름으로 묶으면 그 하나가 모든 오류를 받으므로 잡을 것이 없다.

반례. result 를 문장으로 불러 버린다

examples/ch17/mistake_dropresult.low

module mistake_dropresult .
rem expect: W-RESULT-DISCARD

enum config_error do
  port_zero .
end

fn check_port input port u16 . output result u16 config_error .
  errors port_zero eq port 0 .
do
  guard ne port 0 . else return error port_zero .
  return ok port .
end

proc main input out cap io . output u8 . effects io .
do
  rem ✘ 검사의 결과를 문장으로 버렸다 --- 포트가 0 인데 "started" 가 나온다
  check_port 0 .
  let n u64 be write_out out 1 "started\n" .
  return 0 .
end

실행 결과

$ lowentc --check mistake_dropresult.low
mistake_dropresult.low:18:0 W-RESULT-DISCARD: this op returns a `result` — it says failure is a VALUE — and the value is dropped here, so a failure leaves no trace at all. Bind it and look at it (`let r … be …` then `is_error`), forward it (`try`), or say in the code why the failure does not matter

check_port 0 . 은 오류를 돌려주었지만 아무도 받지 않았다. 포트가 0 인데 “started” 가 나오고 종료 코드도 0 이 된다 — 실패를 값으로 돌려준다는 설계는 부르는 쪽이 그 값을 볼 때만 지켜진다. 그래서 도구가 W-RESULT-DISCARD 로 알린다. result 를 돌려주는 op 은 let 으로 받아 묻거나 try 로 넘기거나 match 로 가른다. 이름에 담기만 하고 한 번도 읽지 않는 것도 같은 알림을 받는다 — 실패가 사라지는 것은 마찬가지이기 때문이다. 실패로 할 일이 정말 없는 자리(오류 경로에서 닫고 나가는 자리)에서는 drop <이름> . 으로 «일부러 넘긴다» 를 적는다. 잘못은 무시하는 것이 아니라 말없이 무시하는 것이다.

17.7 이 장의 문법 한눈에#

모양뜻왜 이렇게
경계의 op output result t e . + errors바깥 값의 실패를 값으로 돌려준다부르는 층이 무엇을 할지 고른다
안쪽의 op requires · range · newtype이미 걸러진 값의 불변식검사가 경계에 한 번 남고 안쪽에서는 지워진다
enum parse_error do empty . not_digit . end부르는 쪽이 다르게 행동할 경우마다 갈래 하나갈래가 많을수록 다루는 짐이 는다
let v u16 be try check_port port .다룰 수 없으면 위로 넘긴다넘긴 사실이 errors 절에 남는다
case error e . do match e do … end end오류 값을 묶고 갈래를 한 번 더 가른다case error <이름> 의 이름이 선언된 갈래가 아니면 새 묶음이다
try … else_none · value_or이유를 버린다버리는 것은 가능한 한 위 층에서
proc … effects panic . + panic "…"어느 층도 다룰 수 없는 상태에서 멈춘다틀린 입력·없는 파일에는 쓰지 않는다
시작점 output u8 .가장 바깥 층 — 실패를 다루고 종료 코드로 알린다더 넘길 곳이 없다

표 17.2 — 실패를 설계하는 모양 — 모양 · 뜻 · 왜 이렇게 생겼나

복습 정리

바깥에서 값이 들어오는 경계에서는 실패를 result 와 errors 로 말하고, 검사를 통과한 안쪽에서는 계약과 타입으로 불변식을 적는다. 오류 갈래는 부르는 쪽이 다르게 행동할 경우마다 둔다. 실패를 받은 층은 다룰 수 있으면 다루고 없으면 try 로 넘긴다. 정보를 버리는 일은 가능한 한 위 층에서 하고, panic 은 어느 층도 다룰 수 없는 상태에만 쓴다.