avatar
Octoping Blog

Phantom Type 기초부터 알아보자

Typescript로 Phantom Type 써보기!
JavaScriptTypescript객체지향FrontendBackend
4 months ago
·
15 min read

팬텀 타입(Phantom Type)은 Haskell, Rust와 같은 언어에서 사용하는 기술이다. 팬텀 타입은 타입 매개변수(Generics)의 역할을 하는데, 실제 런타임에는 사용되지 않는 타입을 의미한다. 코드에서 타입 안전성을 강화하기 위해 사용된다...

.

.

.

위와 같은 내용의 글들을 많이 읽었다. Typescript 사용자로써, 팬텀 타입에 대해 공부하려고 할 때마다 이게 무슨 뜻인지 이해가 잘 안 갔었는데, 공부하고 정리해본 내용을 이해하기 쉽게 예시와 함께 소개해보려고 한다.

TypeScript는 구조적 타이핑을 하는 언어다

구조적 타이핑이 무엇인지 알지 못하는 타 언어 사용자를 위해 짧은 설명을 하고 넘어가자. 구조적 타이핑은 클래스의 이름 따위가 아니라, '객체의 형태와 구조'를 타입의 중요한 요소로 여기는 방식을 의미한다.

class Human {
  name: string;
  age: number;
}

class Animal {
  name: string;
  age: number;
}

const member = {
  name: "Octoping",
  age: 25,
};

// 이 세 가지 객체 or 클래스의 타입은 사실상 전부 같다

function sayName(x: Human) {
  console.log(x.name);
}

// 전부 타입 문제 없이 작동한다
sayName(new Human("Octoping", 25));
sayName(new Animal("Pig", 3));
sayName(member);

다음은 자바 같은 언어라면 생각도 못할 일이다. Human 클래스도, Animal 클래스도, member라는 변수도 string 타입의 name과 number 타입의 age를 가지므로 전부 같은 타입으로 인지하는 것이다.

물론 객체를 클래스를 사용하지 않고도 바로 만들 수 있다는 자바스크립트의 특징 때문에 어쩌면 당연할 수 밖에 없다. 그리고 이 특성으로 인해 Typescript는 큰 생산성을 얻게 되면서 동시에 한 가지 단점을 얻게 되는데.

문제 제기

구조적 타이핑으로 인해 조금 불편을 겪을 수 있는 에피소드를 소개해보고자 한다.

type Dollar = number;
type Won = number;

// 십만원을 가진 나
const octopingMoney: Won = 100000;
const friendMoney: Won = 50000;

// 달러를 더하는 함수
function addDollar(a: Dollar, b: Dollar): Dollar {
  return a + b;
}

// 🥵
const result = addDollar(octopingMoney, friendMoney);

DollarWon은 둘 다 숫자지만, 단위가 다르므로 서로 더하면 안된다. 하지만 구조적 타이핑 덕분에 이런 놀라운 일이 가능해진다.

이런 불편함을 어떻게 하면 좋을까?

도메인 주도 개발에서 이야기하는 값 객체라는 걸 이용해보면 좋을 것 같다.

값 객체를 다루기 어렵다

class Member {
    private Email email;
    private Password password;
    // ...
}

class Email {
    private String value;

    public Email(String value) {
        if (!value.contains("@")) {
            throw new IllegalArgumentException("이메일 주소가 올바르지 않아요");
        }
        
        this.value = value;
    }
}

class Password {
    private String value;

    public Password(String value) {
        if (value.length() < 8) {
            throw new IllegalArgumentException("비밀번호가 너무 짧아요");
        }

        this.value = value;
    }
}

다음은 Java를 이용해서 값 객체 (VO)를 사용한 예제다. Member에 이메일과 비밀번호를 그냥 String으로 저장하는 대신, 각각을 클래스로 묶어서 자기 자신이 값을 지님과 동시에 유효성 검사를 할 수 있도록 했다. 자신이 자기 자신을 그 자체로 소개할 수 있도록 하는 것. 그것이 도메인 주도 개발에서 이야기하는 값 객체이다.

class Member {
  constructor(
    private readonly email: Email,
    private readonly password: Password
  ) {}
}

class Email {
  value: string;

  constructor(value: string) {
    if (!value.includes("@")) {
      throw new Error("이메일 주소가 올바르지 않아요");
    }

    this.value = value;
  }
}

class Password {
  value: string;

  constructor(value: string) {
    if (value.length < 8) {
      throw new Error("비밀번호가 너무 짧아요");
    }

    this.value = value;
  }
}

// 순서가 틀렸지만 당연히 에러가 나지 않는다!
const member = new Member(new Password("email"), new Email("password"));

그런데 아뿔싸! 다음과 같은 코드를 Typescript에서 작성한다면 조금의 문제가 생겨버린다. 바로 EmailPassword의 시그니처가 똑같다는 것. 멤버 변수 타입 똑같고.. 메소드가 없는 것도 똑같은 것 같다. 당연히 에러가 나지 않겠지?

이 문제에 대해서 진지하게 이야기해보면.

일단 첫 번째: 두 객체의 멤버 변수 값을 다르게 하면 된다. 그것이 구조적 타이핑이니까.. 하지만 서로 다른 두 클래스가 각자의 멤버 변수 이름에 강결합되는 프로그래밍을 유도하게 되는 것 같다.

두 번째: value를 private으로 만들면 된다.

문제 갈무리

정리해보자. DollarWon을 그냥 number로써 동일하게 표현하면 타입스크립트는 이 둘을 완전히 같은 타입으로 인지하여 똑같이 다뤄버리게 된다.

이를 위해 값 객체를 사용해보려 하니 이 또한 멤버 변수의 이름을 신경써야 하니 나름의 어려움이 있다.

마지막으로, 그냥 타입을 number로 쓸 때에 비해 객체를 생성해야 하니 메모리도 더 잡아먹게 된다. 객체지향이 원래 그런 것이다만은..

until-1228

이런 상황을 어떻게 하면 좋을까?

Phantom Type

서론이 길었다. 팬텀 타입을 이용해보면 이런 문제를 해결해볼만한 실마리를 잡을 수 있을지도 모른다. 팬텀 타입을 내 나름대로 간단히 설명해보면 이렇다.

형태는 같지만 실제로는 서로 다른 타입을 구분하기 위해, 실제로는 존재하지 않는 타입을 이용하는 것

코드로 살펴보기

type Dollar = number & { __unit: "Dollar" };
type Won = number & { __unit: "Won" };

const myDollar = 2 as Dollar;
const myWon = 1000 as Won;

달러와 원을 그냥 number가 아니라, number이면서 { __unit: string } 인 녀석으로 정의하는 것이다.

물론 이 뒷 부분은 실제 런타임에는 전혀 존재하지 않는 녀석이다. (타입스크립트에서 타입은 컴파일 단계에서 사라지므로 런타임에는 더더욱 존재하지 않게 된다)

하지만 타입스크립트에게 억지로 이것이 존재한다고 함으로써 이 둘을 마냥 서로 같은 number가 아니도록 만드는 것이다.

type Dollar = number & { __unit: "Dollar" };
type Won = number & { __unit: "Won" };


function addDollar(a: Dollar, b: Dollar): Dollar {
  return (a + b) as Dollar;
}

function dollarToWon(dollar: Dollar): Won {
  return (dollar * 1300) as Won;
}


const myDollar = 2 as Dollar;
const myWon = 1000 as Won;


const addedDollar = addDollar(myDollar, myDollar);
const convertedWon = dollarToWon(myDollar);

// 에러!
addedDollar(myWon, myWon);

// 에러!
convertedWon(myDollar);

그렇다면 이렇게, 사실은 서로 같은 number를 서로 다른 타입처럼 사용할 수 있다.

실생활 예제

그럼 이걸 실생활에 어디다 어떻게 쓰라고? 팬텀 타입에 대해 다부지게 설명해주신 이 글에서 소개해준 예제를 조금 가공해서 빌려와볼까 싶다.

https://dev.to/busypeoples/notes-on-typescript-phantom-types-kg9

type MemberJoinPayload = {
  name: string;
  age: number;
};

export const onSubmit = (e: FormEvent<HTMLFormElement>) => {
  const payload: MemberJoinPayload = {
    name: e.currentTarget.name,
    age: e.currentTarget.age,
  };
  
  // validate를 해야 하지만 깜빡했다~~!!!!

  callApi(payload);
};

function validateData(data: MemberJoinPayload) {
  if (data.age < 0) {
    throw new Error("나이가 음수에요");
  }
}

async function callApi(data: MemberJoinPayload) {
  // 대충 api 호출하는 코드
}

회원가입 폼에서 입력 받은 내용을 validate를 거친 후, 성공했다면 회원가입 api 호출 함수를 쏘는 로직을 떠올려보자.

여기서 validate를 까먹어서 검증을 안 거치고 api를 호출하도록 코드를 작성하더라도 타입 상으로는 아무~ 문제가 없이 작동하기 때문에 내가 뭘 잘못했는지 생각 못하고 넘어갈 수 있다.

여기서 팬텀 타입을 적용해보자.

type MemberJoinPayload = {
  name: string;
  age: number;
};

type UnvalidatedData = MemberJoinPayload & { _type: "Unvalidated" };
type ValidatedData = MemberJoinPayload & { _type: "Validated" };


export const onSubmit = (e: FormEvent<HTMLFormElement>) => {
  const payload = {
    name: e.currentTarget.name,
    age: e.currentTarget.age,
  } as UnvalidatedData;

  const validatedData: ValidatedData = validateData(payload);
  
  callApi(validatedData);
};

function validateData(data: UnvalidatedData): ValidatedData {
  if (data.age < 0) {
    throw new Error("나이가 음수에요");
  }

  return { name: data.name, age: data.age } as ValidatedData;
  // return data as unknown as ValidatedData; 사실 이게 더 깔끔할지도
}

async function callApi(data: ValidatedData) {
  // 대충 api 호출하는 코드
}

폼을 입력 받아 실행되는 onSubmit에서 UnvalidatedData 타입의 payload를 생성한다. 물론 이 변수로는 callApi 함수를 실행할 수 없다.

따라서 그 후 validate를 실행하면 ValidatedData 타입의 결과를 받게 되니 이제서야 api를 호출할 수 있게 되는 것이다.

실제 객체의 값은 전혀 변하지 않았고, __type이니 뭐니 하는 것은 그 어디에도 존재하지 않았지만, 있는 타입이라고 타입스크립트에게 공갈을 하여 똑같은 타입의 서브 타입을 두 개를 만들어낸 것이다.

팬텀 타입 추상화하기

이 쯤 봤으면 팬텀 타입을 공용적으로 쓸 수 있게 추상화해볼 수 있겠다는 생각이 들 것이다. type Data = OriginalType & { __value: string } 대충 이런 형태이니..

type PhantomType<Data, Type = never> = Data & { _type: Type };

짜잔!

그럼 이걸 이용해서 다양한 팬텀 타입을 원하는 대로 만들어보자.

type MemberJoinPayload = {
  name: string;
  age: number;
};

type UnvalidatedData = PhantomType<MemberJoinPayload, "Unvalidated">;
type ValidatedData = PhantomType<MemberJoinPayload, "Validated">;
type Dollar = PhantomType<number, "Dollar">;
type Euro = PhantomType<number, "Euro">;
type Won = PhantomType<number, "Won">;

ChatGPT가 추천하는 팬텀 타입 유망 직종

다음은 ChatGPT에게 직접 팬텀 타입을 어디에 써보면 좋겠을지 물어보아 나온 결과인데 공유해본다.

  • 데이터베이스 ID 타입 구분

    • 서로 다른 테이블에서 사용되는 ID 값을 혼동하지 않도록 팬텀 타입을 사용할 수 있습니다

type UserId = PhantomType<number, "User">;
type OrderId = PhantomType<number, "Order">;

function getUserById(userId: UserId) {
  // 사용자 조회 로직
}

function getOrderById(orderId: OrderId) {
  // 주문 조회 로직
}

const userId: UserId = 1 as UserId;
const orderId: OrderId = 1 as OrderId;

// 올바른 사용
getUserById(userId);
getOrderById(orderId);

// 잘못된 사용 방지
// getUserById(orderId);
  • 도메인 모델의 상태 구분

    • 상태 변화에 따른 도메인 모델의 상태를 팬텀 타입으로 구분하여, 잘못된 상태에서 발생할 수 있는 동작을 방지할 수 있습니다.

type Draft = { __status: 'Draft' };
type Published = { __status: 'Published' };

type Document<Status> = {
  content: string;
  status: Status;
};

function publish(doc: Document<Draft>): Document<Published> {
  return { ...doc, status: { __status: 'Published' } };
}

const draftDoc: Document<Draft> = {
  content: 'Draft content',
  status: { __status: 'Draft' }
};

// 올바른 상태 전환
const publishedDoc = publish(draftDoc);

// 잘못된 상태 전환 방지
// publish(publishedDoc); // 이미 게시된 문서는 다시 게시할 수 없음

개인적으로 이건 비즈니스 로직에 관련된 건데 그냥 진짜 타입으로 관리해야 좋을 것 같다는 생각이다.

  • 서로 다른 API 엔드포인트에 안전하게 호출

    • API 요청 시, 서로 다른 API 엔드포인트에 같은 데이터 타입을 사용하더라도, 요청 경로를 팬텀 타입으로 구분하여 잘못된 경로로 요청을 보내는 실수를 방지할 수 있습니다.

type PhantomType<Data, Type = never> = Data & { _type: Type };

type ApiUri = string;
type OrderServerUri = PhantomType<ApiUri, "주문">;
type UserServerUri = PhantomType<ApiUri, "유저">;

async function fetchOrderList(uri: OrderServerUri, payload: any) {
  return fetch(uri, payload);
}

fetchOrderList("/api/order" as OrderServerUri, { userId: 1 });
fetchOrderList("/api/order" as UserServerUri, { userId: 1 }); // Error






반갑습니다 😄