정적 타입 시스템이 ‘열린 세계’ 도메인에서 본질적으로 불리하다는 통념을 반박한다. JSON 이벤트와 프록시/서명 처리, “파싱하지 말고 검증하라” 논의, Python의 pickle 같은 리플렉션, 구조적 vs 명목적 타이핑을 통해, 정적 타입은 필요한 만큼만 가정을 명시하게 하여 부분적으로만 알려진 데이터도 잘 다룰 수 있음을 보인다.
타이핑 규율을 둘러싼 인터넷 논쟁은 동적 타입 시스템이 본질적으로 “오픈 월드” 도메인을 더 잘 모델링한다는 만연한 신화에 계속 시달리고 있다. 흔한 논법은 이렇다. 정적 타이핑의 목표는 가능한 한 모든 것을 못 박는 것이지만, 현실 세계에서는 그게 실용적이지 않다. 실제 시스템은 느슨하게 결합되어야 하고 데이터 표현에 최대한 신경 쓰지 않아야 하므로, 동적 타입이 큰 규모의 시스템을 더 견고하게 만든다는 것이다.
이 이야기는 그럴듯하게 들리지만 사실이 아니다. 결함은 전제에 있다. 정적 타입은 시스템의 모든 값을 “분류”하거나 구조를 못 박는 것이 아니다. 정적 타입 시스템은 컴포넌트가 입력의 구조에 대해 알아야 하는 만큼만, 그리고 반대로 알지 않아도 되는 만큼을 정확히 명시할 수 있게 해 준다. 실제로 정적 타입 시스템은 구조가 부분적으로만 알려진 데이터를 처리하는 데 뛰어나며, 애플리케이션 로직이 과도한 가정을 실수로 하지 않도록 보장하는 데 사용될 수 있다.
이 글은 오래 전부터 쓰고 싶었지만, 결국 쓰기로 한 계기는 이전 글에 달린 오해에 기반한 댓글들이었다. 그중 두 개가 특히 눈에 띄었는데, 첫 번째는 /r/programming에 달린 댓글이다.
글에 강력히 반대한다 […] 근본적으로 얽혀 있고 정적인 세계관을 조장한다. 이 글은 프로그램과 세계 사이의 경계에서 무엇이 “유효한” 입력인지 이론화할 수 있거나 그래야 한다고 가정하며, 그 결과 스키마를 따르지 못하면 프로그램 전체가 자동으로 망가지는 강한 결합을 도입한다.
이런 게 장점이라고 하지만 인터넷이 그렇게 작동한다고 상상해 보자. 어떤 서버가 JSON 출력을 바꾸면, 우리는 인터넷 전체를 다시 컴파일하고 다시 프로그래밍해야 한다. 이것이 여기서 장점으로 주장되는 정적 관점이다. […] “파서 마인드셋”은 근본적으로 경직되고 전역적이며, 견고한 시스템 설계는 탈중앙화되어야 하고 데이터 해석을 수신자가 하도록 놔두어야 한다.
글에서 하는 주장—가능하면 정밀한 타입을 사용하라는—을 생각해 보면 이런 오해가 어디서 왔는지 이해할 수 있다. 프록시 서버는 페이로드의 구조를 예상할 수 없는데, 어떻게 그런 스타일로 작성할 수 있겠는가? 댓글 작성자의 결론은, 엄격한 정적 타이핑은 입력 구조를 미리 알 수 없는 프로그램과 상충한다는 것이다.
두 번째 댓글은 Hacker News에 달린 것으로, 첫 번째보다 훨씬 짧다.
그렇다면 Python의
pickle.load()는 어떤 타입 시그니처를 갖게 되나?
이것은 다른 유형의 주장으로, 리플렉티브 연산의 타입은 런타임 값에 의존할 수 있어 정적 타입으로 포착하기 어렵다는 사실에 기대고 있다. 이 논변은 정적 타입이 그러한 연산을 원천적으로 금지하기 때문에 표현력을 제한한다고 시사한다.
두 주장 모두 오류이지만, 왜 그런지 보이려면 암묵적으로 전제된 믿음을 명시적으로 드러내야 한다. 두 댓글은 주로 정적 타입 시스템이 모양이 알려지지 않은 데이터를 처리할 수 없음을 보여주려 하지만, 동시에 동적 타이핑 언어는 그런 데이터를 처리할 수 있다고 암묵적으로 믿고 있다. 곧 보겠지만, 이 믿음은 잘못되었다. 타이핑 규율과 무관하게, 프로그램은 진정으로 알 수 없는 모양의 데이터를 처리할 수 없으며, 정적 타입 시스템은 이미 존재하는 가정을 명시적으로 만들 뿐이다.
주장은 단순하다. 정적 타입 시스템에서는 데이터를 미리 선언해야 하지만, 동적 타입 시스템에서는 타입이 말 그대로 동적일 수 있다는 것이다! 리치 히키(Rich Hickey)는 사실상 이 감정적 호소에 기반해 강연 커리어를 구축해 왔다. 문제는 사실이 아니라는 점이다.
가상의 시나리오를 보자. 분산 시스템이 있고, 시스템의 서비스들은 다른 서비스가 필요로 할 수 있는 이벤트를 내보낸다. 각 이벤트에는 페이로드가 따라오고, 수신 서비스는 이 페이로드를 사용해 추가 동작을 결정한다. 페이로드 자체는 최소한의 구조만 가진 스키마 없는 데이터이며 JSON이나 EDN 같은 범용 교환 포맷으로 인코딩된다.
간단한 예로, 로그인 서비스가 새 사용자가 가입할 때마다 다음과 같은 이벤트를 발행한다고 하자.
{
"event_type": "signup",
"timestamp": "2020-01-19T05:37:09Z",
"data": {
"user": {
"id": 42,
"name": "Alyssa",
"email": "alyssa@example.com"
}
}
}
다운스트림 서비스는 이런 signup 이벤트를 구독하고 이벤트가 발생할 때마다 추가 작업을 수행할 수 있다. 예를 들어, 트랜잭션 이메일 서비스는 새 사용자가 가입할 때 환영 이메일을 보낼 수 있다. 이 서비스를 JavaScript로 작성한다면 핸들러는 대략 이렇게 생겼을 것이다.
const handleEvent = ({ event_type, data }) => {
switch (event_type) {
case 'login':
/* ... */
break
case 'signup':
sendEmail(data.user.email, `Welcome to Blockchain Emporium, ${data.user.name}!`)
break
}
}
하지만 이 서비스가 Haskell로 작성되었다면 어떨까? 현실을 두려워하는 모범적인 Haskell 프로그래머로서 파싱하지 말고 검증하라를 실천한다면, Haskell 코드는 다음과 비슷할 것이다.
data Event = Login LoginPayload | Signup SignupPayload
data LoginPayload = LoginPayload { userId :: Int }
data SignupPayload = SignupPayload
{ userId :: Int
, userName :: Text
, userEmail :: Text }
instance FromJSON Event where
parseJSON = withObject "Event" \obj -> do
eventType <- obj .: "event_type"
case eventType of
"login" -> Login <$> (obj .: "data")
"signup" -> Signup <$> (obj .: "signup")
_ -> fail $ "unknown event_type: " <> eventType
instance FromJSON LoginPayload where { ... }
instance FromJSON SignupPayload where { ... }
handleEvent :: JSON.Value -> IO ()
handleEvent payload = case fromJSON payload of
Success (Login LoginPayload { userId }) -> {- ... -}
Success (Signup SignupPayload { userName, userEmail }) ->
sendEmail userEmail $ "Welcome to Blockchain Emporium, " <> userName <> "!"
Error message -> fail $ "could not parse event: " <> message
보일러플레이트가 더 많은 것은 사실이지만, 타입 정의를 위한 약간의 오버헤드는 예상되는 일이고(특히 이렇게 작은 예제에서는 과장되기 쉽다), 우리가 논의하는 쟁점은 보일러플레이트가 아니다. Reddit 댓글에 따르면 이 코드의 진짜 문제는, 서비스가 새로운 이벤트 타입을 추가할 때마다 Haskell 코드를 업데이트해야 한다는 점이다! Event 데이터타입에 새로운 경우를 추가하고, 새로운 파싱 로직을 작성해야 한다. 페이로드에 새로운 필드가 추가되면 어떡하나? 유지보수 악몽처럼 보인다.
이에 비해 JavaScript 코드는 훨씬 더 관대하다. 새로운 이벤트 타입이 추가되면 switch를 그냥 통과해서 아무것도 하지 않는다. 페이로드에 추가 필드가 들어와도 JavaScript 코드는 그저 무시한다. 동적 타이핑의 승리처럼 보인다.
하지만 그렇지 않다. 정적 타입의 프로그램이 Event 타입을 갱신하지 않아 실패하는 유일한 이유는 우리가 handleEvent를 그렇게 썼기 때문이다. JavaScript 코드에서도 똑같이 할 수 있다. 알 수 없는 이벤트 타입을 거부하는 기본 케이스를 추가하면 된다.
const handleEvent = ({ event_type, data }) => {
switch (event_type) {
/* ... */
default:
throw new Error(`unknown event_type: ${event_type}`)
}
}
여기서는 분명 우스꽝스럽기 때문에 그렇게 하지 않았을 뿐이다. 서비스가 모르는 이벤트를 받으면 무시하는 게 맞다. 관대함이 명백히 옳은 행동인 경우이며, Haskell 코드에서도 쉽게 구현할 수 있다.
handleEvent :: JSON.Value -> IO ()
handleEvent payload = case fromJSON payload of
{- ... -}
Error _ -> pure ()
이것은 여전히 “파싱하지 말고 검증하라”의 정신에 부합한다. 우리가 신경 쓰는 값은 가능한 한 빨리 파싱하여, 이중 검증 함정에 빠지지 않도록 한다. 타입 시스템의 도움을 받아, 실제로 값이 올바른 형식임을 보장하기 전에는 그 가정에 의존하는 코드 경로로 절대 들어가지 않는다. 잘못된 형식의 값에 오류를 던져 응답할 필요는 없다! 단지 그 값을 무시하겠다는 점을 명시하면 된다.
여기서 중요한 점이 드러난다. 이 Haskell 코드의 Event 타입은 “가능한 모든 이벤트”를 설명하는 것이 아니라, 애플리케이션이 신경 쓰는 모든 이벤트를 설명한다. 마찬가지로, 해당 이벤트의 페이로드를 파싱하는 코드는 애플리케이션에 필요한 필드만 다루고, 나머지는 무시한다. 정적 타입 시스템은 우주 전체에 대한 스키마를 성급히 작성하도록 요구하지 않는다. 단지 필요한 것들을 솔직하게 명시하도록 요구할 뿐이다.
이는 입력 지식이 제한적일 때도 여러 유익을 준다.
timestamp 필드에 관심이 없다는 것을 알 수 있는데, 어떤 페이로드 타입에도 그 필드가 등장하지 않기 때문이다. 동적 타입 프로그램에서는 그 필드를 검사하는 코드 경로가 있는지 모든 경로를 샅샅이 살펴봐야 하며, 매우 오류가 나기 쉽다!SignupPayload 타입 안의 userId 필드는 실제로 사용되지 않는다는 것도 알 수 있다. 그 타입은 보수적으로 정의되어 있다. 정말 필요하지 않은지 확인하려면(예를 들어 아예 그 페이로드에서 사용자 ID를 없애려 하는 경우), 해당 레코드 필드만 지우면 된다. 코드가 타입체크를 통과하면, 그 필드에 실제로 의존하지 않는다는 확신을 얻을 수 있다.이미 주장 첫 절반, 즉 정적 타입 언어는 구조가 완전히 알려지지 않은 데이터를 다룰 수 없다는 말은 반박했다. 이제 다른 절반을 보자. 동적 타입 언어는 구조가 전혀 알려지지 않은 데이터를 처리할 수 있다는 주장이다. 그럴듯해 보일지 모르지만, 차분히 생각해 보면 그럴 수 없다.
위의 JavaScript 코드는 Haskell 코드와 똑같은 가정을 한다. 이벤트 페이로드가 event_type 필드를 가진 JSON 객체라고 가정하고, signup 페이로드에는 data.user.name과 data.user.email 필드가 있다고 가정한다. 진정으로 알 수 없는 입력으로는 아무 쓸모 있는 일도 할 수 없다! 새로운 이벤트 페이로드가 추가되었다고 해서, 동적 타이핑이라는 이유만으로 JavaScript 코드가 마법처럼 적응할 수 있는 게 아니다. 동적 타이핑은 그저 값의 타입이 런타임에 함께 전달되고 실행 중에 검사된다는 뜻일 뿐이며, 이 프로그램도 여전히 특정 타입일 것을 암묵적으로 기대하고 있다.
앞 절에서 정적 타입 시스템이 부분적으로 알려진 데이터를 처리할 수 없다는 생각은 반박했지만, 주의를 기울였다면 원 주장 전체를 완전히 반박한 것은 아님을 눈치챘을 것이다.
우리는 알 수 없는 데이터를 처리하긴 했지만 늘 그냥 버렸다. 프록시 같은 것을 구현하려 한다면 이건 통하지 않는다. 예를 들어, 페이로드마다 서명을 붙여 위조를 막으면서 이벤트를 공용 네트워크를 통해 중계하는 전달 서비스가 있다고 하자. JavaScript로는 이렇게 구현할 수 있다.
const handleEvent = (payload) => {
const signedPayload = { ...payload, signature: signature(payload) }
retransmitEvent(signedPayload)
}
이 경우 페이로드의 구조에는 전혀 관심이 없다(signature 함수는 유효한 JSON 객체라면 무엇이든 동작한다). 하지만 모든 정보를 그대로 보존해야 한다. 정적으로 타입이 지정된 언어에서는 페이로드에 정밀한 타입을 부여해야 할 텐데, 이걸 어떻게 할 수 있을까?
역시나 전제를 거부하면 답이 나온다. 애플리케이션에 필요한 것보다 더 정밀한 타입을 부여할 필요가 없다. 동일한 로직을 Haskell에서도 직관적으로 쓸 수 있다.
handleEvent :: JSON.Value -> IO ()
handleEvent (Object payload) = do
let signedPayload = Map.insert "signature" (signature payload) payload
retransmitEvent signedPayload
handleEvent payload = fail $ "event payload was not an object " <> show payload
여기서는 페이로드의 구조에 관심이 없으므로, JSON.Value 타입의 값을 직접 조작한다. 이 타입은 앞서 보았던 Event 타입에 비해 극도로 불정밀하다—임의의 모양을 가진 모든 합법적 JSON 값을 담을 수 있다—하지만 이 경우에는 불정밀함이 오히려 바람직하다.
그 불정밀함 덕분에 타입 시스템이 도와준다. 우리는 페이로드가 JSON 객체라는(다른 JSON 값이 아니라는) 가정을 하고 있는데, 타입 시스템은 그 사실을 잡아내고 비-객체인 경우를 명시적으로 처리하게 만든다. 여기서는 오류를 던지도록 했지만, 앞서처럼 다른 복구 방법을 택할 수도 있다. 다만 그 사실을 명시해야 한다.
다시 한 번 강조하지만, Haskell에서 명시하도록 강제된 이 가정은 JavaScript 코드도 똑같이 하고 있다! 만약 JavaScript의 handleEvent가 객체가 아니라 문자열을 인수로 받는다면, 결과가 바람직할 가능성은 낮다. 문자열에 객체 스프레드를 하면 다음과 같은 놀라운 결과가 나오기 때문이다.
> { ..."payload", signature: "sig" }
{0: "p", 1: "a", 2: "y", 3: "l", 4: "o", 5: "a", 6: "d", signature: "sig"}
이런! 다시 한 번, 파싱 스타일의 프로그래밍이 우리를 도왔다. JSON 값을 명시적으로 Object 경우에 매칭해 “파싱”하지 않았다면, 코드는 컴파일되지 않았을 것이고, 폴스루 케이스를 생략했다면 포괄적이지 않은 패턴에 대한 경고를 받았을 것이다.
이 현상의 예를 하나만 더 보고 넘어가자. UUID를 사용자 ID로 반환하는 API를 소비한다고 하자. “파싱하지 말고 검증하라”를 정직하게 해석하면, Haskell API 클라이언트에서 사용자 ID를 UUID 타입으로 표현할 수 있다.
type UserId = UUID
그러나 Reddit 댓글 작성자는 이에 반대할 것이다! API 계약이 모든 사용자 ID가 UUID라고 명시하지 않는 한, 이 표현은 선을 넘는다. 오늘은 사용자 ID가 UUID일지 몰라도, 내일은 아닐 수 있고, 그러면 코드는 괜히 망가진다! 이것이 정적 타입 시스템의 잘못일까?
답은 역시 아니다. 이는 잘못된 데이터 모델링의 사례이지만, 정적 타입 시스템의 잘못이 아니다—그저 오용되었을 뿐이다. UserId를 표현하는 적절한 방법은 새롭고 불투명한 타입을 정의하는 것이다.
newtype UserId = UserId Text
deriving (Eq, FromJSON, ToJSON)
위의 타입 별칭처럼 기존 UUID 타입에 새 이름만 붙이는 것이 아니라, 이 선언은 다른 모든 타입(Text 포함)과 구분되는 완전히 새로운 UserId 타입을 만든다. 데이터타입의 생성자를 비공개로 유지한다면(즉, 이 타입을 정의하는 모듈에서 생성자를 내보내지 않으면), UserId를 생성하는 유일한 방법은 FromJSON 파서를 통하는 것이다. 쌍대적으로, UserId로 할 수 있는 일은 다른 UserId와의 동등성 비교나 ToJSON 인스턴스를 통한 직렬화뿐이다. 그 외에는 아무것도 허용되지 않는다. 타입 시스템이 원격 서비스의 사용자 ID 내부 표현에 의존하는 것을 막아준다.
이는 완전히 불투명한 데이터를 다룰 때 정적 타입 시스템이 강력하고 유용한 보장을 제공하는 또 다른 방법을 보여준다. 런타임에서 UserId의 표현은 사실 문자열일 뿐이지만, 타입 시스템은 이를 문자열처럼 실수로 사용하지 못하게 하고, 임의의 문자열에서 무에서 유를 창조하듯 새로운 UserId를 위조하는 것도 막는다.1
타입 시스템은 프로그램에 들어오고 나가는 모든 값의 표현을 극도로 자세히 기술하도록 강요하는 족쇄가 아니다. 오히려, 필요에 가장 잘 맞는 방식으로 사용할 수 있는 도구다.
이제 첫 번째 댓글의 주장들은 충분히 반박했다. 하지만 두 번째 댓글이 던진 질문은 논리의 허점처럼 보일 수 있다. Python의 pickle.load()는 대체 어떤 타입인가? 귀엽게 이름 붙여진 Python의 pickle 라이브러리는 전체 Python 객체 그래프를 직렬화/역직렬화할 수 있게 해 준다. 어떤 객체든 pickle.dump()로 파일에 직렬화해 저장할 수 있고, 나중에 pickle.load()로 역직렬화할 수 있다.
정적 타입 시스템에게 이게 어려워 보이는 이유는 pickle.load()가 만들어내는 값의 타입을 예측하기 어렵기 때문이다—오직 그 파일에 pickle.dump()로 무엇을 썼는지에만 달려 있다. 이는 컴파일 타임에 값을 알 수 없으니 본질적으로 동적인 것처럼 보인다. 언뜻 보면 동적 타이핑 시스템은 할 수 있지만 정적 타이핑은 할 수 없는 일처럼 보인다.
하지만 실은 이 상황은 이전의 JSON 예제와 동일하며, Python의 피클링이 네이티브 Python 객체를 직접 직렬화한다는 사실은 본질을 바꾸지 않는다. 왜일까? 프로그램이 pickle.load()를 호출한 이후를 생각해 보자. 다음 함수를 작성했다고 하자.
def load_value(f):
val = pickle.load(f)
# do something with `val`
문제는 이제 val이 어떤 타입이든 될 수 있다는 점이며, 진정으로 알 수 없고 구조가 없는 입력으로는 아무것도 할 수 없듯이, 어떤 값을 가지고 무언가를 하려면 최소한 뭔가를 알아야 한다는 점이다. 결과에서 메서드를 호출하거나 필드에 접근한다면, 이미 pickle.load(f)가 어떤 종류의 것을 반환했는지에 대한 가정을 한 것이다—그리고 그 가정이 바로 val의 타입이다!
예를 들어, val에 대해 하는 유일한 일이 val.foo() 메서드를 호출하고 그 결과(문자열이어야 함)를 반환하는 것이라고 하자. Java를 쓴다면 val의 예상 타입은 매우 명확할 것이다—다음 인터페이스의 인스턴스일 것이다.
interface Foo extends Serializable {
String foo();
}
그리고 사실, Java에는 pickle.load()와 비슷한 함수에 그럴듯한 타입을 부여할 수 있다.
static <T extends Serializable> Optional<T> load(InputStream in, Class<? extends T> cls);
꼬치꼬치 따지는 사람은 이게 pickle.load()와 같지 않다고 할 것이다. 원하는 타입을 미리 고르기 위해 Class<T> 토큰을 넘겨야 하기 때문이다. 하지만 Serializable.class를 넘기고, 객체를 로드한 뒤에 타입에 따라 분기하면 된다. 그리고 그게 핵심이다. 객체로 무언가를 하는 순간, 동적 타입 언어에서도 그 타입에 대해 무언가를 알아야 한다! 정적 타입 언어는 JSON 페이로드를 다룰 때와 마찬가지로 그 사실을 더 명시적으로 만들도록 요구할 뿐이다.
Haskell에서도 이걸 할 수 있을까? 물론이다—serialise 라이브러리를 사용할 수 있는데, 앞서 언급한 Java API와 유사한 인터페이스를 가진다. 또한 Haskell의 JSON 라이브러리인 aeson과도 매우 비슷한 인터페이스를 갖는다. 미지의 JSON 데이터를 다루는 문제는 미지의 Haskell 값을 다루는 문제와 크게 다르지 않기 때문이다—어느 순간에는 값을 가지고 무언가를 하기 위해 약간의 파싱을 해야 한다.
그렇다고 정말 원한다면 pickle.load()의 동적 타이핑을, 타입 검사를 가능한 한 마지막 순간까지 미루는 방식으로 흉내 낼 수는 있다. 하지만 실제로는 거의 쓸모가 없다. 어떤 시점에는 값을 사용하기 위해 구조에 대한 가정을 해야 하며, 그 가정이 무엇인지 우리는 안다. 왜냐하면 그 코드를 우리가 직접 쓰기 때문이다. 매우 드문 예외(자신의 프로그래밍 언어용 REPL을 구현하는 등)에는 진정한 동적 코드 로딩이 필요하지만, 일상적인 프로그래밍에서는 발생하지 않는다. 정적 타입 언어의 프로그래머들은 기꺼이 그 가정을 미리 제공한다.
이것이 정적 타이핑 진영과 동적 타이핑 진영 사이의 근본적인 괴리 중 하나다. 정적 타입 언어의 프로그래머는 동적 타입 언어의 프로그래머가 정적 타입 언어가 “근본적으로” 막는 무언가를 자신들은 할 수 있다고 주장하면 당혹스러워한다. 정적 타입 언어의 프로그래머는 값이 충분히 정밀한 타입을 부여받지 않았을 뿐이라고 답할 수 있기 때문이다. 동적 타입 언어의 관점에서는 타입 시스템이 합법적 동작의 공간을 제한하지만, 정적 타입 언어의 관점에서는 합법적 동작의 집합이 곧 값의 타입이다.
두 관점 모두 적절한 관점에서 보면 부정확하지 않다. 정적 타입 시스템은 프로그램 구조에 제한을 부과한다. 튜링-완전한 언어에서 모든 잘못된 프로그램을 전부 거부하면서 동시에 어떤 올바른 프로그램도 거부하지 않는 것은 불가능하기 때문이다(이는 라이스의 정리다). 하지만 동시에, 일반 문제를 해결하는 것이 불가능하다고 해서 약간 더 제한된 버전을 유용하게 해결하지 못하는 것은 아니다. 그리고 정적 타입 시스템의 이른바 “근본적인” 불능 중 상당수는 사실 근본적이지 않다.
이 글의 핵심 논지는 이제 전달되었다. 정적 타입 시스템은 구조가 개방적이거나 부분적으로만 알려진 데이터를 처리하는 데 동적 타입 시스템보다 근본적으로 못하지 않다. 글의 서두에 인용한 댓글들이 주장하는 바는 정적 타이핑 프로그램 구성의 실제 모습을 정확히 묘사하지 못하며, 정적 타이핑의 한계를 오해하고 동적 타이핑의 능력을 과장한다.
하지만, 과장되어 있긴 해도 이런 신화들에는 어느 정도 현실적 근거가 있다. 그 일부는 구조적(Structural) 타이핑과 명목적(Nominal) 타이핑의 차이를 오해한 데서 비롯된 것으로 보인다. 이 차이는 불행히도 이 글에서 다루기에는 너무 큰 주제로, 아마 몇 편의 글이 필요할 것이다. 약 6개월 전 이 주제로 글을 쓰려 했지만 설득력이 떨어진다고 느껴 폐기했다. 언젠가 더 잘 전달할 방법을 찾을 수 있기를 바란다.
지금 충분한 지면을 할애하진 못하지만, 관심 있는 독자들이 원한다면 다른 자료를 찾아볼 수 있도록 간단히 언급만 하겠다. 핵심은 많은 동적 타입 언어가 해시맵 같은 단순 자료구조를 관용적으로 재사용해, 정적 타입 언어에서 보통은 특화된 데이터타입(대개 클래스나 구조체로 정의)으로 표현하는 것을 대신한다는 점이다.
이 두 스타일은 매우 다른 프로그래밍 맛을 제공한다. JavaScript나 Clojure 프로그램은 문자열이나 심볼 키에서 값으로의 해시맵으로 레코드를 표현하고, 객체/해시 리터럴로 작성하며, 키와 값을 일반적으로 조작하는 표준 라이브러리 함수로 다룬다. 덕분에 두 레코드의 필드를 합치거나, 기존 레코드에서 임의의(심지어 동적인) 부분 필드만 골라내는 것이 간단하다.
반대로, 대부분의 정적 타입 시스템은 레코드를 맵으로 보지 않으며, 모든 다른 타입과 구분되는 고유 타입으로 보기 때문에 그런 자유로운 조작을 허용하지 않는다. 이런 타입은 (완전 수식된) 이름으로 고유하게 식별되며, 그래서 이를 명목적 타이핑이라고 부른다. 구조체의 일부 필드를 골라내려면 아예 새로운 구조체를 정의해야 하고, 이는 종종 어색한 보일러플레이트의 폭발로 이어진다.
리치 히키가 정적 타이핑을 비판하는 많은 강연에서 논의한 핵심 아이디어 중 하나가 바로 이것이다. 그는 필드를 유동적으로 합치고, 분해하고, 변환하는 능력이 동적 타이핑을 분산되고 개방적인 시스템에 특히 잘 맞게 만든다고 주장해 왔다. 불행히도 이 수사는 두 가지 중대한 문제를 가진다.
이 주장들의 반례로, 동적임에도 꽤나 명목적인 Python 클래스와, 정적이면서도 구조적인 TypeScript 인터페이스를 생각해 보라. 실제로 현대의 정적 타입 언어는 점점 더 구조적으로 타입된 레코드를 네이티브로 지원하고 있다. 이런 시스템에서 레코드 타입은 Clojure의 해시처럼 동작한다—별도의 이름을 가진 고유 타입이 아니라 키-값 쌍의 익명 모음이며—Clojure의 해시가 제공하는 표현력 있는 조작 연산을 정적 타입 프레임워크 내에서 상당 부분 지원한다.
강력한 구조적 타이핑을 지원하는 정적 타입 시스템을 탐색하고 싶다면 TypeScript, Flow, PureScript, Elm, OCaml, Reason 등을 추천한다. 이들 모두 구조적으로 타입된 레코드를 어느 정도 지원한다. 이 목적에는 Haskell은 추천하지 않는다. Haskell의 구조적 타이핑 지원은 형편없다. Haskell은(이 글의 범위를 넘어서는 여러 이유로) 매우 공격적으로 명목적이다.2
그렇다면 Haskell이 나쁘거나, 이런 문제를 실용적으로 풀 수 없다는 뜻인가? 전혀 아니다. Haskell에도 이런 문제를 모델링하는 다양한 방법이 있으며, 그중 일부는 보일러플레이트가 많긴 해도 충분히 잘 작동한다. 이 글의 핵심 논지는 앞서 언급한 다른 언어들뿐 아니라 Haskell에도 똑같이 적용된다. 다만 이 구분을 언급하지 않고 넘어가면 실수가 될 것이다. 역사적으로 정적 타입 언어를 더 좌절스럽다고 느낀 동적 타입 배경의 프로그래머에게, 왜 그렇게 느끼는지 ‘진짜’ 이유를 알려 줄 수 있기 때문이다. (사실 주류의 정적 타입 OOP 언어는 Haskell보다도 더 명목적이다!)
마지막으로: 이 글은 설전을 부추기려는 것도, 동적 타이핑 프로그래밍을 공격하려는 것도 아니다. 동적 타입 언어의 많은 패턴은 정적 타입 문맥으로 옮기기 진짜 어려우며, 그런 패턴들에 대한 논의는 생산적일 수 있다. 이 글의 목적은 특정 논의가 왜 생산적이지 않은지를 분명히 하는 것이다. 그러니 제발, 이런 주장은 그만하자. 타이핑에 관해 훨씬 더 생산적인 대화가 많다.
기술적으로는 FromJSON 인스턴스를 악용해 임의의 문자열을 UserId로 바꿀 수 있다. 하지만 말처럼 쉽지는 않은데, fromJSON은 실패할 수 있기 때문이다. 따라서 그 실패 케이스를 어떻게든 처리해야 하며, 입력 파싱을 이미 하고 있는 문맥이 아닌 이상 이 요령으로 멀리 가긴 어렵다… 그런 문맥이라면 그냥 옳은 일을 하는 게 더 쉽다. 즉, 타입 시스템이 어떻게든 스스로 발등을 찍겠다고 작정한 프로그래머를 완전히 보호하진 못하지만, 올바른 해법으로 이끈다(그리고 자기 인생을 망치겠다고 작정한 프로그래머를 완전히 보호해 줄 수 있는 안전장치는 존재하지 않는다). ↩
이 글을 쓰는 시점에, 이것이 Haskell의 가장 큰 결함이라고 생각한다. ↩