부재를 재고하기: TypeScript에서 Option Type에 대한 부드러운 소개

발행: (2025년 12월 26일 오전 03:07 GMT+9)
20 min read
원문: Dev.to

Source: Dev.to

JavaScript나 TypeScript를 꽤 오래 다뤄봤다면 nullundefined에 익숙할 것입니다. 이들은 우리 코드베이스의 암흑 물질과도 같으며—​런타임 오류라는 블랙홀에 모든 것을 끌어당길 때까지는 눈에 보이지 않습니다.

우리는 편리하기 때문에 이를 사용합니다. 언어에서 “무(無)”의 기본 상태이기 때문이죠:

  • 함수가 값을 반환하지 않으면 undefined를 반환합니다.
  • 변수를 명시적으로 비우고 싶을 때는 null을 할당할 수 있습니다.

문법에 내장돼 있기 때문에 자연스럽게 느껴집니다.

편리함이 우리에게 비용을 부과하는 이유

편리함은 미묘하고 누적되는 비용을 동반합니다. nullundefined는 종종 으로 취급되지만 실제로는 반값(anti‑value)처럼 동작합니다. 이들은 우리의 타입 계약을 깨뜨립니다. 변수가 string | null 타입으로 선언되었다면, null이 아님을 증명하기 전까지는 그 변수를 string으로 사용할 수 없습니다.

문제는 누락된 값이 존재한다는 것 자체가 아니라, 그 값이 조용히 전파되는 방식에 있습니다. 데이터베이스 헬퍼에서 반환된 null이 세 개의 서비스 레이어와 컨트롤러를 거쳐 결국 프론트엔드 뷰 컴포넌트를 충돌시킬 수 있습니다. 오류가 발생했을 때는 왜 값이 없었는지에 대한 맥락이 대부분 사라져 있습니다. 우리는 체인 중 어느 링크가 값을 유지하지 못했는지 찾기 위해 유령을 쫓게 됩니다.

가장 눈에 띄는 비용은 런타임 오류입니다.
Cannot read properties of undefined는 많은 디버깅 세션의 배경음악과도 같습니다. 성숙한 TypeScript 코드베이스에서는 실제 충돌이 (희망컨대) 드물게 발생합니다.

진정한 비용은 우리가 강제로 취해야 하는 방어적 자세입니다.

Guard Clauses Everywhere

if (user) {
  if (user.address) {
    if (user.address.zipCode) {
      // finally do something
    }
  }
}

우리는 이 로직을 반복해서 작성합니다. 더 나아가 무거운 인지 부하를 안게 됩니다. 코드를 한 줄이라도 건드릴 때마다 스스로에게 물어야 합니다:

  • “이게 null일 수 있을까?”
  • “이전 개발자가 undefined를 체크했을까?”
  • “타입 정의가 나에게 거짓말을 하고 있지는 않을까?”

Ambiguity of Intent

함수가 null을 반환한다면, 그것은 무엇을 의미할까요?

  • 레코드가 존재하지 않았는가?
  • 데이터베이스 연결에 실패했는가?
  • 값이 실제로 선택적인가?
  • 아직 초기화되지 않았는가?

null은 “뭔가 잘못되었다” 혹은 “아무것도 없다”는 의미를 포괄하는 일반적인 버킷이며, 이를 소비하는 입장에서는 의도를 추측해야 합니다. 우리는 TypeScript 컴파일러를 만족시켜 안전해 보이는 코드를 작성하지만, 실제로는 의미론적으로 견고하지 못한 코드를 만들게 됩니다.

TypeScript가 제공하는 것

  • strictNullChecksstring | nullstring과 다르다는 것을 인식하도록 강제합니다.
  • 옵셔널 체이닝 (?.)널리시 병합 (??) – 누락된 데이터를 처리할 때 코드를 간결하게 해주는 문법 설탕입니다.
const zip = user?.address?.zipCode ?? '00000';

이는 중첩된 if 문보다 확실히 낫습니다. 하지만 옵셔널 체이닝은 치료법이라기보다 임시 방편으로 작용하는 경우가 많습니다. ?.를 사용함으로써 우리는 본질적으로 “이게 깨졌다면 그냥 계속 진행하고 undefined를 반환한다”는 의미가 됩니다.

새로운 문제: 암묵적 전파

undefined 값이 스택을 타고 계속 전파됩니다. 우리는 부재를 처리하지 않았고, 단지 미뤄두었을 뿐입니다. 데이터가 왜 누락되었는지 모델링하지 않았으며, 단지 그럴 수도 있음을 받아들였을 뿐입니다.

엄격 모드에서도 TypeScript는 부재를 타입 시스템의 부수 효과로 간주하며, 도메인 로직의 1급 객체로 다루지 않습니다.

Source:

대안: Option (또는 Maybe) 타입

이 패턴은 수십 년 동안 다른 언어에 존재했으며, 이제 TypeScript 커뮤니티에서도 서서히 주목받고 있습니다.

핵심 아이디어

User | null처럼 누락될 수 있는 원시 값을 직접 전달하는 대신, 항상 정의된 컨테이너를 전달합니다. 컨테이너 안에는 두 가지 가능한 상태가 있습니다:

StateMeaning
Some컨테이너가 값을 보유하고 있습니다.
None컨테이너가 비어 있습니다.

이 사고 방식의 전환은 매우 중요합니다:

  • 비즈니스 로직에서 null 제거
    컴파일러와 미래의 독자에게 “이 값은 누락될 수 있으며, 데이터를 사용하기 전에 그 가능성을 명시적으로 처리해야 한다”고 알립니다.
  • 명시적인 언래핑 강제
    Option 내부의 값을 실수로 사용할 수 없습니다. 사용 시점에 “언래핑”을 해야 하므로, 의도적인 결정을 내리게 됩니다.
  • 컴파일 타임 보장
    “부재”가 런타임 위험이 아니라 컴파일 타임에 보장됩니다.

최소 라이브러리: @rsnk/option

이 분야에는 많은 라이브러리가 존재하지만, @rsnk/option은 무거운 학술 이론이나 과도한 API 표면 없이 핵심 이점을 제공합니다. 제네릭 클래스 Option을 제공하며, 위험한 데이터를 이 클래스에 감싸고 메서드를 사용해 안전하게 변환하거나 조회할 수 있습니다.

예시 1: 프런트‑엔드 URL 파라미터 파싱

쿼리 파라미터를 처리하는 것은 전형적인 프런트‑엔드 작업입니다. URL 쿼리 문자열에서 page 번호를 읽는다고 가정해 보세요. 파라미터는 다음과 같은 경우가 있을 수 있습니다:

  • 존재하지 않음,
  • 숫자가 아닌 문자열 (예: "abc"),
  • 혹은 음수.

Option 없이

function getPageNumber(param: string | null): number {
  // 값이 없을 경우 처리
  if (!param) {
    return 1;
  }

  // 파싱 시도
  const parsed = parseInt(param, 10);

  // 파싱 결과 검증
  if (Number.isNaN(parsed) || parsed <= 0) {
    return 1;
  }

  return parsed;
}

Option 사용

import O from "@rsnk/option";

function getPageNumber(param: string | null): number {
  return O.fromNullable(param)               // Option<string>
    .map(p => parseInt(p, 10))                // Option<number>
    .filter(n => !Number.isNaN(n) && n > 0)   // Option<number>
    .unwrapOr(1);                             // number
}

우리는 파라미터를 파이프라인으로 다룹니다. 구체적인 실패 상태(값이 없거나 잘못된 값)를 신경 쓰지 않고, 유효한 숫자 혹은 기본값을 얻는 것에만 집중합니다.

예시 2: 서비스‑레이어 데이터 가져오기

리포지토리가 존재할 수도 있고 존재하지 않을 수도 있는 사용자 레코드를 반환한다고 가정해 보겠습니다.

Without Option

async function getUserName(id: string): Promise<string> {
  const user = await db.findUserById(id); // User | null
  if (!user) {
    throw new Error("User not found");
  }
  return user.name;
}

With Option

import O from "@rsnk/option";

async function getUserName(id: string): Promise<string> {
  return O.fromNullable(await db.findUserById(id))
    .map(u => u.name)
    .expect("User not found"); // throws if None
}

의도는 명확합니다: “사용자가 없으면 예외를 발생시킨다.” Option은 값이 필요한 바로 그 지점에서 None 경우를 어떻게 처리할지 결정하도록 강제합니다.

Option을 언제 사용할까

상황Option이 도움이 되는 경우
“nothing”을 반환할 수 있는 공개 API✅ 명확한 계약 (Option 대신 T | null)
복잡한 데이터 흐름 파이프라인✅ 중첩 if를 없애고 변환을 조합 가능하게 함
“부재”가 의미를 갖는 도메인 로직✅ 의미를 모델링하도록 강제 (None vs. Some)
간단한 내부 검사로 빠른 if가 더 명확한 경우❌ 일반 가드가 더 읽기 쉬울 수 있음

TL;DR

  • null / undefined은 편리하지만 런타임 위험을 코드베이스 전체에 퍼뜨린다.
  • TypeScript의 strict mode, optional chaining, 그리고 nullish coalescing은 고통을 완화하지만 종종 문제를 가리기만 한다.
  • Option(또는 Maybe) 패턴은 부재를 일급 개념으로 만들어 런타임 위험을 컴파일‑타임 계약으로 전환한다.
  • @rsnk/option 같은 라이브러리를 사용하면 최소한의 마찰로 이 패턴을 도입할 수 있다.

Option을 받아들임으로 얻는 이점:

  1. 명시적 의도 – 모든 가능한 “nothing”을 의도적으로 처리한다.
  2. 깨끗한 파이프라인 – 변환을 중첩된 가드 없이 구성한다.
  3. 강력한 타입 안전성 – 컴파일러가 None 케이스를 고려하도록 강제한다.
{
  return O.fromNullable(param)
    .map(p => parseInt(p, 10))
    .filter(p => !Number.isNaN(p) && p > 0)
    .unwrapOr(1);
}

이 예시는 패턴의 강점인 조합성을 강조한다. 임시 변수를 선언하거나 수동 if 검사를 작성하지 않고도 파싱 로직과 검증 로직을 하나의 흐름으로 결합했다.

Example 2: Backend Environment Configuration

환경 변수를 읽는 것은 백엔드 버그의 고전적인 원인입니다.

Without Option

const rawPort = process.env.PORT;
let port = 3000;

if (rawPort) {
  const parsed = parseInt(rawPort, 10);
  if (!Number.isNaN(parsed)) {
    port = parsed;
  }
}

With Option

import O from "@rsnk/option";

const port = O.fromNullable(process.env.PORT)
  .map(p => parseInt(p, 10))
  .filter(p => !Number.isNaN(p))
  .unwrapOr(3000);

의도는 매우 명확합니다. 값을 가져와 파싱을 시도하고, filter를 사용해 유효한 숫자인지 확인합니다. 이 중 어느 하나라도 실패하거나 값이 없으면 기본값으로 3000을 사용합니다. 임시 변수도 없고 중첩된 if 블록도 없습니다.

Source:

Example 3: Frontend Data Transformation

거래 목록을 가져오는 대시보드를 생각해 보세요. 최신 거래를 찾아 날짜를 포맷하고 표시해야 합니다. 배열이 비어 있거나, 날짜 문자열이 잘못된 형식이거나, 거래 자체가 없을 수도 있습니다.

import O from "@rsnk/option";

interface Transaction {
  id: string;
  timestamp?: string; // API might return partial data
  amount: number;
}

// Helper to safely get an array element
const lookup = <T>(arr: T[], index: number): O.Option<T> =>
  O.fromNullable(arr[index]);

// Helper to safely parse a date string
const dateFromISOString = (iso: string): O.Option<Date> => {
  const d = new Date(iso);
  return isNaN(d.getTime()) ? O.none : O.from(d);
};

function getLastTransactionDate(transactions: Transaction[]): string {
  return O.some(transactions)
    .andThen(txs => lookup(txs, txs.length - 1))
    .mapNullable(tx => tx.timestamp)
    .andThen(ts => dateFromISOString(ts))
    .map(date => date.toLocaleDateString())
    .unwrapOr("No recent activity");
}

전통적인 접근 방식에서는 이 함수에 네 번 혹은 다섯 번의 조건문이 필요했을 것입니다. 여기서는 선형 흐름으로 구현되었습니다. andThenOption을 반환할 수 있는 연산들을 체인할 수 있게 해 줍니다. 타임스탬프가 잘못된 경우, 체인은 우아하게 단축됩니다.

Note: Option을 채택한다고 해서 TypeScript의 기본 도구인 옵셔널 체이닝(?.)이나 널 병합 연산자(??)를 포기해야 하는 것은 아닙니다. 실제로 이들 도구는 Option과 잘 함께 사용할 수 있습니다.

The “Pragmatic” Approach

function getLastTransactionDate(transactions: Transaction[]): string {
  return O.fromNullable(transactions[transactions.length - 1]?.timestamp)
    .andThen(ts => dateFromISOString(ts))
    .map(date => date.toLocaleDateString())
    .unwrapOr("No recent activity");
}

예제 4: 백엔드 도메인 로직

Node.js 백엔드의 서비스 메서드를 살펴보겠습니다. 우리는 ID로 사용자를 찾고, 해당 사용자가 활성 구독을 가지고 있는지 확인한 뒤, 구독 레벨을 반환하고자 합니다. 만약 어떤 정보가 누락되었다면, 해당 사용자를 “Free” 티어 사용자로 간주합니다.

import O from "@rsnk/option";

interface Subscription {
  level: "pro" | "enterprise" | "basic";
  isActive: boolean;
}

interface User {
  id: string;
  subscription?: Subscription;
}

class UserService {
  private db: Map<string, User>;

  constructor(db: Map<string, User>) {
    this.db = db;
  }

  // Returns an `Option<User>`, signaling that the user might not exist.
  findUser(id: string): O.Option<User> {
    return O.fromNullable(this.db.get(id));
  }

  getUserTier(userId: string): string {
    return this.findUser(userId)
      .mapNullable(user => user.subscription)
      .filter(sub => sub.isActive)
      .map(sub => sub.level)
      .unwrapOr("free");
  }
}

이 예제는 안전한 네비게이션을 보여줍니다. if (user) 혹은 if (user.subscription)과 같은 검사를 직접 할 필요가 없습니다. 우리는 정상 흐름(해피 패스)만 정의하면 되고, Option 타입이 비정상 흐름(슬프게도 발생할 수 있는 경우)을 자동으로 처리합니다.

Option 패턴을 채택해야 하는 이유?

  • Narrative code: if … else 문을 연속해서 사용하는 대신, 연속적인 이야기를 읽는 것과 같습니다—“사용자를 가져오고, 구독을 찾고, 활성 상태인지 확인하고, 레벨을 가져온다.”
  • Safer refactoring: 함수의 반환 타입이 T | null에서 Option으로 바뀔 때, TypeScript는 모든 호출 지점을 업데이트하도록 강제합니다. 컴파일러가 더 엄격하고 도움이 되는 페어‑프로그래머 역할을 하게 됩니다.
  • Explicit missingness: null | undefined 세계에서는 누락된 경우를 처리하는 것이 종종 사후 생각에 불과합니다. Option을 사용하면 처음부터 “박스”가 존재한다는 것을 인식하게 되어, 부재를 염두에 둔 API를 설계하고 보다 견고한 인터페이스를 만들 수 있습니다.

옵션이 모든 것의 은탄환인가? 아니오.

패턴과 마찬가지로, 맥락이 중요합니다.

  • 작은, 일회성 스크립트를 작성하고 있다면 Option 라이브러리를 도입하는 것이 과도할 수 있습니다. 표준 옵셔널 체이닝(?.)은 범위가 작고 로직이 선형적인 단순한 로컬 변수에 충분히 적합합니다.
  • 상호 운용성 비용이 있습니다. React 폼이나 null을 기대하는 서드파티 라이브러리를 많이 사용한다면 값을 자주 감싸고 풀어야 할 상황이 생깁니다. @rsnk/option은 가볍지만 여전히 추상화 레이어입니다.
  • 도메인 로직, 복잡한 데이터 처리, 공유 라이브러리의 경우 Option을 사용하는 이점이 작은 설정 비용을 능가합니다.

Option을 채택할 시점

  • 도메인 수준 코드로, 안전성과 명시성이 중요한 경우.
  • 다수의 nullable 값이 얽힌 복잡한 데이터 파이프라인.
  • 여러 모듈이나 프로젝트에서 사용될 공유 유틸리티.

순수 null / 옵셔널 체이닝을 유지할 시점

  • 작은 스크립트나 일회성 유틸리티.
  • null을 기대하는 API에 크게 의존하는 코드베이스.
  • 추가된 추상화가 가치보다 마찰을 더 많이 일으킬 경우.

시작하기

null에서 벗어나는 것은 하루아침에 이루어지는 일이 아니며, “좋은” 개발자가 되기 위한 필수 조건도 아닙니다. 이것은 단지 도구일 뿐—안전성과 명시성을 우선시하는 세계를 모델링하는 다른 방식입니다.

이 개념에 호기심이 생겼다면, 당신은 …

Back to Blog

관련 글

더 보기 »