잇창명 개발 블로그

검증하지 말고 파싱하라

이 글은 Alexis King님의 Parse, don't validate를 원 작성자님의 동의 하에 한국어로 번역한 글입니다. Haskell에 대한 사전 지식이 필요하며, 보충 글인 급하게 배우는 Haskell에 필요한 만큼만 설명해 두었습니다. 오역이 있을 경우 댓글로 알려주세요.

이어지는 글인 No, dynamic type systems are not inherently more open서동휘님의 블로그에서 한국어로 읽을 수 있습니다.

번역 초안을 검수하고 매끄러운 번역문을 제안해주신 카타님과 RanolP님께 감사드립니다.


그동안 저는 타입 주도 설계를 실천한다는 게 무슨 의미인지 짧고 단순하게 설명하는 데 애를 먹었습니다. 누가 "이런 접근은 어떻게 하신 건가요?"라고 물어볼 때면 만족스러운 답을 내놓지 못할 때가 많았습니다. 뜬금없이 생각난 것은 절대 아니라고 말씀드릴 수 있지만(저는 "올바른" 설계를 주먹구구로 만들어내는 대신 분명한 절차적 방법을 통해 만들어내고 있습니다), 그 과정을 남들에게 이야기하는 건 잘 하지 못했습니다.

그러다 한 달쯤 전에 정적·동적 타입 언어에서 JSON을 파싱할 때 경험한 차이점을 트위터에 회고한 적이 있었는데, 바로 그때 제가 찾던 설명이 무엇인지 드디어 깨달았습니다. 제게 타입 주도 설계가 의미하는 바를 똑 소리 나는 한 문장으로 함축해 보겠습니다. 겨우 3단어밖에 안 됩니다.

검증하지 말고 파싱하라.

타입 주도 설계의 본질

솔직히 말씀드리자면, 독자분께서 타입 주도 설계가 뭔지 미리 알고 오신 게 아니면 이렇게 깔쌈한 한 문장으로 정리해도 그다지 와닿지 않을 것 같습니다. 다행히 이 글의 나머지 분량은 전부 이 설명에 할애할 예정입니다. 이제부터 저 문장을 통해 뭘 전달하고 싶었는지 낱낱이 해부해 볼 텐데, 그 전에 한 번 시행착오를 해 보겠습니다.

가능성의 영역

정적 타입 시스템의 좋은 점 중 하나가 "이런 함수를 작성하는 게 가능할까?" 같은 질문을 가능하게, 어떨 때는 심지어 쉽게 만들어 준다는 것입니다. 극단적인 예시로 다음 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: []
경고: [-Wincomplete-patterns]
    모든 경우의 패턴을 열거하지 않았습니다
    'head'에 대한 등식에서: 매치되지 않은 패턴: []

2

메시지를 잘 읽어보면 이 함수가 부분함수라고, 즉 가능한 모든 입력에 대해 정의되지 않았다고 알려주고 있습니다. 특히 이 함수는 입력이 빈 리스트 []일 때 정의되지 않습니다. 리스트가 비어 있으면 리스트의 첫 번째 원소도 반환할 수 없으니(반환할 원소가 없으니까요!) 말이 되네요. 여기서 우리는 이 함수 역시 구현할 수 없다는 새로운 사실을 알게 됩니다.

부분함수를 전함수로

동적 타입으로 프로그래밍을 하던 분은 이 상황이 당혹스러울 수도 있겠습니다. 리스트가 있으면 첫 원소가 뭔지는 알아야죠. 물론 Haskell에서도 "리스트의 첫 번째 원소 구하기" 연산이 불가능한 건 아니고, 밑작업이 조금 더 필요할 뿐입니다. 이 head 함수를 고치는 방법이 2가지 있는데, 쉬운 방법부터 해 보겠습니다.

기대가 없으면 실망도 없다

위에서 얘기했듯이 head가 부분함수인 이유는 리스트가 비어 있을 때는 반환할 원소가 없기 때문입니다. 지키지 못할 약속을 한 것입니다. 다행히 이 딜레마를 해결하는 쉬운 방법이 있습니다. 더 약한 약속을 하먼 됩니다. 호출자에게 리스트의 원소를 돌려준다는 보장은 할 수 없으니, 애초에 실망하지 않도록 기대를 덜 하게 하는 것입니다. 이러면 head가 리스트의 원소를 반환할 수 있도록 최선을 다하겠지만, 아무것도 반환하지 않을 권리도 보장받을 수 있습니다. Haskell에서는 이런 가능성을 Maybe 타입으로 나타낼 수 있습니다.

head :: [a] -> Maybe a

타입 a의 값을 아예 만들 수 없으면 Nothing을 반환할 수 있도록 해주는 이 Maybe 타입 덕에 드디어 head를 구현할 수 있게 됩니다.

head :: [a] -> Maybe a
head (x:_) = Just x
head []    = Nothing

그럼 다 해결된 거죠? 일단 그렇긴 한데... 이 방법에는 숨어있는 단점이 있습니다.

head구현할 때는 Maybe를 반환하는 것이 편리하다는 건 부정할 수 없습니다. 그런데 이 함수를 실제로 쓸 때가 되면 눈에 띄게 덜 편해집니다! head는 언제든 Nothing을 반환할 가능성이 있습니다. 그런 경우를 처리할 책임은 호출자에게 그대로 전가되고, 이렇게 남의 책임을 뒤집어쓰는 게 짜증날 때가 생각보다 많습니다. 감이 안 오신다면 아래 코드를 읽어 봅시다.

getConfigurationDirectories :: IO [FilePath]
getConfigurationDirectories = do
  configDirsString <- getEnv "CONFIG_DIRS"
  let configDirsList = split ',' configDirsString
  when (null configDirsList) $
    throwIO $ userError "CONFIG_DIRS가 비어 있습니다"
  pure configDirsList

main :: IO ()
main = do
  configDirs <- getConfigurationDirectories
  case head configDirs of
    Just cacheDir -> initializeCache cacheDir
    Nothing -> error "뜨면 안 되는 오류임. configDirs가 비어있지 않은 걸 이미 확인했음"

3

getConfigurationDirectories가 환경 변수에서 파일 경로의 리스트를 받아올 때는 그 리스트가 비어 있지 않은지도 미리 확인합니다. 그런데 main 함수에서 그 리스트의 첫 원소를 구하려고 할 때는 head가 반환하는 Maybe FilePathNothing일 일은 절대 없는 걸 알면서도 그 경우를 확인해서 처리해야 합니다! 이런 상황이 왜 그렇게 끔찍하게 나쁜지 이유를 몇 가지 들어 보겠습니다.

  1. 첫째로, 리스트에 뭐가 있는 걸 이미 확인했는데 굳이 확인한 걸 또 확인하는 더러운 코드를 넣어야 된다고요? 너무 짜증나지 않나요?
  2. 둘째로, 성능에 영향을 미칠 여지가 있습니다. 위에서 든 예시에서는 성능 영향이 미미하겠지만, 빡빡한 루프 안에서 돌아가는 등 성능 하락이 극대화될 수 있는 복잡한 시나리오는 얼마든지 생각할 수 있습니다.
  3. 마지막이자 최악의 이유라면, 이 코드 자체가 터지기만 기다리고 있는 버그 덩어리입니다! 의도했든 아니든 getConfigurationDirectories를 빈 리스트를 확인하지 않도록 수정했다면 무슨 일이 생길까요? 프로그래머가 main도 같이 수정하는 걸 깜박 잊기라도 한다면 "불가능했던" 버그가 가능만 한 수준을 넘어 자꾸 튀어나오게 됩니다.

확인을 중복으로 해야 하는 이 상황 때문에 타입 시스템에 구멍이 뚫려버립니다. 만약에 이 Nothing의 가능성이 없다는 것을 정적으로 증명만 할 수 있다면, getConfigurationDirectories이 빈 리스트를 확인하지 않게 바뀔 때 증명이 무효가 되어 컴파일 오류를 낼 것입니다. 하지만 지금으로서는 이미 보았듯이 테스트 코드에 의존하거나 코드를 직접 읽어서 버그를 잡아낼 수밖에 없습니다.

엄선된 인자만 받습니다

저희가 수정한 head에는 분명 개선할 점이 남아있습니다. 리스트가 비어 있지 않은 것을 확인했으면 이미 불가능한 걸 알고 있는 경우를 처리하지 않아도 무조건 첫 원소를 반환해 주는 똘똘한 녀석이었으면 좋겠는데요. 어떻게 하면 될까요?

이쯤에서 head의 원래 (부분함수였던 때의) 타입 시그니처를 다시 읽어 봅시다.

head :: [a] -> a

위의 문단에서는 반환 타입으로 했던 약속을 약하게 만들어서 부분함수 타입 시그니처를 전함수로 만들 수 있다는 것을 보였습니다. 그런데 그 방법은 또 쓰고 싶지 않으니, 이제 바꿀 수 있는 것은 하나뿐, 바로 인자 타입입니다(여기서는 [a]). 반환 타입을 약화시키는 대신 인자 타입을 강화시키면 애초에 head에 빈 리스트가 전달되는 일은 없을 것입니다.

이 방법을 사용하려면 우선 비어 있지 않은 리스트를 나타내는 타입이 필요합니다. 다행히 Data.List.NonEmpty 모듈의 NonEmpty 타입이 정확히 그 역할을 합니다. 이 타입의 정의는 다음과 같습니다.

data NonEmpty a = a :| [a]

잘 보면 NonEmpty aa 하나와 보통의 비어 있을 수도 있는 [a]의 순서쌍에 불과하다는 것을 알 수 있습니다. 리스트의 첫 원소를 나머지 꼬리와 별도로 보관함으로써 비어 있지 않은 리스트를 편리하게 나타낼 수 있게 된 것입니다. [a] 부분이 []이더라도 a 부분은 항상 있을 수밖에 없습니다. 이제 head 함수를 구현하는 것은 누워서 떡 먹기입니다.4

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가 비어 있습니다"

main :: IO ()
main = do
  configDirs <- getConfigurationDirectories
  initializeCache (head configDirs)

main에 있던 불필요한 확인 코드가 완전히 사라지고 getConfigurationDirectories에서만 딱 한 번 확인하네요! 이때 [a]NonEmpty a로 만드는 데는 Data.List.NonEmpty 모듈의 nonEmpty 함수를 사용했고, 타입은 이렇습니다.

nonEmpty :: [a] -> Maybe (NonEmpty a)

Maybe가 아직 남아 있긴 하지만, 여기서는 Nothing의 경우를 프로그램에서 매우 일찍, 저희가 이미 입력 검증을 하고 있던 바로 그곳에서 처리합니다. 일단 이 확인이 끝나고 나면 NonEmpty FilePath 값이 남고, 이 값에는 리스트가 비어 있지 않다는 정보가 (타입 시스템 수준에서!) 고스란히 남아 있습니다. 관점을 달리하면 NonEmpty a 타입의 값은 [a] 타입의 값에 그 리스트가 비어 있지 않다는 증명이 붙은 것이라고 생각할 수 있습니다.

이전 문단에서 나왔던 문제도 head의 반환 타입을 약화시키는 대신 인자 타입을 강화시킴으로써 전부 해결되었습니다.

게다가 nonEmptyhead를 합성하면 head의 원래 동작도 재현할 수 있습니다.

head' :: [a] -> Maybe a
head' = fmap head . nonEmpty

역은 성립하지 않습니다. 즉, 위 문단의 head로 아래 문단의 head를 만들 수 있는 방법은 없습니다. 종합하자면 두 번째 접근이 모든 면에서 우월하네요.

파싱의 힘

여기까지 읽으셨다면 위에서 든 예시가 이 블로그 글 제목이랑 무슨 상관인지 궁금해하실 것 같습니다. 지금까지 비어 있지 않은 리스트를 검증하는 방법만 2가지 살펴봤는데 대체 파싱이 어디 있다는 거죠? 이렇게 해석하는 것도 잘못된 건 아니지만, 다른 관점도 한번 소개해 보겠습니다. 제가 생각하는 검증과 파싱의 차이는 거의 전적으로 정보가 얼마나 보존되는지에 달려 있습니다. 아래의 두 함수를 읽어보세요.

validateNonEmpty :: [a] -> IO ()
validateNonEmpty (_:_) = pure ()
validateNonEmpty [] = throwIO $ userError "리스트가 비어 있습니다"

parseNonEmpty :: [a] -> IO (NonEmpty a)
parseNonEmpty (x:xs) = pure (x:|xs)
parseNonEmpty [] = throwIO $ userError "리스트가 비어 있습니다"

이 두 함수는 인자로 받은 리스트가 비어 있는지 확인하고, 비었으면 프로그램을 종료하고 오류 메시지를 띄운다는 점에서는 같습니다. 두 함수의 차이는 오직 반환 타입뿐입니다. validateNonEmpty는 항상 정보값이 없는 ()를 반환하지만, parseNonEmpty는 획득한 정보를 타입 시스템에 보존하는, 입력받은 타입을 정제한 NonEmpty a를 반환합니다. 두 함수 모두 같은 것을 확인하지만, validateNonEmpty가 그냥 버려버리는 실행 중에 얻은 정보를 parseNonEmpty는 호출자에게 고스란히 전달해 줍니다.

이 두 함수는 정적 타입 시스템에 대한 두 가지 관점을 명쾌하게 나타내 줍니다. validateNonEmpty도 타입 검사를 적당히 통과하지만, 타입 시스템을 십분 활용하는 것은 parseNonEmpty뿐입니다. 왜 parseNonEmpty가 더 나은지 이해하셨다면, 제가 "검증하지 말고 파싱하라"는 구호에 담은 의미는 모두 이해하신 겁니다. 그래도 여전히 parseNonEmpty라는 이름이 미심쩍을 수도 있겠습니다. 이 함수가 정말 뭔가를 파싱하는 걸까요, 아니면 그냥 입력값을 검증하고 결과를 내놓는 것에 불과할까요? "파싱하다"나 "검증하다"의 의미는 사람마다 조금씩 다르겠지만, 저는 parseNonEmpty가 (꽤 단순하긴 해도) 진정한 의미의 파서라고 믿고 있습니다.

애당초 파서라는 게 뭔가요? 파서란 덜 구조화된 입력을 받아서 더 구조화된 출력을 만드는 함수 그 이상도 이하도 아닙니다. 파서는 정의상 부분함수이므로(정의역의 어떤 값은 공역의 어떤 값과도 대응되지 않음), 모든 파서는 어떻게든 실패한 경우를 나타낼 수 있어야 합니다. 파서는 웬만하면 텍스트를 입력으로 받지만 반드시 그래야 하는 것은 결코 아니며, 리스트를 비어 있지 않은 리스트로 파싱하고, 프로그램을 종료하고 오류 메시지를 띄움으로써 실패를 나타내는 parseNonEmpty는 흠잡을 데 없는 엄연한 파서입니다.

이 유연한 정의를 따를 때 파서는 매우 강력한 도구가 됩니다. 프로그램과 바깥 세계 사이를 꽉 막고 서서 올바른 입력만을 걸러내고, 일단 이 거름망을 통과한 입력은 두 번 다시 확인하지 않아도 됩니다! 파싱의 위력을 잘 알고 있는 하스켈러들은 틈만 나면 여러 가지 종류의 파서를 사용합니다.

이 라이브러리들 모두 내가 작성한 Haskell 프로그램과 바깥 세계 사이를 이어준다는 공통점이 있습니다. 바깥 세계는 합과 곱 타입 대신 바이트열만이 통하는 세계이니 파싱은 거부할 수 없는 운명인 셈입니다. 이렇게 데이터를 실제로 조작하기 전에 데이터를 받자마자 파싱을 하는 것은 여러 종류의 버그, 심지어는 보안 취약점을 예방하는 데도 효과적입니다.

데이터를 받자마자 파싱해버리는 이 접근의 단점이 있다면 데이터를 실제로 사용하기 한참 전부터 파싱을 해 두어야 하는 경우가 있을 수 있다는 것입니다. 동적 타입 언어에서는 테스트 코드를 치밀하게 짜놓지 않으면 파싱과 처리 로직이 서로 잘 어우러지게 코딩하는 것이 힘들어지고, 테스트 코드 자체도 유지보수하기 정말 귀찮습니다. 그런데 정적 타입 시스템만 있다면 위의 NonEmpty 예시에서 보았듯이 문제가 놀랍도록 단순해집니다. 파싱과 처리 로직이 조금이라도 어긋났다가는 애초에 컴파일도 안 되니까요.

검증의 위험성

여기까지 읽고 나서 독자 여러분이 파싱이 검증보다 낫다는 의견에 조금이라도 설득됐기를 바라지만, 그래도 떨쳐내지 못하는 의심이 조금 남아 있을 것 같습니다. 어차피 타입 시스템 때문에 필요한 확인은 다 될 것 같은데 그래도 검증이 그렇게 나쁜가요? 오류 메시지 읽기가 좀 어려울 수는 있어도 중복된 확인 코드 좀 있는 게 뭐 어때서요?

아쉽지만 그게 그렇게 단순한 문제가 아닙니다. 애드 혹 검증은 언어론적 보안6 판에서 샷건 파싱이라고 하는 현상을 일으킵니다. 2016년 논문인 The Seven Turrets of Babel: A Taxonomy of LangSec Errors and How to Expunge Them에서는 샷건 파싱을 다음과 같이 정의하고 있습니다.

샷건 파싱이란 파싱과 입력 검증 코드가 뒤섞여서 데이터를 처리하는 코드에 흩뿌려진 프로그래밍 안티패턴으로, 입력 데이터를 주먹구구로 검증하고 체계적인 정당화 없이 이 정도면 "나쁜" 입력이 전부 걸러지겠지 하고 바라는 것을 의미한다.

Shotgun parsing is a programming antipattern whereby parsing and input-validating code is mixed with and spread across processing code—throwing a cloud of checks at the input, and hoping, without any systematic justification, that one or another would catch all the “bad” cases.

해당 논문에서는 이러한 검증 방법에 내재되어 있는 문제점 역시 서술하고 있습니다.

샷건 파싱은 필연적으로 프로그램이 잘못된 입력을 처리하지 않고 거부하는 능력을 저해한다. 입력 스트림에서 오류를 뒤늦게 발견할 경우 잘못된 입력의 일부분은 이미 처리된 상태로, 프로그램의 상태를 정확하게 예측하기 어려워지는 결과를 낳는다.

Shotgun parsing necessarily deprives the program of the ability to reject invalid input instead of processing it. Late-discovered errors in an input stream will result in some portion of invalid input having been processed, with the consequence that program state is difficult to accurately predict.

다시 설명하자면, 모든 입력을 받자마자 파싱하지 않는 프로그램은 올바른 부분의 입력을 처리하다가 다른 부분이 잘못되었다는 것을 뒤늦게 알고 일관성을 유지하기 위해 이미 수정해버린 것을 허겁지겁 돌려놓아야 하는 리스크를 안게 됩니다. 이것이 가능한 경우도 가끔 있지만(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)

이제 이 확인 함수의 결과는 프로그램을 실행하는 데 꼭 필요하기 때문에 함부로 뺄 수 없습니다!

이 가상의 시나리오를 살펴보면 두 가지 간단한 발상에 주목할 수 있습니다.

  1. 잘못된 상태를 표현할 수 없는 자료구조를 씁시다. 합리적인 선 안에서 생각할 수 있는 가장 정확한 자료구조로 데이터를 모델링하세요. 지금 사용하고 있는 부호화 방식으로 특정한 가능성을 배제하는 것이 너무 어렵다면 여러분이 신경써야 할 성질을 더 쉽게 나타낼 수 있는 다른 부호화 방식도 고려해 보세요. 리팩토링을 두려워하지 마세요.
  2. 증명의 책임을 가능한 한 위로 올리되, 지나치게 올리지는 맙시다. 입력받은 데이터는 가능한 한 빠르게 필요한 만큼 가장 정밀한 표현으로 바꾸세요. 이상적으로는 어떤 데이터도 처리되지 않은 시점에 시스템의 경계에서 바꾸는 것이 좋습니다.7
    어떤 코드 경로에서 어떤 데이터의 더 정밀한 표현을 요구한다면, 그 경로가 선택되는 즉시 그 데이터를 더 정밀한 표현으로 파싱하세요. 자료형이 제어 흐름을 반영하고 적응하도록 합 타입을 신중하게 사용하세요.

다시 말해서, 주어진 자료 표현이 아니라 있었으면 좋겠는 자료 표현에 대한 함수를 작성하세요. 그러면 프로그램의 설계 과정은 여기서 생기는 간극을 메우는 여정이 되고, 보통 양쪽 끝에서 동시에 다리를 지으면서 만날 때까지 좁혀나가게 됩니다. 리팩토링을 하면서 뭔가 새로운 것을 배울 수도 있으니 했던 설계의 부분부분을 반복적으로 수정하는 것도 두려워하지 마세요!

추가로 드릴 만한 조언들을 특별한 순서 없이 더 써 보겠습니다.

언제나처럼 독자 여러분의 판단에 맡기는 것이 최선입니다. 어디 한 곳에 있는 error "불가능함" 하나 없애자고 singletons를 깔아서 프로그램을 전부 뒤집어 엎을 필요까지는 없겠죠. 대신 이런 경우 보기를 방사성 물질같이 하고, 적절하게 처리해 주세요. 정 안 되겠다면 이 코드를 수정할 다음 사람을 위해 주석으로 지켜야 되는 불변 조건 같은 거라도 달아두세요.

톺아보고, 돌아보고, 더 볼 만한 글까지

제가 할 얘기는 여기서 끝입니다. 이 블로그 글을 읽고 Haskell 타입 시스템을 200% 활용한다고 박사 학위를 딸 필요도 없고, 갓 나온 따끈따끈한 GHC 언어 확장 같은 걸 쓸 필요도 없다는 게(물론 있으면 좋을 때가 있긴 하죠!) 전달됐으면 좋겠네요. Haskell을 잘 사용하는 데 가장 큰 걸림돌이 무슨 카드를 쓸 수 있는지 모르는 데 있는 경우도 꽤 있고, 아쉽게도 Haskell 커뮤니티가 작아서 생기는 단점 중 하나가 소수만 아는 디자인 패턴과 기술을 서술하는 문서가 비교적 적다는 데 있습니다.

이 글에는 제가 새로 생각해 낸 발상이 하나도 없습니다. 심지어 핵심적인 발상("전함수를 작성하세요")도 개념적으로는 꽤 단순합니다. 그럼에도 불구하고 제가 Haskell을 짜는 실천적인 요령을 남에게 알려주는 것이 꽤나 어렵게 느껴집니다. 많은 시간을 추상적인 개념(이 중에서도 의미 있는 것들이 꽤 많습니다!) 얘기에만 할애하다가 정작 과정에 대해서는 쓸만한 얘기를 하지 못하기 십상입니다. 이번 글이 조금이라도 그 방향으로 나아가는 데 도움이 되었기를 바랍니다.

이 글의 주제에 대해서 읽어볼 만한 자료를 거의 모르는 것은 아쉽지만, 그나마 아는 자료가 하나 있습니다. Matt Parson님의 멋진 블로그 글 Type Safety Back and Forth는 아무리 추천해도 지나치지 않겠습니다. 이러한 발상에 대한 읽기 좋은 다른 관점과 실제로 잘 돌아가는 다른 예제가 궁금하시다면 한번 읽어보시는 것을 강력히 권합니다. 이 분야의 고급 자료도 읽어보고 싶다면 제가 여기에 쓴 것보다 더 복잡한 불변조건을 담아내는 여러 가지 기술을 소개하는 Matt Noonan님의 2018년 논문 Ghosts of Departed Proofs를 추천드립니다.

글을 마치기 전에, 이 글에서 했던 것과 같이 리팩토링을 하는 것이 항상 쉽지만은 않다는 것을 말씀드리고 싶습니다. 제가 제시한 예제는 단순하지만, 현실은 그것보다 훨씬 복잡한 편이죠. 타입 주도 설계에 정통한 사람들도 어떤 불변조건을 타입 시스템에 담아내는 것을 힘겨워하시기도 하니, 어떤 문제가 원하는 대로 풀리지 않는다고 해서 나는 안 될 거라고 생각하지 말아 주세요! 이 글에 제시한 원리는 반드시 지켜야 할 요구사항이라기보다는 지향해야 할 이상점이라고만 생각해 주세요. 일단 해보는 것이 가장 중요합니다.

  1. 엄밀히 말하면 이 설명은 아무 타입이나 될 수 있는 "바닥 값"을 무시하고 있습니다. (다른 언어의 null과 달리) "진짜" 값이 아니라 무한루프나 예외를 발생시키는 연산 따위이고 하스켈스러운 코드에서는 보통 이런 것들을 피하려고 하기 때문에 바닥 값을 무시하는 논의가 의미가 없어지는 건 아닙니다. 제가 말하는 것만 듣고 넘어가지 마시고 Danielsson et al.이 Fast and Loose Reasoning is Morally Correct라고 했던 것도 한번 읽어보세요. 

  2. (역자 주) 위의 한국어 오류 메시지는 원본 오류 메시지를 임의로 번역한 것으로, 실제 Haskell 컴파일러는 영문 오류 메시지를 출력합니다. 

  3. (역자 주) 함수 getConfigurationDirectories는 환경 변수 CONFIG_DIRS를 확인해서 비어 있지 않으면 콤마로 나누어서 리스트로 반환하고, 비어 있으면 오류를 내면서 종료하는 함수입니다. 

  4. 사실은 Data.List.NonEmpty 모듈에 이미 이 타입의 head 함수가 정의되어 있지만, 설명을 위해 다시 구현하기로 하겠습니다. 

  5. (역자 주) 상태 기계와 테이블을 이용해 파싱하는 전통적인 방법과 달리, 간단한 파서를 합성해 더욱 복잡한 파서를 정의하는 파싱 방법. 

  6. (역자 주) 프로그램의 입출력을 형식 언어를 인식하는 과정으로 보고, 형식언어론적 방법을 이용해 보안 취약점을 예방하거나 방어하는 정보 보안의 분과. 

  7. 어떤 경우에는 서비스 거부 공격을 방어하기 위해 사용자 입력을 파싱하기 전에 사용자 인증 같은 절차를 밟아야 할 텐데, 그 정도는 괜찮습니다. 사용자 인증은 비교적 표면적이 작은 작업이고, 시스템의 상태를 크게 바꾸지도 않습니다. 

검증하지 말고 파싱하라