정적 타입 시스템을 활용하는 타입 주도 설계의 핵심을 "파싱하고, 검증하지 마라"라는 표어로 설명한다. Haskell 예제를 통해 부분 함수를 총 함수로 바꾸고, 입력을 더 풍부한 타입으로 변환해 정보와 불변식을 타입에 보존하는 실무적 기법을 보여준다.
역사적으로, 나는 타입 주도 설계(type-driven design)가 무엇을 의미하는지 간결하고 쉬운 방식으로 설명하는 데 애를 먹어 왔다. 누군가가 “어떻게 이런 접근을 떠올렸나요?”라고 물을 때가 너무 자주 있었지만, 그럴 때마다 만족스러운 답을 해 주지 못하곤 했다. 이게 어느 날 계시처럼 떠오른 건 아니라는 건 안다—나는 올바른 접근을 허공에서 뽑아낼 필요가 없는, 점진적인(iterative) 설계 과정을 가지고 있다—그런데도 그 과정을 다른 사람에게 잘 전달하는 데에는 성공적이지 못했다.
하지만 약 한 달 전, 정적 타입 언어와 동적 타입 언어에서 JSON을 파싱하며 겪은 차이에 대해 트위터에 적다가, 마침내 내가 찾고 있던 것을 깨달았다. 이제 타입 주도 설계가 나에게 의미하는 바를 encapsulate하는 간결한 슬로건이 생겼고, 게다가 세 단어면 충분하다.
파싱하고, 검증하지 마라.
고백하자면: 이미 타입 주도 설계가 무엇인지 알고 있지 않다면, 내 멋진 슬로건은 별 의미가 없게 들릴지도 모른다. 다행히 이 글의 나머지가 바로 그걸 위해 존재한다. 내가 정확히 무슨 뜻으로 말하는지 샅샅이 설명할 것이다—하지만 그에 앞서, 잠깐의 ‘소망적 사고(wishful thinking)’ 연습이 필요하다.
정적 타입 시스템이 가진 멋진 점 중 하나는 “이 함수를 작성하는 게 가능한가?” 같은 질문에 답할 수 있게 해 준다는 것이다. 극단적인 예로, 다음과 같은 Haskell 타입 시그니처를 보자:
foo :: Integer -> Void
foo를 구현하는 게 가능한가? 답은 자명하게 _아니오_다. Void는 값을 하나도 포함하지 않는 타입이기 때문에, 어떤 함수도 Void 타입 값을 만들어낼 수 없다.1 이 예는 꽤 지루하지만, 좀 더 현실적인 예를 고르면 질문은 훨씬 흥미로워진다:
head :: [a] -> a
이 함수는 리스트의 첫 번째 원소를 반환한다. 구현 가능한가? 별로 복잡해 보이진 않지만, 구현을 시도해 보면 컴파일러가 만족하지 않는다:
head :: [a] -> a
head (x:_) = x
warning: [-Wincomplete-patterns]
Pattern match(es) are non-exhaustive
In an equation for ‘head’: Patterns not matched: []
이 메시지는 우리의 함수가 _부분적(partial)_임을 친절하게 지적한다. 즉, 가능한 모든 입력에 대해 정의되지 않았다는 뜻이다. 구체적으로는, 입력이 빈 리스트 []일 때 정의되지 않았다. 빈 리스트의 첫 번째 원소를 반환할 수 없다는 건 당연하다—반환할 원소가 없으니까! 놀랍게도, 우리는 이 함수 역시 구현이 불가능하다는 걸 알게 된다.
동적 타입 배경에서 온 사람에게는 이게 당혹스러울 수 있다. 리스트가 있다면 첫 번째 원소를 얻고 싶을 때가 충분히 있을 테니까. 실제로 Haskell에서 “리스트의 첫 번째 원소를 얻는” 작업이 불가능한 건 아니다. 다만 약간의 추가 의식(ceremony)이 필요할 뿐이다. head를 고치는 방법은 두 가지가 있는데, 더 단순한 것부터 시작해 보자.
이미 본 대로, head가 부분적인 이유는 리스트가 비었을 때 반환할 원소가 없기 때문이다. 우리는 도저히 지킬 수 없는 약속을 해 버린 셈이다. 다행히 이 딜레마에는 쉬운 해법이 있다. 약속을 _약화(weaken)_시키면 된다. 호출자에게 리스트의 원소를 보장할 수 없다면, 기대치 관리를 해야 한다. 가능한 경우에는 원소를 돌려주겠지만, 아무것도 돌려주지 않을 권리도 보유하겠다고 말이다. Haskell에서는 이 가능성을 Maybe 타입으로 표현한다:
head :: [a] -> Maybe a
이렇게 하면 구현에 필요한 자유를 얻는다—원소를 만들어낼 수 없음을 알게 되었을 때 Nothing을 반환하면 된다:
head :: [a] -> Maybe a
head (x:_) = Just x
head [] = Nothing
문제 해결, 맞을까? 당장은 그렇다… 하지만 이 해법에는 숨은 비용이 있다.
Maybe를 반환하는 건 head를 _구현할 때_에는 틀림없이 편리하다. 그러나 실제로 사용하려고 하면 훨씬 덜 편리해진다! head는 항상 Nothing을 반환할 가능성이 있으므로, 그 가능성을 처리할 부담이 호출자에게 전가된다. 그 전가가 극도로 짜증날 때가 있다. 왜 그런지 다음 코드를 보자:
getConfigurationDirectories :: IO [FilePath]
getConfigurationDirectories = do
configDirsString <- getEnv "CONFIG_DIRS"
let configDirsList = split ',' configDirsString
when (null configDirsList) $
throwIO $ userError "CONFIG_DIRS cannot be empty"
pure configDirsList
main :: IO ()
main = do
configDirs <- getConfigurationDirectories
case head configDirs of
Just cacheDir -> initializeCache cacheDir
Nothing -> error "should never happen; already checked configDirs is non-empty"
getConfigurationDirectories가 환경에서 파일 경로 리스트를 가져올 때, 선제적으로 리스트가 비어 있지 않은지 확인한다. 하지만 main에서 head로 첫 원소를 얻으려 하면, 결과 타입이 Maybe FilePath이기 때문에 우리가 결코 발생하지 않으리라 아는 Nothing 케이스를 여전히 처리해야 한다! 이건 여러 이유로 몹시 나쁘다:
우선, 그냥 귀찮다. 이미 리스트가 비어 있지 않음을 확인했는데, 왜 중복된 검사로 코드를 어지럽혀야 하나?
둘째, 성능 비용이 있을 수 있다. 이 예제에서는 중복 검사의 비용이 사소하지만, 타이트 루프 같은 보다 복잡한 시나리오에서는 이런 중복 검사가 누적되어 눈에 띌 수 있다.
마지막이자 최악으로, 이 코드는 버그의 씨앗이다! getConfigurationDirectories가 의도적으로든 실수로든 더 이상 리스트가 비었는지 확인하지 않도록 수정된다면 어떨까? 프로그래머가 main을 업데이트해야 한다는 걸 잊을 수도 있고, 그러면 “불가능”하던 에러가 가능해지기는커녕 일상적으로 벌어질 수도 있다.
이 중복 검사의 필요성은 사실상 타입 시스템에 구멍을 뚫게 만든다. Nothing 케이스가 불가능함을 정적으로 _증명_할 수 있다면, 리스트가 비었는지 확인하지 않도록 getConfigurationDirectories를 변경하는 순간 그 ‘증명’은 무효화되고, 컴파일 타임 실패를 일으켰을 것이다. 하지만 현재처럼 작성되어 있으면, 우리는 테스트나 수작업 점검에 의존할 수밖에 없다.
분명히, 수정된 head에는 아쉬운 점이 있다. 뭔가 더 똑똑해지길 바란다: 이미 리스트가 비어 있지 않음을 확인했다면, head는 우리가 불가능하다고 아는 케이스를 처리하도록 강요하지 않고 무조건 첫 원소를 돌려줘야 한다. 어떻게 그럴 수 있을까?
원래(부분적인) head의 타입 시그니처를 다시 보자:
head :: [a] -> a
앞 절에서는 반환 타입에서의 약속을 약화시켜 부분적인 타입 시그니처를 총(total)으로 바꿀 수 있음을 보았다. 하지만 우리는 그렇게 하고 싶지 않다. 그렇다면 바꿀 수 있는 건 하나 남는다: 인자 타입(여기서는 [a]). 반환 타입을 약화시키는 대신, 인자 타입을 _강화(strengthen)_하여, 애초에 빈 리스트에 head가 호출될 가능성을 없애는 것이다.
이를 위해서는 ‘비어 있지 않은 리스트’를 표현하는 타입이 필요하다. 다행히 Data.List.NonEmpty의 기존 타입 NonEmpty가 딱 그것이다. 정의는 다음과 같다:
data NonEmpty a = a :| [a]
NonEmpty a는 사실 a 하나와 일반적인(비어 있을 수도 있는) [a]의 쌍일 뿐이다. 리스트의 첫 원소를 리스트의 꼬리와 분리해서 저장함으로써 비어 있지 않은 리스트를 알맞게 모델링한다: [a] 부분이 []여도 a 부분은 항상 존재해야 한다. 이렇게 하면 head 구현은 완전히 사소해진다:2
head :: NonEmpty a -> a
head (x:|_) = x
이전과 달리, GHC는 아무 불평 없이 이 정의를 받아들인다—이 정의는 부분 함수가 아니라 _총 함수_다. 프로그램을 새 구현을 사용하도록 업데이트해 보자:
getConfigurationDirectories :: IO (NonEmpty FilePath)
getConfigurationDirectories = do
configDirsString <- getEnv "CONFIG_DIRS"
let configDirsList = split ',' configDirsString
case nonEmpty configDirsList of
Just nonEmptyConfigDirsList -> pure nonEmptyConfigDirsList
Nothing -> throwIO $ userError "CONFIG_DIRS cannot be empty"
main :: IO ()
main = do
configDirs <- getConfigurationDirectories
initializeCache (head configDirs)
주목하라. 이제 main의 중복 검사가 완전히 사라졌다! 대신 검사는 딱 한 번, getConfigurationDirectories에서 수행한다. 이 함수는 Data.List.NonEmpty의 nonEmpty 함수를 사용해 [a]로부터 NonEmpty a를 구성한다. 그 타입은 다음과 같다:
nonEmpty :: [a] -> Maybe (NonEmpty a)
여전히 Maybe가 있다. 하지만 이번에는 프로그램 아주 초기에, 바로 입력 검증을 하던 그 자리에서 Nothing 케이스를 처리한다. 그 검사를 통과하고 나면 우리 손에는 NonEmpty FilePath 값이 있고, 이는 리스트가 정말 비어 있지 않다는 지식을 타입 시스템에 보존한다. 달리 말해, NonEmpty a 타입의 값을 [a]에 더해, 리스트가 비어 있지 않음을 보이는 _증명(proof)_을 함께 들고 있는 값으로 볼 수 있다.
반환 타입을 약화시키는 대신 인자 타입을 강화함으로써, 앞 절의 모든 문제를 말끔히 제거했다:
중복 검사가 없으니 성능 오버헤드도 없다.
게다가 getConfigurationDirectories가 리스트 비어 있음을 더 이상 검사하지 않게 바뀐다면, 그 반환 타입도 바뀌어야 한다. 따라서 main은 타입 체크에 실패하고, 프로그램을 실행하기도 전에 문제가 있음을 알려줄 것이다!
더 좋은 점이 있다. head와 nonEmpty를 합성하면, 이전 head의 동작을 쉽게 복원할 수 있다:
head' :: [a] -> Maybe a
head' = fmap head . nonEmpty
반면 그 역은 성립하지 않는다. 옛 head로부터 새 head를 얻는 방법은 없다. 전반적으로 두 번째 접근이 모든 축에서 우월하다.
위 예제가 이 글의 제목과 무슨 상관이 있는지 궁금할 수 있다. 결국 우리는 리스트가 비어 있지 않음을 검증하는 두 가지 방법만 살펴봤을 뿐—파싱은 보이지 않는다. 그 해석이 틀린 건 아니지만, 나는 다른 관점을 제안하고 싶다. 내게 검증과 파싱의 차이는 거의 전적으로 ‘정보가 어떻게 보존되느냐’에 있다. 다음 두 함수를 보자:
validateNonEmpty :: [a] -> IO ()
validateNonEmpty (_:_) = pure ()
validateNonEmpty [] = throwIO $ userError "list cannot be empty"
parseNonEmpty :: [a] -> IO (NonEmpty a)
parseNonEmpty (x:xs) = pure (x:|xs)
parseNonEmpty [] = throwIO $ userError "list cannot be empty"
두 함수는 거의 동일하다. 주어진 리스트가 비었는지 확인하고, 비었으면 에러 메시지로 프로그램을 중단시킨다. 차이는 전적으로 반환 타입에 있다. validateNonEmpty는 항상 ()—아무 정보도 담지 않은 타입—를 반환하지만, parseNonEmpty는 입력 타입을 정제(refine)한 NonEmpty a를 반환해 타입 시스템에 획득한 지식을 보존한다. 두 함수는 같은 것을 검사하지만, parseNonEmpty는 배운 정보를 호출자에게 건네고, validateNonEmpty는 그 정보를 내다 버린다.
이 두 함수는 정적 타입 시스템의 역할에 대한 두 가지 관점을 우아하게 보여준다. validateNonEmpty는 타입체커의 요구를 그럭저럭 따른다. 그러나 parseNonEmpty만이 그것을 최대한 활용한다. 왜 parseNonEmpty가 더 나은지 보인다면, 당신은 내가 “파싱하고, 검증하지 마라”라는 만트라로 의미하는 바를 이해한 것이다. 그래도 parseNonEmpty라는 이름에 회의적일지 모른다. 정말 무언가를 _파싱_하는가? 아니면 단지 입력을 검증하고 결과를 반환하는가? 파싱과 검증의 정확한 정의는 논쟁의 여지가 있지만, 나는 parseNonEmpty가 분명한 파서(아주 단순하긴 하지만)라고 믿는다.
생각해 보자. 파서란 무엇인가? 본질적으로, 파서는 덜 구조화된 입력을 더 구조화된 출력으로 바꾸는 함수다. 그 본성상 파서는 부분 함수다—정의역의 어떤 값들은 공역의 어떤 값에도 대응하지 않는다—그래서 모든 파서는 실패 개념을 가져야 한다. 종종 파서의 입력은 텍스트지만, 꼭 그래야 하는 것은 아니다. parseNonEmpty는 아주 그럴싸한(cromulent) 파서다. 리스트를 비어 있지 않은 리스트로 파싱하고, 실패는 에러 메시지로 프로그램을 종료함으로써 신호한다.
이처럼 유연한 정의 아래에서 파서는 엄청나게 강력한 도구가 된다. 프로그램과 외부 세계의 경계에서 입력 검사를 선제적으로 수행하고, 그 검사가 끝나면 다시는 같은 것을 검사하지 않아도 된다! 하스켈러들은 이 힘을 잘 알고 있으며, 일상적으로 다양한 종류의 파서를 사용한다:
aeson 라이브러리는 도메인 타입으로 JSON 데이터를 파싱하는 Parser 타입을 제공한다.
마찬가지로 optparse-applicative는 커맨드라인 인자를 파싱하기 위한 파서 콤비네이터를 제공한다.
persistent, postgresql-simple 같은 데이터베이스 라이브러리도 외부 데이터 저장소에 있는 값을 파싱하는 메커니즘을 갖는다.
servant 생태계는 경로 컴포넌트, 쿼리 파라미터, HTTP 헤더 등에서 하스켈 데이터 타입을 파싱하는 것을 중심으로 구축되어 있다.
이 모든 라이브러리의 공통 주제는 하스켈 애플리케이션과 외부 세계의 경계에 앉아 있다는 것이다. 외부 세계는 곱(product)과 합(sum) 타입으로 말하지 않고 바이트의 흐름으로 말한다. 그러니 파싱이 필요하다는 사실을 피해 갈 방법은 없다. 데이터를 조작하기 전에 파싱을 앞당겨 수행하는 것만으로도 많은 종류의 버그—심지어 보안 취약점이 될 수도 있는—를 크게 줄일 수 있다.
이처럼 모든 것을 선제적으로 파싱하는 접근의 단점 하나는, 값들이 실제로 사용되기 훨씬 전에 파싱을 요구할 수 있다는 점이다. 동적 타입 언어에서는 충분한 테스트 커버리지가 없으면 파싱 로직과 처리 로직을 동기화하기가 다소 까다롭고, 그 테스트를 유지하는 일도 수고롭다. 하지만 정적 타입 시스템에서는 문제가 놀랍도록 단순해진다. 위의 NonEmpty 예가 보여 주듯, 파싱과 처리 로직이 어긋나면 프로그램은 컴파일조차 되지 않는다.
이제쯤이면 검증보다 파싱이 낫다는 생각에 어느 정도는 동의했으리라 기대한다. 그래도 의구심이 남을 수 있다. 어차피 타입 시스템이 필요한 검사를 결국 하게 만들 텐데, 검증이 정말 그토록 나쁜가? 에러 보고가 조금 나빠질 수는 있겠지만, 약간의 중복 검사가 해가 되지는 않겠지?
아쉽지만 그렇게 단순하지 않다. 즉흥적인(ad-hoc) 검증은 언어 이론 보안(language-theoretic security) 분야에서 _샷건 파싱(shotgun parsing)_이라 부르는 현상으로 이어진다. 2016년 논문, The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge Them에서 저자들은 다음과 같이 정의한다:
샷건 파싱은 파싱과 입력 검증 코드를 처리 코드에 섞어 여기저기 흩뿌리는 안티패턴이다—입력에 온갖 검사를 던져 놓고, 체계적 근거 없이 그중 하나가 “나쁜” 경우를 다 잡아 주길 바라는 것이다.
그리고 이러한 검증 방식에 내재된 문제를 이렇게 설명한다:
샷건 파싱은 프로그램이 무효한 입력을 처리하는 대신 거부할 능력을 본질적으로 박탈한다. 입력 스트림의 오류가 늦게 발견되면, 무효 입력의 일부가 이미 처리된 이후일 수 있으므로, 프로그램 상태를 정확히 예측하기 어려워진다.
달리 말해, 모든 입력을 선제적으로 파싱하지 않는 프로그램은 입력의 일부가 유효하다고 가정하고 동작을 수행했다가, 다른 일부가 무효임을 뒤늦게 발견하면, 일관성을 유지하기 위해 이미 수행한 변경을 롤백해야 하는 위험을 감수하게 된다. 때로는—RDBMS의 트랜잭션 롤백처럼—가능하지만, 일반적으로는 아닐 수 있다.
샷건 파싱이 검증과 무슨 관련이 있는지 곧바로 와닿지 않을 수도 있다—어차피 모든 검증을 앞당겨 하면 샷건 파싱의 위험을 줄일 수 있으니까. 문제는, 검증 중심 접근에서는 모든 것이 정말로 애초에 검증되었는지, 아니면 그 ‘불가능’하다던 케이스들 중 일부가 실제로는 일어날 수 있는지 여부를 판단하기가 극도로 어렵거나 불가능하다는 데 있다. 프로그램 전체가, 어디서든 예외가 발생할 수 있을 뿐 아니라 정기적으로 필요하다는 가정을 해야 한다.
파싱은 프로그램을 두 단계—파싱과 실행—로 층화하여 이 문제를 피해 간다. 무효 입력 때문에 실패할 수 있는 건 오직 첫 단계뿐이다. 실행 단계에서 남는 실패 양상은 이에 비해 극히 적고, 요구되는 섬세한 주의를 기울여 다루면 된다.
지금까지는 일종의 세일즈 피치였다. “당신, 독자여, 파싱을 하라!”라고 말했고, 내가 제대로 했다면 적어도 몇몇은 설득되었을 것이다. 하지만 “무엇”과 “왜”를 이해했더라도, “어떻게”에 대해서는 자신이 없을 수 있다.
내 조언: 데이터타입에 집중하라.
키-값 쌍의 튜플 리스트를 인수로 받는 함수를 작성하고 있다고 하자. 그런데 리스트에 중복 키가 있을 때 어떻게 해야 할지 확신이 서지 않는 걸 깨달았다. 하나의 해결책은 리스트에 중복이 없다고 단언하는 함수를 작성하는 것이다:
checkNoDuplicateKeys :: (MonadError AppError m, Eq k) => [(k, v)] -> m ()
하지만 이 검사는 약하다. 잊어버리기 너무 쉽다. 반환값이 사용되지 않으므로 언제든 생략할 수 있고, 그 검사가 필요했던 코드도 여전히 타입 체크를 통과한다. 더 나은 해결책은 애초에 중복 키를 구조적으로 금지하는 자료구조—예컨대 Map—를 선택하는 것이다. 함수의 타입 시그니처를 튜플 리스트 대신 Map을 받도록 바꾸고, 평소 하던 대로 구현하라.
그렇게 하면, 새 함수의 호출 지점은 아마 타입 체크에 실패할 것이다. 여전히 튜플 리스트를 전달하고 있을 테니 말이다. 호출자가 그 값을 인수로 받았거나 다른 함수의 결과로 받았다면, 타입을 리스트에서 Map으로 계속 올려 가라. 값이 만들어지는 장소에 이를 때까지, 혹은 중복이 실제로 허용되어야 하는 지점을 발견할 때까지 말이다. 그 시점에서, 수정된 checkNoDuplicateKeys 호출을 삽입하라:
checkNoDuplicateKeys :: (MonadError AppError m, Eq k) => [(k, v)] -> m (Map k v)
이제 이 검사는 생략될 수 없다. 프로그램이 앞으로 진행하려면 그 결과가 실제로 필요하기 때문이다!
이 가상의 시나리오는 두 가지 단순한 아이디어를 부각한다:
불법 상태가 표현 불가능하도록 하는 자료구조를 사용하라. 합리적인 한도에서 가장 정밀한 자료구조로 데이터를 모델링하라. 지금의 인코딩으로는 어떤 가능성을 배제하기 어렵다면, 관심 있는 속성을 더 쉽게 표현할 수 있는 대안 인코딩을 고려하라. 리팩터링을 두려워하지 말라.
증명의 부담을 가능한 한 위로 밀어 올리되, 그 이상은 넘기지 말라. 필요한 가장 정밀한 표현으로 데이터를 가능한 한 빨리 바꿔라. 이상적으로는, 시스템 경계에서—데이터가 어떤 방식으로도 소비되기 전에—이 일이 일어나야 한다.3
특정 분기에서 더 정밀한 표현이 필요해진다면, 그 분기가 선택되는 즉시 데이터를 더 정밀한 표현으로 파싱하라. 합 타입(sum type)을 적절히 사용해 데이터타입이 제어 흐름에 맞춰 반영되고 적응하도록 하라.
다시 말해, 주어진 데이터 표현이 아니라, 바라던 데이터 표현을 대상으로 함수를 작성하라. 그 다음 설계 과정은 그 간극을 메우는 연습이 되고, 종종 양쪽 끝에서 출발해 중간에서 만나게 된다. 진행하면서 설계 일부를 점진적으로 조정하라. 리팩터링 과정에서 새로운 걸 배울 수도 있으니 말이다!
다음은 특별한 순서 없이 몇 가지 추가 조언이다:
데이터타입이 코드에 영감을 주게 하라. 코드가 데이터타입을 지배하도록 두지 말라. 지금 작성 중인 함수에 필요하다는 이유만으로 레코드 어딘가에 Bool 하나를 슬쩍 끼워 넣고 싶은 유혹을 피하라. 올바른 데이터 표현을 사용하도록 코드를 리팩터링하기를 두려워하지 말라—타입 시스템이 변경이 필요한 모든 곳을 챙겨 줄 것이고, 아마 나중의 두통도 덜어 줄 것이다.
m ()를 반환하는 함수는 깊은 의심의 눈초리로 보라. 진짜로 필요한 경우가 있긴 하다. 의미 있는 결과 없이도 수행해야 하는 명령형 효과가 있을 수 있으니까. 하지만 그 효과의 주목적이 에러를 발생시키는 것이라면, 더 나은 방법이 있을 가능성이 크다.
여러 번에 나눠 데이터를 파싱하는 것을 두려워하지 말라. 샷건 파싱을 피한다는 건, 입력 데이터가 완전히 파싱되기 전에 그것을 _사용_하지 말라는 뜻이지, 입력의 일부를 사용해 다른 일부의 파싱 방법을 결정하지 말라는 뜻이 아니다. 유용한 파서 가운데 문맥 의존적(context-sensitive)인 것들도 많다.
비정규화된(denormalized) 데이터 표현을 피하라, 특히 가변적일 때. 같은 데이터를 여러 곳에 중복하면 너무나 쉽게 표현 가능한 불법 상태가 생긴다: 서로 엇갈려 버리는 상태다. 단일한 진실의 원천(single source of truth)을 지향하라.
추상 데이터타입을 사용해 검증기가 ‘파서처럼’ 보이게 하라. 때로는 Haskell이 제공하는 도구로 어떤 불법 상태를 정말로 표현 불가능하게 만드는 게 실용적이지 않을 때가 있다. 예컨대 정수가 특정 범위 안에 있음을 보장하는 일 말이다. 그런 경우 추상 newtype과 스마트 생성자(smart constructor)를 사용해, 검증기로부터 ‘가짜’ 파서를 만들어라.
항상처럼, 최선의 판단을 하라. singletons을 끌어오고 애플리케이션 전체를 리팩터링해서, 어딘가에 있는 단 하나의 error "impossible" 호출을 없애려 드는 건 아마 가치가 없을 것이다—다만 그런 상황을 방사성 물질 다루듯 여기고, 마땅한 주의를 기울여 다루기만 하라. 다른 방법이 없다면, 적어도 다음에 코드를 수정해야 할 사람을 위해 그 불변식을 주석으로 남겨라.
정말로 그게 전부다. 이 글이 하스켈 타입 시스템을 활용하는 데 박사 학위가 필요 없다는 점을 보여 주었길 바란다. 최신 GHC의 번쩍이는 언어 확장을 쓸 필요도 없다—물론 도움이 될 때도 있겠지만! 때로 하스켈을 최대한 활용하는 데 가장 큰 장애물은, 선택지가 무엇인지 알고 있는가이다. 불행히도 하스켈 커뮤니티가 작다는 단점 중 하나는, 암묵지로 굳어진 설계 패턴과 기법을 문서화한 자료가 상대적으로 부족하다는 점이다.
이 글의 아이디어는 어느 것도 새롭지 않다. 사실 핵심 아이디어—“총 함수를 작성하라”—는 개념적으로 매우 단순하다. 그럼에도 나는 하스켈 코드를 어떻게 쓰는지에 대해 실행 가능하고 실천 가능한 세부를 전달하는 일이 놀랍도록 어렵다고 느낀다. 추상 개념에 시간을 많이 쓰는 건 쉽다—그 가운데 상당수는 정말 가치 있다!—하지만 _과정_에 대해 유용한 것을 전달하지 못할 때가 많다. 이 글이 그 방향으로 내딛는 작은 걸음이 되기를 바란다.
안타깝게도 이 주제에 관한 다른 자료를 많이 알지는 못한다. 그러나 하나는 있다. 나는 Matt Parson의 훌륭한 글 Type Safety Back and Forth를 주저 없이 추천한다. 또 다른 접근 가능한 관점을 원한다면—추가로 예제 하나를 더 포함해—반드시 읽어 보길 권한다. 이 글에서 다룬 것보다 훨씬 진보된 관점에 관심이 있다면, Matt Noonan의 2018년 논문 Ghosts of Departed Proofs도 추천한다. 더 복잡한 불변식을 타입 시스템에 담아내는 여러 기법이 정리되어 있다.
마지막으로, 이 글에서 설명한 종류의 리팩터링이 언제나 쉬운 것은 아니다. 예시들은 단순하지만, 현실은 종종 훨씬 덜 직선적이다. 타입 주도 설계에 익숙한 사람들에게도 어떤 불변식을 타입 시스템에 담는 일은 정말로 어려울 수 있다. 그러니 원하는 방식으로 풀지 못한다고 해서 개인적 실패로 여기지 말라! 이 글의 원칙들을 지향해야 할 이상으로 보되, 반드시 만족시켜야 할 절대 기준으로 보지 말라. 중요한 건 시도하는 것이다.
기술적으로, 하스켈에서는 이는 ‘바텀(bottom)’을 무시한다. 바텀은 어떤 타입에도 깃들 수 있는 구성물이다. 이들은 (다른 언어의 null과 달리) ‘진짜’ 값이 아니다—무한 루프나 예외를 발생시키는 계산 같은 것들이다—그리고 관용적인 하스켈에서는 보통 이를 피하려 한다. 그러니 그것들이 존재하지 않는다고 가정하고 추론하는 것도 가치는 있다. 하지만 내 말을 곧이곧대로 믿지 말라—Danielsson 등 저자들이 Fast and Loose Reasoning is Morally Correct에서 설득해 줄 것이다. ↩
사실 Data.List.NonEmpty에는 이미 이 타입의 head가 제공된다. 다만 설명을 위해 여기서는 직접 다시 구현했다. ↩
때로는 서비스 거부(DoS) 공격을 피하기 위해 사용자 입력을 파싱하기 전에 어떤 형태의 인가 처리가 필요할 수 있다. 괜찮다. 인가는 표면적이 상대적으로 작아야 하고, 시스템 상태에 의미 있는 변화를 초래하지 않아야 한다. ↩