모나드와 옵셔널 체이닝
Maybe 형태로 값의 부재를 연쇄 처리하는 모나드적 구성을 이해하고 옵셔널 체이닝과 null 병합으로 안전하게 단순화합니다.
함수형 프로그래밍은 순수 함수와 불변성을 통해 예측 가능하고 안정적인 코드를 지향합니다.
하지만 실제 애플리케이션 개발에서는 null 또는 undefined 값 처리, 오류 관리, 비동기 작업 처리 등 복잡한 상황을 마주하게 됩니다.
이러한 복잡성을 함수형 방식으로 우아하게 다루기 위한 추상화 개념 중 하나가 바로 모나드(Monads)입니다.
모나드는 다소 추상적이고 이해하기 어려운 개념으로 알려져 있지만, 사실 우리는 이미 모나드와 유사한 패턴들을 일상적으로 사용하고 있습니다.
타입스크립트의 옵셔널 체이닝(Optional Chaining)은 모나드적 사고방식이 적용된 구문 설탕(Syntactic Sugar)의 좋은 예시입니다.
옵셔널 체이닝은 각 ?. 왼쪽 값만 nullish인지 검사한다. 체인이 멈추면 undefined 를 만들고, ?? 는 그 결과가 null 또는 undefined일 때만 기본값을 계산한다.
- user 평가
value user 평가 객체 또는 nullish 값에서 시작한다.
- profile 접근
?. profile 접근 user 가 nullish면 남은 체인을 건너뛰고 undefined.
- score 접근
?. score 접근 profile 이 있을 때만 다음 속성을 읽는다.
- 기본값 선택
?? 0 기본값 선택 체인 결과가 nullish일 때만 0을 사용한다.
- foo?.bar.baz
?. 는 바로 왼쪽의 foo만 검사한다. bar도 없을 수 있다면 foo?.bar?.baz 가 필요하다.
- foo?.bar / work()
단락 범위는 체인 밖으로 퍼지지 않는다. bar가 없어도 work() 와 나눗셈은 계속 평가된다.
모나드 (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)); // 최종 Promise<string> 컨텍스트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 안전성)
외부 API나 DOM에서 들어온 nullable 값을 Some/None으로 바꾸면 map, flatMap, unwrapOr 단계에서 빠진 값을 분기할 수 있다.
- 1fromNullable
wrap fromNullable null 값을 Option<T> 경계로 옮긴다.
- 2Some일 때만 순수 변환
map Some일 때만 순수 변환 user.name.trim() 같은 접근을 안전하게 실행한다.
- 3다음 함수도 Option 반환
flatMap 다음 함수도 Option 반환 findUser 뒤 findProfile처럼 연결한다.
- 4기본값 또는 match
unwrap 기본값 또는 match UI 표시값이나 에러 메시지를 결정한다.
| 구분 | 의미 | 판단 |
|---|---|---|
| Some | 값 포함 variant | 변환과 연결을 계속 진행 |
| None | 값 없음 variant | property access를 실행하지 않음 |
| 외부 경계 | API 응답과 화면 출력 | null 변환과 기본값 선택 위치 |
가장 쉽게 이해할 수 있는 모나드 중 하나는 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이므로 바로 반환
}
// 최종 값을 추출 (안전하지 않으므로 주의)
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이 undefined이므로 Maybe.of(null) 반환
.map(name => name.toUpperCase()) // map 호출 안됨
.getOrElse("UNKNOWN");
console.log("User 2 name:", userName2); // UNKNOWN
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); // OthertownMaybe 모나드는 null 값의 전파를 캡슐화하여, 중간에 null이 발생하면 이후의 모든 연산을 건너뛰고 기본값을 반환하도록 합니다.
이는 중첩된 if (obj && obj.prop) 체크를 제거하여 코드를 깔끔하게 만듭니다.
옵셔널 체이닝 (?.)
정의: 옵셔널 체이닝은 ECMAScript 2020(ES2020)에서 도입된 문법으로, 객체의 속성에 접근할 때 해당 속성이 null 또는 undefined인지 명시적으로 확인하지 않고도 안전하게 접근할 수 있도록 해줍니다.
설명:
옵셔널 체이닝은 앞서 설명한 Maybe 모나드의 아이디어를 언어 차원에서 구현한 일종의 null 안전 모나드적 설탕이라고 볼 수 있습니다.
속성 접근 (?.), 메서드 호출 (?.()), 배열 인덱싱 (?.[])에 사용할 수 있습니다.
interface UserData {
id: number;
name?: string;
contact?: {
email?: string;
phone?: string;
};
getProfile?(): string; // 메서드도 옵셔널
roles?: string[]; // 배열도 옵셔널
}
const user1: UserData = {
id: 1,
name: "Charlie",
contact: {
email: "charlie@example.com",
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); // charlie@example.com
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 ?? "no-email@example.com";
console.log("User 2 contact email (with default):", userContactEmail); // no-email@example.com옵셔널 체이닝은 접근, 호출, 인덱싱 단계마다 값이 없으면 흐름을 멈추고, ??로 마지막 기본값을 정리할 수 있습니다.
속성 접근, 메서드 호출, 배열 인덱싱은 같은 nullish 단락 규칙으로 멈추고 마지막 기본값은 ??가 맡는다.
- 1객체 확인
user가 null 또는 undefined인지 먼저 본다.
- 2속성 접근
contact가 없으면 email 접근을 실행하지 않는다.
- 3호출·인덱싱
?.()와 ?.[]도 같은 규칙으로 멈춘다.
- 4기본값
undefined 결과를 ??로 사용자 표시값으로 바꾼다.
| 구분 | 의미 | 판단 |
|---|---|---|
| 결과 타입 | string | undefined | 호출부에 부재 가능성이 남는다. |
| 예외 숨김 아님 | 단락 흐름 표현 | 없는 경로를 명확히 드러낸다. |
| 남용 주의 | 필수 값이면 검증 필요 | 조용한 undefined가 버그를 가릴 수 있다. |
- 코드 간결성:
if (obj && obj.prop && obj.prop.subprop)와 같은 긴 중첩null체크 코드를 제거하여 가독성을 크게 향상시킵니다. - 안전성: 런타임에
TypeError: Cannot read properties of undefined (reading 'prop')와 같은 오류를 방지합니다. - 유지보수성: 속성 구조가 변경될 때 관련된
null체크 로직을 일일이 수정할 필요 없이, 옵셔널 체이닝만 확인하면 됩니다. - 타입스크립트 통합: 타입스크립트는 옵셔널 체이닝의 결과를 정확히 추론합니다. 예를 들어,
user?.contact?.email의 타입은string | undefined로 추론됩니다.
모나드와 옵셔널 체이닝의 관계
옵셔널 체이닝은 Maybe 또는 Optional 모나드의 아이디어를 특정 문제(null/undefined 값 처리)에 특화시켜 언어 기능으로 제공하는 것입니다.
Maybe모나드는flatMap과 같은 메서드를 통해 컨텍스트 내부의 값을 안전하게 변환하고 컨텍스트를 유지합니다.null값이 나타나면 이후의 모든 변환을 스킵하고Maybe<null>컨텍스트를 계속 반환합니다.- 옵셔널 체이닝(
?.)은 유사하게null또는undefined값을 만나면 즉시 연산을 중단하고undefined를 반환합니다. 이는 마치null컨텍스트가 전파되는 것과 같습니다.
결론적으로, 옵셔널 체이닝은 모나드의 개념을 이해하는 데 도움이 되는 매우 실용적인 예시이며, 함수형 프로그래밍의 원칙(특히 안전한 값 처리)을 따르면서도 개발자에게 편리함을 제공하는 구문 설탕입니다.
모나드는 복잡한 개념이지만, Promise, Array, 그리고 옵셔널 체이닝을 통해 이미 그 아이디어를 일상적으로 사용하고 있습니다.
함수형 프로그래밍에서 모나드는 이처럼 부수 효과(오류, 비동기, null 등)를 안전하게 캡슐화하고 순수 함수들 간의 조합을 가능하게 하는 강력한 추상화 도구로 활용됩니다.
Maybe, Promise, Array는 서로 다른 컨텍스트를 감싸고, 옵셔널 체이닝은 nullish 접근을 undefined 흐름으로 단락시킵니다.
- 1컨텍스트 연결 방식
값이 없으면 map과 flatMap을 건너뛰고 기본값으로 수렴합니다. Maybe<T> 비동기 결과를 감싸고 then 체인으로 다음 비동기 값을 잇습니다. Promise<T> 여러 값을 컬렉션 컨텍스트로 유지하며 map과 flatMap을 적용합니다. Array<T> null 또는 undefined를 만나면 남은 접근을 평가하지 않습니다. ?. 그리고 ?? 값 감싸기 연산 연결 컨텍스트 유지 기본값 확정
- 2Maybe
값이 없으면 map과 flatMap을 건너뛰고 기본값으로 수렴합니다. Maybe<T>
- 3Promise
비동기 결과를 감싸고 then 체인으로 다음 비동기 값을 잇습니다. Promise<T>
- 4Array
여러 값을 컬렉션 컨텍스트로 유지하며 map과 flatMap을 적용합니다. Array<T>
- 5옵셔널 체이닝
null 또는 undefined를 만나면 남은 접근을 평가하지 않습니다. ?. 그리고 ??
마지막 다이어그램은 Maybe, Promise, Array, 옵셔널 체이닝이 컨텍스트를 유지하며 값을 확정하는 방식을 함께 묶어 보여줍니다.
값 없음, 비동기, 여러 항목처럼 다른 상황을 감싸고도 같은 방식으로 변환을 이어갈 수 있습니다.
- Maybe
값이 비어 있으면 남은 변환을 건너뛰고 안전한 기본값으로 모읍니다. Maybe<T>
- Promise
아직 도착하지 않은 값을 감싼 채 다음 비동기 변환을 이어갑니다. then 체인
- Array
여러 값을 담은 컨테이너를 유지하며 각 항목 변환을 누적합니다. T[] => U[]
- ?. 접근
중간 값이 비면 더 깊은 접근을 멈추고 마지막에서 기본값을 붙입니다. 단락 후 수렴
아래 다이어그램은 null, 비동기, 컬렉션 컨텍스트가 값 변환과 기본값 수렴을 어떻게 다르게 처리하는지 비교합니다.
null, 비동기, 컬렉션은 모두 값을 감싸지만 변환을 이어가고 최종값으로 모으는 방식이 다릅니다.
- 없으면 건너뜀
Maybe 값이 없으면 남은 변환을 실행하지 않고 기본값으로 안전하게 수렴합니다. T | null
- 나중 값을 이어감
Promise 아직 도착하지 않은 값을 감싼 채 다음 비동기 변환을 연결합니다.
- 여러 값을 보존
Array 각 항목에 같은 변환을 적용하고 결과 컬렉션의 타입을 유지합니다.
- 접근을 단락
Optional `?.`는 중간 값이 비면 더 깊이 접근하지 않고 `??`에서 기본값으로 모읍니다.
아래 다이어그램은 Maybe/Optional 패턴을 값 감싸기, map/flatMap, null 처리, 타입 안전성 기준으로 나눠 봅니다.
strictNullChecks 로 부재를 union에 남긴다. 정상 부재는 기본값으로, 깨진 불변식은 guard·throw·Result로 분리한다.
- 결측 타입화
· MODEL 결측 타입화 T | null | undefined
- guard로 축소
· NARROW guard로 축소 if (value == null) 뒤 성공 경로는 T
- ?. 접근
· ACCESS ?. 접근 왼쪽이 nullish면 해당 optional chain 결과는 undefined
- ?? 기본값
· DEFAULT ?? 기본값 null·undefined만 대체하고 0·빈 문자열·false는 보존
- 오류 승격
· ERROR 오류 승격 필수값 부재는 throw 또는 tagged union으로 표현
- 컴파일 단계
null·undefined를 별도 타입으로 검사하고 control-flow로 좁힌다.
- 런타임 단계
?. 와 ?? 는 JavaScript 연산자로 실제 분기를 수행한다.
- 검사만 제거
non-null ! 은 emit에서 사라져 런타임 검증이 없다.
- 중간값 미보호
CHAIN 중간값 미보호 중간도 nullable이면 foo?.bar?.baz 처럼 표시한다.
- 후속 계산은 실행
MATH 후속 계산은 실행 foo?.bar / 100 은 undefined를 계산에 넣어 strict 오류가 난다.
- || 로 값 손실
DEFAULT || 로 값 손실 0·빈 문자열·false가 유효하면 ?? 를 사용한다.