Haskell의 newtype이 제공하는 안전성과 구성적 데이터 모델링의 차이를 설명하고, 추상화 경계로서의 유용성과 남용 사례를 논한다.
Haskell 프로그래머들은 타입 안전성에 대해 많은 시간을 들여 이야기한다. Haskell 식의 프로그램 구성법은 “불변식을 타입 시스템에 담아내기”와 “불법 상태를 표현 불가능하게 만들기”를 옹호하는데, 둘 다 그럴듯한 목표처럼 들리지만, 그것을 달성하는 구체적인 기법에 대해서는 다소 모호하다. 거의 정확히 1년 전, 나는 그 간극을 메우기 위한 첫 시도로 Parse, Don’t Validate를 게시했다.
그 뒤의 논의들은 대체로 생산적이고 올바른 방향이었지만, 곧 하나의 혼란스러운 지점이 분명해졌다. 바로 Haskell의 newtype 구문이다. 개념은 충분히 단순하다. newtype 키워드는 래퍼 타입을 선언하는데, 이는 감싸는 타입과 표현(representation)상 동치이지만 명목상으로는 구별된다. 겉보기에는 이것이 타입 안전성으로 가는 단순하고 직선적인 경로처럼 “들린다.” 예를 들어, newtype 선언을 사용하여 이메일 주소용 타입을 정의해 볼 수 있다:
newtype EmailAddress = EmailAddress Text
이 기법은 어느 정도 가치를 제공할 수 있으며, 스마트 생성자와 캡슐화 경계를 함께 사용하면 안전성도 어느 정도 제공할 수 있다. 하지만 이것은 1년 전 내가 강조했던 타입 안전성과 의미 있게 다른 종류의 안전성으로, 훨씬 약하다. 그 자체만으로, newtype은 그저 이름일 뿐이다.
그리고 이름은 타입 안전성이 아니다.
구성적 데이터 모델링(이전에 쓴 블로그 글에서 길게 다뤘다)과 newtype 래퍼의 차이를 보여 주기 위해 예시를 생각해 보자. “1 이상 5 이하의 정수”에 대한 타입이 필요하다고 하자. 자연스러운 구성적 모델링은 다섯 가지 경우를 갖는 열거형일 것이다:
data OneToFive
= One
| Two
| Three
| Four
| Five
그다음 Int와 OneToFive 타입 사이를 변환하는 함수를 쓸 수 있다:
toOneToFive :: Int -> Maybe OneToFive
toOneToFive 1 = Just One
toOneToFive 2 = Just Two
toOneToFive 3 = Just Three
toOneToFive 4 = Just Four
toOneToFive 5 = Just Five
toOneToFive _ = Nothing
fromOneToFive :: OneToFive -> Int
fromOneToFive One = 1
fromOneToFive Two = 2
fromOneToFive Three = 3
fromOneToFive Four = 4
fromOneToFive Five = 5
이는 우리가 세운 목표를 달성하기에 완전히 충분하지만, 다소 이상하게 느껴져도 무리는 아니다. 실제로 다루기에는 상당히 불편할 것이다. 완전히 새로운 타입을 만들어 버렸기 때문에 Haskell이 제공하는 보통의 수치 연산 함수를 재사용할 수 없다. 결과적으로 많은 프로그래머는 대신 newtype 래퍼로 기울게 된다:
newtype OneToFive = OneToFive Int
앞서와 마찬가지로 동일한 타입의 toOneToFive와 fromOneToFive 함수를 제공할 수 있다:
toOneToFive :: Int -> Maybe OneToFive
toOneToFive n
| n >= 1 && n <= 5 = Just $ OneToFive n
| otherwise = Nothing
fromOneToFive :: OneToFive -> Int
fromOneToFive (OneToFive n) = n
이 선언들을 별도의 모듈에 넣고 OneToFive 생성자를 export하지 않으면, 이 API들은 전적으로 서로 교환 가능해 보일 수 있다. 순진하게 보자면, newtype 버전이 더 단순하면서도 동일한 수준의 타입 안전성을 가지는 것처럼 보이는데—어쩌면 놀랍게도—이는 사실이 아니다.
이유를 보기 위해 OneToFive 값을 인자로 소비하는 함수를 작성해 보자. 구성적 모델링에서는, 그런 함수는 다섯 생성자 각각에 대해 패턴 매칭만 하면 되고, GHC는 정의가 완전(exhaustive)하다고 받아들인다:
ordinal :: OneToFive -> Text
ordinal One = "first"
ordinal Two = "second"
ordinal Three = "third"
ordinal Four = "fourth"
ordinal Five = "fifth"
newtype 인코딩에서는 그렇지 않다. newtype은 불투명하므로 그것을 관찰하는 유일한 방법은 다시 Int로 변환하는 것뿐이다. 결국 그것은 Int니까. 물론 Int는 1부터 5 이외의 값들도 많이 담을 수 있으므로, 완전성 검사기를 만족시키기 위해 오류 경우를 추가해야 한다:
ordinal :: OneToFive -> Text
ordinal n = case fromOneToFive n of
1 -> "first"
2 -> "second"
3 -> "third"
4 -> "fourth"
5 -> "fifth"
_ -> error "impossible: bad OneToFive value"
이처럼 매우 조작된 예시에서는 별 문제가 아닌 것처럼 보일 수도 있다. 하지만 그럼에도 두 접근법이 제공하는 보장에 중요한 차이가 있음을 보여 준다:
구성적 데이터 타입은 그 불변식을 다운스트림 소비자가 접근할 수 있는 방식으로 담아낸다. 덕분에 ordinal 함수는 불법 값을 처리하는 걱정을 할 필요가 없으며, 그런 값들은 애초에 표현 불가능하게 되었다.
newtype 래퍼는 값을 검증하는 스마트 생성자를 제공하지만, 그 검사 결과의 불리언은 제어 흐름에만 사용될 뿐 함수 결과에는 보존되지 않는다. 따라서 다운스트림 소비자는 제한된 도메인의 이점을 활용할 수 없고, 사실상 Int를 받는 것과 다를 바 없다.
완전성 검사를 잃는 것이 별일 아닌 것처럼 보일지 모르지만, 절대 그렇지 않다. error를 사용함으로써 타입 시스템에 구멍을 뚫어 버렸다. 만약 우리 OneToFive 데이터 타입에 또 다른 생성자를 추가한다면,1 구성적 데이터 타입을 소비하는 ordinal 버전은 컴파일 타임에 즉시 비완전하다고 감지되겠지만, newtype 래퍼를 소비하는 버전은 계속 컴파일되고 런타임에 실패하여 “불가능” 케이스로 떨어질 것이다.
이 모든 것은 구성적 모델링이 내재적으로 타입 안전하다는 사실의 결과다. 즉, 안전성 속성들이 타입 선언 자체에 의해 강제된다. 불법 값은 정말로 표현 불가능하다. 다섯 생성자 중 어떤 것으로도 6을 표현할 방법이 없다. 반면 newtype 선언에는 Int와 본질적으로 의미상의 구별이 없다. 그것의 의미는 toOneToFive 스마트 생성자를 통해 외재적으로(spec) 주어진다. newtype이 의도하는 의미상의 구분은 타입 시스템에게 철저히 보이지 않는다. 그것은 오직 프로그래머의 머릿속에만 존재한다.
OneToFive 데이터 타입은 다소 인위적이지만, 동일한 논리는 훨씬 실용적인 다른 데이터 타입에도 똑같이 적용된다. 최근 블로그 글들에서 내가 반복적으로 강조했던 NonEmpty 데이터 타입을 생각해 보자:
data NonEmpty a = a :| [a]
일반 리스트 위에 newtype으로 표현된 NonEmpty 버전을 상상해 보면 도움이 될 수 있다. 보통의 스마트 생성자 전략을 사용해 원하는 “비어 있지 않음” 속성을 강제할 수 있다:
newtype NonEmpty a = NonEmpty [a]
nonEmpty :: [a] -> Maybe (NonEmpty a)
nonEmpty [] = Nothing
nonEmpty xs = Just $ NonEmpty xs
instance Foldable NonEmpty where
toList (NonEmpty xs) = xs
OneToFive에서와 마찬가지로, 이 정보를 타입 시스템에 보존하지 못한 결과를 곧바로 목격하게 된다. NonEmpty의 동기였던 사용 사례는 안전한 head를 작성할 수 있게 하는 것이었지만, newtype 버전은 또 다른 단언을 요구한다:
head :: NonEmpty a -> a
head xs = case toList xs of
x:_ -> x
[] -> error "impossible: empty NonEmpty value"
이것도 별일 아니라고 느낄 수 있다. 그런 경우가 실제로 일어날 가능성이 낮아 보이니까. 하지만 그 논리는 전적으로 NonEmpty를 정의한 모듈의 정확성을 신뢰하는 데 달려 있다. 반면 구성적 정의는 GHC 타입체커의 정확성만을 신뢰하면 된다. 우리는 일반적으로 타입체커가 올바르게 동작한다고 신뢰하므로, 후자가 훨씬 더 설득력 있는 증명이다.
newtype을 좋아하는 사람이라면, 지금까지의 주장이 다소 불편할 수 있다. 내가 newtype이 타입체커에게 의미가 있기는 하지만 주석보다 나을 게 별로 없다고 암시하는 것처럼 들릴지도 모른다. 다행히 상황이 그렇게 암울하지만은 않다. newtype은 일종의 안전성을 제공할 수 있는데, 다만 더 약한 형태다.
newtype의 주요한 안전성 이점은 추상화 경계에서 비롯된다. newtype의 생성자를 export하지 않으면 다른 모듈에 대해서는 그것이 불투명해진다. newtype을 정의하는 모듈—그의 “홈 모듈”—은 내부 불변식을 안전한 API로 제한함으로써 이를 활용하여 신뢰 경계를 만들 수 있다.
앞서의 NonEmpty 예시를 사용해 이것이 어떻게 동작하는지 보여 보자. NonEmpty 생성자를 export하지 않고, 실제로 실패하지 않는다고 신뢰하는 head와 tail 연산을 제공한다:
module Data.List.NonEmpty.Newtype
( NonEmpty
, cons
, nonEmpty
, head
, tail
) where
newtype NonEmpty a = NonEmpty [a]
cons :: a -> [a] -> NonEmpty a
cons x xs = NonEmpty (x:xs)
nonEmpty :: [a] -> Maybe (NonEmpty a)
nonEmpty [] = Nothing
nonEmpty xs = Just $ NonEmpty xs
head :: NonEmpty a -> a
head (NonEmpty (x:_)) = x
head (NonEmpty []) = error "impossible: empty NonEmpty value"
tail :: NonEmpty a -> [a]
tail (NonEmpty (_:xs)) = xs
tail (NonEmpty []) = error "impossible: empty NonEmpty value"
NonEmpty 값을 구성하거나 소비하는 유일한 방법이 Data.List.NonEmpty.Newtype이 export하는 함수들을 사용하는 것뿐이므로, 위 구현은 클라이언트가 비어 있지 않음 불변식을 위반하는 것을 불가능하게 만든다. 어떤 의미에서 불투명 newtype의 값은 토큰과 같다. 구현 모듈이 생성자 함수들을 통해 토큰을 발행하고, 그 토큰은 자체적으로는 내재적 가치를 갖지 않는다. 유용한 일을 하려면 발행 모듈의 접근자 함수들(여기서는 head와 tail)에 “교환”하여 그 안에 담긴 값을 얻어야 한다.
이 접근은 구성적 데이터 타입을 사용하는 것보다 현저히 약하다. 이론적으로는 실수로 잘못된 NonEmpty [] 값을 구성하는 수단을 제공해 버릴 수도 있기 때문이다. 이러한 이유로, newtype 기반의 타입 안전성은 그 자체만으로 바라는 불변식이 성립함을 “증명”하지는 못한다. 그러나 불변식 위반이 발생할 수 있는 “표면적”을 정의 모듈로 제한하므로, 퍼징이나 property-based testing 같은 기법으로 모듈의 API를 철저히 테스트하면 그 불변식이 실제로 유지된다는 합리적 확신을 가질 수 있다.2
이 트레이드오프는 그리 나빠 보이지 않을 수 있고, 실제로도 종종 아주 좋은 선택이다. 일반적으로 구성적 데이터 모델링으로 불변식을 보장하는 것은 꽤 어렵고, 그래서 실용적이지 않은 경우가 많다. 하지만 실수로 불변식을 위반하도록 허용하는 메커니즘을 제공하지 않도록 주의를 극도로 기울여야 한다는 점을 지나치게 과소평가하기 쉽다. 예를 들어, 프로그래머가 GHC의 편리한 타입클래스 유도를 활용해 NonEmpty에 Generic 인스턴스를 유도하기로 할 수 있다:
{-# LANGUAGE DeriveGeneric #-}
import GHC.Generics (Generic)
newtype NonEmpty a = NonEmpty [a]
deriving (Generic)
하지만 이 무해해 보이는 한 줄은 추상화 경계를 우회하는 사소한 메커니즘을 제공한다:
ghci> GHC.Generics.to @(NonEmpty ()) (M1 $ M1 $ M1 $ K1 [])
NonEmpty []
이것은 특히 극단적인 예시인데, 파생된 Generic 인스턴스는 근본적으로 추상화를 깨뜨리기 때문이다. 하지만 이 문제는 덜 눈에 띄는 방식으로도 발생할 수 있다. 유도된 Read 인스턴스에서도 같은 문제가 생긴다:
ghci> read @(NonEmpty ()) "NonEmpty []"
NonEmpty []
일부 독자에게는 이러한 함정이 명백해 보일 수 있지만, 이런 종류의 안전성 구멍은 실제로 놀랄 만큼 흔하다. 특히 더 정교한 불변식을 가진 데이터 타입에서는, 그 불변식이 모듈 구현에 의해 실제로 지켜지는지 판단하기가 쉽지 않기 때문이다. 이 기법을 제대로 사용하려면 주의와 신중함이 필요하다:
모든 불변식이 신뢰받는 모듈의 유지보수자들에게 명확히 전달되어야 한다. NonEmpty처럼 단순한 타입에서는 불변식이 자명하지만, 더 정교한 타입에서는 주석이 선택 사항이 아니다.
신뢰받는 모듈에 대한 모든 변경은 바라는 불변식을 어떻게든 약화하지 않는지 면밀히 점검되어야 한다.
잘못 사용하면 불변식을 훼손할 수 있는 위험한 비밀 통로(unsafe trapdoor)를 추가하고 싶은 유혹을 억누를 규율이 필요하다.
신뢰 표면적을 작게 유지하기 위해 주기적인 리팩토링이 필요할 수 있다. 신뢰 모듈의 책임이 시간이 지남에 따라 너무 쉽게 누적되어, 어떤 미묘한 상호작용이 불변식 위반을 일으킬 가능성이 크게 늘어난다.
대조적으로, 구성적으로 올바른 데이터 타입은 이러한 문제를 전혀 겪지 않는다. 데이터 타입 정의 자체를 바꾸지 않는 한 불변식을 위반할 수 없으며, 이는 프로그램의 나머지 부분 전체에 파급 효과를 가져와 그 결과를 즉시 분명하게 만든다. 프로그래머의 규율은 필요 없다. 타입체커가 자동으로 불변식을 강제하므로. 그런 데이터 타입에는 “신뢰해야 하는 코드”가 없다. 프로그램의 모든 부분이 데이터 타입이 요구하는 제약을 똑같이 준수해야 하기 때문이다.
라이브러리에서는 캡슐화를 통한 newtype의 안전성 개념이 유용하다. 라이브러리는 종종 보다 복잡한 데이터 구조를 구성하는 빌딩 블록을 제공하며, 일반적으로 애플리케이션 코드보다 더 많은 주의와 관리를 받는다. 특히 변경 빈도가 훨씬 낮기 때문이다. 애플리케이션 코드에서도 이러한 기법은 여전히 유용하지만, 프로덕션 코드베이스의 소용돌이는 시간이 지남에 따라 캡슐화 경계를 약화시키는 경향이 있으므로, 가능할 때에는 구성적 정당성을 선호해야 한다.
이전 섹션은 newtype이 유용한 주된 방식을 다뤘다. 하지만 실제로 newtype은 위 패턴에 들어맞지 않는 방식으로도 자주 사용된다. 그중 일부는 합리적이다:
Haskell의 타입클래스 일관성 개념상, 각 타입은 어떤 주어진 클래스의 인스턴스를 하나만 가질 수 있다. 둘 이상의 유용한 인스턴스를 허용하는 타입의 경우, newtype이 전통적인 해결책이며 이는 좋은 효과를 낼 수 있다. 예를 들어, Data.Monoid의 Sum과 Product newtype은 수치 타입에 유용한 Monoid 인스턴스를 제공한다.
비슷한 맥락에서, newtype은 타입 매개변수를 도입하거나 재배열하는 데 유용할 수 있다. Data.Bifunctor.Flip의 Flip newtype은 간단한 예시로, Bifunctor의 인자를 뒤집어 Functor 인스턴스가 반대쪽에 작동하게 한다:
newtype Flip p a b = Flip { runFlip :: p b a }
이런 종류의 조작에는 newtype이 필요하다. Haskell은 (아직) 타입 수준 람다를 지원하지 않기 때문이다.
ByteString을 newtype으로 감싸(Show 인스턴스를 생략하고) 코드가 실수로 로그로 남기거나 노출하지 않도록 억제할 수 있다.이 모든 적용 사례는 좋은 편이지만, 타입 안전성과는 관련이 적다. 특히 마지막 항목은 종종 안전성으로 오해되며, 공정하게 말해 논리적 실수를 피하는 데 타입 시스템을 활용하긴 한다. 하지만 그런 사용이 오용을 실제로 “방지”한다고 말하는 것은 오해다. 프로그램의 어느 부분이든 언제든지 그 값을 살펴볼 수 있다.
너무 자주, 이러한 안전의 환상은 노골적인 newtype 남용으로 이어진다. 예를 들어, 내가 생업으로 일하는 실제 코드베이스에는 이런 정의가 있다:
newtype ArgumentName = ArgumentName { unArgumentName :: GraphQL.Name }
deriving ( Show, Eq, FromJSON, ToJSON, FromJSONKey, ToJSONKey
, Hashable, ToTxt, Lift, Generic, NFData, Cacheable )
이 newtype은 쓸모없는 소음이다. 기능적으로는 기반 Name 타입과 완전히 상호 교환 가능해서, 무려 열두 개의 타입클래스를 유도하고 있다! 사용되는 모든 곳에서 둘러싼 레코드에서 꺼내는 즉시 곧바로 언랩되므로 타입 안전성 측면에서 아무 이점도 없다. 더 나쁜 것은, ArgumentName이라는 라벨이 명확성을 더해 주지도 않는다는 점이다. 이미 둘러싼 필드 이름이 그 역할을 분명히 해 주기 때문이다.
이런 newtype들은 외부 세계의 분류학을 타입 시스템으로 구현하고자 하는 욕구에서 비롯되는 것처럼 보인다. “인자 이름”은 일반적인 “이름”보다 더 구체적인 개념이므로, 당연히 자신의 타입을 가져야 한다는 식이다. 직관적으로 어느 정도 말이 되지만, 다소 잘못된 생각이다. 분류학은 관심 도메인을 문서화하는 데는 유용하지만, 그것을 모델링하는 데 반드시 도움이 되지는 않는다. 프로그래밍에서 우리는 다른 목적을 위해 타입을 사용한다:
주로, 타입은 값들 사이의 기능적 차이를 구분한다. NonEmpty a 타입의 값은 [a] 타입의 값과 기능적으로 구별된다. 구조적으로 근본적으로 다르고 추가 연산을 허용하기 때문이다. 이런 의미에서 타입은 구조적이다. 타입은 프로그래밍 언어의 내부 세계에서 값이 “무엇인지”를 설명한다.
두 번째로, 때때로 우리는 논리적 실수를 피하는 데 도움을 받기 위해 타입을 사용한다. 예컨대, 둘 다 표현상 실수(real number)일지라도 Distance와 Duration을 구분된 타입으로 두어 이 둘을 덧셈하는 것 같은 무의미한 일을 실수로 하지 않도록 막을 수 있다.
두 사용 모두 실용적이다. 타입 시스템을 도구로 본다. 정적 타입 시스템이 문자 그대로 도구라는 점을 생각하면 자연스러운 관점이다. 그럼에도 불구하고, 타입을 세계를 분류하는 수단으로 사용하는 관점은 놀랍게도 흔하지만, 그 결과는 종종 ArgumentName 같은 도움 안 되는 잡음으로 이어진다.
newtype이 완전히 투명하고, 마음껏 감쌌다 풀었다 할 수 있다면, 아마도 별로 도움이 되지 않을 것이다. 이 특정 사례에서는 구분을 아예 없애고 Name을 사용할 것이다. 하지만 다른 라벨이 실제로 명확성을 더해 준다면, 언제든 타입 별칭을 사용할 수 있다:3
type ArgumentName = GraphQL.Name
이런 newtype은 안전 담요(심리적 위안물)와 같다. 프로그래머에게 몇 가지 후프를 뛰어넘게 강요하는 것은 타입 안전성이 아니다—말하지만, 그들은 기꺼이 아무 생각 없이 그 후프를 뛰어넘을 것이다.
이 글은 오랫동안 쓰고 싶었던 글이다. 표면적으로는 Haskell newtype에 대한 아주 구체적인 비판이며, 내가 생업으로 Haskell을 쓰고 실제로 이런 문제를 접하기 때문에 이렇게 틀을 잡았다. 하지만 핵심 아이디어는 그보다 훨씬 크다.
newtype은 래퍼 타입을 정의하는 한 메커니즘이며, 이 개념은 동적 타이핑 언어를 포함해 거의 모든 언어에 존재한다. Haskell을 쓰지 않더라도, 이 글의 많은 논의는 당신이 선택한 언어에서도 여전히 관련 있을 것이다. 더 넓게 보면, 이것은 지난 1년 동안 다른 각도에서 전달하려 했던 주제의 연속이다. 타입 시스템은 도구이며, 그것들이 실제로 무엇을 하는지, 어떻게 효과적으로 사용하는지에 대해 더 의식적이고 의도적일 필요가 있다.
내가 마침내 앉아 이 글을 쓰게 만든 촉매는 최근에 게시된 Tagged is not a Newtype이었다. 좋은 글이고, 그 전반적인 요지에 전적으로 동의하지만, 더 큰 요점을 만들 기회를 놓쳤다고 생각했다. 사실 Tagged는 정의상 newtype이므로, 글의 제목은 일종의 눈속임이다. 진짜 문제는 조금 더 깊다.
newtype은 신중하게 적용하면 유용하지만, 그 안전성은 내재적이지 않다. 마치 교통 콘의 안전성이 플라스틱 자체에 담겨 있는 것이 아닌 것과 같다. 중요한 것은 올바른 문맥에 놓이는 것이다—그것이 없다면, newtype은 그저 라벨링 체계, 어떤 것에 이름을 붙이는 방식일 뿐이다.
그리고 이름은 타입 안전성이 아니다.