icon
12장 : 함수형 프로그래밍

모나드와 옵셔널 체이닝


함수형 프로그래밍은 순수 함수와 불변성을 통해 예측 가능하고 안정적인 코드를 지향합니다. 하지만 실제 애플리케이션 개발에서는 null 또는 undefined 값 처리, 오류 관리, 비동기 작업 처리 등 복잡한 상황을 마주하게 됩니다. 이러한 복잡성을 함수형 방식으로 우아하게 다루기 위한 추상화 개념 중 하나가 바로 모나드(Monads) 입니다.

모나드는 다소 추상적이고 이해하기 어려운 개념으로 알려져 있지만, 사실 우리는 이미 모나드와 유사한 패턴들을 일상적으로 사용하고 있습니다. 타입스크립트의 옵셔널 체이닝(Optional Chaining) 은 모나드적 사고방식이 적용된 구문 설탕(Syntactic Sugar)의 좋은 예시입니다.


모나드 (Monads) 개념 이해

정의: 모나드는 특정 연산(함수)들을 순서대로 실행하면서 컨텍스트(Context)를 안전하게 전달하는 방법을 제공하는 디자인 패턴입니다. 이는 값을 컨텍스트(wrapper) 안에 넣고, 그 컨텍스트 안에서 값을 변환하는 일련의 연산을 정의하는 것입니다.

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

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

  1. 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> 컨텍스트
  2. 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 안전성)

가장 쉽게 이해할 수 있는 모나드 중 하나는 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 등)를 안전하게 캡슐화하고 순수 함수들 간의 조합을 가능하게 하는 강력한 추상화 도구로 활용됩니다.