TypeScript Optional Parameters: 책임의 계층구조

jingjinge·2025년 6월 2일
0

이모저모

목록 보기
5/6

두 개의 파라미터에 대한 타입을 리팩토링하며 3시간 넘게 고민을 하였다.

이렇게 하든 저렇게 하든 동작은 당연히 가능하지만, 타입에 대해 스트릭하게 가져가면서도 개발자 편의성을 챙기는 방법이 무엇이 있을까에 대해 고민했다.


문제 상황

함수 호출의 연쇄가 있을 때, optional parameter와 기본값을 어느 계층에서 처리해야 할까?

// 사용자 → funA → funB → funC 순서로 호출되는 상황
function funA(foo?: string) { ... }
function funB(foo?: string) { ... }  
function funC(foo?: string) { ... }

이런 상황에서 어디서 optional을 처리하고 기본값을 설정해야할까?

우선 위 상황에서 왜 고찰해야하는지를 이해하려면 기존 상황의 문제부터 살펴보아야 할 것 같다.

undefined 전파의 불확실성

function funA(foo?: string) {
  console.log(`funA received: ${foo}`); // undefined일 수 있음
  return funB(foo);
}

function funB(foo?: string) {
  console.log(`funB received: ${foo}`); // 여전히 undefined일 수 있음
  return funC(foo);
}

function funC(foo?: string) {
  console.log(`funC received: ${foo}`); // 결국 undefined로 처리됨
  return `Result: ${foo ?? 'final-default'}`;
}

// 실행 결과: undefined가 끝까지 전파됨
funA(); // "Result: final-default"

타입 안정성의 부재

function funB(foo?: string) {
  // foo가 undefined인지 string인지 매번 확인해야 함
  const length = foo?.length ?? 0; // 방어 코드 필요
  const upperCase = foo?.toUpperCase() ?? ''; // 또 다른 방어 코드
  
  // 비즈니스 로직이 타입 체크에 묻힘
  if (foo) {
    return funC(foo);
  }
  return funC('fallback');
}

예측 불가능한 동작

function funA(foo?: string) {
  return funB(foo ?? 'A-default');
}

function funB(foo?: string) {
  return funC(foo ?? 'B-default');
}

function funC(foo?: string) {
  return foo ?? 'C-default';
}

// 어떤 기본값이 사용될까?
funA();          // 'A-default' (예상 가능)
funA(null);      // 'A-default' (null도 falsy)
funA('');        // 'B-default' (빈 문자열은 falsy)
funA(undefined); // 'A-default'

// 함수를 개별적으로 호출하면?
funB();          // 'B-default'
funC();          // 'C-default'

유지보수의 복잡성

// 기본값을 변경하려면 여러 곳을 수정해야 함
function funA(foo?: string) {
  return funB(foo ?? 'old-default'); // 여기도 수정
}

function funB(foo?: string) {
  return funC(foo ?? 'old-default'); // 여기도 수정
}

function funC(foo?: string) {
  return foo ?? 'old-default'; // 여기도 수정
}

테스트의 어려움

function funB(foo?: string) {
  // 실제로 사용할 때 undefined 체크를 잊기 쉬움
  return funC(foo.trim());
  // 💥 Runtime Error: Cannot read property 'trim' of undefined
}

function funC(foo?: string) {
  return foo.toUpperCase(); // 💥 또 다른 Runtime Error
}efault'; // 여기도 수정
}

위와 같은 상황으로 한 곳에서 정해놓고 optinal과 기본값 처리를 해야한다.

우리는 총 3개의 접근법이 존재한다.


해결 방법

진입점에서 처리 (권장)

function funA(foo: string = 'default') {
  return funB(foo);
}

function funB(foo: string) {
  return funC(foo);
}

function funC(foo: string) {
  return `Result: ${foo}`;
}

// 사용법
funA();           // 'default' 사용
funA('custom');   // 'custom' 사용

위 함수는 사용자 인터페이스 계층에서만 optional 처리를 하여명확한 책임 분리의 장점과 내부 함수들은 항상 유효간 값을 받는다고 가정이 가능하다.

이로인해 타입 안정성 확보디버깅이 용이 해지는 장점을 갖는다.

각 단계에서 처리 (비권장)

function funA(foo?: string) {
  return funB(foo ?? 'default');
}

function funB(foo?: string) {
  return funC(foo ?? 'fallback');
}

function funC(foo?: string) {
  return `Result: ${foo ?? 'final'}`;
}

위 방식은 어떤 기본값이 사용될지 예측하기 어려우며, 여러 곳에서 기본값 로직이 중복되어 책임이 분리되어 있지 않고, 각 함수가 독립적으로 사용될 때만 의미가 있을 수 있다.

최종 단계에서 처리

function funA(foo?: string) {
  return funB(foo);
}

function funB(foo?: string) {
  return funC(foo);
}

function funC(foo?: string) {
  return `Result: ${foo ?? 'default'}`;
}

위 상황에서는 undefined가 깊숙이 전파되며, 타입 체크가 복잡해진다.

결국 우리는 가장 첫 번째 계층인, 사용자 계층에서 먼저 optional에 대한 처리를 해주어, 로직이 실제로 실행되는 단계에서는 strict하게 가져감으로써서 명확한 책임 분리,항상 유효간 값, 타입 안정성 확보,디버깅이 용이 라는 장점을 갖게 된다.

실제 예시

// 좋은 예
async function fetchUserProfile(userId: string = 'current') {
  const userData = await getUserData(userId);
  const enrichedData = await enrichUserData(userData);
  return formatUserProfile(enrichedData);
}

async function getUserData(userId: string) {
  // userId는 항상 유효한 값이라고 가정
  return api.get(`/users/${userId}`);
}

async function enrichUserData(userData: UserData) {
  // userData는 항상 존재한다고 가정
  return { ...userData, preferences: await getPreferences(userData.id) };
}

function formatUserProfile(data: EnrichedUserData) {
  // data는 완전한 형태라고 가정
  return {
    displayName: data.name,
    avatar: data.avatar,
    settings: data.preferences
  };
}

결론

Optional parameter와 기본값은 사용자에게 가장 가까운 진입점 함수(funA)에서 처리하는 것이 최선의 방법일 것 같다.

내부 함수들은 항상 유효한 값을 받는다고 가정하고 설계하여, 더 안정적이고 테스트하기 쉬운 코드를 만들 수 있게 된다!

같은 코드를 바꾸고 바꿔가며 얻은 결론인데, 자주 까먹어서 정리해두고 두고두고 보려고 한다 ㅜㅜ

0개의 댓글