GN⁺: 검증이 아닌 Parse 기술 (2019)
(lexi-lambda.github.io)파싱, 검증하지 말라
타입 주도 설계의 본질
- 타입 주도 설계(type-driven design)를 설명하는 간단한 슬로건: 파싱, 검증하지 말라
- 이 슬로건은 타입 시스템을 활용하여 코드의 안전성과 정확성을 높이는 방법을 의미함
가능성의 영역
- 정적 타입 시스템은 특정 함수가 구현 가능한지 여부를 쉽게 판단할 수 있게 해줌
- 예시:
foo :: Integer -> Void
는 구현 불가능함 (Void
타입은 값을 가질 수 없음) - 예시:
head :: [a] -> a
함수는 리스트가 비어 있을 경우 정의되지 않음
부분 함수를 전체 함수로 바꾸기
기대 관리
-
head
함수는 리스트가 비어 있을 때 값을 반환할 수 없으므로Maybe
타입을 사용하여Nothing
을 반환할 수 있게 함 - 그러나 이는 사용 시 불편함을 초래할 수 있음
기대를 전달하기
-
NonEmpty
타입을 사용하여 비어 있지 않은 리스트를 표현함으로써head
함수가 항상 값을 반환하도록 보장할 수 있음 -
NonEmpty
타입을 사용하면 불필요한 체크를 제거하고, 타입 시스템을 통해 오류를 컴파일 타임에 잡을 수 있음
파싱의 힘
- 파싱과 검증의 차이는 정보를 어떻게 보존하느냐에 있음
-
validateNonEmpty
함수는 리스트가 비어 있지 않음을 검증하지만, 정보를 보존하지 않음 -
parseNonEmpty
함수는 리스트가 비어 있지 않음을 검증하고,NonEmpty
타입으로 정보를 보존함
검증의 위험성
- 검증 기반 접근 방식은 "shotgun parsing"이라는 문제를 초래할 수 있음
- 이는 프로그램이 입력의 일부를 처리한 후 나머지 입력이 유효하지 않음을 발견하는 상황을 초래할 수 있음
- 파싱은 프로그램을 두 단계로 나누어, 첫 번째 단계에서 입력의 유효성을 확인하고, 두 번째 단계에서 유효한 입력만을 처리하도록 함
실전에서의 파싱
- 데이터 타입에 집중하여 함수의 타입 시그니처를 가능한 한 구체적으로 만듦
- 불법 상태를 표현할 수 없는 데이터 구조를 사용하고, 가능한 한 빨리 데이터를 구체적인 표현으로 변환함
- 데이터 타입이 코드를 안내하도록 하고, 코드가 데이터 타입을 제어하지 않도록 함
-
m ()
를 반환하는 함수는 신중하게 사용해야 함 - 여러 번에 걸쳐 데이터를 파싱하는 것을 두려워하지 말아야 함
- 데이터의 비정규화된 표현을 피하고, 필요한 경우 캡슐화를 통해 관리해야 함
- 검증기를 파서처럼 보이게 만드는 추상 데이터 타입을 사용해야 함
요약, 반성, 관련 읽을거리
- Haskell의 타입 시스템을 최대한 활용하는 것은 어렵지 않으며, 최신 언어 확장을 사용할 필요도 없음
- 핵심 아이디어는 "전체 함수 작성"이며, 이는 간단하지만 실천하기 어려울 수 있음
- 관련 읽을거리로는 Matt Parson의 블로그 포스트 "Type Safety Back and Forth"와 Matt Noonan의 논문 "Ghosts of Departed Proofs"를 추천함
GN⁺의 정리
- 이 글은 Haskell의 타입 시스템을 활용하여 코드의 안전성과 정확성을 높이는 방법을 설명함
- 파싱과 검증의 차이를 이해하고, 파싱을 통해 입력의 유효성을 확인하는 것이 중요함을 강조함
- 타입 시스템을 활용하여 불법 상태를 표현할 수 없는 데이터 구조를 사용하고, 가능한 한 빨리 데이터를 구체적인 표현으로 변환하는 것이 중요함
- 관련 읽을거리로는 Matt Parson의 블로그 포스트와 Matt Noonan의 논문을 추천함
Hacker News 의견
-
이 조언과 기사는 매우 유익함
-
정적으로 타입이 지정된 함수형 언어를 사용하지 않는 사람들에게도 유용함
-
이 아이디어는 패러다임을 초월함
-
80~90년대 객체지향 문헌에서도 유사한 개념을 찾을 수 있음, 예를 들어 Design by Contract
-
TypeScript는 런타임에 타입을 세분화하는 방식으로 작성되는 경우가 많음
-
Design by Contract는 Clojure의 spec에 영향을 미쳤을 것임 (Clojure는 동적 언어임)
-
기본적으로 이는 가정과 보장에 관한 것임 (요구와 제공)
-
가정이 확인되고 보장이 이루어지면 프로그램의 다른 부분에서 중복된 가정을 다시 확인할 필요가 없음
-
코드에서 이미 보장된 속성이 다시 확인되는 것을 보면 혼란스러울 수 있음, 이는 코드 이해와 개선을 어렵게 만듦
-
이 패턴은 현대 C#에서도 잘 작동하며 공간 절약 효과도 있음
- 예시 코드:
if(!Whatever.TryParse<Thingy>(input, out var output)) output = some-sane-default;
- 예시 코드:
if(!Whatever.TryParse<Thingy>(input, out var output)) throw new ApplicationException($"Not a valid Thingy: {input}");
- 커널 모드 드라이버에서는 후자를 사용하지 말 것을 권장함
- 예시 코드:
-
강력한 타입 시스템을 활용하여 오류 사례를 표현할 수 없게 만드는 것이 좋음, 이는 소프트웨어 버그를 줄이는 데 도움이 됨
-
문제를 생각하고 디자인을 따르는 데 시간이 더 걸리지만, 많은 경우 그 시간이 가치가 있음
-
"Parse, don’t validate"라는 슬로건이 타입 기반 설계를 잘 요약함
-
개인적으로는 "항상 단일 생성자에서만 유효성 검사를 수행"하는 것이 좋음, 이렇게 하면 유효하지 않은 객체가 전혀 존재하지 않게 됨
-
객체를 수정하려면 동일한 생성자를 다시 호출하여 새 상태를 구성하는 방식으로 구현해야 함
-
qmail의 섹션 5가 떠오름, 여기에는 "파싱하지 말라"와 "좋은 인터페이스와 사용자 인터페이스가 있다"는 내용이 포함됨
-
중간 규모의 프로그래밍 수업을 가르친다면 학생들에게 이 제안을 비교하고 대조하는 에세이를 작성하게 할 것임, 각 제안은 배울 점이 있으며 처음에는 모순처럼 보일 수 있음
-
관련 자료: Richard Feldman의 "Making Impossible States Impossible"
-
이전 논의:
-
Crowdstrike에 전달됨
-
2000년대 중반 XML 열풍 때 누군가의 댓글이 떠오름, 많은 조직이 XML을 선택한 이유는 XML이 파서를 제공하기 때문임
-
파서를 작성하는 것이 어렵지 않고 재미있음에도 불구하고, 사람들이 파서를 작성하지 않으려는 이유를 이해할 수 없음
-
Protocol Buffers의 "required" 키워드가 큰 실수였다는 의견과 반대되는 것인지 궁금함
-
유연하고 검증되지 않은 파싱과 검증된 파싱 기능을 모두 갖추는 것이 최선일 것임