모든 API 클라이언트 안에는 숨겨진 스테이트 머신이 있습니다. 먼저 handshake. 다음으로 인증. 세 번째로 데이터 전송. 마지막으로 종료. 그 순서를 어기면 런타임 오류, 혼란에 빠진 서버, 또는 더 나쁘게는 조용한 데이터 손상이 발생합니다.
대부분의 팀은 이러한 규칙을 런타임 검사로 인코딩합니다. if (!connected) throw new Error(...). 누군가 검사를 잊거나, refactor로 인해 검사를 건너뛰는 새로운 코드 경로가 생기기 전까지는 동작합니다. 눈치챌 때쯤이면 버그는 이미 프로덕션에 나가 있습니다.
TypeScript의 타입 시스템은 이러한 버그의 전체 클래스를 제거할 수 있습니다. 영리한 린트 규칙이 아니라, 불법적인 상태를 말 그대로 표현 불가능하게 만듦으로써입니다.
API 클라이언트에 숨겨진 스테이트 머신이 있다
Session type은 protocol에서 유효한 연산 순서를 인코딩하는 type system 기법입니다. 런타임에 문자열 필드로 상태를 추적하는 대신, 타입 파라미터에서 추적합니다. Channel<'idle'>은 connect()만 호출할 수 있습니다. Channel<'connected'>은 send()와 close()만 호출할 수 있습니다. 컴파일러는 나머지 모두를 거부합니다.
이것은 학술적인 이야기가 아닙니다. 데이터베이스 트랜잭션, WebSocket 연결, OAuth 흐름을 사용핳은 적이 있다면, 수동으로 session protocol을 강제한 것입니다. Session type은 그 강제를 런타임에서 컴파일 타임으로 옮길 뿐입니다.
Phantom type으로 잘못된 전이를 표현 불가능하게 만들기
다음은 TypeScript에서의 전형적인 스테이트 머신입니다:
class Channel {
private state: 'idle' | 'connected' | 'closed' = 'idle';
connect() {
if (this.state !== 'idle') throw new Error('Already connected');
this.state = 'connected';
}
send(msg: string) {
if (this.state !== 'connected') throw new Error('Not connected');
// ...
}
close() {
this.state = 'closed';
}
}
괜찮아 보이다가 갑자기 그렇지 않게 됩니다. send() 메서드는 잘못된 시점에 호출하면 런타임에 예외를 던집니다. 테스트가 잡아낼 수도 있고, 그렇지 않을 수도 있습니다. refactor로 인해 close() 이후에 새로운 send() 호출이 생길 수도 있는데, 아무도 눈치채지 못할 수 있습니다. 컴파일러는 의견이 없습니다.
TypeScript는 구조적 타이핑을 사용합니다. 모양이 같은 두 클래스는 타입 파라미터가 달라도 서로 교환 가능합니다. Channel<'idle'>과 Channel<'connected'>을 호환되지 않게 만들려면, 타입 파라미터가 구조 자체에 나타나야 합니다.
표준 패턴은 private phantom 필드를 사용하는 것입니다:
class Channel<State extends string> {
private __state!: State;
private constructor() {}
static create(): Channel<'idle'> {
return new Channel();
}
connect(this: Channel<'idle'>): Channel<'connected'> {
return new Channel();
}
send(this: Channel<'connected'>, msg: string): Channel<'connected'> {
console.log(msg);
return new Channel();
}
close(this: Channel<'connected'>): Channel<'closed'> {
return new Channel();
}
}
__state 필드는 절대 값을 가지지 않습니다. 이는 오직 타입 파라미터를 클래스 구조에 바인딩하기 위해 존재합니다. private이므로 외부 코드는 Channel<'connected'>을 조작할 수 없습니다. 타입이 State이므로, Channel<'idle'>과 Channel<'connected'>은 구조적으로 다른 타입입니다.
각 메서드의 this 파라미터는 어떤 상태가 이를 호출할 수 있는지 제한합니다. TypeScript는 메서드 본문 내부뿐만 아니라 호출 지점에서 this 호환성을 검사합니다.
이제 잘못된 호출은 컴파일 타임 오류가 됩니다:
const ch = Channel.create();
ch.send('hello'); // Error: 'Channel<"idle">' is not assignable to 'Channel<"connected">'
이것이 사람들을 당황하게 하는 부분입니다. this 파라미터는 런타임 주석처럼 보입니다. 하지만 그렇지 않습니다. TypeScript는 이를 순전히 수신 객체의 타입 검사를 위해 사용합니다. 수신 객체의 타입이 맞지 않으면, 컴파일러는 호출 자체를 거부합니다.
타입 안전한 파일 업로드 protocol 구축하기
장난감 같은 Channel은 패턴을 보여줍니다. 다음은 실제 protocol에 더 가까운 예입니다: 인증과 체크섬 검증을 포함하는 파일 업로드 클라이언트입니다.
type UploadState = 'idle' | 'ready' | 'uploading' | 'verifying' | 'done';
class Uploader<State extends UploadState> {
private __state!: State;
private file: string;
private constructor(file: string) {
this.file = file;
}
static forFile(file: string): Uploader<'idle'> {
return new Uploader(file);
}
authenticate(
this: Uploader<'idle'>,
token: string
): Uploader<'ready'> {
// verify token...
return new Uploader(this.file);
}
uploadChunk(
this: Uploader<'ready' | 'uploading'>,
data: Uint8Array
): Uploader<'uploading'> {
// stream bytes...
return new Uploader(this.file);
}
finalize(
this: Uploader<'uploading'>,
checksum: string
): Uploader<'verifying'> {
// start verification...
return new Uploader(this.file);
}
verify(
this: Uploader<'verifying'>,
serverChecksum: string
): Uploader<'done'> {
// compare checksums...
return new Uploader(this.file);
}
}
uploadChunk가 this 타입으로 Uploader<'ready' | 'uploading'>을 받는 것에 주목하세요. Union type을 사용하면 여러 상태에서 유효한 메서드를 표현할 수 있습니다. 이 메서드는 Uploader<'uploading'>을 반환하므로, 첫 호출 이후에는 finalize할 때까지 업로드 상태에 고정됩니다.
const chunk1 = new Uint8Array([0x01, 0x02]);
const chunk2 = new Uint8Array([0x03, 0x04]);
const upload = Uploader.forFile('report.pdf')
.authenticate('token-123')
.uploadChunk(chunk1)
.uploadChunk(chunk2)
.finalize('abc123')
.verify('abc123');
// upload is now Uploader<'done'>
uploadChunk() 이후에 authenticate()를 호출하거나, verify() 이후에 uploadChunk()를 호출해보세요. TypeScript가 즉시 오류를 내립니다.
이 패턴이 어색해지는 곳
이 패턴은 공짜가 아닙니다. 가장 큰 고통 지점은 모든 상태 전이가 새 객체를 생성한다는 것입니다. 예제에서 uploadChunk가 Uploader<'uploading'>에서 Uploader<'uploading'>을 반환할 때처럼, 기본 상태가 변하지 않았을 때도 새로운 Uploader를 생성합니다. 많은 상태를 가진 복잡한 객체의 경우, 이는 낭비입니다.
재구성하는 대신 상태를 전달하여 최적화할 수 있지만, 타입이 더 시끄러워집니다. 두 번째 문제: this 파라미터 불일치에 대한 TypeScript의 오류 메시지는 난해합니다. “The ‘this’ context of type ‘X’ is not assignable to method’s ‘this’ of type ‘Y‘“는 정확하지만, 코드를 처음 보는 사람에게는 즉시 명확하지 않습니다. 좋은 이름과 주석은 도움이 되지만, 개발자 경험은 완벽하지 않습니다.
세 번째, 이 패턴은 async 코드와 잘 조합되지 않습니다. 체인 중간에 await가 있으면 플루언트 인터페이스가 깨지고, 중간 타입화된 결과를 변수에 저장해야 합니다. 이것이 결정적인 문제는 아니지만, 이 패턴은 동기적이거나 신중하게 구조화된 async protocol에 가장 잘 작동한다는 의미입니다.
Tagged union은 실용적인 중간 지점이다
전체 phantom type 패턴이 부담스럽다면, exhaustive switch 문을 사용하는 tagged union이 실용적인 대안입니다. 여전히 컴파일 타임 안전성을 얻지만, 타입 레벨이 아닌 값 레벨에서입니다:
type UploadState =
| { tag: 'idle'; file: string }
| { tag: 'ready'; file: string; token: string }
| { tag: 'uploading'; file: string; sent: number }
| { tag: 'done'; file: string };
function authenticate(
state: Extract<UploadState, { tag: 'idle' }>,
token: string
): UploadState {
return { tag: 'ready', file: state.file, token };
}
이것은 더 관용적인 TypeScript이며 대부분의 팀이 유지보수하기 쉽습니다. 트레이드오프는 동일한 this 파라미터 트릭 없이는 타입 레벨에서 잘못된 상태 변형을 함수에 전달하는 것을 막을 수 없다는 것입니다. 컴파일러는 함수 내부에서 이를 잡아내지만, 호출 지점은 제한되지 않습니다.
위반이 실제 피해를 일으킬 때 phantom type을 사용하라
protocol이 충분히 복잡하여 위반하면 실제 피해가 발생하고, API 표면이 충분히 작아서 모든 전이를 제어할 수 있을 때 phantom type을 사용하세요. 데이터베이스 드라이버, 네트워크 protocol 구현체, 상태를 유지하는 SDK가 좋은 후보입니다.
단순한 CRUD API나 추가적인 타입 복잡성이 가끔의 런타임 검사보다 비용이 더 드는 곳에는 사용하지 마세요.
다음에 if (state !== 'connected') throw new Error(...)를 작성할 때, 그 검사가 코드에 속하는지 타입에 속하는지 스스로 물어보세요. TypeScript의 this 파라미터와 phantom 필드는 protocol enforcement를 상위로 밀어낼 방법을 제공합니다. 잡아낼 버그는 극적인 것들이 아닐 것입니다. 코드 리뷰를 빠져나가 고객이 테스트하지 않은 엣지 케이스를 만났을 때만 나타나는 조용한 실수들일 것입니다.
그것이 바로 제거할 가치가 있는 버그입니다.