Results
Results (결과 타입)
Result<'T, 'TFailure> 타입을 쓰면 오류를 다루면서도 조립(compose)할 수 있는 코드를 작성할 수 있어요. 에러가 날 수 있는 일련의 작업을 마치 체인처럼 이어 붙일 수 있게 해 주죠.
출처: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/results
본문
개요
프로그램을 짜다 보면 "이 값은 성공적으로 얻었을 수도, 실패했을 수도 있어요"라는 상황이 자주 생겨요. F#의 Result 타입은 바로 그 상황을 타입으로 표현한 거예요. 성공이면 Ok에 담고, 실패면 Error에 담아요. 이렇게 하면 성공과 실패를 하나의 값으로 다루면서, 앞서 실패한 작업을 그다음 단계에서 자연스럽게 건너뛰는 코드를 만들 수 있어요.
Syntax
Result<'T, 'TFailure> 타입은 FSharp.Core에서 다음과 같이 정의돼요:
// The definition of Result in FSharp.Core
[<StructuralEquality; StructuralComparison>]
[<CompiledName("FSharpResult`2")>]
[<Struct>]
type Result<'T,'TError> =
| Ok of ResultValue:'T
| Error of ErrorValue:'TError
제네릭 타입 인자가 두 개예요. 'T는 성공했을 때 담기는 값의 타입, 'TError는 실패했을 때 담기는 오류의 타입이에요. 그리고 [<Struct>]가 붙어 있죠. 이 타입은 값 타입이라서 구조적 동등성(structural equality)이 적용돼요.
Remarks
Result에는 기본적으로 쓰기 좋은 조합 함수(combinator)들이 제공돼요. 이들은 Result 모듈에 정의되어 있어요.
한 가지 짚고 넘어갈 점은, Result 타입이 구조체 판별 공용체(struct discriminated union)라는 거예요. 그래서 여기에는 구조적 동등성(structural equality) 의미론이 적용돼요.
Result 타입은 주로 모나딕(monadic) 방식의 오류 처리에서 쓰여요. F# 커뮤니티에서는 이런 패턴을 Railway-oriented Programming이라고 부르기도 해요. 이름이 생소하게 들릴 수 있는데, 요점은 간단해요. 각 함수가 Result를 반환하게 만들면, 실패하면 그다음 단계로 넘어가지 않는 "철로"를 따라 작업을 이어 붙일 수 있다는 거예요. 아래의 간단한 예제가 이 접근 방식을 보여줘요.
// Define a simple type which has fields that can be validated
type Request =
{ Name: string
Email: string }
// Define some logic for what defines a valid name.
//
// Generates a Result which is an Ok if the name validates;
// otherwise, it generates a Result which is an Error.
let validateName req =
match req.Name with
| null -> Error "No name found."
| "" -> Error "Name is empty."
| "bananas" -> Error "Bananas is not a name."
| _ -> Ok req
// Similarly, define some email validation logic.
let validateEmail req =
match req.Email with
| null -> Error "No email found."
| "" -> Error "Email is empty."
| s when s.EndsWith("bananas.com") -> Error "No email from bananas.com is allowed."
| _ -> Ok req
let validateRequest reqResult =
reqResult
|> Result.bind validateName
|> Result.bind validateEmail
let test() =
// Now, create a Request and pattern match on the result.
let req1 = { Name = "Phillip"; Email = "[email protected]" }
let res1 = validateRequest (Ok req1)
match res1 with
| Ok req -> printfn $"My request was valid! Name: {req.Name} Email {req.Email}"
| Error e -> printfn $"Error: {e}"
// Prints: "My request was valid! Name: Phillip Email: [email protected]"
let req2 = { Name = "Phillip"; Email = "[email protected]" }
let res2 = validateRequest (Ok req2)
match res2 with
| Ok req -> printfn $"My request was valid! Name: {req.Name} Email {req.Email}"
| Error e -> printfn $"Error: {e}"
// Prints: "Error: No email from bananas.com is allowed."
test()
코드를 하나씩 따라가 볼게요. validateName과 validateEmail은 각각 검사할 값을 받아서 Result를 돌려줘요. 이름이나 이메일이 규칙에 맞으면 Ok req로, 맞지 않으면 문구를 담은 Error로 말이죠.
여기서 주목할 점은 validateRequest예요. 두 검사 함수를 |> Result.bind로 이어 붙였죠. Result.bind는 앞 단계의 결과가 Ok면 다음 함수를 실행하고, Error면 그대로 실패를 전파해요. 즉 이름 검사가 실패하면 이메일 검사는 아예 실행되지 않아요.
실제 실행 흐름을 볼게요. req1처럼 유효한 요청이면 첫 번째 match에서 Ok req로 매칭되어 "My request was valid!"가 출력돼요. 반면 req2의 이메일 주소는 bananas.com으로 끝나서 validateEmail에서 Error로 처리되고, 출력은 "Error: No email from bananas.com is allowed."가 돼요.
보시다시피, 모든 검사 함수가 Result를 반환하게 강제하면 여러 검증 함수를 아주 쉽게 체인처럼 이어 붙일 수 있어요. 기능을 필요에 따라 얼마든지 조립 가능한 작은 조각으로 쪼갤 수 있게 되는 거예요. 여기에 더해 검사가 끝난 뒤에는 패턴 매칭(pattern matching)을 쓰도록 강제되므로, 프로그램의 정확성(correctness)을 더 높은 수준으로 지킬 수 있다는 부가 가치도 있어요.