donghwi.dev

동적 타입 시스템은 더 개방적인 시스템이 아닙니다

2020년 1월 28일 • 약 19분 소요

이 글은 Alexis King님의 블로그에 올라온 No, dynamic type systems are not inherently more open를 한국어로 번역한 글입니다! 원작자의 허락을 받고 올리는 글이며, 저는 번역 작업만 진행하였고 내용에 대한 저작권은 모두 원작자에게 있음을 알립니다 🤗


최근 들어, 타입 시스템에 대한 논의를 계속 방해하고 있는 ‘미신’이 있습니다. 그것은 바로 개방적인 세계를 모델링 하는 데에 있어, 동적 타입 시스템이 정적 타입 시스템보다 본질적으로 더 낫다는 미신입니다. 이런 종류의 주장은 보통 “정적 타이핑의 목적은 최대한 많은 것들을 못 박아두는 것에 있지만, 실제 세계에서는 이것이 실용적이지 않다. 실제 시스템들은 서로 느슨하게 연결되어야 하며, 데이터의 표현 방식에 대해서는 신경을 최대한 쓰지 않아야 한다. 따라서 결국 넓게 보았을 땐, 동적 타입 시스템이 더 견고한 시스템이다.”와 같은 방식으로 전개됩니다.

꽤 설득력 있게 들리지만, 이 주장은 틀렸습니다. 결점은 바로 잘못된 전제에 있습니다. 정적 타이핑의 목적은 ‘모든 세상을 분류’하거나 ‘시스템에 존재하는 모든 값들을 못 박아두는 것’이 아닙니다. 정적 타입 시스템의 실제 목적은 각 요소가 들어오는 입력의 구조에 대해서 얼마나 알고, 또 반대로 얼마나 몰라야 하는지를 결정할 수 있도록 해주는 것입니다. 실제로 정적 타입 시스템은 애플리케이션이 필요 이상의 가정을 하는 것을 막아주기 때문에, 구조가 온전히 알려지지 않은 데이터 구조를 처리하는 데에 굉장히 유용합니다.

두 가지의 오류

저는 이 글을 예전부터 쓰고 싶었습니다. 글을 쓰기로 결심하게 된 결정적인 계기는 바로 제 전 포스트에 있는 잘못된 댓글들입니다. 두 개의 댓글이 제 눈에 들어왔는데, 하나는 /r/programming에 달린 댓글입니다.

내용에 전혀 동의할 수 없습니다. 이 글은 세상을 복잡하고 정적인 시선으로 바라보게 합니다. 글의 내용을 보면, 프로그램과 세상의 사이에서 입력 중에 어떤 것이 “올바른” 입력인지를 명확하게 정의해야 한다고 말합니다. 이런 방식으로 프로그램을 설계하면 전체 소프트웨어들이 필요 이상으로 끈끈한 결합으로 묶여버려서, 어떤 프로그램이 정해진 스키마(schema)를 따르지 않을 시 그냥 동작을 멈춰버리는 지경에 이르고 말 것입니다.

정적 타입 시스템은 이게 의도된 기능인 것 마냥 광고하지만, 인터넷이 이렇게 동작한다고 생각해봅시다. 어떤 서버가 결과로 내놓는 JSON 형식을 변경하면, 인터넷의 모든 것을 다시 컴파일하고 새로 작성해야 할 것입니다. […] ‘파서 정신(parser mentality)’1은 근본적으로 융통성이 없고 지나치게 포괄적입니다. 견고한 시스템 디자인은 분산적(decentralised)이어야 하며, 데이터의 해석을 수신자(receiver)에게 맡겨야 합니다.

“가능한 한 정밀한 타입을 사용해야 한다”가 이 댓글이 달린 블로그 포스트의 논지 중 하나인데, 여기에서부터 잘못된 해석이 시작됩니다. 정적 타이핑의 관점에 따르면 프록시 서버를 만들 때 들어올 데이터의 구조를 미리 알고 있어야 하는데, 이건 불가능하지 않나요? 이 댓글 작성자의 결론은, “입력 데이터의 구조를 미리 알지 못하는 프로그램을 작성하는 경우, 정적 타이핑은 부적합하다”라는 것입니다.

두 번째 댓글은 Hacker News에 달렸는데, 첫 번째 댓글보다 확실히 짧습니다.

그럼, Python의 pickle.load() 의 타입은 무엇이어야 하나요?

이는 다른 맥락의 주장인데, 리플렉션(reflection) 연산의 타입은 런타임 값에 의해 정해지므로, 정적 타입 시스템에서는 구현할 수 없다는 사실을 예시로 들었습니다. 정적 타입 시스템이 리플렉션 같은 종류의 연산을 완전히 금지하기 때문에, 동적 타입 시스템에 비해 표현력에 한계가 있다고 주장합니다.

이 두 주장은 잘못됐습니다. 하지만 왜 잘못됐는지를 보여드리기 위해, 먼저 두 주장에 암시적으로 내포된 주장을 명시적인 주장으로 바꿔보겠습니다. 두 댓글은 주로 “정적 타입 시스템은 알려지지 않은 형태의 데이터를 처리하지 못한다”라는 것에 집중합니다. 하지만 이 주장의 기저에는, “동적 타입 시스템은 알려지지 않은 형태의 데이터를 처리할 수 있다“라는 믿음이 있습니다. 앞으로 보시겠지만, 이 믿음은 사실이 아닙니다. 타입 시스템에 관계 없이 어떤 프로그램이든, 형태가 전혀 알려지지 않은 데이터는 처리할 수 없습니다. 정적 타입 시스템은 그저 미리 알려진 형태에 대한 정보를 명시적으로 만들어줄 뿐입니다.

모르면 처리할 수 없다

그들의 주장은 간단합니다.

정적 타입 시스템에서는 데이터의 형태를 꼭 미리 선언해야 하지만, 동적 타입 시스템에서는, 타입이 말 그대로 동적이에요!

설명이 필요 없을 정도로 자명합니다. 너무나도 자명하고 매력적이어서, Rich Hickey2는 사실상 이 문장을 가지고 강연 커리어를 쌓았다고 보아도 무방할 정도입니다. 하지만 이 주장은 틀렸습니다.

시나리오는 보통 이렇게 펼쳐집니다. 여러분은 분산 시스템을 운영하고 있습니다. 시스템 속의 서비스는 이벤트를 발생시키며, 다른 서비스가 해당 이벤트에 대해 어떤 처리를 해야 한다면 이를 리슨(listen) 할 수 있습니다. 각 이벤트는 리슨하고 있는 서비스에게 추가 정보를 제공하기 위하여 페이로드(payload)를 같이 가지고 있습니다. 페이로드는 JSON 혹은 EDN 등의 범용 포맷으로 인코드 되어있고, 단순하게 설계된, 스키마 없는 데이터입니다.

간단한 예시로, 로그인 서비스는 새로운 유저가 가입할 때마다 이런 이벤트를 발생시킨다고 합시다.

{
  "event_type": "signup",
  "timestamp": "2020-01-19T05:37:09Z",
  "data": {
    "user": {
      "id": 42,
      "name": "Alyssa",
      "email": ["alyssa@example.com](mailto:%22alyssa@example.com)"
    }
  }
}

다른 서비스들은 이 signup 이벤트를 리슨하고 있다가, 이벤트가 발생할 때마다 추가 작업을 실행할 수 있습니다. 예를 들어, 업무 이메일 서비스는 새로운 유저가 가입할 때마다 환영 이메일을 보내준다고 해봅시다.

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 프로그래머들의 코드는 이렇게 생겼을 것입니다.

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

확실히 JavaScript 코드에 비해서 보일러플레이트(boilerplate, 상용구)는 훨씬 많습니다. 하지만 타입 정의 부분은 예상했던 오버헤드이고, (작은 예시임에 비해 굉장히 과장되어 있긴 합니다) 저희의 논의 주제는 보일러플레이트에 대한 것이 아니니 일단 넘어가도록 합시다. 진짜 문제는 (앞에서 언급했던 Reddit에 달린 댓글에 따르면) 서비스가 이벤트 타입을 추가할 때마다 Haskell 코드가 수정되어야 한다는 것입니다. Event 타입에 새로운 생성자를 추가하여야 하고, 그것에 해당하는 파싱 코드도 구현해야 합니다. 그리고 또 새로운 필드가 페이로드에 추가된다면 어떨까요? 정말 악몽 같은 유지보수네요.

반면, JavaScript 코드는 훨씬 더 관대합니다. 새로운 이벤트가 추가되더라도, switch 문에 그 이벤트에 해당하는 부분이 없기 때문에 아무것도 수행되지 않고 넘어갈 것입니다. 새로운 필드가 페이로드에 추가되더라도, 그 필드는 그냥 무시될 것입니다. 확실히 동적 타이핑의 승리로 보이네요!

… 그럴까요? 아닙니다. 정적 타입 프로그램이 새로운 이벤트가 추가될 때마다 Event 타입을 수정해야 하는 이유는, 그저 우리가 handleEvent 함수를 그런 식으로 작성했기 때문입니다. JavaScript에서도 똑같이 할 수 있습니다. default 케이스에서 ‘알 수 없는 이벤트 타입 에러’를 발생시키면 됩니다.

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 타입은 ‘모든 가능한 이벤트’를 나타내는 타입이 아닙니다. 그저 애플리케이션이 신경 쓰는 이벤트만을 나타내는 타입입니다. 똑같이, 이벤트의 페이로드를 분석하는 코드 또한 애플리케이션이 필요한 필드만을 취하고, 나머지는 무시합니다. 정적 타입 시스템은 코드 작성자에게 온 우주의 스키마를 만들어서 달라고 재촉하지 않습니다. 단지 코드 작성자가 필요로 하는 요소들에 대해 미리 말해주길 요구하는 것뿐입니다.

이런 방식은 입력에 대한 정보가 제한되어 있더라도 많은 장점이 있습니다.

  • 타입 정의만 보고 프로그램이 어떤 요소들을 필요로 하는지 쉽게 알 수 있습니다. Haskell 예시 코드를 보면, 프로그램이 timestamp 필드를 사용하지 않는다는 것을 바로 알 수 있습니다. 왜냐하면 페이로드 타입 선언에 timestamp가 없기 때문입니다. 하지만 동적 타입 프로그램의 경우, 실행 코드 경로를 하나하나 분석하며 해당 필드가 사용되는지 확인하여야 하는데 이는 실수를 하기 쉬운 방식입니다.
  • 또, Haskell 프로그램의 코드를 보면 SignupPayload 타입의 userId 필드는 어디에서도 사용되지 않습니다. 실제로 사용되지 않는다는 것을 확실히 확인하려면, 그냥 타입 정의에서 해당 필드를 지워보면 됩니다. 만약 코드가 에러 없이 컴파일 된다면, 프로그램이 확실히 그 필드에 의존하지 않는다는 것을 알 수 있게 됩니다.
  • 마지막으로, 이전 블로그 포스트에서도 언급했던 “Shotgun parsing”(파싱하는 방식과 검증하는 방식을 섞어서 사용하는 방식)이라는 안티 패턴을 피할 수 있습니다.

주장의 절반인 “정적 타입 언어는 구조의 일부만 알려져 있는 데이터를 다룰 수 없다”가 틀렸음을 입증했습니다. 그럼 이제 나머지 절반, “동적 타입 언어는 구조가 아예 알려져 있지 않은 데이터를 다룰 수 있다”를 살펴봅시다. 여전히 맞는 소리처럼 들릴 수 있지만, 조금만 더 천천히 생각해보면 아니라는 것을 알 수 있습니다.

위의 JavaScript 코드 예제는 Haskell 코드와 똑같은 ‘가정’을 합니다. 이벤트 페이로드는 JSON이며, 그것은 event_type 필드를 가지고 있고, signup 이벤트의 페이로드는 data.user.name 필드와 data.user.email 필드를 가지고 있음을 전제로 동작하는 코드입니다. 정말로 아무것도 알려지지 않아서 아무런 가정도 할 수 없는 입력에 대해서는, JavaScript 또한 아무것도 할 수 없습니다. 동적 타입 언어라고 해서, 새로운 이벤트 페이로드가 추가되었을 때 아무런 코드 수정도 없이 마법처럼 그것에 대응할 수 있게 되는 것은 아닙니다. 동적 타이핑은 그저 값의 타입이 런타임에 값과 함께 따라다니며, 런타임 시점에 타입 체킹이 이루어지는 것뿐입니다. 타입은 여전히 존재하며, 프로그램은 결국 그 타입들에 의존할 수밖에 없습니다.

불투명한 데이터들은 불투명하게

이제 “정적 타입 시스템은 일부만 알려진 데이터를 처리할 수 없다”라는 주장을 반박했습니다. 하지만 조금 더 생각해보면, 해당 주장을 완벽하게 논박하지는 못했다는 사실을 알 수 있습니다.

우리는 알려지지 않은 데이터를 ‘무시’함으로써 처리했습니다. 하지만 우리가 프록시 서버 같은 역할의 서비스를 구현하고 있었다고 생각해보면, 이는 잘못된 처리 방식입니다. 예를 들어, 이벤트를 받고, 해킹 시도를 막기 위해 페이로드에 서명 키(signature)를 추가한 다음, 공개 네트워크에 이를 다시 전달해주는 서비스를 만든다고 해봅시다. JavaScript로는 이렇게 구현할 수 있을 것입니다.

const handleEvent = payload => {
  const signedPayload = { ...payload, signature: signature(payload) }
  retransmitEvent(signedPayload)
}

이 경우, 페이로드의 구조에 대해서는 신경을 쓰지 않지만 (signature 함수는 모든 JSON 객체에 대해서 동작한다고 합시다) 담겨있는 정보는 그대로 보존을 해야 합니다. 그러면 페이로드를 정밀한 타입에 넣어줘야 하는 정적 타입 언어에서는 이걸 구현할 수 없지 않나요?

여기에서도, 전제가 틀렸습니다. 애플리케이션이 필요 이상으로 정밀한 타입을 사용해야 할 이유는 없습니다. 위 JavaScript 코드에서 사용된 논리가 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.Value를 사용한 것입니다.

비정밀성과 타입 시스템이 우리를 도와주었습니다. 페이로드가 JSON ‘객체’3라는 가정을 했고, 입력이 객체가 아닌 예외적인 경우를 명시적으로 다룰 수 있도록 해주었습니다. 이 코드에서는 그런 예외적인 경우에 fail 함수를 호출함으로써 에러를 발생시켰지만, 이전처럼 다른 방식의 에러 핸들링을 할 수도 있습니다. 어떤 방식이든지 에러 핸들링 방식을 명시적으로 작성하기만 하면 되는 겁니다.

여기에서도, Haskell 코드에서는 명시적이었던 가정들이 JavaScript 코드에도 (암시적으로) 존재한다는 사실을 알고 계셨나요? JavaScript의 handleEvent 함수가 JSON 객체가 아닌 JSON 문자열을 가지고 호출되었다면, 결과가 원했던 대로 나오지 않을 것입니다. 문자열에 대한 펼치기(spread) 연산은 예상치 못한 결과를 가져옵니다.

> { ..."payload", signature: "sig" }
{0: "p", 1: "a", 2: "y", 3: "l", 4: "o", 5: "a", 6: "d", signature: "sig"}

만약 우리가 Haskell 코드에서 JSON 입력을 ‘파싱’하지 않고, 객체라는 암시적인 가정을 한 다음 사용했다면 컴파일 할 때 타입 에러가 났을 것입니다. 그리고 객체가 아닌 나머지 예외적 경우에 대한 처리 코드를 작성하지 않았다면 컴파일 경고를 받았을 것입니다. 여기서 또 파싱 스타일의 프로그래밍이 도움을 주었네요.


다음으로 넘어가기 전에 한 가지 예를 더 살펴봅시다. 우리가 유저 ID를 반환하는 API를 사용하고 있고, 그 ID가 UUID라고 해봅시다. Haskell API 클라이언트에서는 유저 ID를 이렇게 나타낼 수 있습니다.

type UserId = UUID

하지만, 앞에서 보았던 레딧 댓글 작성자는 이 코드를 굉장히 싫어할 것입니다. API 문서가 모든 유저 ID는 UUID라고 명확하게 선언하지 않는 이상, 이렇게 타입을 정의하는 것은 필요 이상의 가정을 하는 행위가 될 수 있기 때문입니다. 또 유저 ID가 오늘은 UUID이지만, 내일은 아닐 수도 있고, 그렇게 된다면 코드는 내일이 되면 동작하지 않을 것입니다. 이게 정적 타입 시스템의 잘못일까요?

아닙니다. 이것은 데이터 모델링 방식이 잘못된 것이지, 정적 타입 시스템 그 자체의 문제는 아닙니다. 그저 잘못 쓰인 것일 뿐입니다. 유저 ID를 표현하는 올바른 방식은, 조금 더 비정밀한 타입을 새로 정의하는 것입니다.

newtype UserId = UserId Text
  deriving (Eq, FromJSON, ToJSON)

방금 전 코드는 이미 존재하는 UUID 타입에 그저 새로운 이름을 붙여준 것에 불과했습니다. 하지만 이 코드는 완전히 새로운 UserId 타입을 정의합니다. UserId 타입은 다른 타입과는 구별되는 고유한 타입입니다. Text 타입과도 구별됩니다. 만약 우리가 이 타입의 생성자를 외부에 공개하지 않으면 (모듈에서 export 하지 않으면) UserId 타입의 값을 생성하기 위해선 FromJSON 파서를 사용할 수밖에 없을 것입니다. 동시에, UserId 타입의 값을 가지고 할 수 있는 것은, 다른 UserId 값과 같은지 비교하거나 ToJSON 인스턴스를 통해 JSON 형태로 직렬화(serialize) 하는 것 밖에 없을 것입니다. 다른 연산들은 어떤 것도 허용되지 않습니다. 즉, 예를 들어서, 여러분이 다른 원격 서비스의 유저 ID 내부 표현 방식을 실수로 섞어서 사용하는 것을, 타입 시스템이 막아줄 것입니다.

이는 정적 타입 시스템이 불투명한 데이터를 다룰 때 얼마나 강력하고, 유용한 보장들을 제공해주는지 보여줍니다. 물론 런타임에는 UserId가 그저 문자열에 불과하지만, 컴파일 타임에는 타입 시스템이 UserId를 문자열처럼 사용하는 것을 허용하지 않을 것이고, 임의의 문자열로부터 UserId를 실수로 생성하는 것을 막아줍니다.4

타입 시스템은 프로그램에 출입하는 모든 데이터들의 표현 방식을 상세히 설명하도록 강제하는 쇠사슬이 아닙니다. 오히려, 여러분의 필요에 따라 알맞게 사용할 수 있도록 해주는 도구에 가깝습니다.

리플렉션이라고 다르지 않습니다

이제 드디어 첫 번째 댓글 작성자가 주장한 내용들을 모두 반박했습니다. 하지만 두 번째 댓글 작성자의 지적은 여전히 정적 타입 시스템의 결점으로 보일 수 있습니다. 과연 Python의 pickle.load() 의 타입은 무엇이 되어야 할까요?

Python에 친숙하지 않은 분들을 위해 설명을 드리자면, pickle 은 Python의 모든 객체를 타입에 상관없이 .pkl 포맷으로 직렬화, 역직렬화할 수 있게 해주는 라이브러리입니다. pickle.dump()를 통해 Python 객체를 직렬화하여 저장하고, 후에 pickle.load()를 통해서 불러올 수 있습니다.

정적 타입 시스템에서 이것을 구현하는 것이 어려워 보이는 이유는, pickle.load()가 생성하는 값의 타입을 예측하는 것이 불가능하기 때문입니다. 로드하려는 파일에 어떤 것이 쓰여 있는지에 따라 값이 결정되기 때문에, 컴파일을 하는 시점에서는 이 함수가 어떤 타입의 값을 생성할지 알 수 없습니다. 언뜻 보기엔, 이것이야말로 동적 타입 시스템에서만 가능하고 정적 타입 시스템은 하지 못하는 것으로 보일 수 있습니다.

하지만, 조금 생각해보면 이것도 이전에 보여드렸던 JSON 예시와 동일한 경우라는 것을 알 수 있습니다. pickle이 Python의 객체를 직접 직렬화한다고 해서 상황이 변하진 않습니다. 왜 그럴까요? 프로그램이 pickle.load()를 한 이후의 상황을 생각해봅시다.

def load_value(f):
  val = pickle.load(f)
  ## `val`로 무언가를 한다

문제는 여기서 val이 말 그대로 어느 타입이든 될 수 있다는 것입니다. 아무것도 알려진 것이 없고, 구조도 없는 입력 데이터를 가지고는 아무런 처리도 할 수 없는 것처럼, val에 대해서도 아는 것이 아무것도 없으므로 아무것도 할 수 없습니다. 만약 val의 메서드를 호출하거나, 필드에 접근하는 코드를 작성하면, 그 순간 여러분은 val에 대해서 일종의 가정을 한 것입니다. 그리고 그 가정이 바로 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()와 같지 않다고 지적할 수도 있습니다. 왜냐하면 load의 결과 타입이 무엇인지 미리 결정해서 Class<T> 토큰으로 넘겨주어야 하기 때문입니다. 하지만, 일단 Serializable.class를 넘긴 뒤에 실제 타입이 무엇인지는 객체가 로드 된 뒤 나중에 필요할 때 결정하게 할 수도 있습니다. 바로 이것이 요점입니다. 객체를 어떤 목적으로든 실제로 사용하려면, 그 시점에서는 그 객체의 타입에 대해서 무언가 알고 있어야만 합니다. 이것은 동적 타입 언어에서도 마찬가지입니다. 정적 타입 언어는 그저 코드의 작성자가 더욱 명시적이도록 강제하는 역할을 할 뿐입니다. JSON 페이로드 예시에서도 똑같은 상황을 볼 수 있었습니다.


이걸 Haskell에서도 할 수 있을까요? 물론입니다. serialise 라이브러리는 위에서 예시로 든 Java와 비슷한 API 구조를 가지고 있습니다. 그리고 Haskell JSON 라이브러리인 aeson도 비슷한 인터페이스를 가지고 있습니다. 두 라이브러리가 비슷한 이유는, 알 수 없는 JSON 데이터를 다루는 것과 알 수 없는 Haskell 값을 다루는 것이 크게 다르지 않기 때문입니다. 데이터를 가지고 어떤 작업을 해야 하는 그 순간에 파싱을 수행해야 한다는 것도 똑같습니다.

Haskell에서 타입 체킹이 이루어지는 시점을 최대한 뒤로 미루면, Python의 pickle.load()를 모방할 수 있습니다. 그러나 이건 실제로 전혀 유용하지 못할 것입니다. 결국 어느 시점에서는 주어진 값을 사용하기 위해서 일종의 가정을 해야만 합니다. 그런데 코드의 작성자는 그곳에 필요한 가정이 무엇인지 알고 있습니다. 알고 있는 사실에 대한 명시를 뒤로 미루는 것은 좋은 선택이 아닙니다. 하지만 아주 예외적으로, 완전히 동적인 코드를 필요로 하는 경우도 있습니다 (예를 들어 Python의 eval, 혹은 본인이 직접 만든 언어에 REPL을 구현하는 경우). 하지만 이것은 일상적인 프로그래밍에서는 나타나지 않습니다. 그리고 정적 타입 언어의 프로그래머들은 가정의 명시를 최대한 일찍 하는 것을 선호합니다.

이것이 바로 정적 타이핑 선호자들과 동작 타이핑 선호자들 사이에서의 가장 본질적인 의견 차이입니다. 한 프로그래머가 정적 타입 프로그래머들에게 “이건 동적 타입 언어에서는 가능하지만, 정적 타입 언어에서는 기본적으로 불가능해!”라고 말하면, 그들은 이해할 수 없어합니다. 그냥 값에 충분히 정밀한 타입이 주어졌지 않았기 때문이라고 대답하면 되기 때문입니다. 동적 언어를 사용하는 프로그래머의 시선에서는, 타입 시스템이 ‘허용되는 것들의 범위를 제한’하는 것으로 보이지만, 정적 타입 언어 프로그래머의 시선에서는 ‘허용되는 것들의 범위’ 그 자체가 타입인 것입니다.

두 관점은 모두 틀렸다고는 할 수 없습니다. 정적 타입 시스템은 실제로 프로그램의 구조에 제한을 가하긴 합니다. 튜링 완전한 언어는 모든 비정상적인 프로그램을 거부하려면 필연적으로 일부 정상적인 프로그램 또한 거부해야 합니다(라이스 정리). 하지만 어떤 문제를 일반적으로 해결할 수 없다고 해도, 그 문제의 살짝 제한된 버전을 다른 유용한 방법을 통해서 해결할 수 있습니다. 정적 타입 시스템의 ‘근본적인’ 한계점이라고 지적되는 것들은 실제로 전혀 근본적이지 않습니다.

부록 : 미신 뒤의 현실

“정적 타입 시스템은 구조가 일부만 알려져 있거나 유연한 구조를 가진 데이터를 처리하는 것에 있어서 동적 타입 시스템보다 못하지 않다”, 이 글의 핵심 논지에 대한 설명을 마쳤습니다. 글 초반에 인용한 댓글 두 개는, 정적 타입 프로그램 설계에 대한 잘못된 묘사입니다. 정적 타입 시스템의 한계를 오해했으며, 동적 타입 시스템의 효용을 과장했습니다.

그러나 그 미신들이 굉장히 과장되어 있긴 하지만, 아무런 근거 없이 나온 것은 아닙니다. 제가 생각했을 때 이 미신들은 구조적(structural) 타이핑과 명목적(nominal) 타이핑의 차이에 대한 잘못된 이해로부터 나온 것 같습니다. 둘의 차이는 이 글에서 설명하기엔 너무 큽니다. 6달 전쯤에 이 주제를 가지고 글을 작성하려고 해봤었는데, 별로 설득력 있게 전달되지 않는 것 같아서 그만두었습니다. 언젠가는 이 아이디어를 가지고 소통할 더 좋은 방법을 찾을 수 있으리라 생각합니다.

어쨌든 온전하게 설명할 순 없지만, 간략하게나마 설명을 해보겠습니다. 요점은, 동적 프로그래밍 언어들은 대부분 해쉬맵과 같은 단순한 데이터 구조들을 재사용하지만, 정적 타입 언어의 타입은 대부분 해당 목적에 맞추어 새로 만들어진 타입을 사용한다는 것입니다. (보통 클래스 혹은 구조체로 정의됩니다.)

두 스타일은 서로 굉장히 다른 프로그래밍 방식들을 만들어냅니다. JavaScript 또는 Clojure는 레코드(필드의 집합)를 문자열 혹은 심볼 키와 값을 대응시키는 해쉬맵으로 나타냅니다. 그리고 키와 값을 특정 방식으로 처리하는 표준 라이브러리의 함수를 사용하여 레코드를 다룹니다. 두 레코드를 가지고 필드를 합치거나, 임의적으로 (혹은 심지어 동적으로) 한 레코드의 필드 중 일부를 선택하여 새로운 레코드를 만들어낼 수도 있습니다.

반면, 대부분의 정적 타입 시스템들은 이렇게 자유로운 방식의 레코드 조작을 허용하지 않습니다. 왜냐하면 정적 타입 시스템에서 레코드는 해쉬맵이 아니라 고유한 타입을 가지기 때문입니다. 이 타입들은 타입의 구조가 아닌 타입의 이름만을 가지고 구별되는데, 이런 이유로 명목적(nominal) 타이핑이라 불립니다. 동적 타입 언어처럼 레코드의 필드 중 일부를 선택해서 새로운 구조를 생성하려면, 항상 새로운 타입을 만들어야만 합니다. 그리고 이는 굉장히 많은 보일러플레이트들을 만들어냅니다.

Rich Hickey가 그의 강연에서 정적 타이핑을 비판하며 이를 주 논거로 제시하였습니다. 레코드들을 서로 병합하고, 분리하고, 변환할 수 있는 능력이 동적 타입 시스템을 현실 세계에 더 적합한 시스템으로 만들어 준다고 주장했습니다. 하지만 이 주장은 두 가지 중요한 결함이 있습니다.

  1. 이 주장은 명목적이고 정적인 타입 시스템이 근본적인 한계에 의해 유동적인 시스템을 모델링 하지 못하는 것처럼 묘사합니다. 이 글에서 보여주었듯이 이건 사실이 아니며, 논거의 실제 가치(구조적 데이터 모델링의 현실적이고 실용적인 이점)로부터 사람들을 멀어지게 합니다.
  2. 구조적/명목적 타이핑의 구분을 동적/정적 타이핑 구분과 혼동시킵니다. 이는 레코드를 병합하고 분리하는 등의 연산이 동적 타입 언어에서만 가능하다는 잘못된 인식을 심어줍니다. 정적 타입 언어도 구조적 타이핑을 지원할 수 있고, 동적 타입 언어도 명목적 타이핑을 지원할 수 있습니다. 타이핑 방식을 구분하는 두 기준은 느슨하게나마 연관이 있긴 하지만, 이론적으로는 확실히 구분되는 개념입니다.

반례로, Python의 클래스는 동적이지만 명목적입니다. 또 TypeScript의 인터페이스는 정적이지만 구조적이기도 합니다. 최근에는, 정적 타입 언어들도 구조적인 레코드를 언어 차원에서 지원하는 경향이 늘어나고 있습니다. 이런 시스템에서는, 레코드 타입이 Clojure의 해쉬맵처럼 동작합니다. 그리고 정적 타입 시스템임에도 불구하고 Clojure와 비슷한 수준의 레코드 조작 연산을 지원합니다.

혹시 구조적 타이핑을 제대로 지원하는 정적 타입 시스템을 좀 더 살펴보고 싶다면, TypeScript, Flow, PureScript, Elm, OCaml, Reason 중 하나를 살펴보시는 걸 추천합니다. 이들은 정적 타입 시스템이지만, 구조적인 레코드를 지원합니다. 그러나 Haskell은 추천하지 않습니다. Haskell은 구조적 타이핑에 대한 지원이 거의 전무합니다. Haskell은 극단적으로 명목적입니다.5

이게 Haskell이 나쁘다거나, 이런 문제를 다룰 때 Haskell은 실용적이지 못하다는 것을 의미하진 않습니다. 보일러플레이트가 많아지긴 하겠지만, Haskell도 Haskell만의 방식들로 이런 문제들을 모델링 할 수 있습니다. 이 글의 주요 논지는 제가 앞서 언급했던 많은 언어들뿐만 아니라 Haskell에도 똑같이 적용됩니다. 하지만 이 명목적, 구조적 타이핑 방식의 차이에 대해 알게 되면, 정적 타입 언어는 굉장히 불편하다고 생각해왔던 동적 타입 프로그래머들이 스스로 왜 그렇게 느끼는지, 조금 더 명확한 이해를 얻을 수 있게 되기 때문에 이를 언급하지 않을 수 없었습니다. (주류 정적 타입 객체지향 언어들은 심지어 Haskell 보다 더 명목적이기도 합니다.)

마지막으로, 이 글은 타이핑 방식을 둘러싼 전쟁을 시작하기 위해서 쓴 것이 아닙니다. 그리고 동적 타입 프로그래밍에 대한 비난도 아닙니다. 동적 타입 언어에 존재하는 많은 패턴들 중에서는 정적 타입 언어의 맥락에서 해석하는 것이 까다로운 것들이 있고, 이런 패턴들을 주제로 한 토론은 생산적일 수 있습니다. 이 글의 목적은 이 글에서 제시한 특정 논의 주제가 생산적이지 않다는 사실을 지적하기 위함입니다. 그러니 제발, 이런 주장들을 더 이상 하지 말아 주세요. 타입 시스템에 대해서는 훨씬 더 생산적인 토론 주제가 많습니다.


  1. (역주) 이 글의 저자가 이전에 올렸던 포스트 중에 Parse, don’t validate라는 글이 있습니다. “파싱 하되, 검증하지 말라”는 것인데, 해당 포스트에서 말하는 파싱에 관한 내용이 이 글에서 계속 언급됩니다. 여기에서 말하는 “파서 정신”도 이 내용을 의미하는 것입니다. 그런데 통상적으로 쓰이는 파싱의 의미와는 조금 다르게 쓰여서 혼란스러우실 수 있습니다. 간단하게나마 설명드리자면, ‘파싱’은 프로그램에 입력이 주어지면 최대한 이른 시점에 입력의 구조를 분석해서 원하는 구조인지 확인하고, 분석 결과를 바탕으로 추가 작업을 수행하는 프로그래밍 방식입니다. 반면 ‘검증’ 방식은 구조를 분석하지 않고 일단 작업을 수행한 다음 예외적인 상황이 생기면 그때 처리를 하는 방식이고, 저자는 이를 안티 패턴으로 간주합니다. 조금 더 자세히 이해하고 싶으시다면 ”Parse, don’t validate“를 한 번 읽어보시는 것을 추천합니다.

  2. (역주) 유명한 동적 타입 언어인 Clojure를 설계한 프로그래머입니다.

  3. (역주) JSON에서는 정수, 실수, 문자열과 같은 것들을 값(value)이라고 하고, {}로 둘러싸여 key, value를 가지는 것들을 객체(object)라고 합니다.

  4. 임의의 문자열을 통해서 UserId를 생성하는 것이 완전히 불가능하진 않습니다. FromJson 인스턴스를 악용하면 되는데, 이게 그렇게 쉬운 일은 아닙니다. fromJSON은 입력 파싱에 실패할 수 있기 때문에, 실패할 경우에 대한 처리 코드도 작성해야 합니다. 그냥 올바른 코드를 작성하는 것이 더 쉬운 경우이므로, 이 함정에 빠지게 될 확률은 굉장히 낮습니다. 어쨌든, 타입 시스템은 여러분이 직접 스스로의 발에 총을 쏘는 것을 막진 않습니다. 그저 올바른 방향으로 안내하는 역할을 할 뿐입니다. (그리고 스스로 자신의 삶을 비참하게 만들고자 하는 프로그래머를 막는 안전장치는 세상에 존재하지도 않습니다.)

  5. 제 생각에 현시점엔 이것이 Haskell의 가장 큰 단점인 것 같습니다.


Loading script...
© 2020, Made with a lot of love by Suh Donghwi