Haskell에서 제안된 '계층적 자유 모나드' 접근을 비판적으로 검토하며, 태글리스 파이널 스타일, 수학·형식 논리의 역할, 타입 시스템과 소프트웨어 설계 철학에 대해 논의한다.
2020-08-14 업데이트: 이건 정말로 내가 쓰지 않았으면 좋았을 글이다. 특정 응답자를 불필요하게, 그리고 가차 없이 모욕한다. 나를 변호하자면, 그 주말에 기분이 정말 최악이었고, 내가 논하는 글은 내가 지금까지 지향해 온 모든 것들을 공격하고 있었으며, 불필요하게 수학에 적대적이었고, 나를 불쾌하게 만들었다. 완전히 다른 일을 하다가 그 글을 읽었는데, 내 시간을 낭비한 셈이기도 했다.
이 글은 한동안 쓸 계획만 해두고 방치해 두었다. 언젠가 Graninas 가 이 길을 걷다 발견하고 몸 둘 바를 몰라하길 바라는 마음이었는데, 이런 식으로는 친구를 사귀지도, 상처를 치유하지도 못한다. 그리고 현대 사회에서 성장하기 위한 올바른 방식과도 거리가 멀다. 나중에 이 사람에게 왜 Haskell을 선택했는지 물어보며 대화를 했다. 답변을 읽고 나서는 연민을 느끼면서 화가 좀 가라앉았다. 하지만 나는 그가 레딧에서 다운보트가 많이 달리면 글을 지우는 걸 본 적이 있다. 나는 이 관행도 꽤 의심스럽게 본다. 나는 내가 저질렀던 실수들을 잊지 않기 위해 전부 기록으로 남기고 끌어안고 가는 편이다. 그래서 그가 쓴 글들을 영구 참고용으로 복사해 두었다. 아래에서 볼 수 있다.
아참. r/haskell에서 이 글을 처음 올렸을 때 이미 몇몇 사람들은 이 글 자체를 비난했다. 하지만 그게 왜 잘못인지, 충분히 강하게 말해주지는 않았다. 그것도 역시 기록에 남아 있다. 지금 돌아보면, 그들이 나를 적절히 제지하거나, 소외될 위험을 상기시키지 않고 이런 글을 쓰게 내버려 둔 게 좀 짜증난다.
반대로, 해커 뉴스에서는 정반대의 경험을 했다. 이와 비슷한 공격적인 행동을 했다는 이유로 나는 수년째 완전히 차단(ban)된 상태다. 그들은 내 행동을 되돌아볼 기회를 주거나, 고칠 수 있도록 공정한 피드백을 준 적이 없다. 이건 또 다른 극단이다. 겉으로는 남의 감정을 신경 쓰는 척하면서, 실제로는 용서도 하지 않고, 제대로 된 기회조차 주지 않는 것.
어쨌든, 나는 당신이 이 글을 어디에든 링크하지 않았으면 한다. 내가 이런 글을 쓰고 공개했다는 것 자체가 꽤 부끄럽다.
이 글에서는 "Hierarchical Free Monads: The Most developed approach in haskell" 를 논하려 한다. 나는 Tagless Final 스타일에 대한 정보를 찾다가 이 글을 보게 되었다.
나는 Tagless Final을 이해한 뒤 그 다음 단계로 어디로 가야 할지에 관심이 있어서, 그 글의 몇몇 주장에 흥미를 느꼈다. 하지만 그 글은 화려하게 실패한다.
그 자체로는 나름 흥미로운 글이긴 하지만, 그렇게 보기 위해서는 약간의 배경 지식이 필요하다. 여기에서 그 배경을 조금 제공해 보겠다.
본론으로 들어가기 전에, 먼저 해당 글의 프레이밍부터 살펴보자.
Haskell은 꽤 오래된 언어다. 거의 30년 가까이 되었나?
프로그래밍 언어의 긴 역사는, 산업계의 도입 없이는 언어가 정말 살아남을 수 없다는 점을 보여준다. 단지 학계의 장난감에 그친 언어들은 한 십 년, 많아야 이십 년 후에는 완전히 버려졌다. 주류와의 상호작용을 거부하는 언어들은 느리지만 필연적으로 죽을 수밖에 없다. Haskell이라고 다를 이유는 없다. 학계의 과제들은 sooner or later 끝날 것이다. 언어가 성공적으로 살아남기를 원한다면, 장기적으로 산업계의 도입이 유일한 길이다.
만약 당신이 아무렇지 않게 아가씨를 더듬어 보려면, 먼저 그녀가 곤경에 처해 있다고 설득해야 한다. 안타깝게도, 여왕을 아가씨로 착각했다면 이 전략은 통하지 않을 수 있다. Haskell은 이미 상당히 성공했고, 앞으로도 오래 살아남을 것이다.
하지만 산업계는 고급 수학 개념, 멋지고 똑똑한 것들, Haskeller들이 중시하는 호기심 따위에 관심이 없다. 산업계가 중요하게 여기는 건 오직 목표를 달성할 수 있는가, 그리고 그 비용이 얼마인가 뿐이다. 불행히도, 산업계의 목표를 달성하는 데에는 Haskell 커뮤니티에 큰 문제가 있다. 우리는 모두 주류로부터 배워야 하고, 산업에 Haskell이 장난감이 아니라 실제 과제를 풀고 비즈니스를 성공으로 이끄는 도구임을 보여주기 위해, 방법론과 아이디어, 실천을 도입해야 한다. 우리는 모두 좀 더 열린 마음을 가져야 하고, 상아탑에만 머물러 있어선 안 된다.
내 생각엔, 자기 분야의 수학이나 이론에 호기심이 없고, 그것을 흥미롭다고 여기지 않는 산업이라면 스스로의 이익을 위해서라도 너무 오만하고 시야가 좁다. 그런 주류 문화에서 배울 것은 별로 없을 것이다.
이미 "산업계에서 인정받는" "실용적인" 프로그래밍 언어는 차고 넘친다. 그런 환경과, 그 언어들이 만들어내는 동료들의 회사를 충분히 즐길 수 있지 않나? 무한 러닝머신에서 뛰는 듯한 오만이 만들어낸 부류 말이다. 그들은 아마 순수 타입 함수형 프로그래밍과 경쟁하기 위해 "계층적 자유 모나드" 같은 것을 좋아할지도 모르겠다.
만약 "고급 수학 개념"이 산업계에게 그렇게까지 매력 없다면, 그들을 위해 더 좋은 언어들이 이미 있지 않을까? 단기 이익과 분기 실적 사이클을 위해 모든 것이 이미 최적화되어 있다.
...
아, 글쓴이는 Haskell을 유용하다고 생각한다. 하지만 커뮤니티가 자신에게 맞춰주지 않을 때 "큰 드라마"가 벌어진다. 그는 타입을 배울 준비도, 범주론을 공부할 준비도, 보조정리(lemma)나 정리(theorem)가 무엇인지 알아볼 준비도 되어 있지 않다. 그 "우월한 개념들의 새로운 세계"는 아무것도 아닌 것처럼 손 안으로 날아들어 와야 한다.
이 글을 쓰기로 마음먹었을 때, 나는 아직 그 글의 "철학" 섹션을 끝까지 읽지 않은 상태였다.
당신은 아마, Haskeller들이 가장 중요하게 여기는 것이 수학, 즉 범주론, 추상대수, 람다 계산, 고급 타입 시스템 등이라고 들었을 것이다. 또한, 주류 개발자들은 수학에 익숙하지 않기 때문에 그들에겐 "소프트웨어 공학"이 있고, 그것은 수학의 조악한 대체품에 불과하다는 의견도 들었을지 모른다. 그래서 "소프트웨어 공학"이라는 학문은 적용 불가능하고, 더럽고, 불필요하다고 말이다. 왜 우리는 외부 산업계가 가진 방법론과 기타 정신적 결함에 신경을 써야 하는가?
어쩌면 현대 "소프트웨어 산업"은 진행 중인 열차 사고일지도 모른다? 나는 OOP를 공부하면서 그 사실을 배웠다.
그리고 나는 과장하는 게 아니다. Haskeller들은 자신의 언어를 특별하고, 유일하고, 축복받은 무엇으로 여기며, 외부 세계가 쌓아온 이상한 합의들 위에 군림하는 존재라고 생각한다. 이는 명백히 사실이 아니다. 물론 Haskell이 어느 정도 유일한 언어이긴 하지만(유일하지 않은 언어가 어디 있나?), 설계 공간에서 특별히 다를 것은 없다.
일괄적으로 일반화할 만한 단일한 "Haskeller" 집단은 존재하지도 않고, 그 중 가장 똑똑한 사람들은 Haskell이 자기 부류 안에서조차 특별하다고 생각하지 않을 것 같다.
소프트웨어 설계는 낮은 리스크로 동작하는 소프트웨어를 만드는 법에 관한 것이다. 어떻게 하면 신뢰할 수 있고, 유지보수 가능하며, 요구사항을 만족시키는 소프트웨어를 만들 것인가. 그리고 분명히, 어떻게 하면 산업계의 주요 목표 — 실제 문제를 해결하고 돈을 벌어 주는 완제품 —를 달성할 것인가에 관한 것이다.
그런데, 회사의 1차 목표가 돈을 버는 것이라는 생각은 이제 슬슬 바꿀 때도 되지 않았나. 매 순간 최대의 만족을 주는 행동만 선택하는 것이란 건 사실 꽤 멍청하고 말이 안 된다.
현재의 사회적 위계와, 탐욕과 자기 성찰 부족의 문화는 환경과 문화 유산을 열정적으로 파괴하고 있고, 저작권 분쟁 속에서 모든 지식을 파괴하며, 역사적 텍스트를 조작해 자신들을 선한 편으로 보이게 만들고, 전쟁으로 지구상의 모든 이를 죽이면서 자기 종교를 모두에게 강요하고, 어차피 낭비되고 버려질 것들은 공유하기를 거부하는 한편, 토끼처럼 번식하고 값싼 식품을 먹어 치우며 조기 사망으로 향하고 있다.
제발, 이 비겁함 대신 과학과 연민, 수학과 미래를 좀 갖게 해줄 수 없겠나?
소프트웨어 공학은 산업계가 매번 새로 발명하거나, 처음부터 시작하거나, 호기심에 과도하게 시간을 쓰지 않도록 돕는다. 웹 앱, 백엔드, 프런트엔드, 커맨드라인 앱, 머신러닝 앱 같은 일상적인 것들을 만드는, 잘 정립된 방식이 여럿 존재해야 한다. 이것이 개발을 훨씬 저렴하게 만들고, 모든 고급 개념에 익숙하지 않아도 비즈니스 문제를 해결할 수 있는, 숙련도가 덜한 개발자들을 쓸 수 있게 해준다.
이런 "비즈니스 문제"들은 이미 "실용적인 방식"으로, "실용적인 언어들"에서 여러 번 해결되었다.
이런 관점에서, Haskell은 소프트웨어 설계에 대한 완결된 방법론으로 정리된 고수준 접근과 아이디어가 부족했다. 적어도 최근까지는. 물론, 우리는 mtl 접근을 십 년 넘게 사용해 왔다. 이를 Final Tagless라고도 부르고, 코드 작성에 가장 널리 쓰이는 접근이다... 하지만 설계 관점에서 보면, 이것은 산업계가 가진 요구사항을 제대로 만족시키지 못한다. 예를 들어, 테스트 가능성이 매우 낮고, 특히 대규모 타입 레벨 트릭을 쓰는 고급 라이브러리를 통합하려 할 때 복잡성이 매우 높다. mtl/FT의 문제는 아이디어 자체에 있지 않고 구현 방식에 있다. 이것은 레이어를 완전히 분리하는 것을 허용하지 않고, (타입클래스, 타입 동치, 타입 패밀리 같은) 고급 타입 레벨 기능을 강제하며, "즉시 평가" 이외의 의미론을 표현할 좋은 방법을 제공하지 않는다. 그 밖에도 많은 결함이 있고, 샌즈가 새는 추상화다. 나는 큰 코드베이스에 FT를 쓰는 것을 추천하지 않는다.
정리해 보자.
Tagless Final 스타일의 결함들:
좋다. 이 중 몇 가지는 다뤄 볼지도 모르겠다. 하지만 용어가 정밀하지 않은 상황에서 이걸 어떻게 다뤄야 할까? 표현에 좀 더 정밀함이 필요하지 않을까? 이를 위해선 약간의 "수학"이 필요할지도 모른다.
자유 모나드는 평판이 나쁜가 보다. 왜 그런지 모르겠다. Haskell 커뮤니티에 물어보고, 다른 곳의 답변을 읽어본 바에 따르면, 사람들은 이 글 전체에 대해 혼란스러워하고 있다. 아마 풍차들이 잘 있는지 확인하러 가야 할지도 모르겠다.
내가 질문했을 때, 들은 가장 좋은 답은 이거였다.
이런 평판은 어느 정도는 정당하다. 다양한 연산들이 어떻게 구현되는지를 나중으로 미루는 것은, 언제나 컴파일러 최적화에 장애물을 놓는 일이다. -- Cale
잠깐, 자유 모나드가 뭐지? 이해하고 나면 설명은 짧다.
수학적 맥락에서 "자유"란, 단지 해당 구조가 있다고 선언함으로써 얻어지는 구성을 말한다.
자유 모나드를 만들려면, 한 개의 펑터를 가져와서, 그것이 단위(unit)와 join을 갖고 모나드 법칙을 만족한다고 선언한다. 다만 그 연산들이 구체적으로 무엇인지는 말하지 않는다.
자유 모나드 자체에는 아무 잘못도 없다. 오히려 Haskell에서 꽤 흥미로운 구성을 이룬다.
자유 모나드에는 다음과 같은 생성자가 있다.
haskellPure :: a -> Free f a Free :: (f (Free f a)) -> Free f a
아무 펑터나 f에 넣으면 공짜로 모나드를 얻게 된다. 정말 흥미롭다. f가 리스트인 경우를 생각해 보자. 이런 구조를 만들 수 있다.
haskellFree [Pure 1, Pure 2, Free [Pure 3, Pure 4]]
이게 왜 모나드가 될 수 있는지는 꽤 자명하다. 모나드에는 join 연산이 있다는 걸 기억하자.
haskelljoin :: Monad m => m (m a) -> m a
그리고 bind는 join을 이용해 정의할 수 있다.
haskell(>>=) :: Monad m => m a -> (a -> m b) -> m b (m >>= f) = (join . fmap f) m
이건 모나드를 바라보는 꽤 괜찮은 관점을 제공한다.
계층적 자유 모나드는 자유 모나드를 중첩해서 사용한다. 자세히 보면, 글쓴이는 AppF라는 펑터를 만든다.
haskelldata AppF next where EvalLogger :: Logger () -> (() -> next) -> AppF next instance Functor AppF where fmap f (EvalLogger logAct next) = EvalLogger logAct (f . next) type App a = Free AppF a
Logger 역시 비슷하게 정의된 자유 모나드다. 그런 다음 이것이 해석된다.
haskellinterpretAppF :: AppF a -> IO a interpretAppF (EvalLogger loggerAct next) = do runLogger loggerAct pure $ next ()
음, 이건 바보 같아 보인다. 자유 모나드를 쓰고 있지만, 만들어지자마자 바로 해석해 버린다.
자유 모나드 구성 자체는 그냥 별 의미 없는 화려한 문법 설탕으로 전락한다. 이를 보여주기 위해, AppF (Free AppF a)의 생성자를 살펴보자.
haskellEvalLogger :: Logger () -> (() -> Free AppF a) -> AppF (Free AppF a)
우리는 다음과 같이만 해도 같은 결과를 얻을 수 있다.
haskelldata App a where PureApp :: a -> App a EvalLogger :: Logger () -> App a -> App a
여기에 Functor, Applicative, Monad 인스턴스를 주면 완전히 같은 구조가 된다.
(() -> next)가 이해가 안 된다. 이건 next와 동형(isomorphic)이다.
단위 타입은 타입 인자를 마감(capping)하는 데 쓰인다. 예를 들어 IO a에서, a 자리에 unit을 넣으면 반환값이 없는 IO 액션을 만들 수 있다.
하지만 () -> next와 next는 서로 동형이다.
haskellf :: next -> (() -> next) f = const g :: (() -> next) -> next g = ($()) f.g = const . ($()) = id -- ( () -> next 에 대한 에타 확장을 거쳐 ) g.f = ($()) . const = id
어디에서도 첫 번째 표현을 쓸 필요가 없다. 그냥 const x로 얻을 수 있다.
나는 성능에는 그리 신경 쓰지 않지만, 성능 그래프를 한 번 보자.
글쓴이는 어떤 표를 보여 주는데, 그에 따르면 자신의 방법은 백만 개의 연산을 쌓은 뒤에야 숨이 턱 막히고, 다른 방법들은 모두 선형적으로 느려진다고 한다.
여기서, Church 인코딩된 자유 모나드 엔진은 Final Tagless보다 약간 느린데, 그 차이는 신경 쓰지 말라. 백만 개의 연산부터 의미 있는 수준의 차이가 난다. 당신의 시나리오가 그렇게 길 거라고 확신할 수 있는가?
다른 모든 방법은 시간에 대해 선형적인 감소를 보이는데, 당신의 방법은 제곱으로 늘어나다가 임의의 연산 수치에서 결국 망가진다. 이건 그 해결책 안에 어떤 계산 복잡성이 있다는 걸 시사하지만, 애초에 그런 게 왜 있어야 하나?
각 단계가 충분히 짧은 시간만 걸린다면, 피할 수 있음에도 불구하고 위선형 시간 복잡도를 가지는 게 그리 나쁘진 않다. 다만, 각 단계에 걸리는 시간을 좌우하는 요인이 무엇인지 파악했는가?
글쓴이가 타입을 다루는 방식에는 문제가 있다.
그는 계층적 자유 모나드에서 이런 예시를 제시한다.
haskellprintRandomFactorial :: App () printRandomFactorial = do n <- getRandomInt (1, 100) logInfo $ show $ fact n
그리고 이를 Final Tagless 스타일과 비교한다.
haskellprintRandomFactorial :: (Random m, Logger m) => m () printRandomFactorial = do n <- getRandomInt (1, 100) logInfo $ show $ fact n
타입만 다시 보자.
haskellprintRandomFactorial :: App () printRandomFactorial :: (Random m, Logger m) => m ()
두 함수의 본문은 거의 같지만, 함수 정의에는 의미 있는 차이가 있다.
그렇다, FT 쪽이 printRandomFactorial의 동작에 대해 더 많은 정보를 준다. Haskell을 safe 모드로 돌리면, 이 절차가 허용되지 않은 서비스를 사용하지 않는다는 것도 검증할 수 있다.
이건 의존 타입(dependent typing)과도 크게 다르지 않다. 그 쪽에서는 타입이 결과의 성질을 인코딩하는 방식으로 소프트웨어의 정합성을 보장한다. 위 예시와 비슷한 방식으로 말이다.
따라서 기술적으로는, 더 긴 타입 시그니처가 함수의 동작에 대한 세부 정보가 더 많이 들어 있기 때문에 더 낫다.
그러나 이번 주 블로그 포스트의 주제가 고개를 든다.
FT는 제약(constraint) 목록을 명시하도록 요구한다. 이펙트가 많을수록 제약도 많아진다. 보통, 일반적인 웹 서비스의 비즈니스 로직은 수십, 아니면 수백 개의 함수로 이루어져 있고, 이런 보일러플레이트를 계속 타이핑하는 일은 극도로 짜증난다.
제약을 직접 써야 한다는 문제에 대한 해결책이 있는지는 잘 모르겠지만, 아마 있을 것이다. 대략 이런 느낌으로:
haskell{-# LANGUAGE ConstraintKinds #-} type FactorialService m = (Random m, Logger m) printRandomFactorial :: FactorialService m => m ()
확신은 없지만, 나는 타입을 우선시한다. 타입은 당신이 증명하려는 명제이자, 작성 중인 소프트웨어의 명세다.
이건 아무 유용한 것도 가져다주지 않는다. 절대적인 정합성은 요구되지도 않고, 그럴 수도 없다. 이펙트를 문서화한다고 해서 코드가 더 명료해지지 않는다.
App () 같은, 완전히 헛소리에 가까운 타입 시그니처를 원한다면 이미 "인기 있는" 언어들에서 얻을 수 있다.
타입 정보는 전적으로 정합성(correctness)에 관한 것이다. 그것은 표현식의 동작에 대한 보장을 담고 있어야 한다. 타입에서 더 많은 것을 알 수 있다면, 나는 그 뒤의 코드가 올바른지 확인하기 위해 덜 읽어도 된다.
제대로 하려면, 타입을 먼저 써서 소프트웨어를 명세해야 한다. 타입은 동작의 세부를 포함해야 한다. 그렇지 않다면 타입이 거기에 존재할 이유가 무엇인가?
프로젝트의 미세한 구조화가 코드의 명료성에 더 큰 영향을 준다. 좋은 프로젝트 조직은, 네임스페이스(예: app/Product/Storage/Queries 또는 app/Server/API)만 봐도 그곳에서 어떤 이펙트가 사용되는지 알 수 있도록 해준다. 이펙트/제약 목록은 레이어링을 위한 도구가 아니다. 이펙트는 네임스페이스와 상충할 수 있고, 전반적으로, 아무 실질적 목적도 없는, 불필요한 보일러플레이트일 뿐이다. 그저 만일의 경우를 위한 것일 뿐.
이건 "OOP 책에서 곧장 나온 것 같은" 헛소리로 충분하다.
타입이야말로 당신이 가진 구조다. 런타임으로 미뤄지는 모든 것은 결국 추측이 된다. 성능이 "더 나쁜" 이유도 거기에 있다. 프로그램 행동에 대한 보장이 줄어들기 때문이다!
나는 대화형 소프트웨어에 대해 Haskell이 허용하는 타입 정보 수준에 만족하지 못한다. 하지만 이를 고칠 수는 없다. 더 나은 걸 하려면 선형 타입(linear types)이 필요하기 때문이다.
IO ()는 어떤 정보도 주지 않는다. 그런, 소프트웨어에 대해 아무것도 말하지 않는 타입들을 원한다면, Data.IORef를 써서 얻을 수 있다.
Haskell을 1980년대 BASIC처럼 쓸 수 있다. 완전히 가능하다. 하지만... 그렇다면 왜 "인기 있는 언어들"에서 코딩하지 않는가? 그쪽에서도 타입을 완전히 무의미한 소음으로 만들 수 있다. 그 언어들을 써도 정확히 같은 결과를 얻는다! 클래스 장난을 조금만 치면 성능도 의심스러워질 수 있다. Haskell을 실제로 쓰지 말고, 거기서 영감을 받기만 해도 같은 효과를 얻는다.
타입을 보일러플레이트로 착각하는 건 계속된다.
이런 원칙들에 대한 위배는 FT에 내장된 것처럼 보인다. 예를 들어, FT에서 이펙트들은 보통 구현 세부사항으로 너무 강하게 양념되어 있다. 이런 세부사항은 네이티브 라이브러리에서 흘러나와 비즈니스 로직으로 새어 들어간다. 괜찮은 추상화도, 관심사의 분리도 없다. 내장이 드러나 있고, 호기심 많은 누구에게나 다 노출되어 있다.
이런 타입 시그니처를 보자.
haskellprintRandomFactorial :: (Random m, WithLog SomeLogEnvironment String m) => m ()
이전에는 Logger m이 있었다. 이미 이걸로 추상화가 되어 있었다. 이 프로그램이 왜 로깅 환경에 대한 세부사항을 알아야 하지?
뭔가가 비즈니스 로직으로 새어 들어오고 있고, 그게 타입에 보인다. 이는 글쓴이의 주장과 정반대다. 그의 코드는 망가져 있고, 그는 그것을 추상화의 결함으로 오해하고 있다!?
죽은 채 태어난 App () "추상화"는 단지 구현 세부사항에 대한 잠재적으로 문제적인 접근을 허용하면서도, 그것을 드러내지 않게 해 준다. 그러다 보면 결국 언젠가, 아무도 아무것도 모르는 상태에서 어딘가가 망가질 것이다.
이 "기능"도, 당신이 좋아하는 "인기 있는 언어"에서 이미 얻을 수 있다. 그냥 말해보는 거다.
Haskell의 진짜 좋은 점 중 하나는, 모든 것이 잘 합성(composition)된다는 것이다. 예를 들어 이 함수:
haskell(.) :: (b -> c) -> (a -> b) -> (a -> c)
나는 이걸 쓰는 걸 정말 좋아한다. 두 함수를 꼬리에서부터 합성해 준다. 또한 두 함수가 올바르고 적절히 선택되었다면, 그 합성도 올바르다. 이 연산자를 카테고리와 닮은 모든 것에 쓸 수 있도록 오버로드해 두었으면 좋았을 텐데.
FT 지지자들의 또 다른 주장 포인트는 조합 가능성(composability)이다. 하지만 합성 그 자체에는 아무 가치도 없다. 람다, 고계 함수, 타입, 타입클래스, 그 밖의 언어 기능들에도 그 자체로는 가치가 없다. 우리는 언어를 감탄하기 위해 여기 있는 게 아니다. 우리는 실제 문제를 풀고 비즈니스 목표를 달성하기 위해 여기 있고, 도구를 선택할 때 매우 신중해야 한다.
누가 그에게 Haskell을 왜 쓰냐고 다시 상기시켜 줄 수 있을까? 자랑하려고? 허영심 때문인가? 근육이 더 커 보이도록 기름을 주입하는 것과 비슷하다. 결과는 거의 같다.
그 모든 것들이 언어에 들어 있는 데에는 이유가 있다. Haskell은 수학적 논리와 대응(correspondence)을 갖고 있고, 비록 튜링 완전한 언어라 그 논리가 불완전하지만, 프로그램이 종료한다는 가정하에 꽤 많은 정합성 보장을 제공한다.
타입과 구조는 직관 논리(intuitionistic logic)에 맞도록 선택되어 있다. 함수는 함의(implication)에 대응하므로, 화살표로 표기된다. 곱(pair)은 논리곱(conjunction)에, 합(sum)은 논리합(disjunction)에 대응한다.
언어를 이런 식으로 논리에 대응시키는 데에는 강력한 이점들이 있다. 그 중 하나는 구성만으로 올바른(correct-by-construction) 코드를 작성할 수 있다는 점이다. 이는 의존 타입이 언어에 들어와 있을 때 사물의 작동 방식을 이해하는 것과도 맞물린다. 단지 Haskell로 코딩만 한다고 자동으로 배우게 되는 건 아니다.
이펙트를 합성하는 것에는 가치가 없다. 이펙트를 제어(control)하는 것에 가치가 있다. FT의 명세는 버그 옆에 "BUG: 고쳐야 함!"이라는 주석을 다는 것과 비슷하다. 정말 제어하고 있는가? 전혀 아니다. 버그는 여전히 거기 있다. 이펙트 목록을 명시하는 것은 완전한 정합성을 소환하기 위해 활주로 주변을 의미 없이 춤추는 것과 같다.
당신은 또, 합성 불가능성을 당신이 좋아하는 인기 언어에서 얻을 수 있다. 합성을 가치 있게 여기지 않는다면, 왜 합성이 잘 되는 언어를 쓰는가?
글쓴이는 어느 시점에서, Finally Tagless가 확장 가능(extensible)하지 않다고 반박하기 시작한다. 아..
haskell-- 예전에는: -- printRandomFactorial :: (Random m, WithLog SomeLogEnvironment String m) => m () -- 이제는: printRandomFactorial :: (Random m, Database m, WithLog SomeLogEnvironment String m) => m ()
(도대체 왜 랜덤 팩토리얼 출력 함수가 데이터베이스에 접근해야 하지?)
요지는, 변화가 호출 스택을 거슬러 올라간다는 것이다. 이 함수를 호출하는 모든 것을 바꿔야 한다.
haskell-- 예전에는: -- printFactAndFib :: (Random m, WithLog SomeLogEnvironment String m) => m () -- 이제는: printFactAndFib :: (Random m, Database m, WithLog SomeLogEnvironment String m) => m ()
하지만 진지하게, 여기서 무슨 일이 벌어지고 있나? 팩토리얼이 캐시되어서 데이터베이스가 필요한가? 문서도 바뀌어야 하지 않나? 소프트웨어의 나머지 부분에 파급 효과가 있다면, 타입도 바뀌어야 하지 않을까?
이제 위대한 App과 비교해 보자.
haskellprintRandomFactorial :: App () printRandomFibonacci :: App () printFactAndFib :: App ()
좋다...
이건 인기 있는 언어에서도 얻을 수 있다. 똑같이 간단하다.
HFM은 Expression Problem을 해결하려고 시도조차 하지 않으므로, Final Tagless의 대안이 아니다.
그래, 맞다. FT로 Expression Problem은 어느 정도 해결된다. 하지만 우리는 Expression Problem을 해결하라고 돈을 받는 게 아니다. 우리는 비즈니스 문제를 해결하라고 돈을 받는다. 때로는 확장성이 요구사항이지만, 때로는 그렇지 않다. 내 경험상, 핵심 서브시스템의 수는 10개를 넘는 일이 드물다. 많아야 15개쯤. 이 중 일부는 처음부터 구현할 수 있고, 일부는 나중에 추가해도 괜찮다. 우리는 항상 무엇을 왜 하는지 알고 있다.
전체 인용문을 그대로 두겠다. 사실 앞부분만으로도 충분하지만, 점점 더 바보같이 흘러가기 때문이다.
... 그리고, 외부 세계는 "Expression Problem"이라는 용어 자체를 아예 모른다. Haskeller들은 이를 사랑하고, Expression Problem을 해결하는 것을 사랑하지만, 이것은 산업계 개발의 1차 목표가 아니다.
우리는 Final Tagless의 특정 사용법에 대해 이야기하고 있지만, 그 밖의 모든 것은 무시되고 있다. 당신의 1차 목표는 교육이 아니니, 그냥 바닥에 떨어뜨려 두고 괜찮다고 생각하는 모양이다.
Philip Wadler가 Expression Problem을 설명한 인용문을 보자.
Expression Problem은 오래된 문제에 붙은 새로운 이름이다. 목표는 케이스로 정의된 데이터 타입을 정의하면서, 기존 코드를 재컴파일하지 않고, 정적 타입 안전성(예: 캐스트 없음)을 유지한 채로, 그 데이터 타입에 새로운 케이스와 새로운 함수를 추가할 수 있도록 하는 것이다. -- Wadler
이게 무슨 뜻인지 설명해 보겠다. 대수적 표현으로 리스트 타입을 생각해 보자.
haskell[a] = 1 + a * [a]
여기서 두 개의 생성자 nil과 cons를 얻는다.
haskellnil = inl 0 cons a as = inr (a, as)
그리고 소멸자(destructor)도 몇 개 만들어 보자. join과 concat 정도면 재밌겠다.
haskellconcat : [a] -> [a] -> [a] concat (inl 0) a = a concat (inr (a, as)) b = inr (a, concat as b) join : [[a]] -> [a] join (inl 0) = inl 0 join (inr (a, as)) = concat a (join as)
케이스를 추가한다는 건 이렇게 하는 것이다.
haskell[a] = 1 + a * [a] + a
새 생성자를 hbar라고 부르자. 하지만 이제 문제가 있다. concat과 join이 깨진다. hbar를 처리하지 않는다.
원하는 만큼 함수를 추가할 수 있지만, 생성자를 하나라도 더 추가할 수는 없다. 이건 여러 방식으로 프로그래밍 언어들에서 나타나는 문제다.
Final Tagless의 아이디어는, 생성자들을 타입클래스로 추상화할 수 있다는 것이다. 다른 것들과 마찬가지로 말이다.
그래서 다음과 같이 선언할 수 있다.
haskellclass ListC list where empty :: list a cons :: a -> list a -> list a newtype ConcList a = ConcList [a] instance ListC ConcList where empty = ConcList [] cons a (ConcList b) = ConcList (a:b) abstract :: ListC list => [a] -> list a abstract [] = empty abstract (x:xs) = cons x (abstract xs) catenate :: ListC list => ConcList a -> ConcList a -> list a catenate (ConcList a) (ConcList b) = abstract (a++b) main = pure ()
이제, 그러고 싶지 않지만, 그래도 한다고 치자. 리스트 안에 분리 포인트를 두고 싶다고 하자.
haskellclass HBar bar where hbar :: bar a -> bar a newtype BarList a = BarList [[a]] instance ListC BarList where empty = BarList [[]] cons a (BarList (bs:cs)) = BarList ((a:bs):cs) instance HBar BarList where hbar (BarList cs) = BarList ([]:cs) unbar :: BarList a -> [[a]] unbar (BarList as) = as
이전의 abstract와 catenate는 그대로 유지된다. 다만 이제, 새로운 구조에 대해 동작하는 unbar도 있다. 이건 상당히 강력하고, 그래서 꽤 복잡함에도 불구하고 관심을 받는다.
Final Tagless는 여기서 더 나아간다. catenate를, HBar 생성자를 수용하도록 확장할 수 있게 정의할 수도 있다.
어쨌든, 이게 기본 아이디어다. 우리는 여기서 "확장성"과 "확장성"(농담 아니다)을 혼동하고 있다. 글쓴이는 단순한, 예컨대 기존 코드를 패치하는 것 같은 걸 말한다. 우리는, 이미 작성된 프로그램들을 어떻게 끼워 맞출 수 있는지에 관한 선택지를 이야기하는 것이다.
예외 처리 부분은, 언뜻 보기에 잘 작동하는 것 같다. 오류를 데이터로 전달하고, Control.Exception에는 손대지 않으면 괜찮다.
아... 잠깐.
haskellinterpretLangF :: LangF a -> IO a interpretLangF (ThrowException exc next) = throwIO exc interpretLangF (RunSafely act next) = do eResult <- try $ runLang coreRt act pure $ next $ case eResult of Left (err :: SomeException) -> Left $ show err Right r -> Right r
그래도 최소한, 그는 예외를 모순(contradiction)을 다루는 데만 쓰고 있기를 바란다. 즉, 예외는 실제 버그를 잡고 트리거하는 데 쓰이고, 알려진 오류 조건은 정상적인 연산의 일부로, 그 오류들을 조직화할 좋은 계획 속에 포함되어 있기를 바란다.
맞겠지?
마침내 글은 자원 관리에 이른다. Haskell에서는 객체에 자원의 생애주기를 묶을 수 없다. 그게 가능하다고 해도, 논리적 추론의 범위 바깥에 있게 될 것이다.
불행히도, Haskell에는 자원 관리에 관한 도구가 별로 없다. 적어도 선형 타입이 제대로 모양을 갖추기 전까지는 그렇다. 이건 이 언어의 결함이다.
RAII에 대한 또 다른 인기 있는 아이디어는, 자원에 접근할 수 있는 유일한 장소인 스코프를 두는 것이다. 제어 흐름이 이 스코프를 벗어나면, 자원이 파괴된다.
이 기능은 Haskell에서 구현할 수 없다. 하지만, 여기서 Haskell의 결함을 말한다고 해서, 다른 곳에서 이 문제가 잘 해결되었다는 뜻은 아니다.
아... 그런데 왜 이걸 떠올리지 못했을까?
haskellioBracket :: IO a -- 먼저 실행할 연산("자원 획득") -> (a -> IO b) -- 마지막에 실행할 연산("자원 해제") -> (a -> IO c) -- 그 사이에서 실행할 연산 -> LangL c
주석에 있던 편리한 타입 정보를 지워 보자.
haskellioBracket' :: IO a -> (a -> IO b) -> (a -> IO c) -> LangL c
자원 a가 주어졌을 때, 마지막에 실행할 연산을 채워 넣는다.
haskellioBracket'' :: IO a -> (a -> IO c) -> LangL c
자원 획득을 채워 넣자. 자원을 foo라고 부르자.
haskellioBracket''' :: (foo -> IO c) -> LangL c
여기에 pure :: Applicative m => a -> m a를 집어넣자.
haskellioBracket'''' :: LangL foo
어떻게든 자원이 컨텍스트 밖으로 새어나갔다. 이것이, 여러분, 자원 관리를 하려면 선형 타입이 필요하다는 이유들 중 하나다.
또, 타입을 이용한 이런 추론이 가능한 것도 중요하다. 나는 이 문제를 파악하기 위해 어디에도 찾아볼 필요가 없었다. 타입만 봐도 프로그램이 이런 문제를 안고 있다는 게 자명하다.
이미 세상에는 계층적 자유 모나드는 대체로 무의미하다는 판단이 나와 있다. 다만 이들을 자유 모나드 그 자체와 혼동하지 않도록 주의하자.
어쨌든, 나는 이걸 글쓴이를 계몽하려는 의도로 다룬 것이 아니다. 텍스트만 봐도, 그가 들을 준비가 되어 있지 않다는 걸 알 수 있다.
2020-07-03 업데이트: 이 글의 내용에 대한 논의가 r/haskell 에 있다.
주의할 점은, 나는 글을 출간하기 전에 두어 번 읽고 다시 쓴다는 것이다. 당신을 화나게 하는 최근 글을 보았다면, 당연히 분노할 자격이 있다. 적어도 온라인에 던져지기 전에 두 번은 내 검열을 통과한 셈이니까.
피드백에 동의하는 바, 글의 일부는 내용 자체를 깎아내리는 순수한 모욕으로 흐르면서, 전체에 조롱조의 느낌을 준다. 나는 이에 대해 성찰할 것이고, 이렇게 표시해 두겠다. 다음에 던질 찬 수건에는 물을 조금 더 데우고, 돌은 몇 개 빼 두자.
글쓴이가 이걸 듣지 않을 거라는 내 판단이 틀렸을 수도 있다. 하지만 그는 자신의 산물에 정말 깊게 몰입해 있고, 나도 그 자리에 있었던 적이 있다. 순수한 비판조차도 모욕으로 느껴지게 되는 상태 말이다.
2020-08-14: 내가 리뷰를 쓴 이후, 그가 원문에 가한 수정들.
"Hire me to know more, support my book on Patreon, subscribe to me in Twitter, ask questions."는 "Hire me to know more, support me, subscribe to me in Twitter, ask questions."로 바뀌었다.
"Functional Design and Architecture" 책 링크는 graninas.com에서 leanpub.com으로 옮겨졌다.
Nothing of these benefits outweights the fact that Free monads are inherently slow.
여기서 "essentially"가 "inherently"로 바뀌었다.
"not for seasoned haskellers", "I'm exaggerating and overbolizing too much"가 볼드 처리되었다.
"Opinionaed" 비교가 "Opinionated"로 바뀌었다.
Hire me to know more, support me, subscribe to me on Twitter, ask questions.
"Twitter" 앞의 "in"이 "on"으로 바뀌었다.
2020-08-14: graninas와의 대화 기록:
u/htuhola: 조금 주제와 벗어날 수 있는데요, 왜 다른 언어 대신 Haskell을 선택하셨나요?
u/graninas: 저는 실제로 산업계 C++ 경험이 10년 넘습니다. 그리고 C#과 Python(아, 오래전에는 Delphi도)이랑도 일했죠. 그래서 Haskell을 다른 무언가 대신 "골랐다"고 말하긴 어렵습니다. 다만 Haskell로 전향할 기회를 얻자마자 C++ 개발자로 일하는 건 그만뒀습니다. 3년 전의 일입니다. 하지만 취미로는 2011년부터 Haskell을 배우고 있었어요. 저는 이 언어를 사랑합니다. 코드에 엄청난 확신을 주고, C++보다 몇 배는 더 즐겁습니다. C++을 하면서는 너무 자주 우울함을 느꼈습니다. 하지만 Haskell은 너무나 뛰어난 언어이고, 멋진 아이디어로 가득합니다. Haskell을 배운 건 제 개발 인생에서 가장 잘한 일이었다고 말할 수 있습니다.
u/htuhola: C++ 코드에 비해 Haskell 코드가 더 확신을 준다고 느끼는 이유는 뭔가요? 또 Haskell을 멋지게 만든다고 생각하는 점을 조금 더 자세히 말해 줄 수 있나요?
u/graninas C++은 재앙입니다. 21세기의 현대 언어가 아닙니다. 언어 디자인은 끔찍하고 일관성이 없습니다. C++로 작성된 코드는 어느 줄에서든 터질 수 있습니다. C++에는 사용할 수는 있지만 정의되지 않은 동작으로 이어지는 구성 요소들이 너무 많습니다. 인류가 만든 가장 위험하고 복잡한 언어입니다. 기본 요소들의 설계도 엉망이라, 아주 작은 일을 하려 해도 C++에선 100줄의 코드를 써야 합니다. Haskell에선 한 줄이면 되는 일을 말이죠. C++은 수동 메모리 관리와 온갖 함정들이 있는, 매우 저수준 언어입니다. 최고의 성능을 목표로 하지만, 현대 세계에서는 성능이 더 이상 최우선 순위가 아닙니다. 오늘날 우리가 중시하는 것은 출시까지의 시간, 단순함, 숙련도가 낮은 개발자들에게도 접근 가능함, 확장성, 용이성입니다. C++은 오늘날 세계가 가진 요구를 만족시키지 못합니다.
Haskell은 완전히 다른 언어입니다. 매우 고수준이고, 엄청나게 일관성이 있습니다. 오늘날에도 Haskell이 가진 기능들을 다른 언어들에게서 곧 보게 될 거라 기대하기 힘듭니다. ADT, 패턴 매칭, 지연 평가, 훌륭한 타입 시스템. Haskell로는 C++이나 다른 언어보다 더 많은 것을 할 수 있습니다. 그리고 내가 Haskell로 쓰는 코드는 보통 의도한 대로 동작합니다. Haskell에서는 디버거를 쓰지 않지만, C++ 프로그램은 끊임없이 디버깅해야 했습니다. 그런 의미에서 Haskell은 훨씬 낫습니다.
u/htuhola: Haskell이 형식 논리와 대응한다는 점이 차이를 만들고 있는 겁니다.
일관성을 이야기할 때 합성이 핵심 역할을 합니다. 함수를 합성할 때, (f . g) . h와 f . (g . h)가 같은 결과를 낼 것이라고 믿을 수 있습니다. 언어가 따르는 이런 규칙들이 언어를 일관되게 만들어 줍니다. 언어를 이렇게 일관되게 만들고자 하는 관심은 형식 논리에서 비롯됩니다.
ADT와 패턴 매칭도 마찬가지입니다. 이는 폐쇄적이고(total)인 함수를 정의할 수 있도록 하는 동기에서 나온 것입니다. 함수가 항상 결과를 내도록 보장하려는 거죠.
평가 전략으로 지연 평가를 쓸 수 있게 한 것도, 부수 효과를 계산과 분리하기 위한 동기에서 비롯됩니다. 이는 람다 계산에서, 타입이 증명하려는 명제이고, 평가(evaluation)가 정규화(normalization)로 해석되며, 프로그램이 형식 증명(formal proof)으로 해석되는 관점과 맞닿아 있습니다.
디버거를 덜 쓰게 되는 이유도 Curry-Howard 대응으로 거슬러 올라갈 수 있습니다.
추천 읽을거리: Type Driven Development with Idris
또한 The Little Typer, Learn you an Agda 같은 책도 있습니다.
나는 Granina의 책을, 여기보다 덜 엄격한 톤으로 리뷰할까 고민하기도 했다. 하지만 하고 싶지 않다. 나는 끊임없이 모욕감을 느끼고 있고, 며칠 전에 실제로 하려고 했던, Purescript 커뮤니티를 돕는 일이나 하고 싶다.
그는 여전히 8일 전에도 모나드를 까고 있었다:
저는 Haskell 경험이 8년 이상입니다. 모나드가 뭔지 모릅니다. 모나드를 이해하지 못하고, 이해하고 싶지도 않습니다. 왜냐고요? 그 질문은 오직 수학적 관점에서만 의미가 있기 때문입니다. 별로 도움이 되지 않고, 때론 해롭기까지 합니다. Haskell의 모나드는 범주론의 모나드와 다릅니다. 저는 범주론도 모릅니다. 그리고 Vitaly Bragilevsky의 말에 동의합니다. "'모나드를 이해한다'라는 건 존재하지 않는다." 그게 도대체 무슨 뜻인가요? "이해한다"는 게 뭔가요? 누가 당신이 어떤 걸 이해하는지, 이해하지 못하는지를 판단하나요? 아마 수학 지향적인 사람들이겠죠. 하지만 실무 코드를 작성하는 사람들은 아닐 겁니다. 모나드를 사용하기 위해 "모나드를 이해할" 필요는 없습니다. Haskell에서 전형적인 작업들을 해결할 수 있다면, 당신은 괜찮습니다. 수학을 얼마나 깊이 알고 있는지는 상관없습니다.
그래서 저는 모나드가 과도하게 복잡하다는 데 동의합니다. 음, Haskell의 많은 것들이 과도하게 복잡합니다. 이는 기술적이라기보다 사회적 현상입니다. 저는 이게 싫습니다. 왜냐면 이것이 Haskell이 더 인기 있어지는 것을 막기 때문입니다.
나는 이 모든 것이 믿을 수 없이 모욕적이라고 느낀다. 이 글은 Haskell에, 그리고 간접적으로 Purescript에도 얼마나 해가 될 수 있는지, 그 가능성은 무궁무진하다. 그의 글을 읽어보고, 논리가 어떻게 흘러가는지 생각해 보라. 평균적인 프로그래머는 수학을 이해하는 데 어려움을 겪고, 그것이 설계 도구로서 형식 수학을 사용하는 것을 건너뛸 이유가 된다고 한다. 그리고 수학을 존중하는 것이 나쁜 사회적 현상이라고 말한다? 최소 수백 년에 걸친 좋은 작업들을, 이제야 일상 업무의 구원자로 떠오를 기회를 얻은 시점에서 내던지는 꼴이다.
이 글을 그냥 재미 삼아 끝까지 읽었다면, 다시 처음부터 읽어보되, 이번에는 당신이 Graninas라면 이걸 읽으며 어떻게 느낄지를 생각해 보라. 그리고 또 생각해 보라. 당신이 다른 사람들이 이 글을 읽고 나서 어떻게 느낄지에 대해 책임감을 느끼고 있다면, 이런 종류의 글을 쓸 것인가?