모나드와 옵셔널 체이닝
Maybe 형태로 값의 부재를 연쇄 처리하는 모나드적 구성을 이해하고 옵셔널 체이닝과 null 병합으로 안전하게 단순화합니다.
함수형 프로그래밍은 순수 함수와 불변성을 통해 예측 가능하고 안정적인 코드를 지향합니다.
하지만 실제 애플리케이션 개발에서는 null 또는 undefined 값 처리, 오류 관리, 비동기 작업 처리 등 복잡한 상황을 마주하게 됩니다.
이러한 복잡성을 함수형 방식으로 우아하게 다루기 위한 추상화 개념 중 하나가 바로 모나드(Monads)입니다.
모나드는 다소 추상적이고 이해하기 어려운 개념으로 알려져 있지만, 사실 우리는 이미 모나드와 유사한 패턴들을 일상적으로 사용하고 있습니다.
타입스크립트의 옵셔널 체이닝(Optional Chaining)은 모나드적 사고방식이 적용된 구문 설탕(Syntactic Sugar)의 좋은 예시입니다.
모나드 (Monads) 개념 이해
정의: 모나드는 특정 연산(함수)들을 순서대로 실행하면서 컨텍스트(Context)를 안전하게 전달하는 방법을 제공하는 디자인 패턴입니다.
이는 값을 컨텍스트(wrapper) 안에 넣고, 그 컨텍스트 안에서 값을 변환하는 일련의 연산을 정의하는 것입니다.
설명:
모나드는 함수형 프로그래밍에서 "어떤 타입 <T>에 값을 가지고 있는 경우, 이 T 값을 인자로 받는 함수 f: T-> M<U>를 안전하게 적용하여 M<U>를 얻을 수 있게 해주는 패턴"이라고 설명할 수 있습니다.
여기서 M은 컨텍스트를 의미합니다.
가장 흔하게 접하는 모나드의 예시는 다음과 같습니다.
Promise: 비동기 컨텍스트에서 값을 다룹니다.
Promise<T>는 비동기적으로 얻게 될 값T를 감싸고 있는 컨텍스트입니다.Promise.resolve(value)는 값을 Promise 컨텍스트에 넣는 리프트(lift) 또는 unit 연산과 유사합니다..then()메서드는T를U로 변환하는 함수f: T -> Promise<U>를 받아Promise<U>를 반환합니다. 이는 모나드의 핵심 연산인flatMap(또는bind)과 유사합니다.
// Promise는 비동기 모나드의 한 종류
asyncOperation<number>(10) // Promise<number> 컨텍스트
.then(result => processResult(result)) // result(number)를 받아 Promise<string> 반환
.then(finalResult => console.log(finalResult)); // 마지막 console.log 콜백의 반환형에 따라 Promise<void>Array: 컬렉션 컨텍스트에서 값을 다룹니다.
Array<T>는 여러 개의 값T를 담고 있는 컨텍스트입니다.map메서드는T를U로 변환하는 함수f: T -> U를 받아Array<U>를 반환합니다.flatMap(또는map후flat)은T를Array<U>로 변환하는 함수f: T -> Array<U>를 받아Array<U>를 반환합니다.
const numbers = [1, 2, 3]; // Array<number> 컨텍스트
numbers.flatMap(num => [num, num * 2]); // num(number)를 받아 number[] 반환 -> Array<number>
// 결과: [1, 2, 2, 4, 3, 6]모나드를 이해하는 가장 중요한 열쇠는 컨텍스트를 유지하면서 연산을 연결(chain)하는 능력입니다.
모나드는 함수형 프로그래밍에서 이러한 컨텍스트의 흐름을 안전하게 제어하고, 오류 처리나 비동기 처리와 같은 복잡한 부수 효과를 순수 함수형 방식으로 캡슐화하는 데 사용됩니다.
Maybe/Optional 모나드 (Null 안전성)
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 값을 고른다 | 문맥 내부 변환과 최종 사용 정책을 구분한다 |
그림의 fromNullable·Some/None·match는 태그 기반 Option의 일반적인 설계입니다. 아래 간단한 Maybe 클래스는 그 대신 of·내부 nullish 값·getOrElse를 사용하며 match API를 제공하지 않습니다.
가장 쉽게 이해할 수 있는 모나드 중 하나는 null 또는 undefined 값을 안전하게 다루는 Maybe 또는 Optional 모나드입니다.
이는 null 체크를 덕지덕지 붙이는 대신, 값을 컨텍스트 안에 가둬두고, 컨텍스트 내부에서만 연산이 일어나도록 합니다.
// Maybe 모나드 (간단한 예시 구현)
class Maybe<T> {
private value: T | null | undefined;
private constructor(value: T | null | undefined) {
this.value = value;
}
// 값을 Maybe 컨텍스트에 넣는 팩토리 메서드
static of<U>(value: U | null | undefined): Maybe<U> {
return new Maybe(value);
}
// 핵심 연산: map (컨텍스트 안의 값을 변환)
map<U>(fn: (value: T) => U): Maybe<U> {
if (this.value === null || this.value === undefined) {
return Maybe.of(null); // 값이 없으면 null 컨텍스트 유지
}
return Maybe.of(fn(this.value)); // 값이 있으면 함수 적용 후 새 Maybe 생성
}
// flatMap (또는 bind): 컨텍스트 안의 값을 다른 Maybe 컨텍스트로 변환
flatMap<U>(fn: (value: T) => Maybe<U>): Maybe<U> {
if (this.value === null || this.value === undefined) {
return Maybe.of(null);
}
return fn(this.value); // 함수 적용 결과가 이미 Maybe이므로 바로 반환
}
// nullish이면 기본값을, 값이 있으면 그 값을 반환
getOrElse(defaultValue: T): T {
return this.value === null || this.value === undefined ? defaultValue : this.value;
}
}
// 사용 예시
interface User {
id: number;
name?: string;
address?: {
street?: string;
city?: string;
};
}
const user1: User = { id: 1, name: "Alice", address: { street: "123 Main St", city: "Anytown" } };
const user2: User = { id: 2, name: "Bob" };
const user3: User = { id: 3, address: { city: "Othertown" } }; // name 없음
// Maybe 모나드를 사용하여 안전하게 속성 접근
const userName1 = Maybe.of(user1)
.flatMap(u => Maybe.of(u.name)) // name이 Maybe<string>을 반환
.map(name => name.toUpperCase()) // name이 있으면 대문자로 변환
.getOrElse("UNKNOWN");
console.log("User 1 name:", userName1); // ALICE
const userName2 = Maybe.of(user2)
.flatMap(u => Maybe.of(u.name)) // u.name은 "Bob"이므로 Maybe<string> 반환
.map(name => name.toUpperCase()) // name을 "BOB"로 변환
.getOrElse("UNKNOWN");
console.log("User 2 name:", userName2); // BOB
const userCity3 = Maybe.of(user3)
.flatMap(u => Maybe.of(u.address)) // address가 있으면 Maybe<Address>
.flatMap(a => Maybe.of(a.city)) // city가 있으면 Maybe<string>
.getOrElse("Unknown City");
console.log("User 3 city:", userCity3); // Othertownstrict 모드에서 이어 실행하기
원래 클래스의 두 부재 분기에서 Maybe.of(null)을 호출하면, 팩터리의 타입 인수가 null로 추론되어 메서드가 약속한 Maybe<U>와 맞지 않을 수 있습니다. 예제를 별도 파일에 복사할 때는 map과 flatMap의 부재 분기를 각각 다음처럼 바꾸세요. 값이 없는 상태는 그대로 두고, 이어질 값의 타입 U를 명시하는 보완입니다.
// map<U>의 부재 분기
if (this.value === null || this.value === undefined) {
return Maybe.of<U>(null);
}
// flatMap<U>의 부재 분기도 동일하게 적용
if (this.value === null || this.value === undefined) {
return Maybe.of<U>(null);
}두 메서드의 나머지 부분과 사용 예제는 그대로 둡니다. 다음 독립 연습도 이 보완을 적용한 클래스와 함께 실행합니다. 이 클래스는 값의 부재를 이어 전달하는 최소 구현이며, 콜백이 던진 예외까지 처리하거나 모나드 법칙을 증명한 구현은 아닙니다.
예제의 user2.name은 "Bob"이므로 userName2는 BOB가 됩니다. 부재 경로는 이름이 없는 user3으로 설명할 수 있습니다. 앞의 Promise 예제도 마지막 콜백이 console.log만 반환하므로 마지막 체인은 Promise<void>입니다. 이는 코드에서 도출한 결과이며 실행 관측은 아닙니다.
map의 콜백은 일반 값을 반환하고 다시 Maybe로 감쌉니다. flatMap의 콜백은 이미 Maybe를 반환하므로 그 결과를 직접 이어 붙입니다. 같은 이름 변환에 map으로 Maybe.of(u.name)을 반환했다면 다음 값은 이름 문자열이 아니라 안쪽 Maybe 객체입니다. 문맥을 한 겹 더 만들고 싶은지, 그 문맥을 이어 붙이고 싶은지에 따라 두 연산을 구별합니다. 이 간단한 구현은 콜백이 던진 예외를 error 문맥으로 바꾸지 않습니다.
Maybe 모나드는 null 값의 전파를 캡슐화하여, 중간에 null이 발생하면 이후의 모든 연산을 건너뛰고 기본값을 반환하도록 합니다.
이는 중첩된 if (obj && obj.prop) 체크를 제거하여 코드를 깔끔하게 만듭니다.
옵셔널 체이닝 (?.)
정의: 옵셔널 체이닝은 ECMAScript 2020(ES2020)에서 도입된 문법입니다. ?. 바로 왼쪽 값이 null 또는 undefined이면 해당 속성 접근·함수 호출·인덱스 접근을 건너뛰고 undefined를 반환합니다.
설명:
옵셔널 체이닝은 부재이면 다음 접근을 생략한다는 점에서 앞의 Maybe 흐름과 비교할 수 있습니다. 다만 모나드 인터페이스나 법칙을 언어 문법으로 구현한 것은 아니며, ?.가 놓인 위치의 null·undefined 값만 검사합니다.
속성 접근 (?.), 메서드 호출 (?.()), 배열 인덱싱 (?.[])에 사용할 수 있습니다.
interface UserData {
id: number;
name?: string;
contact?: {
email?: string;
phone?: string;
};
getProfile?(): string; // 메서드도 옵셔널
roles?: string[]; // 배열도 옵셔널
}
const user1: UserData = {
id: 1,
name: "Charlie",
contact: {
email: "[email protected]",
phone: "111-222-3333"
},
getProfile: () => "Charlie's Profile"
};
const user2: UserData = {
id: 2,
name: "David"
// contact 속성 없음
};
const user3: UserData = {
id: 3,
roles: ['admin', 'editor']
};
// 1. 옵셔널 속성 접근
const email1 = user1.contact?.email;
console.log("User 1 email:", email1); // [email protected]
const email2 = user2.contact?.email; // user2.contact가 undefined이므로 전체 표현식은 undefined
console.log("User 2 email:", email2); // undefined
const email3 = user3.contact?.email;
console.log("User 3 email:", email3); // undefined
// 2. 옵셔널 메서드 호출
const profile1 = user1.getProfile?.(); // user1.getProfile이 존재하면 호출
console.log("User 1 profile:", profile1); // Charlie's Profile
const profile2 = user2.getProfile?.(); // user2.getProfile이 undefined이므로 호출되지 않음
console.log("User 2 profile:", profile2); // undefined
// 3. 옵셔널 배열 인덱싱
const firstRole = user3.roles?.[0]; // user3.roles가 존재하고 첫 번째 요소가 있으면 반환
console.log("User 3 first role:", firstRole); // admin
const fourthRole = user3.roles?.[3]; // user3.roles는 존재하지만 네 번째 요소는 없으므로 undefined
console.log("User 3 fourth role:", fourthRole); // undefined
const noRolesUser: UserData = { id: 4 };
const firstRoleNoUser = noRolesUser.roles?.[0]; // noRolesUser.roles가 undefined이므로 전체 표현식은 undefined
console.log("No Roles User first role:", firstRoleNoUser); // undefined
// 4. 병합 연산자 (Nullish Coalescing)와 함께 사용
// ?? 연산자는 null 또는 undefined일 때만 기본값을 제공합니다.
const userNameMaybe = user2.name ?? "Guest";
console.log("User 2 name (with default):", userNameMaybe); // David (name이 존재하므로)
const userContactEmail = user2.contact?.email ?? "[email protected]";
console.log("User 2 contact email (with default):", userContactEmail); // [email protected]옵셔널 체이닝은 접근, 호출, 인덱싱 단계마다 값이 없으면 흐름을 멈추고, ??로 마지막 기본값을 정리할 수 있습니다.
- 코드 간결성:
if (obj && obj.prop && obj.prop.subprop)와 같은 긴 중첩null체크 코드를 제거하여 가독성을 크게 향상시킵니다. - 안전성: 런타임에
TypeError: Cannot read properties of undefined (reading 'prop')와 같은 오류를 방지합니다. - 유지보수성: 속성 구조가 변경될 때 관련된
null체크 로직을 일일이 수정할 필요 없이, 옵셔널 체이닝만 확인하면 됩니다. - 타입스크립트 통합: 타입스크립트는 옵셔널 체이닝의 결과를 정확히 추론합니다. 예를 들어,
user?.contact?.email의 타입은string | undefined로 추론됩니다.
모나드와 옵셔널 체이닝의 관계
옵셔널 체이닝은 부재일 때 다음 접근을 건너뛴다는 점에서 Maybe와 비슷하지만, 모나드의 일반적인 인터페이스나 법칙을 제공하는 기능은 아닙니다. ?.는 해당 위치의 nullish 값만 검사하며, 접근자·호출 함수가 던진 예외나 체인 밖 연산까지 보호하지 않습니다.
Maybe는flatMap과 같은 메서드를 통해 컨텍스트 내부의 값을 변환하고 컨텍스트를 유지합니다.null·undefined이면 이후 콜백을 생략합니다. 내부 값이 null이더라도 반환형을Maybe<null>로 고정하는 것이 아니라, 앞의 보완처럼 이어질 값의 타입Maybe<U>를 유지합니다.- 옵셔널 체이닝(
?.)은 유사하게null또는undefined값을 만나면 즉시 연산을 중단하고undefined를 반환합니다. 이는 마치null컨텍스트가 전파되는 것과 같습니다.
결론적으로, 옵셔널 체이닝은 모나드의 개념을 이해하는 데 도움이 되는 매우 실용적인 예시이며, 함수형 프로그래밍의 원칙(특히 안전한 값 처리)을 따르면서도 개발자에게 편리함을 제공하는 구문 설탕입니다.
모나드는 복잡한 개념이지만, Promise, Array, 그리고 옵셔널 체이닝을 통해 이미 그 아이디어를 일상적으로 사용하고 있습니다.
함수형 프로그래밍에서 모나드는 이처럼 부수 효과(오류, 비동기, null 등)를 안전하게 캡슐화하고 순수 함수들 간의 조합을 가능하게 하는 강력한 추상화 도구로 활용됩니다.
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의 일반 인터페이스나 법칙을 구현하지 않습니다. 접근 중단과 부재의 도메인 의미를 구분합니다.
부재와 빈 값은 같은가?
앞의 두 부재 분기를 보완한 Maybe 클래스만 별도 파일에 복사한 뒤 아래 코드를 이어 실행해 보세요. 이름이 없는 user3 예제와 달리 여기서는 빈 문자열도 유효한 값으로 허용합니다.
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");
console.log(emptyName, absentName, transformations); // 빈 문자열, UNKNOWN, 1먼저 왜 콜백이 한 번만 실행되는지 설명하고, Maybe.of(0).getOrElse(99)의 결과를 예측하세요. 이어 이름 타입이 string | null | undefined일 때 name ?? "Guest"와 name || "Guest"가 빈 문자열을 각각 어떻게 처리하는지 적어 보세요.
nullish 검사와 truthy 검사의 차이
빈 문자열은 null·undefined가 아니므로 map의 콜백이 한 번 실행되고 빈 문자열을 반환합니다. undefined 문맥에서는 콜백을 생략해 UNKNOWN을 받습니다. 0도 값이 있으므로 getOrElse(99)의 결과는 0입니다.
??는 빈 문자열을 유지하고 ||는 falsy인 빈 문자열을 Guest로 바꿉니다. 이름이 반드시 비어 있지 않아야 하는 도메인이라면 nullish 검사 뒤 빈 문자열 검사를 별도로 추가해야 합니다. 둘 중 하나를 무조건 정답으로 선택하는 문제가 아닙니다.
검사 위치와 0·빈 문자열의 차이는 TypeScript 3.7의 옵셔널 체이닝·null 병합 설명을 함께 확인할 수 있습니다.