검증이 아닌 파싱 기술 (2019)
(lexi-lambda.github.io)- 타입 주도 설계에서는 입력을 확인만 하고 버리는 검증보다, 확인 결과를 더 정밀한 타입으로 남기는 파싱이 이후 코드의 안전성을 높임
head:: [a] -> a처럼 일부 입력에서 실패하는 함수는 반환 타입을 약화할 수도 있지만, 호출자가 불필요한 실패 분기를 계속 떠안게 됨NonEmpty a는 빈 리스트가 아닌 상태를 타입에 보존해, 중복 검사와 “절대 일어나지 않을” 오류 처리를 줄여줌- 처리 코드 곳곳에 임시 검증을 섞으면 shotgun parsing이 되어, 일부 상태 변경 뒤에야 입력 오류를 발견할 수 있음
- 실무에서는 함수 시그니처에 원하는 데이터 표현을 먼저 담고,
Map, 추상 타입, 스마트 생성자 등으로 불변 조건을 타입 경계에 올려야 함
타입 주도 설계의 출발점
- “Parse, don’t validate”는 타입 주도 설계를 세 단어로 압축한 문구임
- 정적 타입 시스템은 “이 함수를 작성할 수 있는가”라는 질문을 코드 작성 전에 드러내 줌
- Haskell 예시에서
foo :: Integer -> Void는Void에 값이 없기 때문에 실제 값을 만들 수 없음 head :: [a] -> a도 빈 리스트[]가 들어올 수 있어 모든 입력에 대해 정의되지 않음- GHC는 패턴 매칭이
[]를 처리하지 않는다고 경고함 - 가능한 모든 입력에 대해 정의되지 않은 부분 함수임
- GHC는 패턴 매칭이
부분 함수를 전체 함수로 바꾸는 두 방법
-
반환 타입을 약화하기
head :: [a] -> Maybe a로 바꾸면 빈 리스트에서Nothing을 반환할 수 있어 전체 함수가 됨- 구현은 쉬워지지만 호출자는 항상
Nothing가능성을 처리해야 함 CONFIG_DIRS환경 변수를 읽어 리스트가 비어 있지 않은지 이미 확인한 뒤에도,main에서head결과의Nothing분기를 다시 처리해야 하는 예시가 나옴- 중복 검사는 코드를 지저분하게 만들고, 복잡한 경우 성능 비용으로도 누적될 수 있음
- 앞단 검사가 제거되어도 뒤쪽의 “절대 일어나지 않을” 오류가 타입으로 드러나지 않음
- 결국 타입 시스템에 구멍이 생기고, 버그 발견을 테스트나 수동 검토에 의존하게 됨
-
인자 타입을 강화하기
- 반환 타입을 약화하지 않고 인자 타입을 강화하면
head가 빈 리스트에 호출될 가능성을 제거할 수 있음 Data.List.NonEmpty의NonEmpty a는 비어 있지 않은 리스트를 표현함- 정의는
data NonEmpty a = a :| [a] - 첫 원소
a와 나머지 리스트[a]를 분리해, 꼬리가 비어 있어도 첫 원소는 항상 존재함 head :: NonEmpty a -> a는 패턴 하나로 구현되며 전체 함수가 됨getConfigurationDirectories :: IO (NonEmpty FilePath)처럼 반환 타입을 바꾸면, 비어 있지 않다는 사실이 타입에 보존됨nonEmpty :: [a] -> Maybe (NonEmpty a)가 일반 리스트를NonEmpty로 바꿈Nothing처리는 입력 경계에서 한 번만 수행됨main에서는initializeCache (head configDirs)처럼 중복 분기 없이 사용할 수 있음- 이후
getConfigurationDirectories가 비어 있지 않음을 보장하지 않도록 바뀌면 반환 타입도 바뀌어야 하며,main은 타입 검사에 실패함
- 반환 타입을 약화하지 않고 인자 타입을 강화하면
검증과 파싱의 차이
validateNonEmpty :: [a] -> IO ()와parseNonEmpty :: [a] -> IO (NonEmpty a)는 둘 다 빈 리스트를 확인하고 실패 시 오류를 냄- 차이는 반환 타입에 있음
validateNonEmpty는 정보가 없는()를 반환해 검사 결과를 버림parseNonEmpty는NonEmpty a를 반환해 검사로 얻은 지식을 타입 시스템에 남김
- 파서는 덜 구조화된 입력을 소비해 더 구조화된 출력을 만드는 함수로 볼 수 있음
- 이 정의에서
parseNonEmpty는 리스트를 비어 있지 않은 리스트로 파싱하는 단순한 파서임 - 파싱은 프로그램과 외부 세계의 경계에서 검사를 먼저 끝내고, 이후 같은 검사를 반복하지 않게 해줌
Haskell 생태계의 파싱 경계
- Haskell 애플리케이션은 외부 세계와 만나는 지점에서 여러 종류의 파서를 사용함
- aeson: JSON 데이터를 도메인 타입으로 파싱하는
Parser타입 제공 - optparse-applicative: 명령줄 인자 파서 조합자 제공
- persistent, postgresql-simple: 외부 데이터 저장소의 값을 파싱하는 메커니즘 제공
- servant: 경로 구성요소, 쿼리 파라미터, HTTP 헤더 등에서 Haskell 데이터 타입을 파싱함
- aeson: JSON 데이터를 도메인 타입으로 파싱하는
- 외부 세계는 곱 타입과 합 타입이 아니라 바이트 스트림으로 말하므로, 파싱은 피할 수 없음
- 데이터를 사용하기 전에 앞단에서 파싱하면 여러 종류의 버그를 피할 수 있고, 일부는 보안 취약점으로 이어질 수도 있음
- 모든 것을 앞에서 파싱하려면 실제 사용보다 훨씬 이른 시점에 값을 파싱해야 할 수 있음
- 정적 타입 시스템에서는 파싱 로직과 처리 로직이 어긋날 때 프로그램이 컴파일되지 않음
검증 중심 접근의 위험
- 임시 검증은 language-theoretic security 분야에서 말하는 shotgun parsing으로 이어질 수 있음
- 2016년 논문 The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge Them에서 shotgun parsing은 파싱과 입력 검증 코드가 처리 코드에 섞여 흩어지는 안티패턴임
- 입력을 앞에서 모두 파싱하지 않으면, 프로그램이 유효한 일부 입력을 처리한 뒤 다른 부분의 오류를 늦게 발견할 수 있음
- 이 경우 이미 실행한 상태 변경을 되돌려야 함
- RDBMS 트랜잭션처럼 롤백이 가능한 경우도 있지만, 일반적으로 항상 가능한 것은 아님
- 검증 기반 접근은 모든 검증이 정말 앞에서 끝났는지 확인하기 어렵거나 불가능하게 만듦
- 파싱은 프로그램을 파싱 단계와 실행 단계로 나누어, 잘못된 입력으로 인한 실패를 첫 단계에 한정함
실무에서 적용하는 방법
- 함수가 원하는 데이터 표현을 먼저 타입 시그니처에 적고, 현재 주어진 표현과의 차이를 메우는 방식으로 설계함
- 중복 키를 허용하면 안 되는
[(k, v)]리스트를 받는 함수라면, 별도의checkNoDuplicateKeys :: ... => [(k, v)] -> m ()검사는 쉽게 빠질 수 있음 - 더 나은 방식은 중복 키를 구조적으로 허용하지 않는
Map을 함수 인자로 받는 것임- 호출 지점은 타입 검사에 실패할 수 있음
- 호출 체인을 따라 리스트를
Map으로 바꾸는 작업을 위로 밀어 올림 - 값이 생성되는 위치나 중복이 실제로 허용되어야 하는 위치에 도달하면
[(k, v)] -> m (Map k v)형태의 검사를 넣음
- 이때 검사의 결과가 이후 실행에 필요하므로 검사를 생략할 수 없음
- 두 원칙이 반복됨
- 불가능한 상태를 표현할 수 없게 하는 데이터 구조를 사용함
- 증명의 부담을 가능한 한 위로 올리되, 필요한 지점보다 더 멀리 밀어붙이지 않음
추가 설계 지침과 한계
- 데이터 타입이 코드를 이끌게 하고, 현재 작성 중인 함수 때문에 레코드에 단순히
Bool을 넣는 유혹을 피해야 함 m ()를 반환하는 함수는 의심해서 볼 필요가 있음- 명령형 효과만 수행하고 의미 있는 결과가 없을 때는 필요할 수 있음
- 주 목적이 오류를 발생시키는 것이라면 더 나은 방식이 있을 가능성이 큼
- 데이터를 여러 번에 나눠 파싱하는 것을 두려워할 필요는 없음
- shotgun parsing을 피한다는 말은 완전히 파싱하기 전에 입력 데이터에 대해 행동하지 말라는 뜻임
- 일부 입력으로 다른 입력을 어떻게 파싱할지 결정하는 것은 가능함
- 비정규화된 데이터 표현은 특히 변경 가능할 때 피해야 함
- 같은 데이터를 여러 곳에 복제하면 서로 어긋난 상태가 쉽게 표현됨
- 비정규화가 꼭 필요하면 추상화 경계 뒤에 숨기고, 작은 신뢰 모듈만 동기화 책임을 갖게 해야 함
- Haskell 도구만으로 어떤 불변 조건을 진짜로 표현하기 어려울 때는 추상
newtype과 스마트 생성자로 검증기를 파서처럼 만들 수 있음 - 모든
error "impossible"을 없애기 위해 singletons를 도입하고 애플리케이션 전체를 리팩터링할 필요는 없지만, 그런 경우에는 불변 조건을 주석으로 남기는 등 신중히 다뤄야 함
더 읽을 거리와 현실적 주의점
- Haskell 타입 시스템을 잘 활용하는 데 PhD나 최신 GHC 언어 확장이 반드시 필요한 것은 아님
- 출발점은 “전체 함수를 작성하라”는 단순한 원칙에 가깝지만, 실제 코드에 적용하는 과정은 쉽지 않을 수 있음
- Haskell 커뮤니티가 작아 설계 패턴과 기법이 문서보다 구전 지식으로 남는 경우가 있음
- 관련 자료로 Matt Parson의 Type Safety Back and Forth가 있음
- 더 고급 주제로 Matt Noonan의 2018년 논문 Ghosts of Departed Proofs가 더 복잡한 불변 조건을 타입 시스템에 담는 기법을 다룸
- 실제 프로그램에서는 특정 불변 조건을 타입 시스템에 담기 어려울 수 있으며, 이 원칙들은 엄격한 요구사항보다 지향할 이상에 가까움
댓글과 토론
Hacker News 의견들
-
아주 좋은 조언이고 훌륭한 글임. 이 사이트에서 가끔 다시 올라오는 이유가 있음
정적 타입 함수형 언어를 쓰지 않는 사람에게도 이 아이디어는 패러다임을 넘어섬. 80~90년대 객체지향 문헌, 예를 들어 계약에 의한 설계(Design by Contract)에서도 매우 비슷한 개념을 볼 수 있고, 더 거슬러 올라가는 논문·토론·명세도 찾을 수 있을 것임
TypeScript도 런타임에 타입을 좁혀 가는 식으로 자주 작성된다고 봄. 계약에 의한 설계는 동적 언어인 Clojure의 spec에도 영향을 줬을 것 같음
근본적으로는 가정과 보장의 문제임. 어떤 가정을 확인해서 보장을 만들 수 있으면, 프로그램의 다른 부분이 같은 가정을 다시 확인할 필요가 없어짐
코드를 읽을 때 이미 보장된 속성을 다른 곳에서 또 검사하는 모습을 보면 가장 혼란스럽다. 추론과 개선이 더 어려워짐- 그 “이미 보장된 속성”이 어느 순간 사라질 수 있음. 더 정확히는 그 보장을 구현하고 실행하는 절차가 어떤 이유로든 더 이상 제 역할을 하지 않을 수 있음
통계적으로 그런 일은 결국 일어나고, 그때 “원래” 검증 절차에 의존하던 다른 프로세스·스크립트·코드는 매우 곤란해짐 - 강한 타입 시스템을 가진 언어에서는 이게 프로그램이 커지고 복잡해질수록 결국 자유를 주는 실용적 장점 중 하나가 됨
다만 실제로 써야 함. 예를 들어UncheckedEmail,ValidEmail,VerifiedEmail클래스를 두고, 한 단계에서 다음 단계로 바뀌려면 반드시 이메일 검증 과정을 거치게 만드는 식임
그러면 이메일 주소가 미확인인지, 형식상 유효한지, 검증 완료인지 추측할 필요가 없고, 업데이트하거나 확인하는 걸 잊을 수 있는is_email_verified같은 불리언도 필요 없어짐. 잘못된 곳에 잘못된 값을 쓰면 타입 검사기가 소리쳐 주고, 사람은 중요한 일에 집중할 수 있음 - 예전 글의 댓글들을 훑어보니 이 글의 가장 큰 문제 중 하나는 제목인 것 같음. 제목이 닻처럼 작용해서, 본문에는 없고 제목만 맥락 없이 암시하는 내용에 반박하는 사람이 많아짐
그래서 글쓴이가 검증을 전혀 하지 말고 파싱만 하자는 것처럼 받아들이는 경우가 있는데, 실제 글은 데이터를 어디서 검증하고 그 결과로 무엇을 하느냐에 관한 것임. 모든 검증을 없애자는 글이 아님
- 그 “이미 보장된 속성”이 어느 순간 사라질 수 있음. 더 정확히는 그 보장을 구현하고 실행하는 절차가 어떤 이유로든 더 이상 제 역할을 하지 않을 수 있음
-
2019년 글이지만 여전히 꽤 괜찮은 조언임. 이 패턴은 현대 C#에서도 아주 잘 맞고, 명시적 변수 선언을 생략할 수 있어 공간도 절약됨
if(!Whatever.TryParse(input, out var output)) output = some-sane-default;
또는
if(!Whatever.TryParse(input, out var output)) throw new ApplicationException($"Not a valid Thingy: {input}");
전문가 팁: 후자는 커널 모드 드라이버에서는 하지 말 것- 전문가 팁: 둘 다 하지 말 것. 특히 첫 번째는 절대 하지 말 것
맞다고 생각한 값이 틀렸을 때 대신 쓰이는 암묵적 기본값보다 명시적인 처리가 항상 낫다
해야 할 일은 초기에 손을 들고 파싱 실패로 처리한 뒤, 로드할 수 없는 파일을 다루는 과정과 프로토콜을 아주 명확히 정의하는 것임. 그러면 위 두 선택지가 다루지 않는 어려운 질문들을 스스로 하게 됨
최근 CrowdStrike 커널 모드 드라이버가 어떤 def/config 파일 파싱에 실패한 진짜 문제는 개발자·제품 책임자·비즈니스 분석가가 “유효하지 않은 파일을 로드하려 하면 어떻게 되는가?”를 묻지 않았다는 데 있음 - “꽤 괜찮은” 정도인 이유가 뭔가? 그리고 글이 공개된 연도와는 어떤 관련이 있음? 2019년보다 더 일찍 공개됐으면 글의 조언이 더 권위 있었을 거라는 뜻인가?
- 첫 번째 방식은 하지 않았으면 함. 나쁜 경우를 처리해야 함. “합리적인 기본값”으로 되돌아가는 처리는 극히 드물어야 함
명시적 처리 > 암묵적 처리 if(!Whatever.TryParse(input, out var output)) output = some-sane-default;
이 방식은 정말 싫음. 유효하지 않은 입력 오류는 파싱 함수 밖에서 처리해야 한다고 봄. F#에서는 그게 쉬움
type Whatever =
static member create input =
match input with
| ValidWhatever x -> Some x
| _ -> None
match Whatever.create input with
| Some x -> // 파싱된 데이터 처리
| None -> // 제대로 파싱되지 않은 경우 처리
또는Option.map/Option.bind로 연쇄 작업을 처리하는 파이프라인을 더 편하게 만들 수도 있음
이렇게 하면 입력을 파싱하는create메서드를 통해서만 인스턴스를 만들 수 있음
다만 실제로는option보다result를 쓰고 싶을 가능성이 크지만, 그건 부차적인 얘기임if(!Whatever.TryParse(input, out var output)) output = some-sane-default;같은 코드를 보고 싶은 상황은 거의, 아마 전혀 떠오르지 않음
입력이 아예 제공되지 않은 경우, 즉 매개변수가 선택적이라면 합리적인 기본값을 쓰는 게 말이 됨
하지만 잘못된 입력이 제공됐는데 아무 문제 없는 척하지는 않았으면 함
누군가 꽃집에 들어와 커피를 달라고 했을 때 올바른 답은 장미를 건네는 게 아님. 그 사람이 그걸 마시려 하면 입이 다 찢어질 것임
그 입력 집합에 대해 메서드·모듈·프로그램은 정의된 출력을 갖고 있지 않음. 조용히 틀리거나 불명확한 일을 해서 프로그램을 빠르게 추론 불가능하게 만들기보다, 그 사실을 분명히 드러내야 함. 몇 달 뒤 이상 동작 버그로 잡히게 놔두지 말고, 문제를 명확히 발생시켜 문제 지점으로 바로 이어지는 스택 추적을 남기는 편이 자신에게도 좋음
- 전문가 팁: 둘 다 하지 말 것. 특히 첫 번째는 절대 하지 말 것
-
강한 타입 시스템을 활용해 오류 상태를 표현 불가능하게 만들라는 조언임. 소프트웨어 전반에서 버그를 줄이는 데 매우 좋음
문제를 더 깊이 생각하고 이런 설계를 하려면 시간이 더 들지만, 많은 경우 그 시간은 충분히 가치 있음- 대수적 자료형을 지원하는 언어라면 이 방식이 시간이 더 걸리지 않는다고 과감히 말하겠음. 그냥 자연스럽게 그렇게 됨
물론 C++, Java, C#, Python, Go, JavaScript처럼 데이터를 모델링하는 데 의식적 절차가 많이 필요한 언어라면 시간이 더 걸림
- 대수적 자료형을 지원하는 언어라면 이 방식이 시간이 더 걸리지 않는다고 과감히 말하겠음. 그냥 자연스럽게 그렇게 됨
-
“이제 타입 주도 설계가 내게 무엇을 뜻하는지 담은 짧고 강한 표어가 생겼고, 더 좋은 건 세 단어뿐이라는 점이다: Parse, don’t validate.”
내 표어는 오히려 항상 단일 생성자에서만 검증하라에 가까움. 생성자 함수여도 상관없음
그렇게 하면 유효하지 않은 객체가 애초에 존재할 수 없고, 언제나 단일 진실 공급원이 생김. 객체를 수정하고 싶다면 같은 생성자를 다시 호출해 새 상태를 만드는 방식으로 구현하면 됨- 같은 얘기는 아님
핵심은 검증만 하면 그 정보가 나중에 사라진다는 데 있음
예를 들어 어떤int가 양수인지 검증하는 것만으로는 이점이 제한적임. 그 값을 양의 정수로 파싱하지 않으면 이후 타입 수준에는 그런 정보가 남지 않기 때문임. 비어 있지 않은 배열·리스트도 마찬가지라서, 뒤의 소비자가 그 리스트가 정말 비어 있지 않은지 다시 확인해야 할 수 있음
이런 종류의 정보가 항상 객체나 생성자에 인코딩될 수 있는 것은 아님
- 같은 얘기는 아님
-
관련 자료: Richard Feldman의 Making Impossible States Impossible
https://www.youtube.com/watch?v=IcgmSRJHu_8 -
이전에도 좋은 토론들이 있었음
https://news.ycombinator.com/item?id=35053118
https://news.ycombinator.com/item?id=21476261 -
이 주제가 나올 때마다 https://cr.yp.to/qmail/guarantee.html의 5절이 떠오름. 거기에는 “파싱하지 말라”와 “컴퓨팅 세계의 명령 인터페이스에는 두 종류가 있다: 좋은 인터페이스와 사용자 인터페이스” 같은 문장이 있음
작은 규모나 큰 규모가 아니라 중간 규모의 프로그래밍을 가르치는 수업을 한다면, 학생들에게 이 제안들을 비교·대조하는 에세이를 과제로 내고 싶음. 각각 배울 점이 있고, 처음 보기만큼 모순적이지 않을 수도 있음 -
2000년대 중반 XML 유행 때 봤던 댓글이 떠오름. 많은 조직이 설정 언어를 포함한 도메인 특화 언어를 XML로 구현한 이유는, XML이 파서를 제공해 주고 대부분의 조직은 자기 파서를 직접 쓰고 싶어 하지 않았기 때문일 거라는 내용이었음
사람들이 왜 파서를 쓰기 싫어했는지는 모르겠음. 파서 작성은 그렇게 어렵지 않고 꽤 재미있음 -
경력 중 읽은 글 중 가장 좋아하는 글 중 하나임. 사람들이 제목만 읽고 파싱과 검증이 somehow 상호 배타적이라고 가정하는 경우가 많다는 걸 봤지만, 실제로는 그렇지 않음. 파싱은 종종 검증을 포함함
이 내용은 글의 “Use abstract datatypes to make validators ‘look like’ parsers” 부분에서 다뤄짐
원시 타입 집착을 피하자는 얘기와 같은 영역에 있음