개요
Railway-Oriented Programming(ROP)은 함수형 프로그래밍에서 오류 처리를 다루기 위한 설계 패턴이자 비유법입니다. F# 커뮤니티에서 활동하는 개발자 Scott Wlaschin이 2013년 자신의 블로그 F# for Fun and Profit에 연재한 “A recipe for a functional app” 시리즈의 두 번째 글에서 처음 제시했으며, 이후 2014년 NDC London, NDC Oslo, Functional Programming eXchange 등의 컨퍼런스 발표를 통해 널리 알려졌습니다.
핵심 아이디어는 단순합니다. 프로그램의 각 단계를 표현하는 함수가 성공 또는 실패라는 두 가지 결과 중 하나만 낼 수 있다면, 이 함수들을 마치 철도의 두 선로처럼 나란히 놓인 성공 트랙과 실패 트랙 위를 지나가는 스위치(선로 전환기)로 생각할 수 있습니다. 각 단계는 성공 트랙에 있는 데이터를 받아 처리하고, 문제가 생기면 실패 트랙으로 전환하며, 한번 실패 트랙에 들어선 데이터는 이후의 모든 단계를 건너뛰고 파이프라인의 끝까지 그대로 흘러갑니다.
Wlaschin은 이 접근이 자신이 발명한 개념이 아니라 하스켈의 Either 타입, 즉 모나드적 오류 처리를 F# 초심자에게 그림으로 직관적으로 설명하기 위해 고안한 비유라고 밝히고 있습니다. 실제로 이 패턴이 알려진 이후 Ruby, Java, JavaScript, Kotlin, Python, PHP 등 다양한 언어로 이식된 라이브러리와 글이 등장했으며, TypeScript 생태계에서도 neverthrow와 같은 라이브러리로 널리 쓰이고 있습니다.
배경: 함수형 프로그래밍과 해피 패스의 문제
요청과 응답, 그리고 조기 반환의 부재
명령형 프로그래밍에서는 흔히 오류가 발생하면 함수 실행을 즉시 중단하고 조기에 응답을 반환합니다. 하지만 함수형 모델에서 함수는 입력을 받아 출력을 내는 하나의 블랙박스일 뿐이며, 함수형 데이터 흐름은 원칙적으로 앞으로만 진행합니다. 중간에 유턴하거나 조기에 반환할 수 없다는 뜻입니다.
이 제약을 지키면서 오류를 다루려면, 모든 오류를 성공 경로와 마찬가지로 파이프라인의 끝까지 앞으로 흘려보내야 합니다. 결국 유스케이스 전체는 하나의 입력을 받아 하나의 출력을 내는 커다란 함수로 표현되며, 그 내부는 각 단계를 담당하는 작은 함수들의 합성으로 구성됩니다.
하나의 함수, 하나의 출력이라는 제약
문제는 함수가 오직 하나의 출력만 가질 수 있다는 점입니다. 검증 실패, 데이터베이스 갱신 실패, 이메일 발송 실패처럼 서로 다른 실패 유형마다 별도의 출력 경로를 두면, 아래와 같은 케이스별 유니온 타입이 필요해집니다.
type UseCaseResult =
| Success
| ValidationError
| UpdateError
| SmtpError
이 방식은 동작은 하지만 단계마다 새로운 오류 케이스가 늘어나므로 재사용성이 떨어집니다. 더 나은 방법은 성공과 실패라는 단 두 가지 경우만 두고, 각 경우가 담는 값을 제네릭으로 열어두는 것입니다.
type Result<'TSuccess, 'TFailure> =
| Success of 'TSuccess
| Failure of 'TFailure
이 Result 타입은 특정 유스케이스에 종속되지 않는 완전히 범용적인 타입이며, 바로 이 타입 위에서 재사용 가능한 함수 조합 라이브러리, 즉 ROP의 콤비네이터 툴킷이 만들어집니다.
TypeScript에서는 클래스나 태그가 붙은 유니온으로 동일한 개념을 표현할 수 있습니다. 아래 예제에서는 판별 유니온(discriminated union)을 사용합니다.
type Result<TSuccess, TFailure> =
| { kind: "success"; value: TSuccess }
| { kind: "failure"; error: TFailure };
function success<TSuccess, TFailure>(
value: TSuccess
): Result<TSuccess, TFailure> {
return { kind: "success", value };
}
function failure<TSuccess, TFailure>(
error: TFailure
): Result<TSuccess, TFailure> {
return { kind: "failure", error };
}
핵심 모델: 두 개의 트랙
스위치로서의 함수
검증, 데이터베이스 조회처럼 실패할 수 있는 단계 함수는 입력을 하나 받아 성공 또는 실패라는 두 가지 출력 중 하나를 내는 함수입니다. Wlaschin은 이런 함수를 철도의 선로 전환기, 즉 스위치에 비유합니다. 스위치를 여러 개 이어 붙이면 성공 트랙과 실패 트랙이 나란히 뻗은 두 줄짜리 선로가 만들어지고, 위쪽 선로가 해피 패스, 아래쪽 선로가 실패 경로가 됩니다.
이 다이어그램에서 성공 경로는 각 단계를 통과하며 계속 다음 단계로 넘어가지만, 어느 단계에서든 실패가 발생하면 이후 단계를 모두 건너뛰고 바로 결과로 직행합니다. 이것이 ROP가 말하는 단락 평가(short-circuit) 흐름입니다.
F#에서 검증 함수 하나는 다음과 같은 모습입니다.
type Request = { name: string; email: string }
let validateName input =
if input.name = "" then Failure "이름은 비어 있을 수 없습니다"
else Success input
let validateEmail input =
if input.email = "" then Failure "이메일은 비어 있을 수 없습니다"
else Success input
TypeScript로는 다음과 같이 옮길 수 있습니다.
interface Request {
name: string;
email: string;
}
function validateName(input: Request): Result<Request, string> {
return input.name === ""
? failure("이름은 비어 있을 수 없습니다")
: success(input);
}
function validateEmail(input: Request): Result<Request, string> {
return input.email === ""
? failure("이메일은 비어 있을 수 없습니다")
: success(input);
}
한 트랙 합성과 두 트랙 합성
일반적인 함수 합성(F#의 >> 연산자)은 왼쪽 함수의 출력 타입과 오른쪽 함수의 입력 타입이 일치할 때만 성립합니다. 이 규칙은 한 트랙 함수끼리, 또는 두 트랙 함수끼리 연결할 때는 그대로 성립하지만, 두 트랙 출력을 한 트랙 입력에 바로 연결할 수는 없습니다.
문제는 검증 단계 함수 하나하나가 스위치, 즉 한 트랙 입력과 두 트랙 출력을 가진 함수라는 점입니다. 이런 스위치들을 연속으로 이어 붙이려면, 스위치를 완전한 두 트랙 함수(두 트랙 입력, 두 트랙 출력)로 바꿔주는 어댑터가 필요합니다. 이 어댑터가 바로 ROP의 핵심 함수인 bind입니다.
철도 콤비네이터 툴킷
이 절에서는 ROP를 구성하는 핵심 어댑터와 결합자 함수들을 하나씩 살펴봅니다. 이 함수들은 카고(실제로 흐르는 데이터의 타입)에 대해서는 아무것도 알지 못하고, 오직 트랙의 모양(한 트랙인지 두 트랙인지)에만 관심을 둔다는 점에서 완전히 제네릭합니다. 타입이 극도로 일반적이기 때문에 구현 방법이 사실상 하나로 강제되고, 그만큼 버그가 끼어들 여지가 줄어듭니다.
bind: 스위치를 두 트랙 함수로 바꾸기
bind는 스위치 함수를 인자로 받아, 두 트랙 값을 입력받는 새로운 함수를 돌려줍니다. 입력이 성공이면 스위치 함수를 호출하고, 입력이 이미 실패라면 스위치 함수 호출을 건너뛰고 실패를 그대로 전달합니다.
let bind switchFunction twoTrackInput =
match twoTrackInput with
| Success s -> switchFunction s
| Failure f -> Failure f
function bind<A, B, F>(
switchFn: (a: A) => Result<B, F>
): (input: Result<A, F>) => Result<B, F> {
return (input) => (input.kind === "success" ? switchFn(input.value) : input);
}
bind를 파이프 연산자처럼 쓰기 위해 F#에서는 흔히 >>=라는 중위 연산자를 정의합니다.
let (>>=) twoTrackInput switchFunction =
bind switchFunction twoTrackInput
let combined x =
x
|> validateName
>>= validateEmail
TypeScript에는 사용자 정의 중위 연산자가 없으므로, 대신 메서드 체이닝이나 파이프 유틸리티 함수로 같은 흐름을 표현합니다.
function pipeResult<A, B, F>(
input: Result<A, F>,
switchFn: (a: A) => Result<B, F>
): Result<B, F> {
return bind(switchFn)(input);
}
const combined = (request: Request): Result<Request, string> => {
const step1 = validateName(request);
const step2 = pipeResult(step1, validateEmail);
return step2;
};
map: 평범한 함수를 두 트랙 함수로 바꾸기
이메일을 소문자로 바꾸고 공백을 제거하는 canonicalizeEmail처럼, 실패할 일이 없는 평범한 한 트랙 함수도 파이프라인에 끼워 넣어야 할 때가 있습니다. 이때 쓰는 어댑터가 map입니다. 입력이 성공이면 함수를 적용한 뒤 다시 성공으로 감싸고, 실패라면 함수 호출 자체를 건너뜁니다.
let canonicalizeEmail input =
{ input with email = input.email.Trim().ToLower() }
let map oneTrackFunction twoTrackInput =
match twoTrackInput with
| Success s -> Success (oneTrackFunction s)
| Failure f -> Failure f
function canonicalizeEmail(input: Request): Request {
return { ...input, email: input.email.trim().toLowerCase() };
}
function map<A, B, F>(
fn: (a: A) => B
): (input: Result<A, F>) => Result<B, F> {
return (input) =>
input.kind === "success" ? success(fn(input.value)) : input;
}
switch: 평범한 함수를 스위치로 승격하기
map이 두 트랙 값을 다루는 함수를 만든다면, switch(리프트라고도 부릅니다)는 한 트랙 함수를 아예 스위치 모양(한 트랙 입력, 두 트랙 출력)으로 바꿉니다. 이렇게 하면 스위치 합성 연산자로 다른 스위치들과 자연스럽게 이어 붙일 수 있습니다.
let switch f x =
f x |> Success
let (>=>) switch1 switch2 x =
match switch1 x with
| Success s -> switch2 s
| Failure f -> Failure f
let usecase =
validateName
>=> validateEmail
>=> switch canonicalizeEmail
function toSwitch<A, B, F>(fn: (a: A) => B): (a: A) => Result<B, F> {
return (a) => success(fn(a));
}
function composeSwitch<A, B, C, F>(
switch1: (a: A) => Result<B, F>,
switch2: (b: B) => Result<C, F>
): (a: A) => Result<C, F> {
return (a) => {
const r = switch1(a);
return r.kind === "success" ? switch2(r.value) : r;
};
}
const usecase = composeSwitch(
composeSwitch(validateName, validateEmail),
toSwitch(canonicalizeEmail)
);
한 트랙 함수를 파이프라인에 넣을 때는 switch로 승격한 뒤 스위치 합성을 쓰는 방법과, map으로 바로 두 트랙 함수를 만들어 일반 합성을 쓰는 방법이 결과적으로 동일합니다. 어느 쪽을 쓸지는 스타일의 문제입니다.
tee: 부수 효과만 있는 막다른 함수 다루기
데이터베이스 갱신처럼 의미 있는 반환값 없이 부수 효과만 일으키는 막다른 함수도 파이프라인에 넣어야 할 때가 있습니다. tee는 유닉스의 tee 명령에서 이름을 따온 함수로, 입력을 부수 효과 함수에 넘겨 호출한 뒤 그 반환값은 버리고 원래 입력을 그대로 다음 단계로 돌려줍니다. 다른 언어에서는 tap이라는 이름으로도 불립니다.
let tee f x =
f x |> ignore
x
let updateDatabase (request: Request) =
()
let usecase =
validateName
>=> validateEmail
>=> switch canonicalizeEmail
>=> switch (tee updateDatabase)
function tee<A>(fn: (a: A) => void): (a: A) => A {
return (a) => {
fn(a);
return a;
};
}
function updateDatabase(request: Request): void {
// 데이터베이스 갱신 부수 효과
}
tryCatch: 예외를 실패 트랙으로 흡수하기
tee로 감싼 함수 내부에서 예외가 던져질 수도 있습니다. tryCatch는 switch와 비슷하지만, 예외가 발생하면 이를 붙잡아 실패 값으로 변환합니다. 이렇게 하면 예외를 던지는 기존 코드나 외부 라이브러리를 ROP 파이프라인 안으로 안전하게 편입시킬 수 있습니다.
let tryCatch f exnHandler x =
try
f x |> Success
with
| ex -> exnHandler ex |> Failure
let usecase =
validateName
>=> validateEmail
>=> switch canonicalizeEmail
>=> tryCatch (tee updateDatabase) (fun ex -> ex.Message)
function tryCatch<A, B, F>(
fn: (a: A) => B,
onError: (e: unknown) => F
): (a: A) => Result<B, F> {
return (a) => {
try {
return success(fn(a));
} catch (e) {
return failure(onError(e));
}
};
}
const updateDatabaseStep = tryCatch(tee(updateDatabase), (e) =>
e instanceof Error ? e.message : String(e)
);
doubleMap: 양쪽 트랙 모두를 다루기
지금까지 살펴본 대부분의 함수는 성공 트랙만 변형하고 실패 트랙은 그대로 흘려보냅니다. 하지만 성공과 실패 모두를 로깅하고 싶을 때처럼, 양쪽 트랙에 각각 다른 함수를 적용해야 하는 경우도 있습니다. doubleMap(다른 이름으로 bimap)은 성공용 함수와 실패용 함수를 각각 받아 두 트랙 모두를 처리합니다.
let doubleMap successFunc failureFunc twoTrackInput =
match twoTrackInput with
| Success s -> Success (successFunc s)
| Failure f -> Failure (failureFunc f)
let log twoTrackInput =
let onSuccess x = printfn "여기까지 성공: %A" x; x
let onFailure x = printfn "오류 발생: %A" x; x
doubleMap onSuccess onFailure twoTrackInput
function doubleMap<A, B, F, G>(
onSuccess: (a: A) => B,
onFailure: (f: F) => G
): (input: Result<A, F>) => Result<B, G> {
return (input) =>
input.kind === "success"
? success(onSuccess(input.value))
: failure(onFailure(input.error));
}
function log<A, F>(input: Result<A, F>): Result<A, F> {
return doubleMap(
(value: A) => {
console.log("여기까지 성공:", value);
return value;
},
(error: F) => {
console.error("오류 발생:", error);
return error;
}
)(input);
}
plus와 병렬 검증: 여러 스위치를 동시에 실행하기
지금까지의 결합은 모두 직렬, 즉 한 단계가 끝나야 다음 단계로 넘어가는 방식이었습니다. 하지만 검증처럼 여러 스위치를 병렬로 실행하고 그 결과를 합치고 싶을 때도 있습니다. plus는 두 스위치에 같은 입력을 각각 적용한 뒤, 둘 다 성공이면 성공 값을 합치고, 하나라도 실패면 실패 값을 합칩니다. 값을 합치는 방법 자체는 문맥마다 다르므로 호출하는 쪽에서 합치는 함수를 넘겨받습니다.
let plus addSuccess addFailure switch1 switch2 x =
match (switch1 x), (switch2 x) with
| Success s1, Success s2 -> Success (addSuccess s1 s2)
| Failure f1, Success _ -> Failure f1
| Success _, Failure f2 -> Failure f2
| Failure f1, Failure f2 -> Failure (addFailure f1 f2)
// 검증 전용으로 특화한 병렬 결합 연산자
let (&&&) v1 v2 =
let addSuccess r1 _ = r1
let addFailure s1 s2 = s1 + "; " + s2
plus addSuccess addFailure v1 v2
let combinedValidation =
validateName
&&& validateEmail
function plus<A, S, F>(
addSuccess: (s1: S, s2: S) => S,
addFailure: (f1: F, f2: F) => F,
switch1: (a: A) => Result<S, F>,
switch2: (a: A) => Result<S, F>
): (a: A) => Result<S, F> {
return (a) => {
const r1 = switch1(a);
const r2 = switch2(a);
if (r1.kind === "success" && r2.kind === "success") {
return success(addSuccess(r1.value, r2.value));
}
if (r1.kind === "failure" && r2.kind === "failure") {
return failure(addFailure(r1.error, r2.error));
}
return r1.kind === "failure" ? r1 : (r2 as Result<S, F>);
};
}
function and_<A, S>(
switch1: (a: A) => Result<S, string>,
switch2: (a: A) => Result<S, string>
): (a: A) => Result<S, string> {
return plus(
(r1: S) => r1,
(s1: string, s2: string) => `${s1}; ${s2}`,
switch1,
switch2
);
}
const combinedValidation = and_(validateName, validateEmail);
콤비네이터 요약표
| 함수 | 역할 | F# 대응 | TypeScript 대응 |
|---|---|---|---|
| succeed | 값을 성공 트랙으로 감싼다 | Success |
success() |
| fail | 값을 실패 트랙으로 감싼다 | Failure |
failure() |
| bind | 스위치를 두 트랙 함수로 바꾼다 | bind, >>= |
bind(), andThen |
| map | 한 트랙 함수를 두 트랙 함수로 바꾼다 | map |
map() |
| switch | 한 트랙 함수를 스위치로 승격한다 | switch |
toSwitch(), fromThrowable |
| tee | 부수 효과 함수를 통과 함수로 바꾼다 | tee |
tee(), tap |
| tryCatch | 예외를 실패 값으로 바꾼다 | tryCatch |
tryCatch() |
| doubleMap | 양쪽 트랙 모두에 함수를 적용한다 | doubleMap |
doubleMap(), bimap |
| plus | 두 스위치를 병렬로 실행하고 결과를 합친다 | plus, &&& |
plus() |
실전 예제: 사용자 정보 갱신 유스케이스
Wlaschin이 원 글에서 사용한 예제는 다음과 같은 흐름을 가진 웹 서비스 유스케이스입니다.
F#으로 구현하기
type Request = { userId: int; name: string; email: string }
let validateRequest =
validateName
&&& validateEmail
let usecase : Request -> Result<string, string> =
validateRequest
>> map canonicalizeEmail
>> bind (tryCatch (tee updateDatabase) (fun ex -> ex.Message))
>> map (fun r -> sprintf "%s 님의 정보가 갱신되었습니다" r.name)
TypeScript로 구현하기
interface Request {
userId: number;
name: string;
email: string;
}
const validateRequest = and_(validateName, validateEmail);
const usecase = (request: Request): Result<string, string> => {
const validated = validateRequest(request);
const canonicalized = map(canonicalizeEmail)(validated);
const updated = bind(
tryCatch(tee(updateDatabase), (e) =>
e instanceof Error ? e.message : String(e)
)
)(canonicalized);
return map((r: Request) => `${r.name} 님의 정보가 갱신되었습니다`)(updated);
};
파이프 연산자를 선호한다면, 작은 pipe 유틸리티를 두고 단계들을 나열하는 방식으로도 같은 흐름을 표현할 수 있습니다.
function pipe<A>(value: A, ...fns: Array<(a: A) => A>): A {
return fns.reduce((acc, fn) => fn(acc), value);
}
두 트랙 모델과 Either 모나드의 관계
하스켈에 익숙한 개발자라면 이 접근이 실패 케이스를 커스텀 오류 타입의 리스트로 특화한 Either 타입, 즉 Either [error] (success, [error])와 본질적으로 같다는 점을 바로 알아볼 것입니다. Wlaschin 스스로도 이 방식을 자신이 발명한 것이 아니라고 밝히며, 다만 우스꽝스러운 철도 비유만은 자신의 것이라고 말합니다.
그가 모나드라는 용어를 전면에 내세우지 않은 이유는 세 가지로 정리됩니다. 첫째, 이 글의 목적은 모나드 튜토리얼이 아니라 오류 처리라는 구체적인 문제를 푸는 것이었습니다. 둘째, F#에는 타입 클래스가 없어 모나드를 진정으로 범용적인 방식으로 재사용하기 어렵고, 두 트랙 타입에 bind를 구현했다고 해서 엄밀한 의미의 모나드 법칙까지 만족한다고 주장하기는 어렵습니다. 셋째, Either라는 개념 자체는 너무 일반적이어서 “그냥 Either에 bind를 쓰라”는 조언만으로는 실전에서 오류를 다루는 구체적인 레시피가 되지 못한다고 보았습니다. 그래서 bind, map, tee, tryCatch, 병렬 결합 연산자 등을 하나로 묶은 콤비네이터 모음을 제시한 것입니다.
같은 맥락에서, 스위치를 직렬로 연결하는 >=> 연산자는 범주론에서 말하는 클라이슬리 합성(Kleisli composition)에 해당합니다.
언제 사용하고 언제 피해야 하는가
ROP를 소개한 지 여러 해가 지난 뒤, Wlaschin은 “Against Railway-Oriented Programming”이라는 글을 통해 이 패턴이 생각 없이 남용되는 경우가 많다고 지적하며 스스로 절제된 사용을 당부했습니다.
세 가지 오류 분류
그는 오류를 다음 세 범주로 나누어 생각할 것을 제안합니다.
도메인 오류는 청구가 거절된 주문이나 잘못된 상품 코드처럼, 비즈니스 프로세스의 정상적인 일부로 예상되는 오류입니다. 이런 오류는 도메인 모델링 과정에서 도메인 전문가와 함께 논의하고 타입 시스템에 반영해야 하며, Result가 가장 잘 어울리는 자리입니다.
패닉은 메모리 부족이나 프로그래머의 실수로 인한 0으로 나누기, 널 참조처럼 시스템을 알 수 없는 상태로 만드는 오류입니다. 이런 오류는 워크플로를 즉시 포기하고 예외를 던져, 애플리케이션의 가장 바깥쪽 계층에서 잡아 기록하는 편이 낫습니다.
인프라 오류는 네트워크 타임아웃이나 인증 실패처럼 아키텍처상 예상되지만 비즈니스 도메인에는 속하지 않는 오류입니다. 도메인으로 모델링할지 패닉으로 취급할지는 상황에 따라 다릅니다.
남용을 피해야 하는 상황
같은 글에서 제시하는 주의사항을 정리하면 다음과 같습니다.
- 스택 트레이스나 오류 발생 위치 같은 진단 정보가 필요하다면
Result를 예외 대용으로 쓰지 말아야 합니다. - 이미 언어가 제공하는 try/catch 메커니즘을
Result로 재발명할 필요는 없습니다. - 복구할 수 없는 상황이라면
Result를 계속 들고 다니지 말고 즉시 실패해야 합니다. - 복잡한 제어 흐름이 외부에 노출되지 않는 지역적인 코드라면, 지역 예외를 쓰는 편이 오히려 더 명확할 수 있습니다.
- 호출하는 쪽에서 오류의 구체적인 원인에 관심이 없다면
Result대신 더 단순한 옵션 타입으로 충분할 수 있습니다. - 파일 입출력처럼 실패 가능성이 무수히 많은 영역 전체를
Result로 모델링하려 들면 오히려 코드가 비대해집니다. 도메인에 꼭 필요한 최소한만 모델링하는 편이 낫습니다. - 성능이 민감한 구간에서는
Result를 비롯한 다른 내장 타입도 신중하게 다뤄야 합니다. - 외부 상호운용성이 중요한 API라면, 호출자에게
Result나 판별 유니온을 이해할 것을 강요하기보다 더 관용적인 방식을 고려해야 합니다.
이러한 경고는 Microsoft의 공식 F# 코딩 컨벤션에서도 유사하게 나타납니다. 해당 문서는 중첩되지 않은 기본적인 연산에는 Result<'Success, 'Error> 타입이 적절하지만, 이것이 예외를 완전히 대체하는 수단은 아니며 예외 및 오류 관리 정책의 특정 측면을 겨냥해 신중하게 적용해야 한다고 안내합니다.
TypeScript/Node.js 생태계의 구현체
neverthrow
TypeScript 진영에서 ROP 스타일 오류 처리를 대표하는 라이브러리는 neverthrow입니다. Result<T, E> 타입이 Ok<T> 또는 Err<E> 값을 가지며, .map(), .andThen(), .mapErr(), .orElse(), .match() 같은 메서드로 체이닝할 수 있습니다. 프라미스 기반 비동기 작업을 위한 ResultAsync 타입도 제공하여, await 없이도 Result와 동일한 방식으로 메서드를 이어 쓸 수 있습니다.
import { ok, err, Result } from "neverthrow";
function divide(a: number, b: number): Result<number, string> {
if (b === 0) {
return err("0으로 나눌 수 없습니다");
}
return ok(a / b);
}
const message = divide(10, 2).match(
(value) => `결과: ${value}`,
(error) => `오류: ${error}`
);
andThen은 위에서 설명한 bind에 해당하고, map은 동일한 이름 그대로 map 어댑터 역할을 하며, mapErr는 실패 트랙에만 함수를 적용한다는 점에서 doubleMap의 실패 쪽 절반에 해당합니다.
그 외 라이브러리
fp-ts는 Either 타입을 중심으로 훨씬 폭넓은 함수형 프로그래밍 도구를 제공하는 라이브러리로, ROP보다 더 일반적인 범주론적 추상화까지 다룹니다. ts-results는 러스트의 Result, Option API를 본뜬 라이브러리입니다. 이처럼 TypeScript 생태계에는 같은 두 트랙 모델을 서로 다른 API 스타일로 구현한 여러 라이브러리가 공존합니다.
같은 개념은 Kotlin에서도 소개되어 있으며, 검증과 저장 단계마다 Success, Failure 결과를 갖는 함수를 연결하는 방식으로 구현됩니다. 이는 ROP의 핵심 아이디어가 정적 타입을 가진 언어라면 F# 밖에서도 동일하게 적용될 수 있음을 보여줍니다.
관련 개념
Result 자체를 F# 표준 라이브러리에 통합하기 전, Wlaschin의 글이 인기를 끌면서 NuGet에는 이 패턴을 라이브러리 형태로 제공하는 Chessie 프로젝트가 등장하기도 했습니다. 또한 Wlaschin은 2018년 저서 Domain Modeling Made Functional에서 도메인 주도 설계와 함께 이 오류 분류 체계와 Result 활용법을 더 정교하게 다듬어 다시 소개했습니다.
ROP의 사고방식은 검증 실패를 모두 모아 한 번에 보여주는 병렬 검증, 도메인 이벤트나 로깅을 파이프라인에 자연스럽게 끼워 넣는 확장, 보상 트랜잭션(compensating transaction) 설계 등으로 이어질 수 있습니다. 다만 위에서 살펴본 것처럼, 이 모델은 만능 해법이 아니라 도메인 오류를 명시적으로 다뤄야 하는 특정 상황에 적합한 하나의 레시피로 이해하는 것이 바람직합니다.
참고 자료
- Wlaschin, Scott (2013), “Railway oriented programming: A recipe for a functional app, part 2”, F# for Fun and Profit, https://fsharpforfunandprofit.com/posts/recipe-part2/
- Wlaschin, Scott (2013), “How to design and code a complete program: A recipe for a functional app, part 1”, F# for Fun and Profit, https://fsharpforfunandprofit.com/posts/recipe-part1/
- Wlaschin, Scott, “Railway Oriented Programming”, F# for Fun and Profit, https://fsharpforfunandprofit.com/rop/
- Wlaschin, Scott (2019), “Against Railway-Oriented Programming”, F# for Fun and Profit, https://fsharpforfunandprofit.com/posts/against-railway-oriented-programming/
- Wlaschin, Scott (2018), Domain Modeling Made Functional: Tackle Software Complexity with Domain-Driven Design and F#, Pragmatic Bookshelf
- Wlaschin, Scott, Railway-Oriented-Programming-Example, GitHub, https://github.com/swlaschin/Railway-Oriented-Programming-Example
- Microsoft, “F# coding conventions”, .NET documentation, https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/conventions
- supermacro, neverthrow: Type-Safe Errors for JS and TypeScript, GitHub, https://github.com/supermacro/neverthrow
- neverthrow, npm, https://www.npmjs.com/package/neverthrow
- Baeldung, “Railway Oriented Programming in Kotlin”, https://www.baeldung.com/kotlin/rop-functional-programming