PUBLISHED

객체 타입 지향

작성일: 2025.01.10

객체 타입 지향

아래는 전역 상태 관리 라이브러리 Caro-kann의 persist 미들웨어 내부에서 사용되는 유틸리티 함수들의 타입 정의이다. 세 타입 모두 storageKeystorageType이라는 동일한 파라미터를 요구하고 있으며, 이 둘은 저장소의 위치와 종류를 식별하기 위한 핵심 정보라는 점에서 공통된 맥락을 가진다.

untitled
TS
type GetStorage = <T>(props: {
  storageKey: string;
  storageType: keyof StorageConfig | null;
  migrate?: Migrate<T>;
  initState: T;
}) => { state: T; version: number };

type SetStorage = <T>(props: {
  storageKey: string;
  storageType: keyof StorageConfig | null;
  storageVersion: number;
  value: T;
}) => void;

type ExecMigration = <T>(props: {
  storageKey: string;
  storageType: keyof StorageConfig | null;
  migrate?: Migrate<T>;
}) => void;

GetStorageSetStorage는 이름만 보더라도 비교적 직관적이다. 하나는 저장소로부터 값을 읽어오고, 다른 하나는 값을 저장소에 기록한다는 역할이 자연스럽게 연상된다. 반면 ExecMigration은 그렇지 않다. 이 타입이 정확히 어떤 책임을 가지는지, 왜 존재하는지, 그리고 앞선 두 타입과 어떤 관계를 맺고 있는지 한눈에 파악하기 어렵다.

특히 세 타입이 동일한 파라미터 구조를 공유하고 있음에도 불구하고, 이 사실은 타입 선언만 놓고 보면 우연처럼 보인다. 처음 코드를 접한 개발자라면 ExecMigration이 persist 미들웨어의 일부인지조차 확신하기 어렵고, GetStorage, SetStorage와는 별개의 독립적인 유틸리티라고 오해할 가능성도 충분하다. 즉, 타입 자체는 안전하지만, 타입이 전달하는 맥락과 의도는 충분히 드러나지 않는다.

이 지점에서 객체 지향 프로그래밍(OOP)의 사고방식이 하나의 힌트를 제공한다. OOP에서는 데이터를 중심으로 객체를 모델링하고, 그 객체가 가질 수 있는 행위들을 메서드로 함께 묶는다. 객체의 내부 구조와 그에 속한 동작들이 하나의 응집된 단위로 표현되기 때문에, 개발자는 “이 객체가 무엇을 책임지는지”를 자연스럽게 이해할 수 있다.

물론 TypeScript의 타입 설계가 OOP와 완전히 동일하다고 할 수는 없다. 하지만 “서로 밀접하게 연관된 데이터와 동작을 하나의 개념으로 묶어낸다”는 아이디어 자체는 충분히 차용할 수 있다. 이를 타입 수준에서 구현하는 한 가지 방법이 바로 인덱싱 타입과 객체 타입을 활용한 구조화이다.

untitled
TS
export type PersistUtils = {
  common: {
    storageKey: string;
    storageType: keyof StorageConfig | null;
  };
  getStorage: <T>(props: PersistUtils["common"] & {
    migrate?: Migrate<T>;
    initState: T;
  }) => { state: T; version: number };
  setStorage: <T>(props: PersistUtils["common"] & {
    storageVersion: number;
    value: T;
  }) => void;
  execMigration: <T>(props: PersistUtils["common"] & {
    migrate?: Migrate<T>;
  }) => void;
};

이 구조에서는 세 함수가 공통으로 사용하는 파라미터가 common이라는 명시적인 이름 아래에 모인다. 그 결과, 중복을 제거했다는 기술적인 이점뿐 아니라, 이 파라미터들이 “persist 유틸리티 전반에서 공유되는 맥락”이라는 사실이 타입 자체로 드러난다. 또한 getStorage, setStorage, execMigration이 각각 독립적인 유틸리티가 아니라, 하나의 persist 도메인에 속한 역할이라는 점 역시 훨씬 분명해진다.

이러한 구조화의 효과는 타입 선언에만 국한되지 않는다. 실제 구현부에서도 동일한 이점을 얻을 수 있다.

untitled
TS
export const getStorage: PersistUtils["getStorage"];

export const setStorage: PersistUtils["setStorage"];

export const execMigrate: PersistUtils["execMigration"];

별도의 주석이나 설명이 없어도, 이 함수들이 어디에 속해 있고 어떤 맥락에서 사용되는지 타입 시그니처만으로 충분히 전달된다. 새로운 개발자는 구현을 열어보지 않더라도, 이 함수들이 persist 미들웨어를 구성하는 유틸리티 집합의 일부라는 사실을 자연스럽게 이해할 수 있다.

복잡한 타입 구조를 하나의 객체 타입으로 묶어 체계적으로 정리하는 행위는 단순히 “타입스크립트 문법을 잘 쓰는 것”과는 다른 차원의 문제다. 이는 코드의 응집도를 높이고, 암묵적인 규칙을 명시적인 구조로 끌어올리며, 결과적으로 협업과 유지 보수를 훨씬 수월하게 만든다. 코드는 더 이상 기계만을 위한 산출물이 아니라, 팀 내 다른 개발자와의 지속적인 커뮤니케이션 수단이 된다.

타입스크립트에서 타입 안전성을 보장하는 것은 분명 중요하다. 그러나 그것은 필요조건일 뿐, 충분조건은 아니다. 타입스크립트의 본질적인 가치는 단순한 타입 검사에 머무르지 않는다. 코드가 스스로 의도를 설명하고, 맥락을 드러내며, 개발자 간의 이해를 돕도록 설계할 수 있다는 점에 있다. 나는 타입 설계가 바로 그 지점까지 도달했을 때, 비로소 “좋은 타입스크립트 코드”라고 부를 수 있다고 믿는다.