옵션 타입
개요
사용자 번호로 도시 이름을 찾는 함수 string FindCity(int userId)가 있다고 하자. 사용자가 없거나 도시가 비어 있으면 null을 돌려준다. 시그니처에는 그 사실이 드러나지 않으므로, 확인을 잊고 city.Length를 읽는 호출부는 실행 중에 NullReferenceException을 만난다. 조회가 사용자, 프로필, 도시로 이어지면 단계마다 null 확인이 붙는다.
옵션 타입(Option type)은 값이 있을 수도 없을 수도 있음을 반환 타입에 담는다. 있는 경우가 Some, 없는 경우가 None이며 Haskell에서는 같은 구조를 Maybe(Just, Nothing)라고 부른다. 값을 꺼내는 함수가 두 경우의 처리를 요구하므로 없는 경우를 빠뜨리면 컴파일 단계에서 막힌다.
사용자와 프로필, 도시처럼 중간에 비어 있을 수 있는 조회를 이으려고 연산 세 가지를 쓴다. Map은 값이 있을 때만 함수를 적용하고 None은 그대로 넘기며, 다음 함수도 Option을 돌려준다면 Bind로 이어 Option<Option<T>>처럼 겹치지 않게 한다. 끝의 GetValueOrDefault에서 None 대신 쓸 값을 정한다.
흐름
각 단계가 돌려준 Option이 Some이면 다음으로 이어지고, 어디서든 None이 나오면 남은 단계를 건너뛴다. 마지막 GetValueOrDefault에서 그 None을 기본값으로 바꾼다.
역사
null 참조는 Tony Hoare가 1965년 ALGOL W에 넣은 것이다. 그는 구현하기 쉬웠기 때문이라고 설명하며 이 결정을 10억 달러짜리 실수(billion-dollar mistake)라고 불렀다.1
Standard ML과 Haskell은 값이 없을 가능성을 대수적 자료형(여러 경우 중 하나를 값으로 갖는 타입)으로 표준 라이브러리에 두었다. Standard ML 기본 라이브러리는 datatype 'a option = NONE | SOME of 'a를 정의하고, 기본값을 주는 getOpt, 함수를 적용하는 map, Option을 돌려주는 함수를 잇는 mapPartial을 제공한다.2 Haskell 98 보고서의 표준 Prelude는 data Maybe a = Nothing | Just a와 maybe 함수를 정의한다. Maybe의 Monad 인스턴스는 Nothing >>= k = Nothing으로 정해져 있어, 앞 단계가 비면 뒤 함수 k를 부르지 않는다.3
지금은 F#의 option과 Rust의 Option<T>가 같은 구조를 표준으로 제공한다. F# 문서는 컬렉션에서 값을 찾지 못할 수 있는 검색 함수가 흔히 option을 돌려준다고 설명한다.45
왜 사용하는가?
반환 타입이 Option<string>이면 문자열이 없을 수도 있다는 사실부터 드러난다. 꺼내려면 Match나 GetValueOrDefault로 빈 경우도 다뤄야 하므로 처리를 빠뜨린 코드는 컴파일되지 않고, Bind로 조회를 이으면 중간마다 쓰던 null 확인과 기본값 결정도 체인 끝으로 모을 수 있다.
C#에는 표준 Option 타입이 없다. 값 형식에는 Nullable<T>(int?)가 있어 HasValue와 GetValueOrDefault를 제공하지만, 참조 형식에는 쓸 수 없다.6 참조 형식의 string?은 별도 타입이 아니라 nullable 참조 형식이라는 컴파일 시점 기능이다. 컴파일러는 확인 없는 역참조에 경고를 낼 뿐이고 실행 동작은 바뀌지 않는다.7 멤버를 따라가는 짧은 체인이라면 C#의 ?.(왼쪽이 null이면 null을 돌려주는 멤버 접근)와 ??(왼쪽이 null일 때 대신 쓸 값)로 같은 일을 할 수 있다. Option이 더하는 것은 비어 있음을 하나의 값으로 저장하고 넘길 수 있다는 점, 그리고 경고가 아니라 타입이 처리를 요구한다는 점이다.
None은 왜 비었는지까지 알려 주지 않는다. 기본값으로 바꿀지, 사용자에게 안내할지, 오류로 올릴지는 정책으로 정해야 하며 사용자가 없는 것과 도시가 빈 것을 구분하려면 실패 이유를 담는 결과 타입을 쓴다. .NET 표준 API와 직접 만든 Option을 오갈 때는 null이나 TryGetValue 결과를 변환하는 코드도 필요하다.
예제
사전(Dictionary)에서 이름으로 나이를 찾는 예제다. TryGetValue 결과를 Option<int>로 바꾸고 Match에 값이 있을 때와 없을 때의 처리를 함께 넘긴다. 생성자와 값을 숨긴 sealed record로 Option<T>를 만들어 Some과 None으로만 생성하도록 했으며, 연산은 Func<> 대리자를 받는 메서드로 둔다. Map과 Bind의 모양은 각각 LINQ의 Select와 SelectMany에 대응한다.
using System;
using System.Collections.Generic;
var ages = new Dictionary<string, int> { ["kim"] = 31 };
// 찾지 못할 수 있는 조회 결과를 null 대신 Option으로 돌려준다.
Option<int> FindAge(string name) =>
ages.TryGetValue(name, out var age) ? Option<int>.Some(age) : Option<int>.None;
foreach (var name in new[] { "kim", "lee" })
{
// Match는 값이 있을 때와 없을 때의 처리를 둘 다 받아야 호출할 수 있다.
string text = FindAge(name).Match(
some: age => $"{name}: {age}",
none: () => $"{name}: unknown");
Console.WriteLine(text);
}
// 값이 있는지(HasValue)와 그 값을 한 타입 안에 묶는다. 값은 밖에서 직접 읽을 수 없다.
sealed record Option<T>
{
private readonly T value;
public bool HasValue { get; }
private Option(T value, bool hasValue) { this.value = value; HasValue = hasValue; }
public static Option<T> Some(T value) => new(value, true);
public static Option<T> None { get; } = new(default!, false);
public TOut Match<TOut>(Func<T, TOut> some, Func<TOut> none) =>
HasValue ? some(value) : none();
// 값이 있을 때만 f를 적용하고, None은 그대로 넘긴다.
public Option<TOut> Map<TOut>(Func<T, TOut> f) =>
HasValue ? Option<TOut>.Some(f(value)) : Option<TOut>.None;
// f 자신이 Option을 돌려줄 때 Option<Option<TOut>>으로 겹치지 않게 한 겹으로 잇는다.
public Option<TOut> Bind<TOut>(Func<T, Option<TOut>> f) =>
HasValue ? f(value) : Option<TOut>.None;
public T GetValueOrDefault(T fallback) => HasValue ? value : fallback;
public override string ToString() => HasValue ? $"Some({value})" : "None";
}kim: 31
lee: unknownlee는 사전에 없지만 예외 없이 none 쪽 함수가 실행된다.
범위 넓힌 예제
사용자에서 프로필을 거쳐 도시를 찾는 조회로 넓혀 보자. 사용자 2는 프로필이 없고 3은 프로필만 있고 도시가 비었으며, 4는 사용자부터 없는데, 같은 조회를 null 반환과 ?., ??로 적은 CityOrNull의 결과도 함께 출력한다.
using System;
using System.Collections.Generic;
var users = new Dictionary<int, User>
{
[1] = new User(1, ProfileId: 10),
[2] = new User(2, ProfileId: null),
[3] = new User(3, ProfileId: 30),
};
var profiles = new Dictionary<int, Profile>
{
[10] = new Profile(10, City: "Seoul"),
[30] = new Profile(30, City: null),
};
// 조회 단계마다 "없을 수 있음"을 반환 타입에 드러낸다.
Option<User> FindUser(int id) =>
users.TryGetValue(id, out var user) ? Option<User>.Some(user) : Option<User>.None;
Option<Profile> FindProfile(User user) =>
user.ProfileId is int pid && profiles.TryGetValue(pid, out var profile)
? Option<Profile>.Some(profile)
: Option<Profile>.None;
Option<string> CityOf(Profile profile) =>
profile.City is string city ? Option<string>.Some(city) : Option<string>.None;
// 비교용: 같은 조회를 null 반환으로 쓴 경우. 반환 타입 string?은 컴파일러 경고용 표시다.
string? CityOrNull(int id)
{
User? user = users.GetValueOrDefault(id);
if (user?.ProfileId is not int pid) return null;
return profiles.GetValueOrDefault(pid)?.City;
}
foreach (var id in new[] { 1, 2, 3, 4 })
{
// 어느 단계에서 None이 나오면 뒤 단계는 실행되지 않고 None이 끝까지 전달된다.
Option<string> city = FindUser(id)
.Bind(FindProfile)
.Bind(CityOf)
.Map(name => name.ToUpperInvariant());
// 기본값을 정하는 곳은 이 한 지점이다.
string shown = city.GetValueOrDefault("(city not set)");
string viaNull = CityOrNull(id)?.ToUpperInvariant() ?? "(city not set)";
Console.WriteLine($"user {id}: {city} -> {shown} | null version: {viaNull}");
}
record User(int Id, int? ProfileId);
record Profile(int Id, string? City);
// 값이 있는지(HasValue)와 그 값을 한 타입 안에 묶는다. 값은 밖에서 직접 읽을 수 없다.
sealed record Option<T>
{
private readonly T value;
public bool HasValue { get; }
private Option(T value, bool hasValue) { this.value = value; HasValue = hasValue; }
public static Option<T> Some(T value) => new(value, true);
public static Option<T> None { get; } = new(default!, false);
public TOut Match<TOut>(Func<T, TOut> some, Func<TOut> none) =>
HasValue ? some(value) : none();
// 값이 있을 때만 f를 적용하고, None은 그대로 넘긴다.
public Option<TOut> Map<TOut>(Func<T, TOut> f) =>
HasValue ? Option<TOut>.Some(f(value)) : Option<TOut>.None;
// f 자신이 Option을 돌려줄 때 Option<Option<TOut>>으로 겹치지 않게 한 겹으로 잇는다.
public Option<TOut> Bind<TOut>(Func<T, Option<TOut>> f) =>
HasValue ? f(value) : Option<TOut>.None;
public T GetValueOrDefault(T fallback) => HasValue ? value : fallback;
public override string ToString() => HasValue ? $"Some({value})" : "None";
}user 1: Some(SEOUL) -> SEOUL | null version: SEOUL
user 2: None -> (city not set) | null version: (city not set)
user 3: None -> (city not set) | null version: (city not set)
user 4: None -> (city not set) | null version: (city not set)두 방식의 결과는 같다. ?.가 Map의 자리를, ??가 GetValueOrDefault의 자리를 맡는다. 차이는 타입에 있다. CityOrNull의 string?을 확인 없이 string처럼 쓰면 경고만 나고 컴파일은 되지만, Option<string>은 GetValueOrDefault나 Match 없이 문자열을 꺼낼 수 없다. 한편 사용자 2, 3, 4는 모두 None으로 출력되어 어느 단계에서 비었는지 알 수 없다. 이 구분이 필요하면 결과 타입으로 바꿔야 한다. 이 예제의 Option은 Some(null)도 막지 않으므로, 참조 형식을 감쌀 때는 만드는 쪽에서 null을 걸러야 한다.
TypeScript 예제
TypeScript에서는 kind로 두 경우를 나누는 판별 유니온(discriminated union)을 쓴다. kind를 확인한 분기에서만 value를 읽을 수 있어 C#의 Match처럼 컴파일러가 경우 구분을 확인하는데, --strict라면 string | undefined도 확인 없이 string으로 쓸 때 오류가 난다. 따라서 여기서 Option을 쓰는 이유는 map, bind로 단계를 잇고 비어 있음을 한 값 none으로 통일하는 데 있다.
// kind 필드로 두 경우를 구분하는 판별 유니온(discriminated union)이다.
type Option<T> = { readonly kind: "some"; readonly value: T } | { readonly kind: "none" };
const some = <T>(value: T): Option<T> => ({ kind: "some", value });
const none: Option<never> = { kind: "none" };
function map<T, U>(option: Option<T>, f: (value: T) => U): Option<U> {
return option.kind === "some" ? some(f(option.value)) : none;
}
function bind<T, U>(option: Option<T>, f: (value: T) => Option<U>): Option<U> {
return option.kind === "some" ? f(option.value) : none;
}
function getValueOrDefault<T>(option: Option<T>, fallback: T): T {
return option.kind === "some" ? option.value : fallback;
}
function show<T>(option: Option<T>): string {
return option.kind === "some" ? `Some(${String(option.value)})` : "None";
}
type User = { readonly id: number; readonly profileId: number | null };
type Profile = { readonly id: number; readonly city: string | null };
const users = new Map<number, User>([
[1, { id: 1, profileId: 10 }],
[2, { id: 2, profileId: null }],
[3, { id: 3, profileId: 30 }],
]);
const profiles = new Map<number, Profile>([
[10, { id: 10, city: "Seoul" }],
[30, { id: 30, city: null }],
]);
const findUser = (id: number): Option<User> => {
const user = users.get(id);
return user === undefined ? none : some(user);
};
const findProfile = (user: User): Option<Profile> => {
const profile = user.profileId === null ? undefined : profiles.get(user.profileId);
return profile === undefined ? none : some(profile);
};
const cityOf = (profile: Profile): Option<string> =>
profile.city === null ? none : some(profile.city);
// 비교용: 같은 조회를 undefined 반환과 ?., ??로 쓴 경우.
function cityOrUndefined(id: number): string | undefined {
const profileId = users.get(id)?.profileId;
if (profileId === undefined || profileId === null) return undefined;
return profiles.get(profileId)?.city ?? undefined;
}
for (const id of [1, 2, 3, 4]) {
// 어느 단계에서 none이 나오면 뒤 단계는 실행되지 않는다.
const city = map(bind(bind(findUser(id), findProfile), cityOf), (name) => name.toUpperCase());
// 기본값을 정하는 곳은 이 한 지점이다.
const shown = getValueOrDefault(city, "(city not set)");
const viaNull = cityOrUndefined(id)?.toUpperCase() ?? "(city not set)";
console.log(`user ${id}: ${show(city)} -> ${shown} | null version: ${viaNull}`);
}user 1: Some(SEOUL) -> SEOUL | null version: SEOUL
user 2: None -> (city not set) | null version: (city not set)
user 3: None -> (city not set) | null version: (city not set)
user 4: None -> (city not set) | null version: (city not set)C#과 같은 결과가 나오며, 어느 단계에서 none이 나왔는지는 여기서도 남지 않는다.
같이 보기
예제 실행 환경: C#은 독립된 .NET 10 콘솔 프로그램, TypeScript는 ES2022 이상으로 컴파일한 뒤 Node.js에서 실행한다.
각주
- Hoare, Tony. Null References: The Billion Dollar Mistake. InfoQ (2009). QCon 강연 영상과 소개문. ↩
- The Standard ML Basis Library: The Option structure.
datatype 'a option = NONE | SOME of 'a,getOpt,map,mapPartial. ↩ - The Haskell 98 Report, 8장 Standard Prelude.
data Maybe a = Nothing | Just a,maybe,instance Monad Maybe. ↩ - Microsoft Learn. Options (F# 언어 참조). ↩
- Rust 표준 라이브러리: enum Option. ↩
- Microsoft Learn. Nullable value types (C# 참조). ↩
- Microsoft Learn. Nullable reference types. 실행 동작은 바뀌지 않는 컴파일 시점 기능이며,
string과string?은 모두System.String이다. ↩