타입은 유즈케이스의 명세다
TL;DR
- 옵셔널 하나는 필드가 아니라 상태 공간을 확장한다 — 존재하지 않는 상태를 타입에 추가한 것일 수 있다
- 타입을 그 순간 떨어지는 데이터의 묘사로 쓰면 런타임이 명세를 지배한다
- 애플리케이션의 상태는 닫힌 집합이다 — 실재하는 상태만 남기고 이름을 붙여야 한다
- 상태의 개수가 아니라 이름이 명세를 결정한다
- 타입은 명세의 조각을 주석에서 구조로 옮긴 만큼 강해진다
- 명세가 관대해지면 진짜 명세는 소비자 각자의 머릿속으로 흩어진다
옵셔널 하나
최근에 코드를 보다가 이런 타입을 만났다.
type Res = {
title?: string | null
}
잘못된 건 알겠는데 그 잘못이 말로 만들어지지 않아서, 한동안 화면 앞에서 못 넘어갔다. 불편했고, 왜 불편한지 설명할 수 없으니 혼란스러웠다.
맥락을 살펴보니 이유는 이랬다. 서버가 내려주는 값에 title이 없을 수도 있어서, 개발할 때 편하라고 널널하게 받아두려고. 있을 수도 있고 없을 수도 있는 건 나도 안다. 그런데 그 사실 하나로 마음이 편치 않아지는 지점이 어딘가에 있었다.
이 글은 그 불편함에 이름을 붙이는 글이다.
상태 공간
title?: string | null이 허용하는 상태를 세어 보면 둘이 아니라 셋이다. 키가 아예 없는 경우, 키가 있는데 값이 null인 경우, 값이 문자열인 경우. 옵셔널과 널러블을 동시에 붙이는 순간 필드 하나가 세 개의 상태를 짊어진다.
그다음 질문은 정해져 있다. 없을 수도 있는가가 아니라, 없는 유즈케이스가 실재하는가. 이 응답에서 title이 없는 경우가 단 한 번도 일어나지 않는다면 그 ?는 데이터를 반영한 게 아니라, 존재하지 않는 상태 하나를 상태 공간에 밀어 넣은 것이다.
상태가 실제로 여러 개인 경우도 만난 적이 있다. 서류 제출을 다루는 화면이었는데, 서류 제출이 필요한 상태와 제출을 완료한 상태, 그리고 이 사용자에게는 서류 제출 자체가 불가능한 상태, 이렇게 셋이 실재했다. 이걸 이렇게 쓰면 어떻게 되나.
type Submission = {
submittedAt?: Date | null
}
제출이 필요한지, 완료됐는지, 아예 불가능한지가 필드의 유무와 null 여부로 흩어져서 인코딩된다. submittedAt이 undefined일 때 그게 '아직 안 했다'인지 '애초에 못 한다'인지는 타입 어디에도 없고, 읽는 사람이 문맥으로 추측한다.
좋은 타입의 조건을 하나 말할 수 있다. 타입이 허용하는 상태 공간과 제품에 실재하는 상태 공간이 정확히 일치할 때, 그 타입은 명세다. 허용하는 상태가 실재보다 많으면 소비하는 모든 코드가 존재하지 않는 상태까지 처리해야 하고, 반면 실재보다 적으면 어딘가에서 as로 타입을 강제로 벌리게 된다. 많은 쪽의 비용은 눈에 잘 띄지 않는데, 이런 타입을 소비하는 코드에는 실제로 방어 코드가 늘어 있었다. ??로 기본값을 깔고, 일어나지 않는 분기를 다루는 코드가.
그리고 명세가 거짓말을 하기 시작하면 진짜 명세는 사라지지 않고 옮겨 간다. 상태의 진짜 의미가 타입 대신 사람들 머릿속에 저장되고, 사람마다 버전이 조금씩 다른 채로. 그 순간부터 이 API의 명세는 코드가 아니라 사람들 머릿속의 분산 저장소에 있는데, 나는 그 저장소를 실제로 조회하러 다녔다. 서류 제출 상태의 의미를 확실히 알려면 그 제품을 이전에 만들었던 사람에게 슬랙으로 물어보거나, 서버 개발자들의 레포를 뒤져 도메인 모델링을 거슬러 읽어야 했다. 타입 정의 몇 줄을 이해하는 데 사람과 레포를 오가는 역추적이 필요했다.
이렇게 명세가 소비자에게 흩어지는 세계에 익숙한 이름이 하나 있다. 덕 타이핑이다. 값이 오리인지는 선언이 아니라 쓰는 쪽의 시도가 결정하고, 아니었을 때의 실패는 런타임에야 온다. 거기에는 중앙의 명세가 아예 없고, 소비자 각자가 필요한 만큼만 오리를 가정한다. 타입 선언이 있는 언어에서 널널한 타입은 그 세계를 다시 끌어들이는 일이다. 선언의 수고는 들였는데 명세는 여전히 흩어져 있다. 덕 타이핑의 비용을 지불하고 덕 타이핑의 가벼움만 반납한 셈이다.
묘사와 명세
타입을 대하는 태도에는 두 가지가 있는 것 같다. 하나는 묘사다. 지금 떨어지는 데이터가 이 모양이니까 이렇게 쓰고, 데이터가 변하면 타입을 따라 고친다. 이 관점에서 타입은 런타임의 그림자고, 런타임이 명세를 지배한다. 다른 하나는 명세다. 이 타입은 이런 유즈케이스를 허용한다를 먼저 쓰고, 데이터가 그 밖에서 놀면 데이터가 잘못된 것이지 타입을 늘려주는 게 아니다. 이 관점에서는 컴파일타임이 런타임을 지배한다.
앞의 title 타입은 묘사 쪽이었다. 그 순간 서버가 내려주는 모양을 그대로 찍어 둔 것이지, 이 응답이 어떤 유즈케이스를 위한 것인지 정의한 게 아니었다. 나는 타입이 명세 쪽에 서야 한다고 생각한다. 특히 애플리케이션 안에서는 그렇다. 외부에서 들어오는 입력은 신뢰할 수 없으니 검증의 영역이지만, 애플리케이션 내부에서 스스로 설계하고 관리하는 상태는 열거할 수 있다. 상태의 목록을 애초에 정해 놓을 수 있다는 뜻에서 닫힌 집합이고, 닫힌 집합은 설계의 대상이 될 수 있다.
그런데 타입스크립트에서 타입이 명세가 된다는 말은 한 번 더 풀어야 한다. 타입스크립트는 구조적 타이핑을 쓴다. Submission이라는 이름은 컴파일러에게 아무것도 알려 주지 않는 별명이고, 타입끼리의 호환은 오직 모양으로 판정된다. 이름과 주석과 문서는 사람을 위한 명세다. 컴파일러가 지킬 수 있는 명세는 모양뿐이고, 그래서 타입을 명세로 쓴다는 말은 모양 자체에 의미를 싣는다는 뜻이 된다. 구조가 느슨하면 명세가 느슨하다.
그래서 상태의 타입은 닫힌 상태로 설계해야 한다. 실재하는 상태만 남기고, 상태에 이름을 붙인다.
type Submission =
| { status: 'required' }
| { status: 'submitted'; submittedAt: Date }
| { status: 'unavailable' }
부재와 존재로 상태를 흩뜨려 두는 것과 상태에 이름을 붙여 두는 것은 다르다. 흩어져 있으면 명세라기보다 암호고, 암호는 해석자마다 다르게 푼다. 이름이 붙어 있으면 해석이 필요 없다.
개수와 이름
타입을 값의 집합으로 읽으면 지금까지의 이야기가 숫자가 된다. union은 집합의 크기를 더하고 객체는 곱한다. 함수형 언어에서 오래 써 온 대수적 데이터 타입의 관점이다. 이 눈으로 다시 세어 보면 title?: string | null의 크기는 문자열 개수에 2를 더한 값인데 실제 응답의 상태는 문자열뿐이므로, 그 타입이 끌어들인 존재하지 않는 상태는 정확히 두 개다. 그때 화면 앞에서 느낀 불편함은 이제 셀 수 있다.
서류 제출에서는 더 흥미로운 일이 벌어진다. submittedAt?: Date | null의 크기는 날짜 개수에 2를 더한 값이고, 태그를 달아 고친 union도 마찬가지다. 필요와 불가능이 각각 하나씩 있고 제출 완료가 날짜 개수만큼 있으니 역시 날짜 개수에 2를 더한 값이다. 개수만 보면 두 타입은 차이가 없다. 차이는 선택지가 스스로를 밝히느냐에 있다. 고치기 전의 타입은 undefined와 null이라는 익명의 값에 '아직 제출 안 함'과 '애초에 불가능'이라는 뜻을 짝지어 얹어 둔 것이고, 고친 후의 타입은 'required'라는 문자열 리터럴이 선택지의 이름 그 자체다. 상태의 개수를 맞추는 일과 상태에 이름을 붙이는 일은 다르다. 개수가 같아도 익명의 합은 여전히 암호고, 이름 붙은 합이 명세다.
구조만 보는 타입 시스템에서 리터럴은 값 자체에 이름을 새기는 수단이다. 그리고 이름 붙은 합은 컴파일러가 닫힘을 검사하게 만든다.
function describe(s: Submission): string {
switch (s.status) {
case 'required': return '서류를 제출해 주세요'
case 'submitted': return '제출이 확인되었습니다'
case 'unavailable': return '제출 대상이 아닙니다'
default: {
const left: never = s.status
return left
}
}
}
상태를 하나 빠뜨리면 default의 left가 never가 아니게 되고 컴파일이 실패한다. 부재 관례로 흩어 둔 상태는 이 검사를 받을 수 없다. if (s.submittedAt == null) 체인에서 컴파일러는 내가 '아직 제출 안 함'과 '애초에 불가능'을 한 덩어리로 묶어 버린 걸 알 방법이 없다. 닫힌 집합이라는 말이 여기서 취향이 아니라 검사가 된다.
물론 정당한 옵셔널도 있다. 부재 자체가 의미인 경우다. 부분 업데이트 요청에서 '이 필드는 건드리지 않는다'를 부재로 표현한다거나. 포인트는 옵셔널 금지가 아니다. ?를 찍는 순간 내가 상태 하나를 상태 공간에 추가한다는 걸 알고 찍는 것과, 모르고 찍는 것은 다르다.
강한 타입
지금까지는 상태 공간을 실재에 맞추는 이야기를 했다. 같은 원리로 값을 좁히고, 이름을 구조에 새기고, 경계를 해석할 수 있다. 타입이 강하다는 건 컴파일러가 대신 검사해 주는 게 많다는 뜻인데, 기법은 여러 개여도 방향은 하나다. 주석과 관례와 암묵지에 있던 명세의 조각을 구조로 옮기는 일.
값을 좁히는 것부터. 정렬 순서를 받는 함수에서 string을 쓰면 그 함수의 명세는 아무 문자열이나 받겠다는 뜻이 되는데, 실재하는 유즈케이스는 둘뿐인 경우가 많다.
type SortOrder = 'asc' | 'desc'
function sort(items: Item[], order: SortOrder) {
// ...
}
sort(items, 'ascending')은 이제 컴파일이 실패한다. 오타가 런타임 버그까지 가는 경로가 사라진다. string과 number를 그대로 쓰는 습관은 편하지만, 둘의 상태 공간은 사실상 무한해서 실재와 일치할 수 없다. 리터럴 유니온은 그 공간을 유즈케이스의 크기로 줄인다.
이름은 구조에 새길 수도 있다. 앞에서 타입스크립트는 이름이 아니라 모양을 본다고 했다. 그러니 이름을 모양 안에 넣으면 된다.
type UserId = string & { readonly __brand: 'UserId' }
type OrderId = string & { readonly __brand: 'OrderId' }
런타임에는 아무 일도 일어나지 않는데, UserId를 OrderId 자리에 넘기는 순간 컴파일이 실패한다. userId: string이라는 시그니처의 실제 의미는 모든 문자열이 아니라 실재하는 사용자의 ID인 경우가 많다. string은 존재하지 않는 값까지 허용하는 널널한 타입이고, 브랜드는 그 공간을 다시 닫는다. 검증을 통과한 값에만 브랜드를 붙여 주는 생성자를 두면 소비하는 쪽은 방어 코드를 쓸 필요가 없어진다.
경계에서는 해석이 필요하다. 묘사와 명세에서 외부 입력은 검증의 영역이라고 했는데, 검증을 통과한 값을 원래의 넓은 타입에 그대로 두면 명세는 경계에서 죽는다.
function parseSubmission(raw: unknown): Submission {
// 검증을 통과한 값만 닫힌 타입으로 돌려준다
}
외부의 값은 unknown으로 받고, 통과한 값만 닫힌 Submission으로 돌려준다. 검증이 통과했는지만 묻는 함수와 달리 해석은 타입을 바꾼다. Parse, don't validate라는 문장이 정확히 이 뜻이다. 이런 함수를 경계마다 두면 애플리케이션 안쪽은 전부 명세의 영역이 된다.
리터럴은 값에 이름을 새기고, 브랜드는 타입에 이름을 새기고, 파서는 경계의 넓은 타입을 닫힌 타입으로 바꾼다. 셋 다 같은 일을 한다. 주석에 적어 두던 명세를 구조로 옮겨서 컴파일러가 지키게 만드는 일.
명세 감각
처음의 불편함으로 돌아가 보면, 나는 그걸 미학적인 문제로만 여겼다. 어쩐지 타입이 지저분하니까 심기가 불편한가 보다 했다. 아니었다. 그 불편함은 타입이 묘사하는 상태 공간과 제품에 실재하는 상태 공간이 일치하지 않는다는 걸 감지한 결과였다. 명세가 데이터를 따라가는 순간, 읽는 사람이 매번 유즈케이스를 역추적해야 한다. 슬랙과 레포를 오가던 그 역추적이었다.
결과만 놓고 보면 나쁘지 않은 거래였다. 역추적하는 동안 제품에 대한 이해가 눈에 띄게 높아졌으니까. 그래도 그 고생이 필요했다고 생각하진 않는다. 타입이 상태를 제대로 명세하고 있었다면 나는 그 이해를 슬랙과 레포가 아니라 타입 정의 한 화면에서 얻었을 것이다. 잘 설계된 타입은 읽는 사람에게 제품을 가르쳐 준다. 고생이 이해로 돌아온 건 맞지만, 그 고생은 이해의 대가가 아니라 흩어진 명세를 다시 모으는 데 든 비용이었다.
지금은 타입을 볼 때 네 가지를 묻는다.
- 이
?는 실재하는 상태인가, 아니면 그냥 편의인가 - 선택지마다 이름이 붙어 있는가
- 명세의 조각 중 주석과 관례에만 있는 건 없는가
- 진짜 명세가 소비자의 머릿속으로 흩어지고 있지 않은가
쓸데없는 관대함도 명세를 훼손한다. ? 하나는 가볍게 보이지만 상태 공간에 추가된 상태 하나고, 그 상태를 처리하는 비용은 타입을 쓴 사람이 아니라 이 타입을 소비하는 모든 사람이 지불한다. 타입은 그 순간의 데이터를 가리키기도 하지만, 나는 유즈케이스의 명세로 쓰는 쪽을 택한다.