본문으로 건너뛰기

안동민 개발노트

본문 시작

모나드와 옵셔널 체이닝

Maybe 형태로 값의 부재를 연쇄 처리하는 모나드적 구성을 이해하고 옵셔널 체이닝과 null 병합으로 안전하게 단순화합니다.

함수형 프로그래밍은 순수 함수와 불변성을 통해 예측 가능하고 안정적인 코드를 지향합니다.

하지만 실제 애플리케이션 개발에서는 null 또는 undefined 값 처리, 오류 관리, 비동기 작업 처리 등 복잡한 상황을 마주하게 됩니다.

이러한 복잡성을 함수형 방식으로 우아하게 다루기 위한 추상화 개념 중 하나가 바로 모나드(Monads)입니다.

모나드는 다소 추상적이고 이해하기 어려운 개념으로 알려져 있지만, 사실 우리는 이미 모나드와 유사한 패턴들을 일상적으로 사용하고 있습니다.

타입스크립트의 옵셔널 체이닝(Optional Chaining)은 모나드적 사고방식이 적용된 구문 설탕(Syntactic Sugar)의 좋은 예시입니다.


모나드 (Monads) 개념 이해

정의: 모나드는 특정 연산(함수)들을 순서대로 실행하면서 컨텍스트(Context)를 안전하게 전달하는 방법을 제공하는 디자인 패턴입니다.

이는 값을 컨텍스트(wrapper) 안에 넣고, 그 컨텍스트 안에서 값을 변환하는 일련의 연산을 정의하는 것입니다.

설명: 모나드는 함수형 프로그래밍에서 "어떤 타입 <T>TT 값을 가지고 있는 경우, 이 T 값을 인자로 받는 함수 f: T-> M<U>를 안전하게 적용하여 M<U>를 얻을 수 있게 해주는 패턴"이라고 설명할 수 있습니다.

여기서 M은 컨텍스트를 의미합니다.

가장 흔하게 접하는 모나드의 예시는 다음과 같습니다.

Promise: 비동기 컨텍스트에서 값을 다룹니다.

  • Promise<T>는 비동기적으로 얻게 될 값 T를 감싸고 있는 컨텍스트입니다.
  • Promise.resolve(value)는 값을 Promise 컨텍스트에 넣는 리프트(lift) 또는 unit 연산과 유사합니다.
  • .then() 메서드는 TU로 변환하는 함수 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 메서드는 TU로 변환하는 함수 f: T -> U를 받아 Array<U>를 반환합니다.
  • flatMap (또는 mapflat)은 TArray<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은 부재 문맥을 파이프라인 끝까지 보존한다

OPTION · CONTEXT PIPELINE

Option은 부재 문맥을 파이프라인 끝까지 보존한다

외부 nullable 값을 Some 또는 None으로 감싸면 부재가 타입 문맥에 남는다. map은 값 변환, flatMap은 중첩 문맥 연결, match는 최종 도메인 결과 결정을 맡는다.

nullable 값이 Option으로 변환되고 Some과 None branch를 거쳐 match 결과로 합쳐지는 흐름 nullable 외부 값이 fromNullable을 거쳐 Some 또는 None이 된다. Some만 map과 flatMap 콜백을 실행하고 None은 건너뛴다. 두 경로는 match에서 명시적인 도메인 결과로 합쳐진다. nullable Option 아니요 Some None BOUNDARY T | nullish 외부 값 LIFT fromNullable Option<T> CONTEXT Some인가? tag로 분기 SOME map → flatMap 콜백 실행 NONE 문맥 유지 콜백 건너뜀 FOLD match 결과
  1. 문맥으로 올리기

    경계의 nullable 값을 한 번만 fromNullable로 감싸 Option<T>를 만듭니다.

  2. Some 변환

    값이 있을 때만 map callback이 실행되고, 문맥을 반환하는 작업은 flatMap으로 연결합니다.

  3. None 전파

    값이 없으면 callback을 실행하지 않고 동일한 부재 문맥을 다음 단계로 전달합니다.

  4. 경계에서 접기

    match가 Some과 None을 모두 처리해 화면·응답 같은 명시적 도메인 결과를 만듭니다.

Option의 가치는 null을 감추는 데 있지 않다. 부재 branch를 생략하지 못하게 하고 문맥을 벗어나는 위치를 한곳으로 모으는 데 있다.

가장 쉽게 이해할 수 있는 모나드 중 하나는 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); // Othertown

Maybe 모나드는 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

옵셔널 체이닝은 접근, 호출, 인덱싱 단계마다 값이 없으면 흐름을 멈추고, ??로 마지막 기본값을 정리할 수 있습니다.

옵셔널 체이닝의 이점
  • 코드 간결성: 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, 옵셔널 체이닝이 컨텍스트를 유지하며 값을 확정하는 방식을 함께 묶어 보여줍니다.

아래 다이어그램은 null, 비동기, 컬렉션 컨텍스트가 값 변환과 기본값 수렴을 어떻게 다르게 처리하는지 비교합니다.

아래 다이어그램은 Maybe/Optional 패턴을 값 감싸기, map/flatMap, null 처리, 타입 안전성 기준으로 나눠 봅니다.

nullish 값은 가드 뒤에서 값·기본값·오류로 수렴한다

NULLISH VALUE · CONTROL FLOW

nullish 값은 가드 뒤에서 값·기본값·오류로 수렴한다

T | null | undefined를 숨기지 말고 제어 흐름으로 좁힌다. 값이 있으면 T로 진행하고, 없으면 선택적 접근과 ?? 기본값 또는 불변식 오류 중 도메인에 맞는 결과를 고른다.

null과 undefined를 포함한 값이 가드와 optional chaining을 지나 세 결과로 수렴하는 흐름 nullish 입력이 존재 여부 결정에 도달한다. 값이 있으면 T로 좁혀지고, 없으면 optional chain 결과 undefined를 기본값으로 바꾸거나 필수 불변식 오류로 끝낸다. union 아니요 T 허용 필수 INPUT T | null | undefined 외부·검색 결과 GUARD 값이 존재하는가? value != null NARROWED T로 좁힘 안전한 멤버 접근 NULLISH 접근 결과 undefined optional chain VALUE 도메인 값 사용 정상 경로 DEFAULT ?? 기본값 부재가 허용될 때 INVARIANT 명시적 오류 부재가 결함일 때
  1. union 유지

    외부 입력과 검색 결과는 처음부터 T | null | undefined로 정확히 표현합니다.

  2. 가드로 좁힘

    값이 존재하면 제어 흐름 분석이 이후 구간을 T로 좁힙니다.

  3. 허용된 부재

    부재가 정상이라면 optional chain 뒤의 undefined??로 기본값에 합칩니다.

  4. 금지된 부재

    도메인 불변식에 어긋나면 non-null assertion 대신 의미 있는 오류를 즉시 만듭니다.

?.는 검증이 아니라 접근 중단 연산자다. 부재의 의미를 결정하는 일은 가드·기본값·오류 경계가 맡는다.