TECH 으로 돌아가기
TECH HACKER NEWS 오늘 9분 읽기 29 READS

값에서 타입, 타입에서 Kind로: Haskell 타입 시스템의 한 층 위

프로그래밍에서 우리가 직접 다루는 대상은 1, True, "hello" 같은 값(value), 함수형 언어에서는 term이라 부르는 것들이다. 이 값들을 생성하고 소비하고 계산하는 일이 소프트웨어 작업의 큰 부분을 차지한다. 서로 비슷하게 만들어지고 쓰이는 값들을 묶어 집합적으로 가리키는 수단이 바로 타입이며, Int, Bool, Text 같은 이름이 그 역할을 한다. 타입의 가장 큰 효용은 추상화다. 어떤 값에 어떤 연산을 할 수 있고 할 수 없는지를 프로그래머와 컴파일러 모두에게 알려주기 때문이다. 그렇다면 한 걸음 더 나아가, 타입 자체는 무엇으로 분류하는가. 이 질문에 답하려면 Kind라는 층으로 올라가야 한다.

Kind는 타입을 분류한다

Kind는 타입에 대해, 타입이 값에 대해 하는 일을 한다. 즉 타입 수준의 모든 구성물을 분류하는 체계다. 흥미로운 점은 Haskell에서 '타입 수준의 것'이 일상적인 타입만을 뜻하지 않는다는 데 있다. 타입 수준 숫자(Kind는 Nat), 타입 수준 문자열(Kind는 Symbol), 타입클래스에서 나오는 제약(Kind는 Constraint) 같은 것들이 함께 존재한다. 예를 들어 Show 타입클래스는 Type -> Constraint라는 Kind에 속하고, 완전히 적용된 Show Int는 Constraint에 속한다. 평범한 타입은 Type이라는 Kind에 속한다. 오래된 문서나 GHC 오류 메시지에서는 Type 대신 가 보이는데, 둘은 같은 것이고 는 구식 표기라 GHC가 폐기를 예고한 상태다(-Wall은 이미 경고를 낸다).

Maybe, Either, Proxy 같은 타입 생성자의 Kind는 인자의 개수(arity)와 인자의 Kind에 따라 결정된다. Kind가 없다면 Maybe, Maybe Int, Show처럼 성격이 전혀 다른 타입 수준 구성물을 구별할 방법이 없을 것이다. 엄밀히 말하면 평범한 타입도 인자가 없는 타입 생성자, 이른바 nullary 타입 생성자다. 이런 분류 덕분에 타입 시스템은 더 많은 것을 표현할 수 있고, 결과적으로 더 안전한 프로그램을 쓰기 쉬워진다.

고차 타입(HKT)이라는 발상

함수 시그니처를 떠올리면 이해가 빨라진다. Int -> Bool이 특정한 함수들의 집합을 가리키듯, Kind Type -> Type은 평범한 타입 하나를 인자로 받는 모든 타입 생성자를 집합적으로 가리킨다. Maybe뿐 아니라 Either String 같은 부분 적용까지 여기 포함된다. 그런데 함수에서 (Int -> Int) -> Bool처럼 함수를 인자로 받는 것을 고차 함수라 부르듯, Kind에서도 (Type -> Type) -> Type처럼 타입 생성자를 인자로 받는 것을 고차 타입, 줄여서 HKT라 부른다. 글쓴이가 든 Wrap 예시는 타입 생성자 f를 받아 그 안에 Int를 넣으므로 Kind가 (Type -> Type) -> Type이고, Wrap Maybe나 Wrap []는 평범한 타입이 된다. 다른 언어의 제네릭이 Maybe나 Either라면, HKT는 제네릭을 인자로 받는 제네릭인 셈이다.

HKT가 타입클래스와 결합하면 타입 수준 구성물에 기능을 구현할 수 있다. Functor는 Kind가 (Type -> Type) -> Constraint여서 그 자신은 HKT가 아니지만, 인자가 타입 생성자이므로 higher-Kinded하다. 핵심은 인스턴스를 Maybe Int가 아니라 Maybe 자체에 정의한다는 점이다. 그래서 Functor 인스턴스를 한 번만 정의해도 Maybe Int, Maybe String 등 모든 구체화에 fmap을 쓸 수 있다. 사실 다른 방식은 불가능하다. Functor의 Kind는 Type -> Type인 생성자만 받으므로 Type인 Maybe Int는 애초에 후보가 아니다. 글쓴이의 비유대로, 전자레인지 사용 가능 표시는 그릇에 붙지 그 안의 수프에 붙지 않는다.

고차 타입 데이터가 해결한 실무 문제

이 개념을 데이터 타입에 적용한 것이 고차 타입 데이터(HKD)다. 데이터 타입이 타입 생성자를 인자로 받아 일부 혹은 모든 필드를 그것으로 감싸는 패턴이다. 글쓴이는 Haskell로 만든 Redis 서버 클론에 커맨드라인 옵션과 redis.conf 파일에서 오는 타입 설정을 추가하면서 이 패턴을 썼다. 문제는 중복이었다. 설정 타입이 두 맥락을 지원해야 했는데, 하나는 값이 없을 수도 있는 병합용이고 다른 하나는 기본값과 서버 전체가 쓰는 완성본이었다. 필드를 Last로 감싸면 defaults confFileOpts cliOpts 순으로 우측 우선 병합이 되어 커맨드라인이 가장 높은 우선순위를 갖는 원하는 의미를 얻는다. 다만 Bool이나 Int는 Semigroup 인스턴스가 없어 단순 Maybe로는 안 되고, Last는 존재하는 최신 값을 고른다는 점이 중요했다.

그러나 필드를 전부 Last로 감싼 단일 타입만 쓰면, 병합이 끝나 모든 값이 채워진 뒤에도 읽는 쪽마다 절대 Nothing일 리 없는 Last를 벗겨야 하는 불편이 남는다. 그렇다고 완성본용 레코드를 따로 두면 다시 정의가 둘로 늘어난다. 글쓴이가 피하고 싶었던 것은 바로 두 벌의 정의, 즉 하나를 고치고 다른 하나를 깜빡해 생기는 단일 진실 공급원의 붕괴였다. 해법이 RedisConfigF다. f 파라미터가 Type -> Type이라 이 타입은 (Type -> Type) -> Type, 곧 Wrap과 같은 Kind의 HKD가 된다. 기본값과 최종 병합본에는 f = Identity를, 값이 없을 수 있는 병합 단계에는 f = Last를 넣어 하나의 정의로 두 맥락을 모두 처리한다. 병합은 Last의 Semigroup으로 두 설정을 합친 뒤 defaults로 빈칸을 메우는 식이다.

실무자 관점에서 이 패턴의 매력은 분명하다. Identity로 얻은 최종 레코드는 여전히 접근 시 언래핑이 필요하지만(글에서는 이를 자동화했다고만 밝힌다) 설정 스키마가 단 한 곳에 존재한다는 이점이 그 비용을 상쇄한다. 같은 RedisConfigF를 f = Const (Text, ReadM ...)로 재사용하면 각 필드의 redis.conf 이름과 파서를 같은 레코드에 담을 수 있어, 새 필드를 추가하고 이름이나 파서를 빠뜨리면 컴파일러가 경고한다. 다만 이 글은 소개 수준이며 언래핑 자동화의 구체적 구현이나 각 Kind의 전체 표를 다루지 않으므로, 실제 코드 적용에는 추가 학습이 필요하다. 그럼에도 값, 타입, Kind라는 세 층위를 하나의 직관으로 꿰어 보여준다는 점에서, 타입 수준 프로그래밍이 왜 더 안전한 코드를 가능하게 하는지 체감하기에 좋은 출발점이다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://thunk.blog/posts/types-of-types-haskell-kind-system/
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기 →
처리 중...