−검색 화면에 loading, data, error를 각각 두면 로딩 중인데 이전 오류가 남아 있거나, 성공한 결과와 실패 표시가 함께 켜지는 조합을 만들 수 있다. 각 필드의 타입은 맞아도 화면 전체의 뜻은 어긋나는 셈이다. 화면이 실제로 가질 수 있는 경우부터 정한 뒤, 각 경우에 필요한 필드를 묶으면 이런 모순을 줄일 수 있다.
이 방법은 대수적 데이터 타입(algebraic data type)의 합과 곱으로 설명할 수 있다. 한 경우가 여러 필드를 함께 가지는 것은 곱이고, 여러 경우 중 하나를 고르는 것은 합이다. 예를 들어 성공은 결과 배열을, 실패는 오류 문장을 가지며, 화면은 둘 중 한 경우에 속한다.[각주 1]
데이터의 모양을 여러 대안으로 나타내기까지
2007년 Haskell 설계자들의 회고 논문은 Haskell의 데이터 선언을 설명하면서 NPL·Hope·Standard ML·Miranda 등의 선행 언어를 함께 다룬다. 이 기록에서는 대안을 생성자로 나누고 각 생성자에 필드를 붙이는 표현이 함수형 언어들 사이에서 발전한 과정을 확인할 수 있다. 특정 현대 언어 하나를 이 개념 전체의 발명자로 정하는 것보다, 각 언어가 어떤 표현과 검사 기능을 제공했는지 구별하는 편이 정확하다.[각주 2]
TypeScript는 2015년 1.4에서 A | B 형태의 유니온 타입을 도입했고, 2016년 2.0에서는 문자열 리터럴 꼬리표를 검사해 해당 대안으로 타입을 좁히는 tagged union 지원을 설명했다. 유니온의 등장과 판별 속성에 따른 제어 흐름 분석은 같은 시점의 변화가 아니다.[각주 3][각주 4]
여기서 algebraic data type과 abstract data type은 구별해야 한다. 전자는 대안과 필드의 조합을 나타내는 구조를, 후자는 표현을 감추고 연산을 통해 사용하는 추상화를 가리킨다. 두 용어가 ADT라는 약자를 공유해서 문맥을 확인할 필요가 있다.[각주 2]
경우를 정하면 필드도 함께 정해진다
다음은 문자열 목록을 검색하는 화면의 작은 모델이다. kind는 어느 경우인지를 나타내는 판별 속성이다. 코드가 ready를 확인하면 TypeScript는 그 분기에서 data가 있는 것으로 다룬다.
type SearchState =
| { kind: 'idle' }
| { kind: 'loading'; query: string }
| { kind: 'ready'; data: string[] }
| { kind: 'failed'; message: string };
function describe(state: SearchState): string {
switch (state.kind) {
case 'idle': return 'idle';
case 'loading': return `loading:${state.query}`;
case 'ready': return `items:${state.data.length}`;
case 'failed': return `error:${state.message}`;
}
const missing: never = state;
return missing;
}
console.log(describe({ kind: 'ready', data: ['Ada', 'Grace'] }));
console.log(describe({ kind: 'failed', message: 'offline' }));
출력은 items:2와 error:offline이다. 실패에는 message가 있고 성공에는 data가 있다는 관계를 타입에 담았으므로, { kind: 'ready' }처럼 결과가 빠진 객체는 타입 검사에서 거절된다. 마지막 never 대입은 새 경우를 추가하고 switch 처리를 빠뜨렸을 때 그 누락을 드러낸다.[각주 5]
외부 JSON은 어디에서 검사할까
외부 응답을 unknown으로 받아 검사를 시작한다. as SearchState라고 적으면 컴파일러에게 그 타입으로 간주하라고 지시할 뿐, JSON의 실제 필드를 검사하지 않는다.[각주 6] 다음 함수는 JSON.parse로 얻은 데이터에서 이 예제의 성공 응답 모양을 검사한다. getter나 Proxy가 코드를 실행하는 임의의 JavaScript 객체는 이 입력 계약 밖이다.
type Ready = { kind: 'ready'; data: string[] };
type ParseResult =
| { ok: true; value: Ready }
| { ok: false; error: 'INVALID_READY' };
function parseReady(input: unknown): ParseResult {
if (typeof input !== 'object' || input === null ||
!('kind' in input) || !('data' in input)) {
return { ok: false, error: 'INVALID_READY' };
}
const kind = input.kind;
const data = input.data;
if (kind !== 'ready' || !Array.isArray(data)) {
return { ok: false, error: 'INVALID_READY' };
}
const snapshot = [...data];
if (!snapshot.every(item => typeof item === 'string')) {
return { ok: false, error: 'INVALID_READY' };
}
return { ok: true, value: { kind: 'ready', data: snapshot } };
}
console.log(parseReady({ kind: 'ready', data: ['Ada'] }).ok);
console.log(parseReady({ kind: 'ready' }).ok);
console.log(parseReady(null).ok);
출력은 true, false, false다. 배열을 복사한 뒤 그 복사본을 검사하므로 원래 응답 객체의 배열을 나중에 바꾸더라도 반환한 배열에는 그 변경이 반영되지 않는다. 객체의 필드 조합을 검사하는 이 경계와, 내부 코드에서 대안을 빠짐없이 처리하는 타입 검사는 역할이 다르다.
타입으로 줄일 오류의 범위를 먼저 정해야 한다. 위 모델은 성공에 결과 배열이 필요하다는 조건을 담지만, 검색 결과가 최신 요청의 것인지나 접근 권한이 있는지는 판단하지 않는다. 빈 문자열을 금지하려면 값 검증을 추가하고, 허용된 상태 전이만 제공하려면 전이 함수의 입력과 출력을 설계한다. 상태 패턴이 상태에 따른 행동을 나누는 방법이라면, 여기서는 먼저 어떤 데이터 조합을 표현할지 고른다.
예제 환경: strict 검사를 켠 TypeScript. 실행 시 타입은 제거되므로 외부 입력 검사는 실행 코드로 남는다.
+개요
대수적 데이터 타입(algebraic data type)은 여러 값을 함께 갖는 구조와, 여러 대안 중 하나를 고르는 구조를 조합해 데이터를 표현하는 방식이다. 이름의 대수적이라는 말은 값의 조합을 곱과 합으로 해석할 수 있다는 데서 나온다.
회원의 이름과 이메일을 함께 갖는 레코드는 두 필드의 조합이다. 반면 검색 결과가 대기·성공·실패 중 하나라면 각 경우에 필요한 데이터가 달라진다. 성공에는 결과 목록이 필요하고 실패에는 오류가 필요하다. 모든 필드를 선택 사항으로 둔 객체보다, 각 대안을 따로 정의하면 무엇이 함께 존재해야 하는지 타입에 나타낼 수 있다.
이런 모델은 값의 표현 공간을 설계하는 방법이다. 프로그램이 만들 수 있는 조합 중 업무에서 의미가 있는 조합을 골라 표현하고, 소비하는 코드는 각 경우에 맞는 필드를 다룬다. 흔히 “잘못된 상태를 표현하기 어렵게 만든다”는 설명이 이 지점에서 나온다.
타입은 데이터의 모양과 관계를 설명한다. 어떤 주문이 실제로 결제됐는지, 응답을 보낸 서버를 신뢰할 수 있는지 같은 사실은 실행 중 확인해야 한다. 상태의 표현, 전이 규칙, 외부 입력 검증을 각각 어디에서 맡는지 구분하면 타입 모델이 해결하는 범위가 분명해진다.
1. ML에서 Haskell로 이어진 데이터 선언
대안을 가진 데이터와 패턴 매칭은 ML 계열과 이후 함수형 언어에서 중요한 구성 요소로 발전했다. Haskell 설계자들이 쓴 2007년 회고 논문도 Haskell의 데이터 타입을 선행 언어의 계보 안에서 설명한다. 특정 현대 언어가 처음 만든 기능으로 보기보다, 생성자로 값을 만들고 패턴으로 경우를 나누는 언어 설계가 이어진 과정으로 읽을 수 있다.[각주 1]
Haskell 2010 보고서의 data 선언은 여러 생성자와 각 생성자가 받는 필드로 타입을 정의한다. 하나의 타입 안에서 서로 다른 모양의 값이 대안으로 존재한다. 재귀적인 생성자를 사용하면 리스트나 구문 트리처럼 크기가 정해지지 않은 구조도 표현한다.[각주 2]
이 계보에서 데이터 선언은 단순히 이름이 붙은 상수 목록을 만드는 데 그치지 않는다. 각 경우가 자기 데이터를 갖는다는 점이 중요하다. 실패 이유를 담는 실패 생성자와 결과를 담는 성공 생성자를 같은 결과 타입의 대안으로 둘 수 있다.
2. 합과 곱이라는 이름의 의미
곱 타입(product type)은 여러 필드가 함께 있는 값이다. 가능한 값이 2개인 필드와 3개인 필드를 독립적으로 조합하면 가능한 조합은 2 × 3 = 6개다. 튜플과 레코드가 이 구조를 보여준다.
합 타입(sum type)은 태그로 구별되는 여러 대안 중 하나인 값이다. 한 대안의 가능한 값이 2개이고 다른 대안은 3개라면 구별되는 전체 경우는 2 + 3 = 5개다. 태그가 있으므로 서로 다른 대안에서 같은 내용의 값을 사용해도 어느 경우인지 구분할 수 있다.
이 계산은 유한한 예제로 구조를 설명한 것이다. 실제 언어의 메모리 크기를 그대로 더하고 곱한다는 뜻은 아니다. 컴파일러는 태그·정렬·포인터·표현 최적화 등 언어와 구현에 맞는 방식으로 값을 배치한다.
합 안에 곱을 넣으면 “성공이면 결과와 갱신 시각을 함께 갖고, 실패면 오류를 갖는다”는 모델이 된다. 프로그램에서 필요한 관계가 데이터 선언의 구조에 드러난다.
3. 일반적인 객체 조합에서 상태의 대안으로
loading: boolean, data?: string[], error?: string을 독립 필드로 두면 코드가 여러 조합을 만들 수 있다. 로딩 중에 결과와 오류를 모두 보관하는 상태가 제품에 필요할 수도 있지만, 그런 화면을 의도하지 않았다면 각 소비자가 어떤 조합을 믿을지 판단해야 한다.
상태를 대안으로 정하면 그 판단을 데이터 선언에 모을 수 있다. 새 검색 중 이전 결과를 유지하는 화면이 필요하다면 그 경우에 맞는 필드나 별도 대안을 추가하면 된다. 목표는 상태 수를 무조건 줄이는 것이 아니라, 제품이 사용하는 조합을 명확하게 표현하는 것이다.
4. TypeScript에서 판별 유니온으로
TypeScript 1.4는 2015년에 유니온 타입을 도입했고, 2.0은 2016년에 판별 속성에 따른 tagged union 처리를 지원했다. 같은 문자열 속성의 값을 보고 각 대안의 타입을 좁힐 수 있게 된 것이다.[각주 3][각주 4]
다음 예제는 성공 상태에는 결과가 있고 실패 상태에는 오류가 있다는 관계만 표현한다. 복잡한 검색 API 없이 선언과 분기가 어떻게 연결되는지 보기 위한 코드다.
type Search =
| { kind: 'loading' }
| { kind: 'ready'; data: string[] }
| { kind: 'failed'; error: string };
function describe(state: Search): string {
switch (state.kind) {
case 'loading': return 'Loading';
case 'ready': return state.data.join(', ');
case 'failed': return state.error;
}
const impossible: never = state;
return impossible;
}
console.log(describe({ kind: 'ready', data: ['Ada', 'Grace'] }));
출력은 Ada, Grace다. ready 분기에서는 data가 존재하고, failed 분기에서는 error를 읽을 수 있다. strict 검사를 켠 TypeScript에서 { kind: 'ready' }처럼 필수 데이터를 빠뜨린 값은 이 타입에 맞지 않는다.
마지막 never 대입은 처리하지 않은 대안이 남았는지 검사하는 지점이다. 타입에 새로운 경우를 추가하고 분기를 빠뜨리면 이 지점에서 타입 오류를 얻는다.[각주 5]
5. 상태의 모양과 상태 전이
합 타입은 현재 값이 어떤 모양인지 설명한다. “결제 전에는 배송 완료로 갈 수 없다”는 전이 규칙까지 표현하려면 별도의 함수와 모듈 경계를 설계해야 한다. 값을 직접 만들 수 있는 코드가 어디까지인지도 영향을 준다.
상태 머신은 어떤 사건을 받았을 때 다음 상태가 무엇인지 정의한다. 대수적 데이터 타입은 그 상태와 사건의 모양을 표현하는 데 쓰일 수 있다. 상태 패턴은 상태별 행동을 객체로 나누는 설계 방식이다. 세 개념은 함께 사용할 수 있지만 설명하는 대상이 다르다.
6. 타입 검사와 외부 데이터
TypeScript의 타입 표기는 실행 때 제거된다. 타입 단언으로 외부 JSON을 Search라고 써도 JSON의 필드를 검사하는 코드가 생기지는 않는다. 외부 값은 실행 중 검증한 뒤 내부 모델로 옮겨야 한다.[각주 6]
반대로 내부 코드에서 같은 검증을 매번 반복할 필요가 있는지는 신뢰 경계와 데이터의 변경 가능성에 따라 정한다. 검증한 배열을 외부와 공유해 나중에 바꿀 수 있는지, 태그와 데이터가 함께 바뀌는지처럼 값의 소유와 수명도 모델의 사용에 영향을 준다.
7. 다형성과의 관계
다형성은 하나의 연산이나 인터페이스가 여러 타입과 맺는 관계를 설명한다. 합 타입은 데이터에 어떤 대안이 들어갈지를 표현한다. Result<T, E>처럼 매개변수를 가진 합 타입에서는 두 개념이 함께 나타난다. 결과의 성공·실패 모양은 합 구조이고, 여러 T와 E에 재사용하는 성질은 매개변수적 다형성에 해당한다.
이 관계를 구분하면 인터페이스 구현을 추가하는 방식과 데이터 대안을 추가하는 방식의 차이도 읽을 수 있다. 새 경우를 어디에서 선언하고, 기존 소비자가 무엇을 더 처리해야 하는지가 설계의 중요한 축이다.