Search

Algebric Data Type 을 이용해 상태 모델링 개선

상태 모델링에서는 어떤 상태가 가능한지만큼, 어떤 상태가 존재해서는 안 되는지도 중요합니다. 그런 상태를 어떻게 모델링하느냐에 따라 타입 시스템이 이 경계를 강제할 수도, 전혀 모를 수도 있습니다.
저는 헬스케어 도메인의 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에서 objecttuple을 쓰면 자동으로 곱 타입이 됩니다.
그래서 앞서 문제로 본 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를 잘 활용한다는 것은 타입을 추가하는 행위보다, 그 설계 결정을 얼마나 의식적으로 다루고 있는지와 더 관련이 있다고 느꼈습니다.