상태 모델링에서는 어떤 상태가 가능한지만큼, 어떤 상태가 존재해서는 안 되는지도 중요합니다. 그런 상태를 어떻게 모델링하느냐에 따라 타입 시스템이 이 경계를 강제할 수도, 전혀 모를 수도 있습니다.
저는 헬스케어 도메인의 RTM SaaS 모니터링 시스템을 담당하고있는데 redux-toolkit으로 관리하는 타이머 상태를 확장하는 과정에서 하스겔 함수형 프로그래밍의 아이디어인 Algebraic Data Type(ADT)이 타입스크립트에서 어떻게 적용되는지 그리고 컴파일 수준에서 개선할 수 있는 타입화 방식을 담았습니다.
대수란 무엇인가
먼저 위키피디아에서 algebric data type은 특히 함수형 프로그래밍에서 다른 타입의 결합으로 만들어지는 합성 타입을 말합니다. 이 또한 하나의 자료형 인데요. 우리가 알고 있는 자료형들 string, number, array, object, undefined etc 과는 사뭇 다릅니다. 대수적 자료형을 지원하는 프로그래밍 언어들도 있는데 타입스크립트를 포함해 스칼라, 코틀린, 스위프트, 하스켈 등 이름만 들어도 함수형을 지원하는 언어들입니다.
여기서 말하는 대수란 무엇일까요?
우리가 알고 있는 대수는 보통 x + 3 = 5 같은 방정식을 푸는 학문 입니다. 하지만 수학에서의 대수는 조금 더 넓은데요. 어떤 대상 들과, 그 대상들을 결합하는 연산들, 그리고 그 연산이 지켜야 할 규칙을 함께 묶어 정의한 구조를 말합니다. 즉 무엇 들을 다룰 것 인가와 그것 들을 어떻게 결합할 수 있는가를 정의합니다.
예를 들어 숫자 대수 에서는 대상이 숫자 이고 연산은 덧셈, 곱셈, 규칙은 교환법칙, 결합법칙 등 이렇게 세가지가 묶여 하나의 대수가됩니다.
이 개념을 프로그래밍에 접목해 보면, 타입을 하나의 값이 아니라 ‘결합 가능한 대상’으로 바라보게 됩니다. 타입들은 일정한 규칙에 따라 결합될 수 있고, 그 결과로 새로운 타입을 만들어낼 수 있습니다.
타입을 결합하는 방식은 크게 두가지로 나눌 수 있습니다. 여러 가능성중 하나를 선택하는 방식과 여러 요소를 함께 가지는 방식으로 대수적 합과 곱입니다.
대수적 합 타입 - 여러 가능성 중 하나
대수적 합 타입은 여러 대안 중 하나를 선택하는 타입입니다. 불리언 논리의 OR에 해당합니다. 하스켈에서 Bool을 정의하는 방식을 보면 직관적으로 이해됩니다.
data Bool = True | False
JavaScript
복사
True 아니면 False, 둘 중 하나입니다. 가능한 값의 수는 2개입니다.
TypeScript에서는 유니온 타입이 이에 해당합니다.
type Direction = "left" | "right" | "up" | "down";
JavaScript
복사
Direction이 가질 수 있는 값은 정확히 4가지이고, 그 중 하나입니다. 동시에 두 가지 방향이 될 수는 없습니다.
대수적 곱 타입 — 여러 요소를 동시에
대수적 곱에 대해 이야기 하겠습니다. 곱(Product)은 불리언 논리의 AND와 연결 됩니다. 그래서 구성 요소를 모두 가질 수 있습니다.
•
객체의 모든 필드를 가지고 있다
•
여러 값이 모두 존재해야 의미가 있다
예를 들어:
typePoint = {x:number;y:number };
TypeScript
복사
이 타입은 다음처럼 함께 사용됩니다:
•
x와 y 각각의 타입 값이 모두 결합돼 있다 — 이것이 곱(Product)입니다.
TypeScript에서 object 나 tuple을 쓰면 자동으로 곱 타입이 됩니다.
그래서 앞서 문제로 본 type State = { status; pauseReason }도 곱 타입입니다.
곱 타입일 때 중요한 점은:
구성 요소가 독립적이기 때문에
가능한 모든 조합이 허용된다는 것입니다.
이것이 나중에 “불가능한 상태 조합까지 허용하는 문제”를 만드는 원인입니다.
모니터링 타이머 시스템에서 pause 상태를 관리해야 했습니다. 초기 설계 에서는 pauseReason 이 다음과 같이 정의되어 있었습니다.
export type PauseReason = "manual" | "inactive" | null;
Plain Text
복사
이 타입은 전역 상태에서 그대로 사용되었고, pause 액션 에서는 단순히 이 값을 설정합니다.
pause: (state, action: { payload: PauseReason }) => {
state.pauseReason = action.payload;
}
Plain Text
복사
문제는 resume 이후의 상태 였습니다. 타이머가 다시 시작되었다면, 더 이상 pauseReason 은 존재해서는 안 됩니다. 즉, 논리적으로는 다음과 같은 규칙이 성립해야 합니다.
•
paused 상태일 때만 pauseReason 이 존재한다
•
started 상태라면 pauseReason 은 반드시 없어야 한다
하지만 기존 타입은 이를 전혀 표현하지 못했습니다.
type State = {
status: "started" | "paused" | "stopped";
pauseReason: "manual" | "inactive" | null; // 항상 존재
}
Plain Text
복사
이 구조에서는 타입 시스템 관점에서 다음 값도 완전히 유효합니다.
{ status: "started", pauseReason: "inactive" }
Plain Text
복사
이로 인해 실제 코드에서는 다음과 같은 문제가 발생할 수 있습니다.
function doSomethingWhenResumed(state: State) {
if (state.status === "started") {
// 개발자는 여기서 pauseReason은 null일 거라고 가정
if (state.pauseReason === "inactive") {
// 🚨 이 분기는 "절대 오지 않는다"고 믿었지만
// 실제로는 충분히 발생 가능
sendMonitoringTime();
}
}
}
Plain Text
복사
이 코드는 논리적으로 우리가 의도한 시스템에서 결합을 가질 수있는 이상한 코드입니다. 그런데 타입스크립트 컴파일러는 아무런 경고도 주지 않습니다. 왜냐하면 타입 시스템 입장에서는 이 상태 조합에선 문제가 없다고 생각하기 때문입니다.
구조적 타이핑 한계
flat한 데이터 구조는 구조적 타이핑 언어에서 단일 객체 타입으로 상태를 표현하면,
•
어떤 필드가 언제 의미가 있는지
•
어떤 필드 들이 서로 배타적인지
•
“이 상태에서는 이것만 가능하다”라는 제약
을 타입으로 명확히 드러내기 어렵습니다.
예를 들어 초기 상태 모델을 보면, status: 3가지 가능성, pauseReason: 3가지 가능성 총 9가지 상태 조합을 허용합니다.
그중 일부만 살펴보면 다음과 같습니다.
- `{ status: "paused", pauseReason: "manual" }` //의도한 상태 OK
- `{ status: "paused", pauseReason: null }` // 이유 없는 pause ?
- `{ status: "started", pauseReason: "inactive" }` //논리적으로 불가능
- `{ status: "stopped", pauseReason: "manual" }` // 의미 없는 조합
JavaScript
복사
객체 타입은 모든 필드를 동시에 가지기 때문에, 각 필드는 서로 독립적인 선택지가 됩니다. 그래서 타입 시스템 관점에서는 이 모든 조합이 동등하게 유효합니다. 위에서 설명한 대수적 곱으로 모델링 되어있습니다. 하지만 도메인 상태에서는 “여러 가능성 중 정확히 하나만 성립한다” 라는 의도를 타입에 담아낼 수 없습니다.
구조적 타이핑 한계 대응 : optional never
이런 구조적 타이핑에 대한 한계를 never를 이용해 배타성을 표현할 수 있습니다. never를 사용하면 이 상태에서는 이 필드가 존재할 수 없다를 알려줄 수 있습니다.
type StartedState = {
status: "started";
pauseReason?: never;
};
type PausedState = {
status: "paused";
pauseReason: "manual" | "inactive";
};
type StoppedState = {
status: "stopped";
pauseReason?: never;
};
type State = StartedState | PausedState | StoppedState;
JavaScript
복사
타입 레벨에서 불가능한 조합을 제거 했으니 기능적으로는 성공입니다.
{ status: "started", pauseReason: "inactive" } // ❌ 에러
JavaScript
복사
하지만 의도가 타입 시그니처에서 바로 보이지 않고 타입 추론 에서도 optional 때문에 status가 started라면 pauseReason 속성이 깔끔하게 배타적 속성이 되지 못하고 여전히 추론이 가능해 집니다.
곱으로 모델링 된 구조위에 제약을 얹는 방식은 타입을 이해하기위해선 도메인이 아닌 문법 트릭을 해석해야 하는 구조라는 한계가 있습니다.
Tagged Union: 배타성을 구조로 표현하기
Exclusive를 더 확실하게 표현할 수 있는 방법이 있는데요. 태그를 이용해 상태 시스템을 ADT로 모델링한 경우의 예입니다. status가 각 상태를 구분하는 태그(discriminant) 역할을 하고, 각 상태는 필요한 데이터만을 포함시켜 논리적으로 불가능한 조합을 아예 표현할 수 없게 합니다.
이 방식은 서로 배타적인 경우들의 합(Sum)이라고 볼 수 있습니다.
type State =
| { status: "started" }
| { status: "paused"; pauseReason: PauseReason }
| { status: "stopped" };
JavaScript
복사
마무리
현실의 도메인에는 규칙이 있습니다. 어떤 상태에서는 특정 데이터가 반드시 존재해야 하고, 다른 상태에서는 그 데이터가 존재해서는 안 됩니다. 하지만 때론 이런 제약을 대부분 주석이나 개발자의 암묵적인 합의에 맡깁니다. 이런 결정들이 쌓일 수록 안정적인 소프트웨어와는 거리가 멀어지는것 같습니다.
이 글에서 다룬 타이머 예제로부터 ADT를 적용했다고 해서 로직이 극적으로 단순해진 것은 아닙니다. 다만, 더 이상 동료의 가정이나 문서에 의존하지 않아도 되는 구조가 되었고, 타입을 읽는 것만으로도 “이 상태에서 가능한 행동이 무엇인지”를 판단할 수 있게 되어 생산성과 안정성에 도움이될 수 있다고 생각합니다.
상태 모델링은 도메인의 규칙을 타입 시스템 안으로 끌어들이는 설계 결정입니다. TypeScript를 잘 활용한다는 것은 타입을 추가하는 행위보다, 그 설계 결정을 얼마나 의식적으로 다루고 있는지와 더 관련이 있다고 느꼈습니다.
