3P by GN⁺ | ★ favorite | 댓글 1개
  • 타입 주도 설계에서는 입력을 확인만 하고 버리는 검증보다, 확인 결과를 더 정밀한 타입으로 남기는 파싱이 이후 코드의 안전성을 높임
  • head:: [a] -> a처럼 일부 입력에서 실패하는 함수는 반환 타입을 약화할 수도 있지만, 호출자가 불필요한 실패 분기를 계속 떠안게 됨
  • NonEmpty a는 빈 리스트가 아닌 상태를 타입에 보존해, 중복 검사와 “절대 일어나지 않을” 오류 처리를 줄여줌
  • 처리 코드 곳곳에 임시 검증을 섞으면 shotgun parsing이 되어, 일부 상태 변경 뒤에야 입력 오류를 발견할 수 있음
  • 실무에서는 함수 시그니처에 원하는 데이터 표현을 먼저 담고, Map, 추상 타입, 스마트 생성자 등으로 불변 조건을 타입 경계에 올려야 함

타입 주도 설계의 출발점

  • “Parse, don’t validate”는 타입 주도 설계를 세 단어로 압축한 문구임
  • 정적 타입 시스템은 “이 함수를 작성할 수 있는가”라는 질문을 코드 작성 전에 드러내 줌
  • Haskell 예시에서 foo :: Integer -> VoidVoid에 값이 없기 때문에 실제 값을 만들 수 없음
  • head :: [a] -> a도 빈 리스트 []가 들어올 수 있어 모든 입력에 대해 정의되지 않음
    • GHC는 패턴 매칭이 []를 처리하지 않는다고 경고함
    • 가능한 모든 입력에 대해 정의되지 않은 부분 함수

부분 함수를 전체 함수로 바꾸는 두 방법

  • 반환 타입을 약화하기

    • head :: [a] -> Maybe a로 바꾸면 빈 리스트에서 Nothing을 반환할 수 있어 전체 함수가 됨
    • 구현은 쉬워지지만 호출자는 항상 Nothing 가능성을 처리해야 함
    • CONFIG_DIRS 환경 변수를 읽어 리스트가 비어 있지 않은지 이미 확인한 뒤에도, main에서 head 결과의 Nothing 분기를 다시 처리해야 하는 예시가 나옴
    • 중복 검사는 코드를 지저분하게 만들고, 복잡한 경우 성능 비용으로도 누적될 수 있음
    • 앞단 검사가 제거되어도 뒤쪽의 “절대 일어나지 않을” 오류가 타입으로 드러나지 않음
    • 결국 타입 시스템에 구멍이 생기고, 버그 발견을 테스트나 수동 검토에 의존하게 됨
  • 인자 타입을 강화하기

    • 반환 타입을 약화하지 않고 인자 타입을 강화하면 head가 빈 리스트에 호출될 가능성을 제거할 수 있음
    • Data.List.NonEmptyNonEmpty 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는 정보가 없는 ()를 반환해 검사 결과를 버림
    • parseNonEmptyNonEmpty a를 반환해 검사로 얻은 지식을 타입 시스템에 남김
  • 파서는 덜 구조화된 입력을 소비해 더 구조화된 출력을 만드는 함수로 볼 수 있음
  • 이 정의에서 parseNonEmpty는 리스트를 비어 있지 않은 리스트로 파싱하는 단순한 파서임
  • 파싱은 프로그램과 외부 세계의 경계에서 검사를 먼저 끝내고, 이후 같은 검사를 반복하지 않게 해줌

Haskell 생태계의 파싱 경계

  • Haskell 애플리케이션은 외부 세계와 만나는 지점에서 여러 종류의 파서를 사용함
    • aeson: JSON 데이터를 도메인 타입으로 파싱하는 Parser 타입 제공
    • optparse-applicative: 명령줄 인자 파서 조합자 제공
    • persistent, postgresql-simple: 외부 데이터 저장소의 값을 파싱하는 메커니즘 제공
    • servant: 경로 구성요소, 쿼리 파라미터, HTTP 헤더 등에서 Haskell 데이터 타입을 파싱함
  • 외부 세계는 곱 타입과 합 타입이 아니라 바이트 스트림으로 말하므로, 파싱은 피할 수 없음
  • 데이터를 사용하기 전에 앞단에서 파싱하면 여러 종류의 버그를 피할 수 있고, 일부는 보안 취약점으로 이어질 수도 있음
  • 모든 것을 앞에서 파싱하려면 실제 사용보다 훨씬 이른 시점에 값을 파싱해야 할 수 있음
  • 정적 타입 시스템에서는 파싱 로직과 처리 로직이 어긋날 때 프로그램이 컴파일되지 않음

검증 중심 접근의 위험

  • 임시 검증은 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” 부분에서 다뤄짐
    원시 타입 집착을 피하자는 얘기와 같은 영역에 있음