Haskell에서 type 별칭과 newtype, Tagged의 차이를 짚으며 Tagged를 임시 newtype 대용으로 쓸 때 드러나는 타입 안전성의 한계와 도메인 모델링의 기회 상실을 논의하고, Tagged의 실제 용도를 정리합니다.
by @eborden on 2020년 10월 26일
Haskell에서 투자 대비 수익이 가장 높은 기능 중 하나는 newtype입니다. 모든 Haskeller는 여정을 type으로 별칭을 정의하는 것부터 시작합니다. 곧 type은 값싸지만 그에 비해 얻는 이점이 거의 없고, 오히려 더 많은 혼란/간접화를 낳는다는 것을 배우게 됩니다. 시행착오와 경험칙, 그리고 약간의 고통을 통해 특별한 경우를 제외하고는 이를 피하게 되지요. 결국 newtype은 약간의 보일러플레이트만 추가하면 극적으로 더 많은 이점을 제공하며, GeneralizedNewtypeDeriving과 DerivingVia 같은 GHC 기능 덕분에 점점 더 싸게 쓸 수 있게 된다는 사실을 깨닫습니다. 여기서 여정을 마쳐도 좋을 듯하지만, 종종 그렇지 않습니다.
생태계를 더 깊이 파고들며 더 많은 GHC 확장을 탐색하다 보면 Tagged와 DataKinds를 발견합니다. 이 둘을 결합하면 임의(ad‑hoc)로 타입을 구분하는 범용 메커니즘을 만들 수 있습니다. 보일러플레이트 없는 newtype인 셈이죠!
오류를 부르기 쉬운 다음과 같은 코드 대신에
power :: Double -> Double -> Double
power work time = work / time
보일러플레이트가 많은 다음 코드 대신에
newtype Time = Time { unTime :: Double }
newtype Work = Work { unWork :: Double }
newtype Power = Power { unPower :: Double }
power :: Work -> Time -> Power
power work time = Power $ unWork work / unTime time
다음처럼 정의할 수 있습니다.
-- | Data.Tagged에 정의되어 있음
newtype Tagged k b = Tagged { untag :: b }
power :: Tagged "work" Double -> Tagged "time" Double -> Tagged "power" Double
power work time = Tagged $ untag work / untag time
DataKinds를 사용해 Tagged의 팬텀 타입 k를 우리가 고른 Symbol로 채웁니다. 짜잔, 임시(ad‑hoc) newtype입니다.
이런 식의 Tagged 사용은 타입 안전해 보입니다. 컴파일러도 우리의 함수가 매우 구체적이라는 데 동의합니다. 그러나 이 타입 안전성은 Tagged 값을 함수 사이에 전달할 때에만 거둘 수 있습니다.
여기서는 타입 안전성을 체감합니다:
allThePower :: [(Tagged "time" Double, Tagged "work" Double)] -> Tagged "power" Double
allThePower = foldl' (+) (Tagged 0) . fmap (uncurry power)
-- 오류:
-- • Couldn't match type ‘"time"’ with ‘"work"’
-- Expected type: [(Tagged "time" Double, Tagged "work" Double)]
-- -> Tagged "power" Double
-- Actual type: [(Tagged "work" Double, Tagged "time" Double)]
-- -> Tagged "power" Double
-- • In the expression: foldl' (+) (Tagged 0) . fmap (uncurry power)
-- In an equation for ‘allThePower’:
-- allThePower = foldl' (+) (Tagged 0) . fmap (uncurry power)
-- |
-- | allThePower = foldl' (+) (Tagged 0) . fmap (uncurry power)
-- | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
하지만 power를 직접 호출할 때는 얻는 이점이 거의 없고, 결과적으로 부작용을 낳을 수 있는 동적 타이핑과 비슷한 양상을 보입니다.
λ> let time = Tagged 22
λ> let work = Tagged 0
λ> power work time
Tagged 0.0
λ> power time work
Tagged Infinity
도메인에 특화된 newtype을 쓰면 GHC가 우리를 도와줄 수 있습니다.
λ> let time = Time 22
λ> let work = Work 0
λ> power work time
Power 0.0
λ> power time work
-- 오류:
-- • Couldn't match expected type ‘Work’ with actual type ‘Time’
-- • In the first argument of ‘power’, namely ‘time’
-- In the expression: power time work
-- In an equation for ‘it’: it = power time work
-- 오류:
-- • Couldn't match expected type ‘Time’ with actual type ‘Work’
-- • In the second argument of ‘power’, namely ‘work’
-- In the expression: power time work
-- In an equation for ‘it’: it = power time work
게다가 Tagged 값을 언랩하는 것도 동적임을 드러내며, 초기에 작성하거나 리팩터링 중에 쉽게 발생할 수 있는 단순 치환 오류에 취약합니다.
-- 오류를 찾을 수 있나요? GHC는 못 찾습니다.
power :: Tagged "work" Double -> Tagged "time" Double -> Tagged "power" Double
power time work = Tagged $ untag work / untag time
경계(함수 호출부)와 최종 사용 지점에서 타입 안전성이 녹아내리길 원치 않는다면, 점진적으로 안전성을 끌어올릴 수는 있습니다. TypeApplications를 이용해 더 명시적으로 함수를 호출하는 거죠. 하지만 그 시점이 되면 newtype의 보일러플레이트를 넘어서는 소음을 코드에 흩뿌리게 됩니다.
power :: Tagged "work" Double -> Tagged "time" Double -> Tagged "power" Double
power time work = Tagged @"power" $ untag @"work" work / untag @"time" time
-- 오류:
-- • Couldn't match type ‘"time"’ with ‘"work"’
-- Expected type: Tagged "work" Double
-- Actual type: Tagged "time" Double
-- • In the second argument of ‘untag’, namely ‘work’
-- In the first argument of ‘(/)`’, namely ‘untag @"work" work’
-- In the second argument of ‘($)’, namely
-- ‘untag @"work" work / untag @"time" time’
-- |
-- | power time work = Tagged @"power" $ untag @"work" work / untag @"time" time
-- | ^^^^
--
-- 오류:
-- • Couldn't match type ‘"work"’ with ‘"time"’
-- Expected type: Tagged "time" Double
-- Actual type: Tagged "work" Double
-- • In the second argument of ‘untag’, namely ‘time’
-- In the second argument of ‘(/)’, namely ‘untag @"time" time’
-- In the second argument of ‘($)’, namely
-- ‘untag @"work" work / untag @"time" time’
-- |
-- | power time work = Tagged @"power" $ untag @"work" work / untag @"time" time
-- | ^^^^
λ> power (Tagged @"time" 3) (Tagged @"work" 37)
-- 오류:
-- • Couldn't match type ‘"time"’ with ‘"work"’
-- Expected type: Tagged "work" Double
-- Actual type: Tagged "time" Double
-- • In the first argument of ‘power’, namely ‘(Tagged @"time" 3)’
-- In the expression: power (Tagged @"time" 3) (Tagged @"work" 37)
-- In an equation for ‘it’:
-- it = power (Tagged @"time" 3) (Tagged @"work" 37)
-- 오류:
-- • Couldn't match type ‘"work"’ with ‘"time"’
-- Expected type: Tagged "time" Double
-- Actual type: Tagged "work" Double
-- • In the second argument of ‘power’, namely ‘(Tagged @"work" 37)’
-- In the expression: power (Tagged @"time" 3) (Tagged @"work" 37)
-- In an equation for ‘it’:
-- it = power (Tagged @"time" 3) (Tagged @"work" 37)
우리는 타입 안전성을 한쪽 끝으로 밀어붙였습니다. 하지만 그 끝으로 충분할까요? 그만한 가치가 있을까요? 투자보다 더 많은 것을 얻었을까요?
“Parse don’t validate”, “제약을 상류로 밀어올려라”, “떠나간 증명의 유령들”. 선호하는 슬로건이 무엇이든, Tagged를 임시 newtype처럼 쓰면 그런 이점을 제공하지 못합니다. 그런 방식의 Tagged 사용은 파싱 부재, 검증 부재, 도메인 인코딩 부재가 장전된 산탄총과 같습니다. Tagged를 임시 newtype로 쓰는 것은 도메인 개념을 정의하고, 스마트 생성자를 통해 불변식을 보장하며, FromJSON 같은 경계 타입클래스 인스턴스를 다듬고, 동료와 더 효과적으로 소통할 수 있는 기회를 놓치는 일입니다.
프로그래밍은 자동화의 과정이면서 동시에 가정들을 인코딩하는 작업입니다. newtype은 우리의 가정을 인코딩하고, 격리하며, 정제할 수 있게 해줍니다. Tagged를 통한 임시 newtyping은 그 가정들을 코드베이스 전반에 흩뿌려서 우리를 영원히 괴롭히도록 만듭니다.
때로는 인자들이 정말 임시적이고, 일회적이며, 즉흥적일 수 있습니다. 이 경우의 Tagged는 결국 이름 있는 인자나 더 나은 레코드 시스템을 대체하는 초라한 수단이 됩니다. 익명 레코드가 있는 언어라면 훨씬 더 나을 것입니다.
power :: {work :: Double, time :: Double} -> Double
power {work, time} = work / time
λ> power {work = 3, time = 37}
111
여전히 모호한 도메인 개념에서 비롯되는 모든 주의를 요하지만, 이 방식은 간결하고 쓰기 쉽고, 오류가 덜 나며, 위치적 의미(positional semantics)가 없고, 비용 대비 매력적인 이점을 제공합니다.
Tagged는 무엇을 위한 것인가?Tagged에는 매우 구체적이고 틈새적인 용도가 있습니다. 이는 Proxy의 논리적 확장입니다. Proxy가 타입에 대한 널-인자(nullary) 생성자 증인이라면, Tagged는 타입 증인으로 “태그”된 단일-인자(unary) 생성자 값입니다. 이게 언제 유용할까요? 드뭅니다.
data Proxy k = Proxy
newtype Tagged k b = Tagged b