October 11, 2025 • ☕️☕️☕️ 13 min read
Alexis King의 Parse, don’t validate 문서를 한국어 + TypeScript로 완전 내 입맛대로(^^) 이해해가며 정리한 글이다.
지금까지, 나는 타입 주도 설계(type-driven design)를 실천한다는 것이 정확히 무슨 뜻인지 간결하고 단순하게 설명할 방법을 찾는 데 어려움을 겪어 왔다. 누군가 내 이 접근법이 어떻게 떠올랐냐고 물으면 만족스러운 대답을 못 하곤 했다. 어떤 계시가 내려온 건 아니며, 나는 정해진 방식이 아니라 반복적 설계 과정을 거쳐 왔다. 하지만 그 과정을 다른 사람에게 전달하는 데는 실패해 왔다.
그러다 약 한 달 전, 트위터에서 정적 타입 언어와 동적 타입 언어에서 JSON을 파싱하던 경험의 차이를 살펴보던 중, 내가 찾고 있던 것을 깨달았다. 지금은 타입 주도 설계가 내게 무슨 의미인지 함축하는 간결한 슬로건 하나가 생겼고, 그것은 단 세 단어로 이루어져 있다:
Parse, don’t validate.
검증하지 말고, 파싱하라.
아직 타입 주도 설계가 무엇인지 모르는 사람에게 이 슬로건이 바로 와닿지는 않을 것이다. 다행히도 남은 글에서 내가 무슨 의미로 이 말을 쓰는지를 자세히 설명하려 한다. 우선, 약간 이상적인 사고를 해보자.
정적 타입 시스템의 멋진 점 중 하나는 ‘이 함수를 작성할 수 있나?‘와 같은 질문에 답할 수 있게 한다는 것이다. 극단적인 예로 다음과 같은 타입 시그니처를 보자.
function foo(n: number): never이 foo 함수를 구현할 수 있을까? 대답은 명백히 ‘아니오’다. 왜냐면 never 타입은 값이 절대 존재하지 않는 타입이기 때문이다. 따라서 어떤 함수도 never 타입의 값을 반환할 수 없다.
이 예는 다소 단순하지만, 조금 더 현실적인 예를 든다면 질문은 더욱 흥미로워진다.
function head<T>(xs: T[]): T {
return xs[0];
}이 함수는 리스트의 첫 번째 요소를 반환한다. 그런데 이 함수가 구현 가능한가? 직관적으로는 복잡해 보이지 않지만, 실제로 구현하려면 문제가 생긴다.
function head<T>([x, ..._]: T[]): T {
return x;
}이 함수는 모든 가능한 입력값에 대해 정의되지 않았다. 구체적으로, 빈 리스트 []에 대한 처리가 빠져 있다. 즉, 빈 리스트에는 첫 번째 요소가 존재하지 않기 때문에 이 함수는 실행되지 않을 수 있다. 따라서 이 head 함수는 ‘partial function(부분 함수)‘이 된다.
동적 타입 언어 배경에서는 이런 것이 혼란스러울 수 있다. ‘리스트가 있으면 첫 번째 요소를 꺼내고 싶다’는 건 자연스러운 생각이니까. head 함수를 고치는 두 가지 방식이 있는데, 가장 간단한 방식부터 살펴보자.
기대 조정하기 (약속 약화)
설계상 head 함수는 리스트가 비어있으면 반환할 값이 없으므로 부분 함수다. 충족시킬 수 없는 약속을 한 것이다. 다행히도 이 딜레마에 대한 쉬운 해결책이 있는데, 그 약속을 약하게 만드는 것이다. 호출자가 리스트의 요소를 보장할 수 없으므로, ‘가능한 경우에만 요소를 반환하겠다’라는 약속으로 바꾸는 것이다. Haskell에서는 Maybe 타입을 이용해 표현할 수 있다. (TypeScript에서는 Maybe 타입을 Haskell스러운 방식으로 임의로 설계했다.)
type Maybe<T> = { kind: "just"; value: T } | { kind: "nothing" };이렇게 하면 head를 원하는 대로 구현할 수 있다. a 타입을 반환할 수 없을 때는 Nothing을 반환하면 된다.
type Maybe<T> =
| { kind: "just"; value: T }
| { kind: "nothing" };
function Just<T>(value: T): Maybe<T> {
return { kind: "just", value };
}
function Nothing<T = never>(): Maybe<T> {
return { kind: "nothing" } as Maybe<T>;
}
function head<T>(xs: T[]): Maybe<T> {
return xs.length === 0 ? Nothing() : Just(xs[0]);
}하지만 이 방식에는 단점이 있다. 호출하는 쪽에서 항상 Nothing 가능성을 다루어야 하며, 때로 이는 엄청나게 귀찮아진다.
미리 타입 강화하기
앞서 우리가 수정한 head 버전에는 여전히 아쉬운 점이 남아 있다. 이미 ‘리스트가 비어 있지 않다’고 확인했음에도 불구하고, 여전히 Nothing의 가능성을 처리해야 했다. 이제는 head가 좀 더 똑똑하게 동작하길 바란다, 이미 비어 있지 않다고 보장된 리스트라면, head는 무조건 첫 번째 요소를 반환하도록 만들고 싶다. 어떻게 해야 할까?
먼저 원래의 (부분 함수) 타입 시그니처를 떠올려보자.
function head<T>(xs: T[]): T {
return xs[0];
}이전 섹션에서 우리는 이 타입 시그니처를 부분 함수에서 전체 함수로 바꾸는 방법을 봤다. 그때는 반환 타입의 약속을 약화시켜서 해결했다. 즉, a 대신 Maybe a를 반환하도록 바꿨다. 하지만 이번에는 그 방법을 쓰는 대신, 인수 타입을 강화할 것이다. 즉, [a] 대신 더 강력한 타입을 사용해서, 애초에 빈 리스트가 들어올 가능성을 제거해야 한다.
이를 위해 필요한 건 ‘빈 리스트가 아님’을 표현할 수 있는 타입이다. 다행히도 Haskell에는 이미 Data.List.NonEmpty 모듈에 NonEmpty 타입이 존재한다. 그 정의는 다음과 같다. (TypeScript에서는 NonEmpty 타입을 Haskell스러운 방식으로 임의로 설계했다.)
type NonEmpty<T> = [T, ...T[]];NonEmpty a는 사실상 (a, [a]) 즉, 첫 번째 요소와 나머지 리스트의 쌍이다. 이 구조는 리스트의 첫 번째 요소를 별도로 분리하여 저장함으로써 ‘비어 있지 않음’을 자연스럽게 모델링한다. 심지어 [a] 부분이 []일지라도, 앞의 a는 반드시 존재한다. 이 덕분에 head 함수는 아주 간단하게 구현할 수 있다:
type NonEmpty<T> = [T, ...T[]];
function head<T>(xs: NonEmpty<T>): T {
const [x, ..._] = xs;
return x;
}이제 이 정의는 완전 함수(total function)가 된다.
흥미롭게도, 새로운 head 버전으로부터 예전의 (약한) head를 복원하는 것도 아주 간단하다. head와 nonEmpty를 조합하면 된다.
type NonEmpty<T> = [T, ...T[]];
function nonEmpty<T>(xs: T[]): NonEmpty<T> | undefined {
return xs.length === 0 ? undefined : (xs as NonEmpty<T>);
}
function head<T>(xs: NonEmpty<T>): T {
return xs[0];
}
function headPrime<T>(xs: T[]): T | undefined {
const ne = nonEmpty(xs);
return ne ? head(ne) : undefined;
}반대로, 예전의 head로부터 새로운 버전을 만드는 건 불가능하다. 즉, 새 접근법은 이전 방식보다 모든 면에서 더 낫다.
여기까지 예시는 리스트 ‘빈 여부’라는 속성을 처리하는 두 방법을 보여 준 것이다. 이게 Parse, don’t validate 슬로건과 무슨 상관이 있을까? 검증(validation)과 파싱(parsing)의 차이는 ‘정보를 얼마나 보존하느냐’에 달려있다.
다음 두 함수를 보자.
function validateNonEmpty<T>(xs: T[]): Promise<void> {
return new Promise((resolve, reject) => {
if (xs.length === 0) {
reject(new Error("list cannot be empty"));
} else {
resolve();
}
});
}
function parseNonEmpty<T>(xs: T[]): Promise<NonEmpty<T>> {
return new Promise((resolve, reject) => {
if (xs.length === 0) {
reject(new Error("list cannot be empty"));
} else {
resolve(xs as NonEmpty<T>);
}
});
}validateNonEmpty는 단지 ‘리스트가 비어 있지 않다’는 사실을 확인하고, 그렇지 않으면 오류를 던진다. 반환 타입 ()는 아무런 정보를 담지 않는다.parseNonEmpty는 NonEmpty a 타입을 반환하므로, 검증된 사실을 타입 수준에 보존한다.두 함수는 같은 검사를 하지만, parseNonEmpty는 그 결과를 호출자에게 주고, 호출자는 그 정보를 사용할 수 있다. 반면 validateNonEmpty는 이 정보를 버려 버린다. 이 두 함수는 정적 타입 시스템이 어떤 역할을 할 수 있는지를 잘 보여준다. validateNonEmpty 는 타입 체커를 겨우 통과할 뿐이지만, parseNonEmpty는 그 타입 시스템의 힘을 완전히 활용한다.
이런 관점에서, parseNonEmpty는 진정한 ‘파서’라 할 수 있다. 파서는 더 덜 구조화된 입력을 받아 더 구조화된 출력을 만든다. 이 과정에서 일부 입력은 실패로 처리된다(예: 빈 리스트). 그래서 파서는 보통 부분 함수이며 실패 가능성이 있다. 이 정의를 유연하게 적용하면, parseNonEmpty는 리스트를 non-empty 리스트로 파싱하는 역할을 한다고 볼 수 있다.
이처럼 유연한 정의 하에서, 파서는 엄청나게 강력한 도구가 된다. 파서는 프로그램과 외부 세계의 경계에서 입력을 미리 검증할 수 있게 해주며, 한 번 그 검사를 통과하면 이후에는 다시 검사할 필요가 없다.
많은 Haskell 라이브러리들이 바로 이 방식의 파싱을 활용한다:
aeson 라이브러리는 JSON 데이터를 도메인 타입으로 파싱할 수 있는 Parser 타입을 제공한다.optparse-applicative 라이브러리는 커맨드라인 인수를 파싱하기 위한 파서 조합기(parser combinator)를 제공한다.persistent, postgresql-simple 같은 데이터베이스 라이브러리들은 외부 데이터 저장소에 담긴 값을 파싱하는 기능을 갖고 있다.servant 생태계는 경로(path) 컴포넌트, 쿼리 파라미터, HTTP 헤더 등으로부터 Haskell 데이터 타입을 파싱하는 것을 중심으로 구성되어 있다.이들 모두 외부 세계(바이트 스트림 등)를 프로그램 내부의 구조화된 타입으로 바꾸는 경계 지점에 놓여 있다. 가능한 한 이 지점에서 모든 검사를 수행하고, 프로그램의 나머지 부분에서는 이미 유효한 데이터만 다루게 만드는 것이 중요하다.
물론, 모든 것을 초기에 파싱하는 방식에는 단점도 있다. 때로는 어떤 값이 실제로 사용되기 훨씬 전에 미리 파싱되어야 할 수도 있다. 동적 타입 언어(dynamically-typed language)에서는 이로 인해 파싱 로직과 처리 로직을 일치시키는 것이 어려워질 수 있으며, 이를 유지하기 위해서는 광범위한 테스트 커버리지가 필요하다.
하지만 정적 타입 시스템(static type system)을 사용하면 이야기가 완전히 달라진다. 앞서 본 NonEmpty 예시가 그것을 잘 보여준다. 파싱 로직과 처리 로직이 불일치하면, 프로그램은 컴파일조차 되지 않는다.
이쯤 되면 아마 ‘파싱이 검증보다 낫다’는 주장에 어느 정도는 설득되었을 것이다. 하지만 아직 마음 한켠에는 의문이 남아 있을 수도 있다. 어차피 타입 시스템이 필요한 검사를 강제로 하게 만들 텐데, 검증(validation)이 그렇게 나쁜가? 에러 메시지가 좀 덜 친절할 순 있어도, 중복된 검사가 조금 있는 게 뭐가 문제겠는가?
불행히도, 문제는 그리 단순하지 않다. ad-hoc 검증은 언어 이론 기반 보안(Language-Theoretic Security, LangSec) 분야에서 ‘샷건 파싱(shotgun parsing)‘이라 부르는 현상으로 이어진다.
2016년 논문 The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge The 에서는 샷건 파싱을 다음과 같이 정의한다.
샷건 파싱이란 파싱과 입력 검증 코드를 처리 로직(processing code)과 뒤섞어 프로그램 곳곳에 흩뿌려 놓는 안티패턴이다. 즉, 입력에 대해 온갖 검사를 마구 던져 놓고, 체계적인 근거 없이 그중 하나쯤은 ‘나쁜’ 경우를 걸러줄 것이라 막연히 기대하는 방식이다.
이 논문은 이러한 검증 방식이 지닌 문제점도 이어서 설명한다.
샷건 파싱은 프로그램이 유효하지 않은 입력을 처리 전에 막을 능력을 근본적으로 빼앗는다. 입력 스트림에서 오류가 늦게 발견되면, 이미 일부 잘못된 입력이 처리된 뒤일 수 있으며, 그 결과 프로그램의 상태를 정확히 예측하기 어려워진다.
다시 말해, 프로그램이 입력 전체를 미리 파싱하지 않으면 유효한 부분을 일단 처리하다가, 나중에 다른 부분이 잘못된 걸 발견하는 상황이 생길 수 있다. 이때는 이미 실행된 작업을 되돌려야 전체 상태의 일관성을 유지할 수 있다. 물론 일부 시스템(예를 들면 RDBMS의 트랜잭션)에서는 이런 롤백이 가능하지만, 일반적인 경우에는 그렇지 않다.
샷건 파싱과 검증이 무슨 상관이냐고 생각할 수도 있다. 입력 전체를 미리 검증만 잘 하면 샷건 파싱 문제는 생기지 않지 않나? 하지만 문제는 바로 그 ‘검증’에 있다. 검증 기반 접근법은 ‘정말로 모든 입력이 사전에 검증되었는가?‘를 보장하거나 증명하기가 매우 어렵다. 즉, 프로그램의 어느 부분에서라도 예외가 발생할 가능성이 항상 존재하며, 심지어 그것이 ‘불가능한 경우’라고 가정했더라도 실제로는 언제든 일어날 수 있다고 가정해야 한다. 결국 프로그램 전체가 ‘예외는 언제든 발생할 수 있다’는 전제 위에서 돌아가게 된다.
반면, 파싱은 이 문제를 우아하게 해결한다. 파싱은 프로그램을 두 단계로 명확히 분리한다. 바로 ‘파싱 단계’와 ‘실행 단계’로 나누는 것이다. 입력이 잘못되어 실패할 가능성은 오직 첫 번째 단계(파싱)에서만 발생한다. 그 이후의 실행 단계에서는 실패 가능성이 매우 제한적이며, 남은 에러들은 정말 필요한 곳에서만, 세심한 주의로 다루면 된다.
지금까지 ‘검증하지 마라, 파싱하라’는 철학을 설명했다. 그러나 이를 어떻게 실행해야 하는지는 아직 잘 감이 오지 않을 수도 있다.
내 조언은 간단하다. 데이터 타입(datatype)에 집중하라.
예를 들어, (key, value) 쌍들의 리스트를 인자로 받는 함수를 작성하고 있다고 하자. 그런데 리스트에 중복된 키가 있을 경우 어떻게 처리해야 할지 확신이 서지 않는다. 한 가지 해결책은 리스트에 중복 키가 없음을 확인하는 검증 함수를 작성하는 것이다.
type KeyValue<K, V> = [K, V];
class AppError extends Error {}
function checkNoDuplicateKeys<K, V>(pairs: KeyValue<K, V>[]): void {
const seen = new Set<K>();
for (const [k] of pairs) {
if (seen.has(k)) throw new AppError(`Duplicate key: ${k}`);
seen.add(k);
}
}하지만 이런 검사는 매우 취약하다. 쉽게 잊어버릴 수 있기 때문이다. 이 함수의 반환값이 ()이므로, 호출하지 않아도 타입 검사를 통과해버린다. 더 나은 방법은, 애초에 중복 키를 허용하지 않는 자료구조를 사용하는 것이다. 예를 들어 Map이 그런 구조다. 함수의 타입 시그니처를 (k, v) 리스트 대신 Map을 받도록 바꾸고, 그에 맞게 구현을 수정하라.
이렇게 바꾸면, 새로운 함수를 호출하는 코드가 아마 타입 검사에서 실패할 것이다. 왜냐하면 여전히 (k, v) 리스트를 전달하고 있기 때문이다. 이제 호출하는 쪽 코드를 따라가며 그 인자 값이 어디서 오는지를 추적하면 된다. 그 값이 다른 함수의 반환값에서 오거나, 상위 함수의 인자로 전달되는 것이라면, 그 타입 역시 리스트에서 Map으로 점진적으로 바꿔 나갈 수 있다. 이 과정을 계속 따라가다 보면, 결국 값이 처음 생성되는 지점까지 도달하거나, 혹은 중복이 실제로 허용되어야 하는 곳에 이를 것이다. 그 지점에서 다음과 같이 수정된 checkNoDuplicateKeys를 호출하면 된다.
type KeyValue<K, V> = [K, V];
class AppError extends Error {}
function checkNoDuplicateKeys<K, V>(pairs: KeyValue<K, V>[]): Map<K, V> {
const map = new Map<K, V>();
for (const [k, v] of pairs) {
if (map.has(k)) throw new AppError(`Duplicate key: ${k}`);
map.set(k, v);
}
return map;
}이제 검증은 생략될 수 없다. 왜냐하면 그 결과값(Map)이 실제로 프로그램 진행에 필요하기 때문이다.
이 가상의 예시는 두 가지 단순한 원칙을 보여준다.
잘못된 상태를 표현할 수 없는 데이터 구조를 사용하라.
데이터를 가능한 한 정확하게 모델링하라. 현재 사용 중인 인코딩(표현 방식)으로 어떤 상태를 배제하기 어렵다면, 그 속성을 더 쉽게 표현할 수 있는 다른 인코딩 방식을 고려하라. 리팩터링을 두려워하지 마라.
증명의 부담(proof burden)을 가능한 위쪽으로 밀어올리되, 그 이상은 하지 마라.
데이터가 프로그램 내로 들어오자마자 가능한 한 정밀한 표현으로 변환하라. 이상적으로는 시스템 경계(즉, 외부 입력을 받는 시점)에서 이 변환이 이루어져야 한다.
어떤 코드 분기가 특정 데이터의 더 정밀한 표현을 요구한다면, 그 분기로 진입하는 즉시 데이터를 그 표현으로 파싱하라. 필요하다면 sum type(합 타입)을 활용하여 데이터 타입이 제어 흐름에 따라 유연하게 변하도록 하라.
요약하자면, 주어진 데이터 표현이 아니라, 원하는 데이터 형식에 맞춰 코드를 작성하라. 그렇게 하면 설계 과정은 ‘두 표현 사이의 간극을 메우는 작업’이 된다. 이는 보통 양쪽 끝에서 출발해 중간에서 만나는 식으로 진행된다. 리팩터링 과정에서 새로운 사실을 배우게 될 수도 있으니, 필요에 따라 설계 일부를 반복적으로 조정하는 것을 두려워하지 마라.
다음은 추가 조언들이다.
데이터 타입이 코드를 이끌게 하라.
코드가 데이터 타입을 지배하게 두지 마라. 지금 작성 중인 함수에 필요하다고 해서 아무 생각 없이 boolean 필드를 하나 추가하는 유혹을 피하라. 대신, 올바른 데이터 표현을 사용할 수 있도록 코드를 리팩터링하라. 타입 시스템이 모든 변경 지점을 잡아줄 것이고, 그 덕분에 나중에 생길 두통도 줄어든다.
실패 시 에러만을 반환하는 함수는 경계심을 가지고 다뤄라.
물론 실제로 사이드 이펙트를 수행하는 경우도 있다. 하지만 그 효과의 주된 목적이 단지 ‘에러를 던지는 것’이라면, 더 나은 방법이 있을 가능성이 크다.
데이터를 여러 단계에 걸쳐 파싱하는 것을 두려워하지 마라.
‘샷건 파싱을 피하라’는 말은, 데이터를 완전히 파싱하기 전에 사용하는 일을 피하라는 뜻이지, 입력 데이터 일부를 이용해 나머지를 파싱하지 말라는 뜻이 아니다. 실제로 유용한 파서는 대부분 문맥 의존적이다.
데이터의 비정규화(denormalization)를 피하라,
특히 데이터가 변경 가능한 경우에는 더욱 그렇다. 동일한 데이터를 여러 곳에 복제하면 ‘동기화되지 않은 상태’라는 불법 상태가 너무 쉽게 발생한다. 가능한 한 단일한 진실의 원천(single source of truth)을 유지하라.
불가피하게 비정규화를 해야 한다면,
그것을 추상화 경계 뒤로 숨겨라. 작은, 신뢰할 수 있는 모듈 하나만이 여러 표현 간의 일관성을 유지하도록 책임을 맡게 하라.
검증기를 파서처럼 보이게 만들어라.
어떤 잘못된 상태를 완전히 표현 불가능하게 만드는 것은 개발 도구만으로는 비현실적일 수도 있다. 예를 들어, ‘정수가 특정 범위 내에 있어야 한다’는 제약이 그렇다. 이럴 때는 검증기를 파서처럼 보이게 추상화하고, 타입으로 불가능한 상태를 표현하라.
마지막으로 언제나 그렇듯, 상식적인 판단을 사용하라. 단지 코드 어딘가에 ‘절대 일어나지 않아야 하는 불가능한 에러’가 한 번 등장했다는 이유만으로 싱글턴 타입을 도입하고 애플리케이션 전체를 리팩터링할 필요는 없다. 다만, 그런 상황을 방사능 물질처럼 조심스럽게 다뤄라. 적절한 주의를 기울이고, 최소한 주석이라도 남겨 이 불변 조건을 다음에 코드를 수정할 사람에게 명확히 알려두어라.
리팩터링을 두려워 하지 말고, ‘검증’이 아닌 ‘파싱’을 통해 데이터의 안정성을 보장하자! 🥸