수학적 순수성과 실용적 해킹 사이에서, 저자가 Haskell의 아름다움을 인정하면서도 실제 생산성과 메타프로그래밍, REPL 워크플로우 때문에 Scheme과 Lisp를 계속 선택하는 이유를 설명한다.
소프트웨어 공학에는 프로그램의 아름답고 수학적으로 순수한 이상과, 어떻게든 일을 끝내야 하는 지저분하고 실용적인 현실 사이에 지속적인 긴장이 존재한다. 나는 내 경력 동안 해킹을 위한 개인적인 최적점을 찾기 위해 이 두 극단의 깊은 곳까지 탐험해 왔다.
제목을 보고 키보드를 벼리며 불꽃 논쟁을 시작하려 하기 전에, 이 글이 Haskell이나 그 밖의 어떤 도구를 깎아내리기 위해 쓰인 것이 아니라는 점을 먼저 밝히고 싶다. 사실 나는 Haskell을 사랑한다. 나는 스스로 그것을 익혔고, 3년에 걸쳐 벽에 머리를 박아 가며 씨름했고, 그것으로 여러 실제 프로젝트도 만들었다. 그중 일부는 약간 수익성까지 있었다.
웹 개발 세계, Go 세계, Java, Scala, Kotlin이 있는 JVM 세계에서 보낸 시간, 그리고 Lisp(Emacs, Common, Scheme)로 오래 해킹해 온 역사 사이에서, 나는 함수형 프로그래밍을 깊이 감사하게 되었다.
Haskell은 아마도 작업해 볼 수 있는 타입 시스템 중 가장 놀랍고, 깨우침을 주며, 복잡한 시스템을 가지고 있다(ML 계열 언어도 그렇다).
또한 프로그래밍에 수학적 아이디어와 개념을 도입하고, 그것들을 대중화한 확고부동한 왕이기도 하다. Haskell 커뮤니티에는 박사들, 컴퓨터 과학 연구자들, 범주론자들, 그리고 온갖 똑똑한 사람들이 모여든다(하지만 Schemers 같은 다른 공동체도 과소평가하지는 말자).
내 정신을 여러 번 뒤흔들었던, Haskell의 놀라운 혁신 또는 그것이 대중화하는 데 도움을 준 것들 가운데 일부는 다음과 같다.
이런 종류의 것들은 다른 언어에서는 종종 억지로 덧붙여진 것처럼 느껴지거나 아예 빠져 있는 경우가 많다!
그 모든 탁월함에도 불구하고, Haskell은 사람들이 그냥 해킹하면서 유용한 코드를 빠르게 쓰려는 대부분의 시도에 저항한다.
특히 함수형 프로그래밍이 처음인 사람들(혹은 하느님 맙소사 monad와 functor가 처음인 사람들! 모나드는 endofunctor의 범주에서 monoid일 뿐인데, 뭐가 문제란 말인가?)에게는 더 그렇다.
Scheme(그리고 일반적으로 Lisp)는 Haskell의 혁신과 순수성은 부족할 수 있고, 대신 미니멀한 유연성을 선호하지만, 실용성과 함수형의 아름다움을 섞는 방식 때문에 인간을 위한 함수형 언어가 된다.
실제로 내 의견으로는, Scheme(그리고 Lisp)는 다른 어떤 언어보다도 복잡한 시스템과 문제 영역을 더 단순한 용어로 표현할 수 있게 해 준다.
예를 들어 최근 내가 겪은 한 모험을 보자. 나는 수년에 걸쳐 떠올린 많은 프로젝트 중 하나인 북마크 관리 도구의 프로토타입을 만들고 있었다.
나는 데이터 모델링의 아름다움과 부작용 없는 순수한 추론이 잘 맞을 것이라 생각해 Haskell로 시작했다. 게다가 그것은 빠르고 우아하며, Parsec, Servant, optparse-applicative 같은 모듈을 한 번 써 보고 나면, 파서 같은 어떤 것들을 그것 없이 작성하는 모습을 상상하기 어려워진다.
개념 증명 단계 중 하나는 몇 가지 데이터 모델을 XML로 변환해 파일로 출력하는 것이었다.
이걸 Kotlin이나 Java로 했다면 아주 사소했을 것이다. Gradle에 의존성 하나를 넣고, Jackson이나 표준 DOM 파서를 연결하면, 10분 뒤에는 데이터가 메모리에 올라와 조작할 준비가 되어 있다.
하지만 Haskell 프로젝트로는 답답한 한 시간을 보낸 뒤에도, 그리고 그 언어에 대한 수년간의 경험이 있었음에도, 나는 여전히 의존성과 씨름하고 있었고, 그다음에는 monadic API와 싸우고 있었으며, 결국 애초에 내가 무엇을 하려 했는지조차 잊어버렸다는 걸 깨닫고는 전부 포기하고 말았다.
이것이 Haskell에서 내가 자주 부딪히는 마찰 지점이었다. 그것은 아름답지만, 처음부터 큰 설계 없이 그저 손을 더럽히며 프로토타입을 만들고 싶을 때는 당신과 싸운다. 물론 type-driven development가 멋지고 어떤 경우에는 잘 작동할 수도 있지만 말이다.
Scheme(내 경우에는 GNU Guile)은 Haskell의 무시무시할 정도로 효율적인 컴파일러는 없지만, C 기반 덕분에 꽤 빠르다. 그리고 그것이 가진 것은 간결함, 강력함, 그리고 더 중요하게는 실제로 해킹하는 행위를 즐겁게 만든다는 점이다.
Haskell의 순수 함수형 기반이 아무리 우아하더라도, 파일에 쓰거나 네트워크를 통해 통신하는 것 같은 단순하지만 중요하고 불순한 작업을 정말로 복잡하게 만들 수 있다.
Monad는 이것에 대한 Haskell의 해답이지만, 종종 무거운 추상화 세금처럼 느껴진다. 그것들은 유용한 소프트웨어를 쓸 수 있게 해 주지만, 직관적이거나 빠른 프로토타이핑을 가능하게 해 주는 경우는 드물다.
내 생각에 이런 종류의 강압적인 추상화는 정말 아름답지만, 대부분의 프로젝트에서는 정당화되기 어렵다. 스스로에게 물어보라. 내가 정말 함수형 effect system이 필요한가? 그 복잡성과 인지 부하를 감수할 가치가 있는가? 정말로 순수/불순 계산의 엄격함을 컴파일 타임에 강제할 필요가 있는가? 나중에 단순한 print 하나를 어딘가에 추가하는 것조차 리팩터링 없이는 되지 않을 것이라는 점을 기억하라(IO monad의 세계에 온 것을 환영한다).
오랫동안 Lisper였던 내게 이것은 사용성에 대한 엄청난 장벽이다. 많은 면에서, 관찰할 수 있는 것만 고칠 수 있다.
Scheme은 학문적 순수성을 기꺼이 희생하고, 코드 어디에나 (write ...)를 툭 넣어 즉시 무슨 일이 벌어지는지 보게 해 준다. 지금쯤 어떤 Haskell 순수주의자는 얼굴을 감싸 쥐고 Debug.Trace를 언급하거나, 내가 왜 게으르고 잘 최적화된 언어에서 부작용을 원하느냐고 묻고 있을지도 모른다. 기술적으로 그들이 틀린 것은 아니지만, 빠르고 지저분한 디버깅에 추가되는 그 마찰 비용은 내가 빠르게 움직이려 할 때는 도저히 감수할 생각이 없다.
Monad의 두 번째 문제는 그것의 가장 큰 강점과 직접 연결되어 있다. 그것들은 도메인 특화 언어(DSL)와 사실상 동의어다.
DSL의 약속은 환상적이다. 문제를 해결하기 위해 복잡한 프로그램을 쓰지 말고, 그 작업만을 위해 설계된 맞춤형 언어 안에서 단순한 프로그램을 쓰라는 것이다. 여기서 Parsec은 대표적인 성공 사례다. 파싱 함수는 사실상 BNF 문법과 거의 동일하다.
하지만 Parsec의 성공은 Hackage를 온갖 일을 위한 수백 개의 맞춤형 DSL로 가득 채워 놓았다. 파싱용 하나, XML용 하나, PDF 생성용 하나. 각각은 완전히 다르고, 각각이 저마다의 학습 곡선을 요구한다. XML을 파싱하고, 웹 API의 어떤 JSON을 바탕으로 그것을 수정한 뒤, PDF로 쓰는 일을 생각해 보자. 예를 들어 Java 생태계에서는 어느 정도의 일관성을 기대하게 된다. 라이브러리 세 개를 가져오면, 대체로 익숙한 객체지향 또는 약한 함수형 관습을 따른다. 하지만 Haskell에서는 서로 다른 세 작업을 위해 설계된 세 DSL이 대개 각 영역에만 엄격히 최적화되어 있고, 문법의 일관성은 완전히 무시된다는 뜻이다. JavaDocs를 5분 훑어보는 대신, 당신 앞에는 몇 시간짜리 DSL 문서와 튜토리얼이 놓이게 된다.
Schemers인 우리는 알다시피, Scheme은 의도적으로 단순하다. 그리고 그 단순함은 한계가 아니라, 그것을 끝없이 유연하게 만드는 요소다.
현대의 JVM 언어들이 이것을 달성하기 위해 리플렉션이나 복잡한 컴파일러 플러그인(Kotlin의 KSP 같은 것)에 크게 의존하는 반면, Lisp 해커들은 강력한 매크로 시스템을 사용해 수십 년 동안 언어를 손쉽게 재구성하고, 자신의 의지에 맞게 언어를 확장하고 휘어 왔다.
(define-syntax define-repo-method (syntax-rules () ((_ method-name accessor docstring) (define* (method-name repo . args) docstring (apply (accessor repo) args))))) Haskell은 Scala의 고급 타입 수준 프로그래밍과 마찬가지로, 비슷한 유연성을 얻기 위해 종종 산더미 같은 언어 확장을 요구한다(Template Haskell와 그 강력하지만 무서운 API를 생각해 보라).
{-# LANGUAGE TemplateHaskell #-} import Control.Monad import Language.Haskell.TH
curryN :: Int -> Q Exp curryN n = do f <- newName "f" xs <- replicateM n (newName "x") let args = map VarP (f:xs) ntup = TupE (map (Just . VarE) xs) return $ LamE args (AppE (VarE f) ntup) 나는 내 개인적인 _“스위트 스폿”_에 닿게 해 주는 기능과 철학의 조합 때문에 무수한 프로젝트에 Scheme을 사용해 왔다. 또한 그것은 계속 개척을 이어 가고 있는 고급 언어이며, 제약 없는 혁신의 언어이기도 하다(예: delimited continuations). 문법 자체를 당신 뜻대로 빚고 싶을 때, Scheme은 방해하지 않고 그것을 이루도록 도와준다.
물론 내 도구 상자에 대해 완전히 공정하게 말하자면, 표준 Scheme은 때때로 JVM과 비교할 때 거대한 엔터프라이즈급 운영 환경에 필요한 묵직한 “배터리 포함” 생태계가 부족할 수 있다. 또한 Haskell과 비교하면 Lisp 컴파일러는 기껏해야 소박하고 단순하다. 하지만 그 덕분에 훨씬 더 접근하기 쉽고(오류 메시지도 훨씬 더 친절하다).
내가 Scheme이 Haskell보다 객관적으로 더 낫다고 말하는 것은 아니다. 언어는 도구이며, 우리는 작업에 맞는 도구를 골라야 한다.
나는 Haskell의 함수형 아름다움과 아이디어들에서 배운 모든 것을 언제나 기억할 것이다. 하지만 내게 Haskell은 여전히 프로그래밍 언어의 플라톤적 이상에 머문다. 특정 방향으로 길을 비춰 주지만, 내가 하는 대부분의 일에는 다소 지나치게 경직되어 있다.
REPL(Read-Eval-Print Loop)은 콘솔, 실행 중인 애플리케이션, 언어 컴파일러 등에 연결해 사용할 수 있는 대화형 환경으로, 엔지니어에게 초능력을 준다 🦸🏼.
Lisp 방언들, 더 구체적으로는 Guile Scheme은 이것을 아주 잘 지원한다. 개인적으로 나는 물론 Guix, Emacs, (Arei/Ares + sesman)과 함께 이런 방식을 좋아한다. 그러면 전통적인 IDE보다 몇 마일은 앞서는, 궁극적으로 확장 가능하고 강력한 편집기 경험을 얻을 수 있다 🐂 .
그리고 아니, 이것은 당신이 Haskell(GHCIDE 등)이나 Python에서 아는 그런 종류의 REPL과는 다르다. Lisp REPL은 훨씬 더 많은 것을 할 수 있고, 편집기에 매끄럽게 통합된다. 실시간으로 평가하고, 확인하고, 바꾸고, 디버깅하라. 끊김 없이.
이것은 느린 편집, 저장, 컴파일, 실행 주기를 없애면서 개발 워크플로우를 근본적으로 바꿔 놓는다. 무슨 일이 일어나는지 보기 위해 전체 프로그램을 작성한 뒤 실행하는 대신, 빠르고 대화적인 워크플로우를 얻게 된다. 실제로 이것이 무엇을 의미할까?
print 문을 추가하고 다시 시작하는 일은 잊어라. 멈추고, 객체를 검사하고, 값을 바꾸고, 심지어 깨진 함수를 즉석에서 다시 정의해 어떤 환경에서든 수정 사항을 시험할 수 있다(그렇다, 실행 중인 프로덕션에서도 가능하다).코드 편집기에 이것이 통합되면, 키보드 단축키 하나로 코드의 어떤 조각이든(한 줄, 선택 영역, 또는 파일) 실행하고 결과를 즉시 볼 수 있어, 매끄럽고 강력한 개발 경험이 만들어진다.
전반적으로 Lisp 계열 언어는 내게 그냥 스위트 스폿이며, 내가 좋은 개발자 경험이라고 생각하는 것의 중심에 있다. 또한 그것들은 당신에게 초능력을 주고, 오래 지속될 아름다운 시스템을 만들 수 있게 해 준다.