모나드와 옵셔널 체이닝
값을 감싼 문맥을 유지한 채 연산을 이어 가는 모나드의 생각을 Array와 Promise에서 확인하고, 값의 부재를 전달하는 간단한 Maybe를 직접 만든 뒤, 옵셔널 체이닝과 null 병합이 어디까지 검사하는지와 필수 값이 없을 때의 처리를 정리합니다.
순수 함수와 불변성만으로는 실제 프로그램의 사정을 모두 다루기 어렵습니다. 값이 없을 수도 있고, 계산이 실패할 수도 있고, 결과가 나중에 도착할 수도 있기 때문입니다. 함수형 프로그래밍은 이런 사정을 값과 함께 하나의 문맥(context)으로 감싸 두고, 그 문맥을 유지한 채 연산을 이어 가는 방식으로 다루며, 이 방식을 일반화한 것을 모나드(monad)라고 부릅니다.
모나드라는 이름은 어렵게 들리지만, 배열과 Promise를 쓰면서 이미 그 생각을 쓰고 있습니다. 이 글은 배열과 Promise에서 그 모양을 확인하고, 값의 부재를 전달하는 간단한 Maybe를 만든 뒤, 같은 문제를 문법으로 다루는 옵셔널 체이닝(optional chaining)이 어디까지 검사하는지 봅니다.
문맥을 유지하며 연산을 잇는다는 것
모나드는 값을 문맥에 넣는 연산과, 문맥 안의 값으로 다음 문맥을 만드는 함수를 받아 두 문맥을 하나로 이어 주는 연산을 가진 타입입니다. 앞의 연산은 흔히 of나 unit, 뒤의 연산은 flatMap이나 bind라고 부르며, 함수 타입으로 적으면 T => M<T>와 (M<T>, T => M<U>) => M<U>입니다. 여기서 M이 배열이나 Promise 같은 문맥이고, 정식으로 모나드라고 부르려면 두 연산이 몇 가지 법칙도 만족해야 합니다.
const nested = [1, 2, 3].map((num) => [num, num * 2]);
const pairs = [1, 2, 3].flatMap((num) => [num, num * 2]);
console.log(nested.length, pairs.join(","));3 1,2,2,4,3,6배열은 여러 값이라는 문맥입니다. map에 배열을 돌려주는 함수를 넘기면 number[][]처럼 문맥이 한 겹 더 생기지만, flatMap은 각 결과 배열을 이어 붙여 number[] 하나로 돌려줍니다. 그래서 nested는 길이 3의 배열의 배열이고, pairs는 여섯 개의 숫자입니다.
Promise는 나중에 도착할 값이라는 문맥이며, Promise.resolve가 값을 문맥에 넣고 then이 연산을 이어 줍니다.
const doubledLater: Promise<number> = Promise.resolve(10).then((n) => Promise.resolve(n * 2));then의 콜백이 Promise를 돌려주어도 결과는 Promise<Promise<number>>가 아니라 Promise<number>입니다. then이 안쪽 Promise가 끝나기를 기다렸다가 그 값으로 바깥을 이행하므로, flatMap처럼 두 문맥이 하나로 합쳐집니다. 이렇게 문맥은 그대로 둔 채 그 안의 값만 바꾸어 가는 것이 모나드의 생각입니다.
값의 부재를 전달하는 Maybe
값이 없을 수 있는 사정도 문맥으로 감쌀 수 있습니다. 다음 Maybe는 null이나 undefined일 수 있는 값을 감싸고, 값이 있을 때만 콜백을 실행하며, 값이 없으면 그 상태를 끝까지 전달합니다.
Option은 부재 문맥을 파이프라인 끝까지 보존한다 — 상태 추적
Some/None에서 map/flatMap 콜백 실행 또는 건너뜀과 최종 추출이 달라진다. 본문 Maybe 구현과 도표 Option 일반 모델을 구별해 확장 사례임을 명시해야 한다.
OPTION / PRESERVE ABSENCE
Option은 값 변환 중 생기는 부재도 전달한다
태그 기반 Option은 nullable 값을 Some/None으로 표현합니다. Some에만 변환을 적용하고 None은 건너뜁니다. flatMap의 콜백도 None을 돌려줄 수 있으므로 최종 결과에서 두 경우를 처리합니다.
MAP
값을 변환하고 다시 감싼다
태그 Option의 map은 T→U 함수를 적용해 Option<U>로 감쌉니다. Option 반환 함수를 map하면 중첩 문맥이 되므로 연결 방식이 달라집니다.
FLAT MAP
이미 감싼 결과를 연결
T→Option<U> 콜백 결과를 바로 연결합니다. Some 입력도 콜백이 None을 반환하면 이후 부재 경로를 따릅니다.
LESSON API
원문 Maybe는 간단한 별도 구현
원문은 of·map·flatMap·getOrElse를 사용하며 Some/None 태그나 match를 제공하지 않습니다. 원문 map은 nullish 반환도 부재로 다룹니다.
ACTUAL VALUES
Bob은 값이 있는 경로
user2.name은 Bob이므로 결과는 BOB입니다. 이름 없는 user3이면 기본 이름 경로를 설명할 수 있습니다. 함수의 throw를 이 간단한 Maybe가 자동으로 잡지는 않습니다.
태그 Option 구조와 간단한 Maybe 예제의 API를 구분합니다. 부재를 숨기거나 모든 오류를 자동 처리하는 추상화로 해석하지 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 입력 문맥 | 입력: 값 또는 nullish / 문맥: Some/None 모델 | fromNullable은 존재 값 또는 없음 문맥을 만든다 | 본문 Maybe API와 그림 Option 일반 모델은 별개 확장이다 |
| callback 적용 | Some: 변환 결과 / None: callback 미실행 | 존재하면 map/flatMap을 실행하고 없으면 건너뛴다 | 없음을 보통 값처럼 callback에 넘기지 않는다 |
| 경계에서 추출 | 존재: 계산 값 / 없음: 명시한 대체 값 | match/fold 또는 본문 getOrElse 정책으로 UI 값을 고른다 | 문맥 내부 변환과 최종 사용 정책을 구분한다 |
Option은 부재 문맥을 파이프라인 끝까지 보존한다 — 상태 추적
Some/None에서 map/flatMap 콜백 실행 또는 건너뜀과 최종 추출이 달라진다. 본문 Maybe 구현과 도표 Option 일반 모델을 구별해 확장 사례임을 명시해야 한다.
OPTION / PRESERVE ABSENCE
Option은 값 변환 중 생기는 부재도 전달한다
태그 기반 Option은 nullable 값을 Some/None으로 표현합니다. Some에만 변환을 적용하고 None은 건너뜁니다. flatMap의 콜백도 None을 돌려줄 수 있으므로 최종 결과에서 두 경우를 처리합니다.
MAP
값을 변환하고 다시 감싼다
태그 Option의 map은 T→U 함수를 적용해 Option<U>로 감쌉니다. Option 반환 함수를 map하면 중첩 문맥이 되므로 연결 방식이 달라집니다.
FLAT MAP
이미 감싼 결과를 연결
T→Option<U> 콜백 결과를 바로 연결합니다. Some 입력도 콜백이 None을 반환하면 이후 부재 경로를 따릅니다.
LESSON API
원문 Maybe는 간단한 별도 구현
원문은 of·map·flatMap·getOrElse를 사용하며 Some/None 태그나 match를 제공하지 않습니다. 원문 map은 nullish 반환도 부재로 다룹니다.
ACTUAL VALUES
Bob은 값이 있는 경로
user2.name은 Bob이므로 결과는 BOB입니다. 이름 없는 user3이면 기본 이름 경로를 설명할 수 있습니다. 함수의 throw를 이 간단한 Maybe가 자동으로 잡지는 않습니다.
태그 Option 구조와 간단한 Maybe 예제의 API를 구분합니다. 부재를 숨기거나 모든 오류를 자동 처리하는 추상화로 해석하지 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 입력 문맥 | 입력: 값 또는 nullish / 문맥: Some/None 모델 | fromNullable은 존재 값 또는 없음 문맥을 만든다 | 본문 Maybe API와 그림 Option 일반 모델은 별개 확장이다 |
| callback 적용 | Some: 변환 결과 / None: callback 미실행 | 존재하면 map/flatMap을 실행하고 없으면 건너뛴다 | 없음을 보통 값처럼 callback에 넘기지 않는다 |
| 경계에서 추출 | 존재: 계산 값 / 없음: 명시한 대체 값 | match/fold 또는 본문 getOrElse 정책으로 UI 값을 고른다 | 문맥 내부 변환과 최종 사용 정책을 구분한다 |
그림은 값이 있는 Some과 없는 None을 태그로 구분하고 마지막에 match로 두 경우를 처리하는, 흔히 쓰는 Option의 설계입니다. 아래의 Maybe는 이보다 간단하게 만든 것으로, 태그 대신 안에 든 값이 nullish인지로 부재를 판단하고 match 대신 getOrElse로 기본값을 고릅니다.
class Maybe<T> {
private constructor(private readonly value: T | null | undefined) {}
static of<U>(value: U | null | undefined): Maybe<U> {
return new Maybe(value);
}
map<U>(fn: (value: T) => U): Maybe<U> {
if (this.value === null || this.value === undefined) {
return Maybe.of<U>(null);
}
return Maybe.of(fn(this.value));
}
flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U> {
if (this.value === null || this.value === undefined) {
return Maybe.of<U>(null);
}
return fn(this.value);
}
getOrElse(defaultValue: T): T {
return this.value === null || this.value === undefined ? defaultValue : this.value;
}
}map은 일반 값을 돌려주는 콜백의 결과를 다시 Maybe로 감싸고, flatMap은 이미 Maybe를 돌려주는 콜백의 결과를 그대로 돌려줍니다. 값이 없을 때 Maybe.of<U>(null)처럼 타입 인수를 적는 이유는, 그냥 Maybe.of(null)이라고 쓰면 타입 인수가 null로 추론되어 반환 타입 Maybe<U>와 맞지 않는다는 진단이 나오기 때문입니다. 또 map은 결과를 Maybe.of로 감싸므로, 콜백이 null이나 undefined를 돌려주어도 부재로 바뀝니다.
이 Maybe를 서버에서 받은 사용자 정보에 써 봅니다. 아래의 User 타입과 세 사용자는 이 글의 뒤쪽 예제에서도 계속 씁니다.
interface User {
id: number;
name?: string;
address?: { street?: string; city?: string };
contact?: { email?: string; phone?: string };
roles?: string[];
getProfile?(): string;
}
const user1: User = {
id: 1,
name: "Alice",
address: { street: "123 Main St", city: "Anytown" },
contact: { email: "[email protected]" },
getProfile: () => "Alice's Profile",
};
const user2: User = { id: 2, name: "Bob" };
const user3: User = { id: 3, address: { city: "Othertown" }, roles: ["admin", "editor"] };
function upperName(user: User): string {
return Maybe.of(user)
.flatMap((u) => Maybe.of(u.name))
.map((name) => name.toUpperCase())
.getOrElse("UNKNOWN");
}
const city3 = Maybe.of(user3)
.flatMap((u) => Maybe.of(u.address))
.flatMap((a) => Maybe.of(a.city))
.getOrElse("Unknown City");
console.log(upperName(user1), upperName(user2), upperName(user3), city3);ALICE BOB UNKNOWN Othertownuser1과 user2는 이름이 있으므로 대문자로 바뀝니다. user3은 이름이 없어서 flatMap이 부재를 돌려주고, 그 뒤의 map은 콜백을 부르지 않은 채 부재를 전달하며, 마지막 getOrElse가 기본값 UNKNOWN을 고릅니다. 중간에 값이 없을 때마다 if로 검사하는 대신, 부재의 전달은 Maybe가 맡고 호출하는 쪽은 끝에서 한 번만 기본값을 정합니다.
이 Maybe는 부재를 전달하는 최소한의 구현입니다. 콜백이 던진 예외를 잡아 주지 않고, 모나드의 법칙을 만족하는지 따져 만든 것도 아닙니다. 예를 들어 Maybe.of(null).flatMap(f)는 f를 부르지 않으므로 f(null)과 결과가 다를 수 있으며, 실무에서는 태그로 부재를 표현하는 라이브러리의 Option 타입을 쓰는 편이 안전합니다.
옵셔널 체이닝과 null 병합
옵셔널 체이닝 ?.은 ES2020에 들어온 문법으로, ?. 바로 왼쪽의 값이 null이나 undefined이면 그 뒤의 속성 접근, 함수 호출, 인덱스 접근을 하지 않고 undefined를 돌려줍니다. null 병합 연산자(nullish coalescing operator) ??는 왼쪽이 null이나 undefined일 때만 오른쪽 값을 씁니다. 둘을 함께 쓰면 앞의 Maybe가 하던 부재의 전달과 기본값 고르기를 문법만으로 할 수 있습니다.
console.log(user1.contact?.email, user2.contact?.email);
console.log(user1.getProfile?.(), user2.getProfile?.());
console.log(user3.roles?.[0], user3.roles?.[3], user2.roles?.[0]);
console.log(user2.name ?? "Guest", user3.name ?? "Guest");
console.log(user2.contact?.email ?? "[email protected]");[email protected] undefined
Alice's Profile undefined
admin undefined undefined
Bob Guest
[email protected]user2에는 contact가 없으므로 user2.contact?.email은 email을 읽지 않고 undefined가 됩니다. getProfile?.()은 함수가 있을 때만 부르고, roles?.[0]은 배열이 있을 때만 인덱스로 접근합니다. user3.roles?.[3]이 undefined인 것은 배열은 있지만 네 번째 요소가 없기 때문이며, 이것은 옵셔널 체이닝과 관계없는 보통의 배열 접근 결과입니다.
타입스크립트는 옵셔널 체이닝의 결과 타입에 undefined를 더해 줍니다. 그래서 user1.contact?.email의 타입은 string | undefined이고, 이 값을 바로 string이 필요한 자리에 쓰면 진단됩니다. 그런 자리에는 ??로 기본값을 정하거나, 다음 절처럼 값이 있는지 검사해 타입을 좁힙니다.
더 깊이: 단락 평가가 미치는 범위와 문법의 제한
?.의 왼쪽이 null이나 undefined이면, 그 뒤로 이어지는 체인은 아예 평가되지 않습니다. MDN의 옵셔널 체이닝 설명의 예처럼 obj?.[x++]에서 obj가 null이면 x도 늘어나지 않고, obj?.a.b는 obj가 null이어도 .b를 읽지 않으므로 오류가 나지 않습니다. 그런데 이 단락 평가는 끊기지 않은 하나의 체인 안에서만 일어납니다. (obj?.a).b처럼 앞부분을 괄호로 묶으면 괄호 안의 결과인 undefined에서 .b를 읽게 되어 TypeError가 납니다.
두 연산자에는 문법의 제한도 있습니다. 옵셔널 체이닝의 결과에는 값을 대입할 수 없어서 object?.property = 1은 문법 오류이고, new의 생성자 자리나 태그 템플릿의 태그 자리에도 쓸 수 없습니다. ??는 &&나 ||와 괄호 없이 섞어 쓸 수 없어서 a || b ?? c는 문법 오류이며, (a || b) ?? c처럼 묶는 순서를 직접 적어야 합니다. 그리고 ??의 오른쪽은 왼쪽이 null이나 undefined일 때만 평가되므로, 기본값을 만드는 비용이 큰 함수 호출도 필요할 때만 실행됩니다.
검사하는 위치와 부재의 처리
?.는 자신이 놓인 위치의 값만 검사합니다. user1.contact?.phone.length에서 contact가 없다면 체인 전체가 멈추지만, contact는 있고 phone이 없다면 .length를 읽다가 실행 중 오류가 납니다. 타입스크립트는 phone이 undefined일 수 있다는 진단으로 이 코드를 미리 막으므로, 필요한 위치마다 ?.를 적어야 합니다.
NULLISH / GUARD AND DOMAIN POLICY
nullish 검사와 선택적 접근·기본값의 역할을 구분한다
T | null | undefined를 정확히 표현하고 nullish 검사를 통과한 값만 좁혀 사용합니다. 부재가 허용되는 경우 기본값을 고르고, 필수 값의 부재라면 명시적 오류를 만듭니다.
GUARD
falsy와 nullish는 다르다
value != null은 null·undefined를 제외합니다. 0·false·빈 문자열도 값으로 유지합니다. 이것만으로 도메인 범위나 외부 데이터 구조까지 검증되지는 않습니다.
OPTIONAL ACCESS
?.는 해당 위치만 보호
contact?.email, getProfile?.(), roles?.[0]처럼 해당 왼쪽 값이 nullish면 체인을 멈춥니다. 모든 이후 속성·체인 밖 연산·getter/함수 예외를 보호하지 않습니다.
DEFAULT
??는 부재에만 기본값
null/undefined일 때만 오른쪽을 평가합니다. ||와 달리 0·false·빈 문자열을 덮지 않습니다. 교재 user2 contact는 기본 email로 정리됩니다.
REQUIRED VALUE
필수 부재는 오류로 처리
필수 조건이라면 의미 있는 오류를 만듭니다. non-null assertion !는 runtime 검증이나 예외 처리를 추가하지 않습니다.
옵셔널 체이닝은 Maybe의 일반 인터페이스나 법칙을 구현하지 않습니다. 접근 중단과 부재의 도메인 의미를 구분합니다.
그림처럼 T | null | undefined 타입의 값은 value != null 검사를 통과하면 T로 좁혀집니다. != null은 null과 undefined만 거르므로 0, false, 빈 문자열은 값으로 남고, 이 검사가 값의 범위나 바깥 데이터의 구조까지 확인해 주지는 않습니다. 값이 없을 때 무엇을 할지는 그 값이 무슨 뜻인지에 따라 정합니다. 없어도 되는 값이면 기본값을 고르고, 반드시 있어야 하는 값이면 의미 있는 오류를 만듭니다.
function requireEmail(user: User): string {
const email = user.contact?.email;
if (email != null) {
return email;
}
throw new Error(`user ${user.id} has no email`);
}
for (const user of [user1, user2]) {
try {
console.log(requireEmail(user));
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
}
}[email protected]
user 2 has no emailif 안에서 email은 string으로 좁혀져 반환 타입과 맞습니다. 이메일이 필수인 곳에서 user2를 기본 이메일로 조용히 넘기면 잘못된 주소로 메일을 보내게 되므로, 여기서는 어느 사용자에게 무엇이 없는지 알려 주는 오류를 던집니다. 반면 user.contact!.email처럼 null 아님 단언(non-null assertion) !를 쓰면 검사는 통과하지만 실행 중의 확인은 더해지지 않아서, user2에서는 Cannot read properties of undefined (reading 'email') 같은 TypeError가 납니다.
??와 ||도 구분해야 합니다. ||는 0, false, 빈 문자열 같은 falsy 값에서도 오른쪽을 쓰지만, ??는 null과 undefined일 때만 오른쪽을 쓰므로 0이나 빈 문자열을 유효한 값으로 남깁니다. 옵셔널 체이닝은 Maybe처럼 부재를 전달하지만 모나드의 일반적인 연산이나 법칙을 제공하는 기능은 아니며, 자세한 동작은 TypeScript 3.7 릴리스 노트의 옵셔널 체이닝에서 확인할 수 있습니다.
정리
- 모나드는 값을 문맥에 넣는 연산과 문맥을 이어 주는
flatMap을 가진 타입이며, 배열의flatMap과Promise의then에서 그 모양을 볼 수 있습니다. map에 문맥을 돌려주는 함수를 넘기면 문맥이 겹치고,flatMap은 그 결과를 한 겹으로 이어 붙입니다.Maybe는 값의 부재를 문맥으로 감싸 콜백을 건너뛰며 끝까지 전달하고, 호출하는 쪽은 마지막에 기본값을 정합니다.- 옵셔널 체이닝은
?.가 놓인 위치의 nullish 값만 검사하고,??는null과undefined일 때만 기본값을 씁니다. - 필수 값이 없을 때는 기본값으로 덮지 않고 의미 있는 오류를 만들며,
!단언은 실행 중의 확인을 더하지 않습니다.
연습 문제
1. map에 Maybe를 돌려주는 함수를 넘기면
Maybe.of(user2).map((u) => Maybe.of(u.name))의 타입은 무엇인가요? 이 결과에 .getOrElse("UNKNOWN")을 붙이면 어떻게 되나요?
해설 보기
콜백이 Maybe<string>을 돌려주고 map이 그것을 다시 감싸므로 타입은 Maybe<Maybe<string>>입니다. 이때 getOrElse의 매개변수 타입은 Maybe<string>이어서 문자열 "UNKNOWN"을 넘기면 진단됩니다. 문맥을 돌려주는 함수를 이을 때는 flatMap을 써야 문맥이 한 겹으로 유지됩니다.
2. 부재와 빈 값
다음 코드에서 emptyName, absentName, transformations의 값은 무엇인가요? 이어서 Maybe.of(0).getOrElse(99)의 결과도 예측하세요.
let transformations = 0;
const emptyName = Maybe.of("")
.map((value) => {
transformations++;
return value.toUpperCase();
})
.getOrElse("UNKNOWN");
const absentName = Maybe.of<string>(undefined)
.map((value) => {
transformations++;
return value.toUpperCase();
})
.getOrElse("UNKNOWN");해설 보기
빈 문자열은 null도 undefined도 아니므로 콜백이 한 번 실행되고, emptyName은 빈 문자열입니다. absentName은 콜백을 건너뛰어 UNKNOWN이 되므로 transformations는 1입니다. 0도 값이 있는 것이므로 Maybe.of(0).getOrElse(99)는 0입니다. 이름이 비어 있으면 안 되는 상황이라면, nullish 검사와 별도로 빈 문자열 검사를 더해야 합니다.
3. 기본값과 오류 중 무엇을 고를까
화면에 사용자의 도시를 보여 줄 때와, 사용자에게 영수증 메일을 보낼 때 각각 user2의 부재를 어떻게 처리하는 것이 좋을까요? user2.address?.city ?? "Unknown City"의 결과도 함께 답하세요.
해설 보기
user2에는 address가 없으므로 그 식의 결과는 Unknown City입니다. 도시는 화면에 없어도 되는 정보이므로 이렇게 기본값을 보여 주면 됩니다. 영수증 메일은 이메일이 없으면 보낼 수 없으므로, requireEmail처럼 무엇이 없는지 알려 주는 오류를 만들어 호출한 쪽이 처리하게 합니다.
다음 글 함수형 유틸리티 라이브러리 사용에서는 함수 조합, 매핑과 필터링, 리듀싱, 부분 적용, 메모이제이션을 타입을 지키는 작은 함수로 직접 구현하고, 함수형 라이브러리를 들일지 판단하는 기준을 정리합니다.