결과 타입
개요
주문의 수량 문자열을 숫자로 바꾸고 상품과 수량을 검사한 뒤 쿠폰을 적용한다고 하자. 어느 단계든 실패할 수 있는데, 예외를 던지는 함수는 decimal Price(OrderInput input) 같은 시그니처만으로 실패 가능성이 드러나지 않고 호출부가 catch를 빠뜨려도 컴파일러는 알려 주지 않는다. null이나 -1로 실패를 대신하면 이유까지 사라진다.
결과 타입(Result type)은 성공 값인 Ok와 실패 이유인 Err 중 하나를 반환 타입에 담는다. 실패 이유도 자유 문장 대신 오류 코드 같은 값으로 두니, 값이 없다는 사실만 담는 옵션 타입과 달리 왜 실패했는지를 알 수 있다. 호출부에서 값을 꺼내려면 성공과 실패를 모두 처리해야 한다.
단계를 이을 때는 성공 값에 실패하지 않는 함수를 적용하는 Map과, 다시 결과 타입을 돌려주는 다음 함수를 잇는 Bind를 쓴다. 어느 쪽이든 이미 Err를 받았으면 함수는 부르지 않고 그 실패를 넘기며, 마지막 Match에서 성공과 실패를 각각 처리해 값 하나로 만든다.
흐름
실패할 수 있는 Parse, Validate, ApplyDiscount는 Bind로 잇고 소계 계산은 Map으로 잇는다. 중간에 Err가 나오면 뒤의 계산 대신 Match로 바로 간다.
역사
Haskell 98 보고서의 표준 Prelude는 data Either a b = Left a | Right b를 정의한다.1 Either는 두 타입 중 하나를 담는 일반 타입이다. Haskell base 라이브러리 문서는 관례상 Left에 오류 값을, Right에 올바른 값을 담는다고 설명한다.2
Rust 표준 라이브러리는 enum Result<T, E> { Ok(T), Err(E) }를 오류를 반환하고 전파하는 타입으로 둔다. 문서에 따르면 오류가 예상되고 복구 가능할 때 함수가 Result를 반환하며, Result에 #[must_use] 속성이 붙어 있어 반환값을 무시하면 컴파일러가 경고한다.3 F#은 RFC FS-1004를 거쳐 F# 4.1에서 FSharp.Core에 Ok와 Error를 갖는 Result 타입을 추가했다.4
Scott Wlaschin은 2013년 글 "Railway oriented programming"에서 성공 선로와 실패 선로라는 철도 비유로 이 조합 방식을 설명했다. 실패 선로로 넘어간 입력은 나머지 함수를 건너뛰어 끝까지 간다.5 그는 2014년 NDC London 등에서 같은 주제로 발표했다.6 Microsoft의 F# 문서는 Result를 쓰는 모나드식 오류 처리를 F# 커뮤니티에서 흔히 railway-oriented programming이라 부른다고 적는다.7
왜 사용하는가?
Result<decimal>을 받은 호출부는 Match를 거쳐야 decimal을 꺼낼 수 있으니 실패 처리를 놓치기 어렵다. 이유도 값으로 남아 어느 단계에서 무슨 코드로 실패했는지 출력하거나 테스트에서 비교할 수 있고, 각 단계는 입력에서 결과를 만드는 보통 함수로 남아 따로 시험하기 쉽다.
Bind는 첫 실패에서 멈추므로 입력의 문제를 한꺼번에 보여 주는 폼 검증에는 맞지 않는다. 그때는 검사를 독립적으로 실행하고 실패를 목록으로 모으는 applicative 방식의 검증을 쓴다.
파일·네트워크·데이터베이스 라이브러리가 던지는 예외는 그 호출을 감싸는 경계에서 잡아 Err로 바꾼다. 다만 null 역참조나 배열 범위 초과처럼 호출부가 복구할 수 없는 버그까지 Result에 넣으면, 처리할 방법도 없는 실패만 늘어난다.
C#에는 내장 Result 타입이 없어 직접 정의해야 한다. 가능한 경우가 정해져 있다고 컴파일러가 아는 닫힌 합 타입도 없으므로, switch 식에서 Ok와 Err를 모두 다뤄도 컴파일러는 빠진 경우가 있다고 경고한다(CS8509). Rust의 ? 연산자 같은 문법 지원도 없어서 Map과 Bind 호출이 길어진다.
예제
수량 문자열을 정수로 바꾸는 함수가 결과 타입을 돌려준다. 추상 record Result<T> 안에 sealed record Ok와 Err를 두고, 실패 이유는 ErrorCode 열거형과 메시지를 담은 Failure record로 표현한다. switch 식이 두 경우가 전부라는 사실을 알지 못하므로 각 경우가 Map, Bind, Match를 직접 구현한다. 함수 인자는 C#의 Func<> 대리자로 받는다.
using System;
using System.Globalization;
Console.WriteLine(Show(ParseQuantity("3").Map(q => q * 2)));
Console.WriteLine(Show(ParseQuantity("three").Map(q => q * 2)));
static Result<int> ParseQuantity(string text) =>
int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var n)
? new Result<int>.Ok(n)
: new Result<int>.Err(new Failure(ErrorCode.NotANumber, $"'{text}' is not a whole number"));
static string Show(Result<int> result) =>
result.Match(value => $"Ok {value}", f => $"Err {f.Code}: {f.Message}");
// 실패 종류는 문자열 대신 열거형으로 고정해 오타를 컴파일 시점에 잡는다.
enum ErrorCode { NotANumber }
sealed record Failure(ErrorCode Code, string Message);
// 성공 값 또는 실패 이유 중 하나만 담는다.
abstract record Result<T>
{
public abstract Result<TOut> Map<TOut>(Func<T, TOut> f);
public abstract Result<TOut> Bind<TOut>(Func<T, Result<TOut>> f);
public abstract TOut Match<TOut>(Func<T, TOut> onOk, Func<Failure, TOut> onErr);
public sealed record Ok(T Value) : Result<T>
{
public override Result<TOut> Map<TOut>(Func<T, TOut> f) => new Result<TOut>.Ok(f(Value));
public override Result<TOut> Bind<TOut>(Func<T, Result<TOut>> f) => f(Value);
public override TOut Match<TOut>(Func<T, TOut> onOk, Func<Failure, TOut> onErr) => onOk(Value);
}
// 실패는 함수를 호출하지 않고 같은 Failure를 그대로 넘긴다.
public sealed record Err(Failure Failure) : Result<T>
{
public override Result<TOut> Map<TOut>(Func<T, TOut> f) => new Result<TOut>.Err(Failure);
public override Result<TOut> Bind<TOut>(Func<T, Result<TOut>> f) => new Result<TOut>.Err(Failure);
public override TOut Match<TOut>(Func<T, TOut> onOk, Func<Failure, TOut> onErr) => onErr(Failure);
}
}Ok 6
Err NotANumber: 'three' is not a whole number"three"는 파싱에서 Err가 되므로 Map의 q => q * 2는 실행되지 않고 같은 실패가 출력된다.
범위 넓힌 예제
주문 입력 일곱 개를 Parse, Validate, ApplyDiscount 순서로 처리해 보자. 실패할 수 있는 단계에는 Bind, 소계 계산에는 Map을 쓰고, Failure에 단계 이름(Step)도 담아 실패한 위치를 출력한다. 수량은 NumberStyles.None으로 부호와 공백 없는 숫자만 받으며 금액은 CultureInfo.InvariantCulture로 표시해 실행 환경의 문화권에 따라 서식이 바뀌는 것을 막는다.
using System;
using System.Collections.Generic;
using System.Globalization;
var prices = new Dictionary<string, decimal> { ["PEN"] = 9.00m, ["BOOK"] = 25.00m };
OrderInput[] inputs =
[
new("PEN", "3", "SAVE10"),
new("BOOK", "2", ""),
new("PEN", "three", "SAVE10"),
new("CUP", "1", ""),
new("PEN", "0", ""),
new("PEN", "3", "FREE"),
new("CUP", "x", "FREE"),
];
foreach (var input in inputs)
{
// Bind는 실패할 수 있는 단계, Map은 실패하지 않는 계산을 잇는다.
var result = Parse(input)
.Bind(order => Validate(order, prices))
.Map(line => new Subtotal(line.Quantity * line.UnitPrice, line.Coupon))
.Bind(ApplyDiscount);
var label = $"[{input.Sku}, \"{input.Quantity}\", \"{input.Coupon}\"]";
Console.WriteLine(result.Match(
total => $"ok {label} total {total.ToString("0.00", CultureInfo.InvariantCulture)}",
f => $"fail {label} {f.Step} {f.Code}: {f.Message}"));
}
static Result<Order> Parse(OrderInput input) =>
int.TryParse(input.Quantity, NumberStyles.None, CultureInfo.InvariantCulture, out var quantity)
? new Result<Order>.Ok(new Order(input.Sku, quantity, input.Coupon))
: Fail<Order>(Step.Parse, ErrorCode.NotANumber, $"quantity '{input.Quantity}' is not a whole number");
static Result<Line> Validate(Order order, IReadOnlyDictionary<string, decimal> prices)
{
if (!prices.TryGetValue(order.Sku, out var unitPrice))
return Fail<Line>(Step.Validate, ErrorCode.UnknownProduct, $"no product '{order.Sku}'");
if (order.Quantity is < 1 or > 100)
return Fail<Line>(Step.Validate, ErrorCode.OutOfRange, $"quantity {order.Quantity} is outside 1..100");
return new Result<Line>.Ok(new Line(order.Quantity, unitPrice, order.Coupon));
}
static Result<decimal> ApplyDiscount(Subtotal subtotal) => subtotal.Coupon switch
{
"" => new Result<decimal>.Ok(subtotal.Amount),
"SAVE10" => new Result<decimal>.Ok(Math.Round(subtotal.Amount * 0.90m, 2, MidpointRounding.AwayFromZero)),
_ => Fail<decimal>(Step.Discount, ErrorCode.UnknownCoupon, $"coupon '{subtotal.Coupon}' is not recognized"),
};
static Result<T> Fail<T>(Step step, ErrorCode code, string message) =>
new Result<T>.Err(new Failure(step, code, message));
sealed record OrderInput(string Sku, string Quantity, string Coupon);
sealed record Order(string Sku, int Quantity, string Coupon);
sealed record Line(int Quantity, decimal UnitPrice, string Coupon);
sealed record Subtotal(decimal Amount, string Coupon);
// 어느 단계에서 무엇이 실패했는지 Failure에 함께 남긴다.
enum Step { Parse, Validate, Discount }
enum ErrorCode { NotANumber, UnknownProduct, OutOfRange, UnknownCoupon }
sealed record Failure(Step Step, ErrorCode Code, string Message);
abstract record Result<T>
{
public abstract Result<TOut> Map<TOut>(Func<T, TOut> f);
public abstract Result<TOut> Bind<TOut>(Func<T, Result<TOut>> f);
public abstract TOut Match<TOut>(Func<T, TOut> onOk, Func<Failure, TOut> onErr);
public sealed record Ok(T Value) : Result<T>
{
public override Result<TOut> Map<TOut>(Func<T, TOut> f) => new Result<TOut>.Ok(f(Value));
public override Result<TOut> Bind<TOut>(Func<T, Result<TOut>> f) => f(Value);
public override TOut Match<TOut>(Func<T, TOut> onOk, Func<Failure, TOut> onErr) => onOk(Value);
}
// 첫 실패 뒤에는 남은 Map과 Bind가 함수를 호출하지 않는다.
public sealed record Err(Failure Failure) : Result<T>
{
public override Result<TOut> Map<TOut>(Func<T, TOut> f) => new Result<TOut>.Err(Failure);
public override Result<TOut> Bind<TOut>(Func<T, Result<TOut>> f) => new Result<TOut>.Err(Failure);
public override TOut Match<TOut>(Func<T, TOut> onOk, Func<Failure, TOut> onErr) => onErr(Failure);
}
}ok [PEN, "3", "SAVE10"] total 24.30
ok [BOOK, "2", ""] total 50.00
fail [PEN, "three", "SAVE10"] Parse NotANumber: quantity 'three' is not a whole number
fail [CUP, "1", ""] Validate UnknownProduct: no product 'CUP'
fail [PEN, "0", ""] Validate OutOfRange: quantity 0 is outside 1..100
fail [PEN, "3", "FREE"] Discount UnknownCoupon: coupon 'FREE' is not recognized
fail [CUP, "x", "FREE"] Parse NotANumber: quantity 'x' is not a whole number마지막 입력은 상품과 수량, 쿠폰이 전부 잘못됐어도 Bind가 첫 실패에서 멈춰 Parse 오류만 보고된다. 여기서는 모든 단계가 메모리 안에서 끝나 예외를 잡을 곳이 없지만, 가격을 데이터베이스에서 읽는다면 그 호출을 감싸 Err로 바꾸는 경계가 필요하다. 할인 금액은 MidpointRounding.AwayFromZero로 소수 둘째 자리까지 반올림하며 통화별 반올림 규칙은 다루지 않는다.
TypeScript 예제
TypeScript에서는 ok 필드로 두 경우를 구분하는 유니온 타입으로 결과 타입을 표현한다. 이런 타입을 판별 유니온이라 하며, if (result.ok)로 검사하면 컴파일러가 분기마다 타입을 좁혀 성공 분기에서는 value만, 실패 분기에서는 failure만 쓸 수 있게 한다. 오류 코드는 문자열 리터럴 유니온이라 목록에 없는 문자열은 컴파일되지 않는다. 같은 입력 일곱 개를 처리한다.
type Step = "Parse" | "Validate" | "Discount";
// 실패 종류를 문자열 리터럴 유니온으로 제한해 오타를 컴파일 시점에 잡는다.
type ErrorCode = "NotANumber" | "UnknownProduct" | "OutOfRange" | "UnknownCoupon";
type Failure = { step: Step; code: ErrorCode; message: string };
// ok 필드로 두 경우를 구분하는 판별 유니온이다.
type Result<T> = { ok: true; value: T } | { ok: false; failure: Failure };
const ok = <T>(value: T): Result<T> => ({ ok: true, value });
const fail = <T>(step: Step, code: ErrorCode, message: string): Result<T> =>
({ ok: false, failure: { step, code, message } });
// 실패면 함수를 호출하지 않고 같은 실패를 그대로 돌려준다.
const map = <T, U>(r: Result<T>, f: (value: T) => U): Result<U> => (r.ok ? ok(f(r.value)) : r);
const bind = <T, U>(r: Result<T>, f: (value: T) => Result<U>): Result<U> => (r.ok ? f(r.value) : r);
type OrderInput = { sku: string; quantity: string; coupon: string };
type Order = { sku: string; quantity: number; coupon: string };
type Line = { quantity: number; unitPriceCents: number; coupon: string };
type Subtotal = { cents: number; coupon: string };
// 금액은 부동소수점 오차를 피하려고 센트 단위 정수로 계산한다.
const pricesInCents = new Map<string, number>([["PEN", 900], ["BOOK", 2500]]);
function parse(input: OrderInput): Result<Order> {
if (!/^[0-9]+$/.test(input.quantity))
return fail("Parse", "NotANumber", `quantity '${input.quantity}' is not a whole number`);
return ok({ sku: input.sku, quantity: Number(input.quantity), coupon: input.coupon });
}
function validate(order: Order): Result<Line> {
const unitPriceCents = pricesInCents.get(order.sku);
if (unitPriceCents === undefined) return fail("Validate", "UnknownProduct", `no product '${order.sku}'`);
if (order.quantity < 1 || order.quantity > 100)
return fail("Validate", "OutOfRange", `quantity ${order.quantity} is outside 1..100`);
return ok({ quantity: order.quantity, unitPriceCents, coupon: order.coupon });
}
function applyDiscount(subtotal: Subtotal): Result<number> {
if (subtotal.coupon === "") return ok(subtotal.cents);
if (subtotal.coupon === "SAVE10") return ok(Math.round((subtotal.cents * 90) / 100));
return fail("Discount", "UnknownCoupon", `coupon '${subtotal.coupon}' is not recognized`);
}
const inputs: OrderInput[] = [
{ sku: "PEN", quantity: "3", coupon: "SAVE10" },
{ sku: "BOOK", quantity: "2", coupon: "" },
{ sku: "PEN", quantity: "three", coupon: "SAVE10" },
{ sku: "CUP", quantity: "1", coupon: "" },
{ sku: "PEN", quantity: "0", coupon: "" },
{ sku: "PEN", quantity: "3", coupon: "FREE" },
{ sku: "CUP", quantity: "x", coupon: "FREE" },
];
for (const input of inputs) {
const line = bind(parse(input), validate);
const subtotal = map(line, (l) => ({ cents: l.quantity * l.unitPriceCents, coupon: l.coupon }));
const result = bind(subtotal, applyDiscount);
const label = `[${input.sku}, "${input.quantity}", "${input.coupon}"]`;
// result.ok를 검사하면 각 분기에서 value와 failure 중 하나만 쓸 수 있다.
if (result.ok) console.log(`ok ${label} total ${(result.value / 100).toFixed(2)}`);
else console.log(`fail ${label} ${result.failure.step} ${result.failure.code}: ${result.failure.message}`);
}ok [PEN, "3", "SAVE10"] total 24.30
ok [BOOK, "2", ""] total 50.00
fail [PEN, "three", "SAVE10"] Parse NotANumber: quantity 'three' is not a whole number
fail [CUP, "1", ""] Validate UnknownProduct: no product 'CUP'
fail [PEN, "0", ""] Validate OutOfRange: quantity 0 is outside 1..100
fail [PEN, "3", "FREE"] Discount UnknownCoupon: coupon 'FREE' is not recognized
fail [CUP, "x", "FREE"] Parse NotANumber: quantity 'x' is not a whole number금액을 정수 센트로 계산해 number의 부동소수점 오차를 피했으며 출력은 C#과 같다. 수량은 정규식으로 숫자만 받으므로 아주 긴 숫자 문자열은 Number로 바뀐 뒤 범위 검사에서 걸린다.
같이 보기
예제 실행 환경: C#은 독립된 .NET 10 콘솔 프로그램, TypeScript는 ES2022 이상으로 컴파일한 뒤 Node.js에서 실행한다.
각주
- The Haskell 98 Report: Standard Prelude. Either 정의. ↩
- base 4.22.0.0, Data.Either. Either 타입 설명. ↩
- Rust standard library, Module std::result. 모듈 개요, #[must_use] 속성, ? 연산자. ↩
- F# RFC FS-1004, Result type. F# 4.1에서 구현. ↩
- Wlaschin, Scott. Railway oriented programming (2013). 연재 A recipe for a functional app의 2편. ↩
- Wlaschin, Scott. Railway Oriented Programming. 발표 영상과 슬라이드 모음. NDC London 2014, NDC Oslo 2014, Functional Programming eXchange 2014. ↩
- Microsoft Learn, F# Results. Remarks 절. ↩