Parse, Don't Validate
검증은 사실을 버리고, 파싱은 사실을 타입에 새긴다
- Haram Lee
- 2026-09-04
- studies / Topics
입력을 확인하는 코드는 어디에나 있다. 비어 있으면 안 되는 리스트, 정해진 값 중 하나여야 하는 문자열, 범위 안에 있어야 하는 숫자. 그런데 같은 확인이라도 확인한 뒤에 무엇이 남는지는 코드마다 다르다.
Alexis King의 에세이 제목이 이 차이를 한 문장으로 요약한다. 검증하지 말고 파싱하라. 검증은 사실을 확인하고 버리지만, 파싱은 확인한 사실을 타입에 새겨서 이후의 코드가 다시 의심할 필요를 없앤다.
이 글은 에세이의 핵심 개념을 정리하고, 개념만으로는 잘 보이지 않는 질문 하나를 더 다룬다. 파서를 만드는 것으로 끝나지 않는다. 그 파서가 어디에 서 있느냐에 따라 장애의 크기가 달라진다.
부분 함수에서 시작한다
에세이의 출발점은 리스트의 첫 원소를 꺼내는 함수다.
def head[A](xs: List[A]): A // 빈 리스트면 예외이 함수는 모든 입력에 답하지 못하는 부분 함수(partial function)다. 대응하는 방법은 세 가지가 있다.
- 호출 전에 검사한다.
if xs.nonEmpty then xs.head else …처럼 쓰면 동작은 한다. 하지만 검사 결과가 어디에도 기록되지 않는다. 같은 리스트를 다른 곳에서 쓰는 코드는 이미 검사했는지 알 길이 없어서, 다시 검사하거나 그냥 믿는다. - 실패를 값으로 돌려준다.
headOption: List[A] => Option[A]는 정직하다. 대신 호출자마다None처리를 반복한다. - 빈 리스트가 아예 못 들어오게 한다.
NonEmptyList[A]를 받으면head: NonEmptyList[A] => A는 전함수(total function)가 된다. 실패 처리가 사라진 것이 아니라 더 앞 단계로 옮겨진 것이다.
3번을 가능하게 하는 함수가 파서다.
def parseNonEmpty[A](xs: List[A]): Option[NonEmptyList[A]]시그니처가 차이를 말해 준다
검증과 파싱의 차이는 반환 타입에 있다.
def validate[A](xs: List[A]): Unit // 통과해도 남는 것이 없다
def parse[A](xs: List[A]): Option[NonEmptyList[A]] // 통과하면 "비어 있지 않음"이 타입에 남는다둘은 같은 것을 확인한다. 차이는 확인한 뒤다. 검증은 지식을 버리고, 파싱은 지식을 보존한다. NonEmptyList[A] 값을 손에 쥔 코드는 컴파일러가 비어 있지 않음을 보증하므로 어디서든 다시 검사할 필요가 없다.
에세이의 정의로 말하면, 파서는 덜 구조화된 입력을 받아 더 구조화된 출력을 내는 함수다. 검증은 구조를 더하지 않는 확인이다.
Option, Either, sum type은 실패를 값으로 만든다. 파싱은 거기서 한 걸음 더 나아가 성공의 증거까지 값으로 만든다. “이 문자열은 검사를 통과했다"가 아니라 “이 값은 Port 타입이다"가 되는 것이다.
잘못된 상태를 표현할 수 없게
에세이에 나오는 격언 하나가 설계 원칙을 요약한다.
Use a data structure that makes illegal states unrepresentable.
비어 있으면 안 되는 리스트는 주석이나 검사가 아니라 NonEmptyList라는 타입으로 말한다. 네 값 중 하나여야 하는 문자열은 String에 검사를 붙이지 않고 enum으로 만든다. 타입이 좁을수록 그 타입의 값이 존재한다는 사실 자체가 증명이 된다.
같은 원칙은 설정 DSL을 설계할 때도 그대로 적용된다. 자유 문자열을 받는 필드는 어떤 값이든 일단 받아 두고, 오타가 있으면 실제로 그 값을 쓰는 실행 시점에야 터진다. 받을 수 있는 값이 정해져 있다면 처음부터 그 값만 표현할 수 있는 타입으로 받는 편이 낫다. 검증할 수 없는 입력은 처음부터 받지 않는다.
증명의 부담은 위로 밀어 올린다
에세이는 같은 원칙의 뒷면도 말한다.
Push the burden of proof upward as far as possible, but no further.
파싱은 데이터가 시스템에 들어오는 가장 이른 지점에서 한 번 일어나야 한다. 더 늦으면 잘못된 데이터가 시스템 깊숙이 들어와 있다가 엉뚱한 곳에서 터진다. 문제의 원인(입력)과 증상(터진 지점)이 멀어질수록 원인을 찾기 어렵고 피해는 넓어진다.
// 경계에서 한 번 파싱한다
def parseConfig(raw: Json): Either[ConfigError, Config]
// 내부는 이미 파싱된 타입만 받는다
def schedule(config: Config): Planschedule은 Json을 받지 않는다. Config를 손에 쥐었다는 것은 이미 경계를 통과했다는 뜻이므로, 이 함수 안에서는 형식 오류를 걱정할 필요가 없다.
반대말은 shotgun parsing이다
검사를 경계에서 한 번 하지 않으면 자연히 그 반대가 된다. 검사가 비즈니스 로직 곳곳에 흩어지는 것을 language-theoretic security 쪽에서는 shotgun parsing이라고 부른다.
흩어진 검사는 세 가지 문제를 낳는다.
- 어떤 코드가 어떤 검사 뒤에 있는지 아무도 모른다.
- 같은 검사가 여러 곳에 중복된다.
- 일부 경로는 검사 없이 지나간다.
근본 원인은 타입이다. 검사되지 않은 입력이 검사된 입력과 같은 타입(String, Map)으로 흘러 다니기 때문에 컴파일러도 둘을 구분해 주지 못한다.
파서가 잘못된 문에 서 있으면
여기까지는 에세이의 내용이다. 실제 시스템에서는 질문이 하나 더 생긴다. 파서와 좁은 타입을 잘 만들어 두었는데도 문제가 생긴다면, 그 파서는 어디에 서 있었을까?
가상의 설정 관리 서비스를 하나 생각해 보자.
- 사용자는 HTTP API로 설정을 저장한다. 서버는 받은 JSON을 그대로 DB에 넣는다.
- 다른 시스템들은 내보내기 API로 전체 설정 목록을 받아 자기 상태를 갱신한다. 이 API는 저장된 JSON을 하나씩 꺼내 설정 모델로 파싱한 뒤 스트리밍 응답으로 흘려보낸다.
- 설정 모델의 어떤 필드는 enum이라 허용 값이 네 개뿐이다.
누군가 이 enum 필드에 허용 범위 밖의 값을 저장한다. 그 뒤에 일어나는 일은 이렇다.
- 저장은 통과한다. 저장 API는 모델로 파싱하지 않으므로 어떤 값이든 받아 준다.
- 내보내기에서 죽는다. 내보내기 API가 그 설정을 모델로 되살리는 순간 파싱이 실패한다.
- 실패가 넓게 번진다. 스트림 중간에 실패했으므로 그 뒤의 설정들이 통째로 빠지고, 목록을 받아 가던 시스템들이 한꺼번에 갱신을 멈춘다.
파서는 있었다. enum도 있었다. 잘못된 상태는 표현할 수 없게 되어 있었다. 그런데 파서가 데이터가 들어오는 문이 아니라 나가는 문에 서 있었다. 증명의 부담을 위로 밀어 올리라는 원칙을 어긴 것이고, 저장과 내보내기 사이의 시간만큼 원인과 증상이 벌어졌다.
설정 하나가 전체의 단일 장애점이 되는 구조이기도 하다. 검증 환경과 운영 환경이 같은 내보내기 경로를 공유한다면, 두 환경을 나누는 지점이 실패 지점보다 뒤에 있으므로 두 환경이 같이 멈춘다.
스트림 중간에는 에러가 없다
이 사례에서 피해가 유난히 커지는 이유는 HTTP 스트리밍의 실패 의미론이다.
상태 코드와 헤더는 본문보다 먼저 나간다. 스트리밍 응답이 200 OK를 보내고 본문을 흘리기 시작한 뒤에 중간에서 실패하면, 프로토콜에는 “지금부터 실패"를 표현할 방법이 사실상 없다. 서버가 할 수 있는 일은 연결을 끊는 것뿐이고, 클라이언트에는 불완전한 본문과 저수준 예외만 남는다.
ChunkedEncodingError: ("Connection broken: InvalidChunkLength(got length b'', 0 bytes read)")일반 응답이었다면 500 하나로 끝났을 일이, 스트리밍에서는 “몇 번째 줄부터 조용히 없음"이 된다. 그래서 스트리밍 경계로 나가는 데이터는 그 전에 파싱이 끝나 있어야 한다. 파싱 실패를 정상적으로 알릴 수 있는 마지막 지점은 응답 헤더를 쓰기 전이다.
닫힌 타입에는 대가가 있다
이 사례에는 반대 방향의 교훈도 하나 있다.
enum이 네 값으로 닫혀 있었기 때문에, 그 밖의 정당한 요구는 표현할 수 없었다. 그리고 표현할 수 없다는 사실이 우회 시도, 즉 허용 범위 밖의 값을 일단 저장해 보는 시도를 불렀다.
잘못된 상태를 표현할 수 없게 만들면, 필요한 상태도 타입을 고쳐야만 표현할 수 있게 된다. 이것은 결함이 아니라 맞바꿈이다. 닫힌 타입은 표현력을 내주고 보장을 얻는다. 내준 표현력이 아깝다면 타입을 넓히도록 고쳐야 하고, 계약 밖의 값을 밀어 넣어서는 안 된다.
파싱을 앞 경계로 옮기기
구조적인 해법은 분명하다. 저장 API가 내보내기 API와 같은 모델로 파싱하고, 실패하면 저장을 거부하면 된다. 그러면 잘못된 값은 저장 요청의 422 응답으로 끝나고, 원인과 증상이 같은 요청 안에 있게 된다.
서버를 바로 고칠 수 없다면 저장하는 쪽에서 같은 일을 대신할 수 있다.
- 보내기 전에 같은 모델로 로컬에서 파싱해 본다. 읽기 경계의 파서를 저장 전에 미리 돌리는 것이다.
- 서버에 배포된 스키마가 그 필드를 아는지 확인한다. 클라이언트와 서버의 스키마 버전 차이도 계약의 일부다.
- 저장한 직후 내보내기 경로를 끝까지 받아 본다. 실제 경계에서 파싱이 성공하는지 확인하는 마지막 단계다.
세 단계 모두 같은 일을 한다. 파싱을 데이터가 들어오는 문 쪽으로 옮기는 것이다.
정리
1. 검증은 확인하고 버린다. 파싱은 확인한 사실을 타입에 남긴다.
2. 잘못된 상태는 표현할 수 없는 타입으로 막는다.
3. 파싱은 데이터가 들어오는 가장 이른 경계에서 한 번 한다.
4. 스트리밍처럼 실패를 표현할 수 없는 경계 앞에서는 반드시 파싱이 끝나 있어야 한다.파서를 만드는 것만으로는 충분하지 않다. 코드 리뷰에서 “검증이 있나?“와 함께 “그 검증은 어디에 서 있나?“를 물어야 한다. 검증은 사실을 버리고, 파싱은 사실을 타입에 새긴다. 그리고 파서는 데이터가 들어오는 문에 세운다.