head.hackage 패치 분석을 통해 GHC 릴리스 간의 언어 확장 의미 변화가 Haskell 코드 깨짐에 어떤 영향을 주는지 살펴보고, 안정성을 위해 무엇을 배워야 하는지 정리한다.
Jappie Klooster 2025년 11월 30일 [Haskell 재단] #커뮤니티 #안정성
안녕하세요, 저는 Jappie이고, Haskell 재단 안정성 워킹 그룹에서 자원봉사를 하고 있습니다. 최근 우리는 새로운 GHC 릴리스에서 왜 코드가 깨지는지 이해하기 위해 head.hackage 패치를 분석했습니다. "head.hackage"는 Hackage용 패치 저장소입니다. GHC 엔지니어들은 여기에 패치를 넣어, 패치를 업스트림1하지 않고도(이는 시간이 걸릴 수 있습니다) 다양한 Hackage 패키지에 대해 새로운 GHC 빌드를 시험해 볼 수 있습니다. 대신 패치를 "head.hackage"에 넣으면, 곧바로 광범위한 패키지들에 대해 테스트할 수 있습니다.
의외로, 대부분의 깨짐은 Template Haskell 때문에 발생한 것이 아니라, 언어 확장의 더 깊은 의미 변화에서 비롯되었습니다. GHC 릴리스 사이에 (일부) 언어 확장의 의미가 바뀐 것입니다. 이 글에서는 주요한 깨짐 유형들, 그것이 왜 발생했는지, 그리고 장기적 안정성에 대해 무엇을 말해 주는지를 살펴봅니다. Haskell 사용자들을 위한 더 매끄러운 업그레이드 경로에 관심이 있다면, Haskell 재단 안정성 워킹 그룹에 참여해 주시길 초대합니다.
이전의 초기 조사를 확장하면서, 우리는 깨짐이 왜 발생하는지도 이해하고 싶었습니다. 그래서 최근 head.hackage에 대해 추가 분석을 진행했고, 놀랍게도 많은 깨짐의 근본 원인이 Template Haskell이 아니라 언어 확장 의미론2에 있다는 것을 알게 되었습니다. 우리는 안정성을 향상시키기 위해 어떤 곳에 노력을 집중해야 할지 더 잘 이해하고자 이 조사를 하고 있습니다.
그 결과, 다음과 같은 표를 얻었습니다:
| 이름 | 원인 | 경고 있었나? |
|---|---|---|
| Cabal-2.4.1.0.patch | simplified subsumption | 아니오 |
| Cabal-3.0.2.0.patch | simplified subsumption | 아니오 |
| Cabal-3.2.1.0.patch | simplified subsumption | 아니오 |
| data-r-tree-0.6.0.patch | 파서 변경 (1 참고) | 아니오 |
| drinkery-0.4.patch | simplified subsumption | 아니오 |
| ghc-lib-parser-9.8.1.20231121.patch | forall 식별자 이름 변경 (2) | 예 |
| hgeometry-ipe-0.13.patch | 스플라이스 강제 때문에 인스턴스 이동 | 아니오 |
| singletons-3.0.2.patch | TypeAbstractions 언어 확장 추가 | 예 |
| singletons-base-3.1.1.patch | TypeAbstractions 언어 확장 추가 | 예 |
| vector-space-0.16.patch | Star is type (4) | 예 |
th-compat-0.1.4.patch는 잘못 집계되어 제외했습니다. simplified subsumption이 많이 보이지만, 그중 3개는 Cabal에 대한 것이므로 실제로는 2번만 발생한 셈입니다. 다만 이 변화가 안정성 워킹 그룹을 만들게 된 주요 동기 중 하나였기 때문에, 훨씬 더 많이 나타날 것이라 예상했었습니다.
이제까지 blissfully ignorant(모르는 게 약인) 상태였던 독자를 위해 말하자면, simplified subsumption은 특정 존재형(existential) 상황에서 다음과 같이 코드를 고치도록 만듭니다:
diff--- a/Distribution/Simple/Utils.hs +++ b/Distribution/Simple/Utils.hs @@ -1338,7 +1338,7 @@ withTempFileEx opts tmpDir template action = (\(name, handle) -> do hClose handle unless (optKeepTempFiles opts) $ handleDoesNotExist () . removeFile $ name) - (withLexicalCallStack (uncurry action)) + (withLexicalCallStack (\x -> uncurry action x))
람다를 삽입해야 하며, 이는 성능에 어느 정도 영향을 주는 것으로 알려져 있습니다. 이 변화는 Yesod 스택에 상당한 영향을 주었는데, 해당 코드 생성이 템플릿 안에서 데이터베이스 별칭(alias)을 편리하게 만들어 주었기 때문입니다:
haskelltype DB a = forall (m :: Type -> Type). (MonadUnliftIO m) => ReaderT SqlBackend m a
보통은 꽤 편리한 패턴이지만, simplified subsumption 변경 이후로는 데이터베이스와 상호작용하는 코드가 모두 저 람다를 삽입해야만 하게 되었습니다. 상상할 수 있듯이, 상용 코드베이스에서는 이런 곳이 아주 많았을 것이고, 그 결과 산업 사용자들에게 수많은 컴파일 에러를 일으켰습니다.
람다를 삽입하는 대신, 문제를 해결하기 위해 이런 존재형 별칭을 삭제하는 방법도 있습니다. 혹은 언어 확장 DeepSubsumption을 활성화할 수도 있습니다. 이 확장은 기존 동작을 복원해 줍니다.
이 변화는 인스턴스를, 그것이 사용되는 스플라이스보다 같은 모듈 안에서 위쪽에 두도록 강제합니다. 제 동료 한 명이 Template Haskell로 인스턴스를 생성하기로 한 적이 있는데, 이걸 맞춰 보는 게 꽤 큰 퍼즐이었습니다! 저는 왜 이런 변경을 했는지 GHC 개발자들에게 물었고, 그 결과 이것이 타입체커의 건전성(soundness) 문제였다는 것을 알게 되었습니다. 여기서 건전성이란, 타입 시스템이 잘못된 프로그램을 허용하도록 속아 넘어가지 않는다는 뜻입니다. 그래서 꽤 많은 작업을 요구하긴 했지만, 커뮤니티 전체로 보면 이 변화가 더 이로운 셈입니다.
diff--- a/src/Ipe/Content.hs +++ b/src/Ipe/Content.hs @@ -288,6 +288,14 @@ +instance Fractional r => IsTransformable (IpeObject r) where + transformBy t (IpeGroup i) = IpeGroup $ i&core %~ transformBy t + ... makePrisms ''IpeObject @@ -303,14 +311,6 @@ -instance Fractional r => IsTransformable (IpeObject r) where - transformBy t (IpeGroup i) = IpeGroup $ i&core %~ transformBy t - ...
파서는 컴파일러에서 텍스트를, 컴파일러가 다룰 수 있는 메모리 구조로 변환하는 구성요소입니다. 이 구조를 추상 구문 트리(abstract syntax tree, AST)라고 부릅니다.
diff- Node4 {getMBB :: {-# UNPACK #-} ! MBB, getC1 :: ! (RTree a), getC2 :: ! (RTree a), getC3 :: ! (RTree a), getC4 :: ! (RTree a) } - | Node3 {getMBB :: {-# UNPACK #-} ! MBB, getC1 :: ! (RTree a), getC2 :: ! (RTree a), getC3 :: ! (RTree a) } - | Node2 {getMBB :: {-# UNPACK #-} ! MBB, getC1 :: ! (RTree a), getC2 :: ! (RTree a) } + Node4 {getMBB :: {-# UNPACK #-} !MBB, getC1 :: !(RTree a), getC2 :: !(RTree a), getC3 :: !(RTree a), getC4 :: !(RTree a) } + | Node3 {getMBB :: {-# UNPACK #-} !MBB, getC1 :: !(RTree a), getC2 :: !(RTree a), getC3 :: !(RTree a) } + | Node2 {getMBB :: {-# UNPACK #-} !MBB, getC1 :: !(RTree a), getC2 :: !(RTree a) } | Node {getMBB :: MBB, getChildren' :: [RTree a] } - | Leaf {getMBB :: {-# UNPACK #-} ! MBB, getElem :: a} + | Leaf {getMBB :: {-# UNPACK #-} !MBB, getElem :: a} | Empty
이는 2020년까지 거슬러 올라가는 변화로, 코어 언어 자체가 괄호 앞에 !를 두는 것을 허용하지 않도록 바뀐 것입니다. 여기서 느낌표 !는 엄격한(strict) 필드를 의미합니다. 엄밀히 말하면 코어 언어는 언어 확장이 아니므로, 이 글의 범주와는 살짝 어긋날 수도 있습니다. 하지만 어쨌든 의미론이 바뀐 것은 사실입니다! 사실 이런 종류의 사례를 찾을 거라고는 예상하지 못했습니다.
누군가가 이런 문법을 쓰는 경우는 상당히 드물어 보이기 때문에, 이것이 얼마나 더 논의할 만한 가치가 있는지 잘 모르겠습니다. 단, StrictData를 Cabal 파일에서 활성화하고 저 느낌표들을 모두 제거하는 식으로도 해결할 수 있습니다!
이 변화는 term 레벨에서 forall 식별자를 키워드로 만드는 것입니다. 타입 레벨에서는 이미 키워드였습니다. 관련 이슈는 여기에서 논의됩니다.
diffhintExplicitForall :: Located Token -> P () hintExplicitForall tok = do - forall <- getBit ExplicitForallBit + forAll <- getBit ExplicitForallBit rulePrag <- getBit InRulePragBit - unless (forall || rulePrag) $ addError $ mkPlainErrorMsgEnvelope (getLoc tok) $ + unless (forAll || rulePrag) $ addError $ mkPlainErrorMsgEnvelope (getLoc tok) $ (PsErrExplicitForall (isUnicode tok))
매뉴얼을 보면, 타입 추상화(type abstraction)를 위한 문법의 일부는 이미 GHC 9.2에 도입되었으나, 9.8 이후부터는 해당 언어 확장을 명시적으로 활성화해야 합니다.
이 상황은, 이 제안에 따르면, 새로운 기능이 기존 언어 확장 플래그 뒤에 도입되면서 생겨났습니다. 제안서에서는, 새로운 기능을 이미 확립된 확장 뒤에 슬쩍 넣지 말자는 취지로, 이전에는 ScopedTypeVariables와 TypeApplications만으로 충분했지만 이제는 TypeAbstractions를 필수로 요구하게 되었다고 설명합니다.
이 확장은 패턴 매칭 안에서 타입 변수를 바인딩할 수 있게 해 줍니다. 왜 이런 식으로 일이 진행되었는지는 정확히 모르겠지만, 어쨌든 2023년에 이런 일이 있었습니다:
haskell+-- 타입 선언에서 보이지 않는(invisible) 타입 바인더는, 예를 들어 +-- +-- type family Sing @k +-- +-- 와 같이 쓰일 때, TypeAbstractions 확장을 필요로 한다. +#if __GLASGOW_HASKELL__ >= 909 +{-# LANGUAGE TypeAbstractions #-} +#endif +
이 변화는 경고를 통해 미리 공지되었습니다.사용자들에게, 타입을 나타내는 kind에는 * 대신 Type을 쓰라고 알려 주는 것입니다. kind는 본질적으로 "타입의 타입"이며, 타입 수준 프로그래밍과 타입 안정성을 위해 사용되는 개념입니다.
diff- type Basis v :: * + type Basis v :: Type
이러한 깨짐은 종종 짜증스럽고 좌절감을 줍니다. 하지만 조금 더 깊이 들여다보면, 각각의 변화에는 작은 사연과, 그것이 도입된 나름의 타당한 이유가 있다는 것을 알 수 있습니다. 만약 이런 내용이 저만큼이나 흥미롭다면, 안정성 워킹 그룹 미팅에 참가하는 것도 고려해 보셨으면 합니다!
업스트리밍(upstreaming)은 패치를 오픈 소스 프로젝트의 "관리자(maintainer)"에게 보내는 과정을 말합니다. 관리자는 이 패치를 머지(merge)함으로써 '공식' 변경사항으로 만듭니다. 원칙적으로는 단순한 과정이지만, 실제로는 (특히 대형 프로젝트에서는) 입증 책임이 패치를 보낸 사람에게 있습니다. 그들은 패치가 유용하다는 것을 관리자를 설득해야 하고, 이 과정에는 커뮤니케이션이라는 형태로 시간이 듭니다.
언어 확장으로 활성화되는 기능의 정확한 의미를 뜻합니다. 파서 변화도 여기에 포함된다고 봐야겠지요.