saewoo
githubemail
2024-5-26

타입스크립트로 유효한 상태만을 설계하기

불가능한 경우의 수 없애기

개발시 경우의 수를 줄이는것은 유지보수 측면에서 중요하다고 생각됩니다. 예를들어 boolean 플래그를 하나, 둘 추가할 수록 2^n의 수많큼 늘어나게 되는데 모든 경우의 조합이 유효하면 상관없지만, 실제로 비지니스 상에서는 일어나지 않을 조합일 경우 문제가 발생합니다. 불필효한 경우를 고려해야하기 때문이죠. 경우의 수가 불필요하게 늘어날 수 있는 부분중 하나는 잘못된 상태 설계 입니다. 상태란 무엇일까요? 각자 경험기반으로 떠오르는 정의가 있을것이라고 생각됩니다. 프론트엔드 개발자라면 React에서의 상태가 생각날 수 있겠네요. 상태는 어떤 맥락에서 이야기하느냐에 따라 달라질 수 있을것 같습니다. 우선 상태에 대한 정의를 내려보고 타입스크립트로 유효한 상태를 표현하여 불필요한 경우의 수를 줄이는 방법을 살펴보겠습니다.

1. 상태란?

'유효한 상태 설계'에 대한 이야기를 하기 앞서 상태를 정의하는것부터 시작해야할 것 같습니다. 상태란 '소프트웨어의 실행 과정에서 데이터의 누적된 상황'입니다. 예를 들어서 정부24의 여권발급현황 조회 서비스의 여권 발급은 '심사중 - 발급처리중 - 우편배송중 - 교부대기 - 수령'과 같은 상태를 가집니다.

2. 유효한 상태란?

유효한 상태는 한번에 하나의 상태만을 가질 수 있습니다. 앞서 언급한 여권발급현황 조회 상태를 예로 들면 심사중인 상태를 가지면서 발급처리중인 상태를 갖는 경우는 존재할 수 없습니다. 더 간단한 예시를 들자면 잠을 자면서, 깨어있는 경우는 유효하지 않은 상태입니다.

2-1) 유효한 상태 정의를 해야하는 이유

상태관리 라이브러리인 XState의 코어 컨셉인 State Machine에대한 설명으로 유효한 상태를 정의헤애 하는 이유를 말하고 싶습니다.

Using state machines makes it easier to find impossible states and spot undesirable transitions.

state machines를 사용하면 불가능한 상태를 더 쉽게 찾고 바람직하지 않은 transitions들을 발견할 수 있다.

'state machines를 사용하면'을 '유효한 상태를 설계하면'으로 변경하여 읽으면 이해가 되실거라 생각됩니다.

3. 타입스크립트로 유효한 상태 설계하기

그렇다면 타입스크립트로 어떻게 유효한 상태를 표현할 수 있을까요?

유저 정보를 다루는 간단한 예시를 들어보겠습니다.

interface User {
  state: "unauthenticated" | "authenticated";
  name: string;
  token: string;
}

상태는 '소프트웨어 실행 과정에서 데이터의 누적된 상황'이므로 상태는 로그인 여부를 다루는 state 프로퍼티 입니다. 그렇다면 'unauthenticated' 상태에서 name, token을 가질수 있을까요? 당연하게도 가질 수 없습니다. 해당 키들은 로그인된 상태에서만 가질 수 있습니다. 이 부분을 유효한 상태만을 갖도록 인터페이스의 유니온으로 분리할 수 있습니다.

interface Unauthenticated {
  state: "unauthenticated";
}

interface Authenticated {
  state: "authenticated";
  username: string;
  token: string;
}

type AuthState = Unauthenticated | Authenticated;

이처럼 유효한 상태를 인터페이스의 유니온으로 정의하면 유효한 상태만을 쉽게 찾을 수 있습니다. 이론은 이쯤이면 충분하고 어떤 냄새로 잘못된 상태 설계를 찾을 수 있는지 알아보겠습니다.

4. 문제 발견하기

유효한 상태를 설계해야겠다는 점은 이해가 갑니다. 그렇다면 어떤 냄새로 문제를 발견하여 코드를 개선할 수 있을까요?

예를들어서 특정 상태에서만 유효한 url의 쿼리들이 존재하는데 다음과 같이 스팸(뭉쳐져 있는..)과 같은 interface를 작성한다면 추후 비지니스에 익숙하지 않은 신규 입사자가 읽었을 때 어떤 상태에서 유효한 쿼리인지 인지하기 어렵습니다. 모두 유효하다는 착각이 들기도 합니다. 실제로 유효한 상황들을 문서로 뒤져서 찾아보아야 합니다.

interface Reservation {
  status: "available" | "reserved" | "check_out";
  room_type: "single_room" | "double_room";
  name?: string;
  phone?: string;
}

해당 타입의 유효한 조합 수는 총 24((status 3가지) _ (room_type: 2가지) _ (name 2가지) _ (phone 2가지))가지 입니다. 정말 모든 경우가 유효할까요? 비지니스를 생각해보겠습니다. 방이 예약 가능상태일 경우 사전에 입력된 방에 대한 정보를 갖고 있지만 예약되지 않은 상황이므로 예약 정보를 갖고 있을 수 없습니다. 반면 방의 상태가 예약 혹은 체크아웃일 경우 예약자 정보를 반드시 갖고 있습니다. 유효한 경우를 모두 작성해보면 다음과 같이 6가지 경우입니다.

1. { status: 'available', room: { room_type: 'single_room' } }
2. { status: 'available', room: { room_type: 'double_room' } }
3. { status: 'reserved', room: { room_type: 'single_room' }, reservation: { name: '...', phone: '...' } }
4. { status: 'reserved', room: { room_type: 'double_room' }, reservation: { name: '...', phone: '...' } }
5. { status: 'check_out', room: { room_type: 'single_room' }, reservation: { name: '...', phone: '...' } }
6. { status: 'check_out', room: { room_type: 'double_room' }, reservation: { name: '...', phone: '...' } }

실제 존재할 수 있는 경우의 수인 6가지 보다 타입을 읽었을때 추론할 수 있는 경우의 수는 24로 더 많습니다. 이 부분에서 '잘못된 상태 설계'의 냄새가 납니다.

5. 문제 해결하기

우선 경험적으로 연관된 유형끼리 묶으면 가독성이 좋아질것 같습니다.

interface Reservation {
  status: "available" | "reserved" | "check_out";
  room: {
    room_type: "single_room" | "double_room";
  };
  reservation?: {
    name: string;
    phone: string;
  };
}

하지만 근본적인 문제는 해결되지 않았습니다. 실제 일어나는 경우와 코드상의 경우의 수의 간격이 여전히 존재합니다. 이는 유효하지 않은 상태를 설계했다는 증거입니다. 그렇다면 유효한 상태를 어떻게 표현할 수 있을까요?

5-1) interface의 union으로 유효한 상태 설계하기

상태를 기준으로 interface를 정의해야 합니다. 상태는 위에서 정의한것 처럼 '소프트웨어 실행 과정에서 데이터의 누적된 상황'이므로 방의 예약 상황을 나타내는 status입니다. interface의 union으로 6가지의 유효한 상태만을 타입으로 표현하면 다음과 같습니다.

interface AvailableRoom {
  status: "available";
  room: {
    room_type: "single_room" | "double_room";
  };
}

interface ReservedRoom {
  status: "reserved";
  room: {
    room_type: "single_room" | "double_room";
  };
  reservation: {
    name: string;
    phone: string;
  };
}

interface CheckedOutRoom {
  status: "check_out";
  room: {
    room_type: "single_room" | "double_room";
  };
  reservation: {
    name: string;
    phone: string;
  };
}

type ValidReservation = AvailableRoom | ReservedRoom | CheckedOutRoom;

코드는 길어졌지만 유효한 상태만을 표현하여 개발시 유효하지 않은 상태에 대한 고민을 하지 않을수 있게 됐습니다.

6. 정리

타입스크립트로 유효한 상태를 설계하는 방법을 알아보기 위해서 먼저 상태를 '소프트웨어의 실행 과정에서 데이터의 누적된 상황'으로 정의했습니다. 유효한 상태를 설계하기 위해서 인터페이스의 유니온으로 타입을 설계하는 방법을 살펴보았고, 스팸처럼 뭉쳐져 있는 인터페이스의 경우의 수와 실제 비지니스에서의 유효한 경우의 수의 간격이 발생하여 스팸 interface에서 상태를 뽑아내고 상태를 기준으로 인터페이스의 유니온으로 정리해보았습니다. 이렇게 유효한 상태만을 설계한다면 가독성이 높아질뿐만 아니라 불가능한 상태를 더 쉽게 찾고 바람직하지 않은 변경들을 발견할 수 있습니다. 따라서 유지보수시 유효하지 않은 상태에 대한 고민을 덜게되어 비용을 줄일 수 있습니다.