Mercury에서 수백만 줄 규모의 Haskell 코드베이스를 프로덕션에서 운영하며 얻은 신뢰성, 타입 설계, 내구성 있는 실행, 관측 가능성, 그리고 실용주의에 대한 교훈.
Ian Duncan 2026년 3월 30일 [현장의 Haskeller들] #프로덕션 #Mercury The editors of the Haskell Blog는 "현장의 Haskeller들"이라는 새 연재를 기쁘게 소개합니다. 이 연재에서는 경험 많은 엔지니어들을 초대해 자신들의 전문 분야, 모범 사례, 그리고 프로덕션 현장의 이야기를 들려받습니다.
엔지니어링의 엄밀함과 예술적 창의성은 훌륭한 조합이며, 이 연재는 Haskell 세계에서 이 두 측면을 하나로 엮어내는 것을 목표로 합니다.
내가 Haskell에 대해 처음 들은 것은 열여섯 살 때였다. 고등학교 컴퓨터 과학 수업에서 Java를 쓰고 있었고, 그 과정에서 무엇보다도 NullPointerException이 소프트웨어 개발로 진로를 정하면 사실상 삶의 방식이 된다는 것을 배우고 있었다. 방과 후 /r/programming 서브레딧을 보다가 null pointer exception이 아예 일어날 수 없는 언어, 타입 시스템이 내가 매주 씨름하던 버그의 한 범주 전체를 막아줄 수 있는 언어에 대한 언급을 우연히 발견했다. Haskell이었다. 나는 그 아이디어에 즉시, 그리고 완전히 매료되었다.
나는 이제 거의 20년째 Haskell을 쓰고 있고, 열여섯 살에 반했던 그 가치 제안은 기본적으로 옳았다고 여전히 생각한다. 다만 그 약속이 코드베이스가 커지고, 회사가 문서보다 빠르게 성장하고, 시스템이 돈을 만질 수 있게 된 뒤에는 어떤 모습이 되는지를 배우는 데는 더 오랜 시간이 걸렸다. 그런 환경에서 Haskell은 여러 방식으로, 그리고 단단하게 제 몫을 한다. 운영 지식을 API에 담아 넣을 수 있게 해주고, 위험한 기계를 좁은 경계 뒤에 두게 해주며, 안전한 길을 가장 쉬운 길로 만들어준다. 성장하는 회사에서 이것은 단지 취향의 문제가 아니다. 처음 그 시스템을 이해했던 사람들이 떠난 뒤에도 시스템을 이해 가능하게 유지하는 방법이다.
그리고 오늘에 이르렀다. 나는 은행 서비스를 제공하는 핀테크 회사 Mercury에서 일하고 있다.* 우리는 30만 개가 넘는 기업 고객에게 서비스를 제공한다. 2025년에는 연간 환산 매출 6억 5천만 달러를 바탕으로 2,480억 달러의 거래 규모를 처리했다. 그리고 이 글을 쓰는 시점에는 미국 OCC로부터 전국 은행 인가를 취득하는 절차를 진행 중이다. 직원 수는 약 1,500명이다. 우리의 엔지니어링 조직은 대체로 제너럴리스트를 채용하며, 그들 대부분은 입사 전까지 Haskell 코드를 한 줄도 써본 적이 없다.
Mercury에서 일한 시간은 순수성에 대한 어떤 설교보다도 내가 이 언어를 바라보는 방식을 더 많이 바꿔놓았다. 우아함은 기분 좋지만, 비즈니스를 계속 살아 있게 유지하는 것은 필수다.
우리 코드베이스는 주석 같은 것을 제외하면 대략 200만 줄의 Haskell이다.
여기서 보통은 공포에 질려 움찔해야 한다.
수백만 줄의 Haskell. 현업에서 언어를 배운 사람들이 유지보수하고, 막대한 돈이 오가는 회사에서 돌아간다고? 통념에 따르면 이건 재앙이어야 한다. 하지만 놀랍게도 그렇지 않다. 우리가 만든 시스템은 수년 동안 잘 작동해 왔다. 초고속 성장기에도, 우리에게 5일 만에 20억 달러의 신규 예금을 몰고 온 SVB 사태 속에서도,1 규제 심사 속에서도, 그리고 대규모 금융 시스템에서 일어나는 온갖 평범하고 비범한 사건들 속에서도 그랬다.
이 글은 왜 그것이 작동하는지에 대한 이야기다. "Haskell은 아름답다"는 의미에서가 아니다. 물론 아름답다. "컴파일러가 우리를 우리 자신으로부터 구해준다"는 의미에서도 아니다. 물론 나는 그 점에 자주 감사한다. 내가 말하려는 것은 훨씬 덜 낭만적이고 훨씬 더 유용한 의미다. 우리는 이 언어를 프로덕션에서, 대규모로, 빠르게 변하는 팀과 함께 운영하고 있으며, 이 전체 사업을 계속 떠 있게 유지하려면 무엇이 필요한지에 대해 꽤 힘든 교훈들을 배웠다. Haskell의 아름다움은 충분히 매력적이지만, 그 너머에는 방대한 운영적·조직적 현실이 있다. 그리고 그 현실을 너무 오래 무시하면, 회사는 아마 Haskell 팀 전체를 해고하고2 대신 PHP 같은 것을 쓰기 시작할 것이다.
실질적인 조언으로 들어가기 전에, 먼저 철학에 대한 메모를 하나 하겠다.
시스템 신뢰성을 생각하는 전통적인 방식은 실패를 막는 데 초점을 맞춘다. 잘못될 수 있는 일을 열거한다. 검사를 추가한다. 각각의 나쁜 경우에 대한 테스트를 쓴다. 버그를 잡는다. 물론 이것은 필요한 일이고, 우리도 한다. 하지만 이것만으로는 충분하지 않다. 여기에만 완전히 초점을 맞추면 특정한 사각지대가 생긴다. 시스템이 어떻게 망가지는지는 아주 잘 목록화하게 되지만, 평소에는 왜 잘 작동하는지를 이해하는 데는 아주 서툴러진다.3
우리는 그것을 다르게 생각하려 한다. 시스템이 안정적으로 작동하는 이유는 변화를 흡수할 수 있기 때문이다. 시스템은 우아하게 성능 저하를 겪고, 운영자는 그것을 이해하고 조정할 수 있으며, 아키텍처는 올바른 일을 쉽게 하고 잘못된 일을 어렵게 만든다.4 신뢰성은 단지 실패의 부재가 아니다. 그것은 적응 역량의 존재다. 현실이 가만히 있기를 완강하게 거부하는 오랜 습관을 계속 유지하는 동안에도 시스템이 계속 기능할 수 있는 능력이다.
수백 명의 엔지니어가 수백만 줄 규모의 코드베이스에서 일하고, 그중 많은 이들이 Haskell을 시작한 지 6개월밖에 되지 않았다면, "적응 역량"은 더 이상 복원력 엔지니어링 논문 속 멋진 표현이 아니다. 그것은 일상적인 관심사가 된다. Patrick McKenzie는 해마다 2배씩 성장하는 회사에서는 동료의 절반이 항상 경력 1년 미만이라고 지적한 바 있다. 1년 후에도 동료의 절반은 여전히 경력 1년 미만일 것이다. 아주 성공적인 회사에서는 이것이 결코 사실이 아니게 되는 순간이 오지 않는다.5 좋든 싫든 당신은 조직 내에서 아주 빠르게 고대 유물이 되고, 당신이 아는 것들은 제도적 암흑물질이 된다. 구조를 떠받치고 있지만, 주변 대부분의 사람들 눈에는 보이지 않는 그런 것 말이다.
그래서 우리가 던지는 질문은 운영적이다. 당신 팀의 신입이 이 모듈을 읽고 그것이 무엇을 하는지 이해할 수 있는가? 데이터베이스가 느려지면 이 서비스는 점진적으로 저하되는가, 아니면 무너져 내리며 이웃 시스템들까지 끌고 가는가? 누군가 인터페이스를 잘못 사용하면 컴파일러가 알려주는가, 아니면 온콜 담당자에게 호출이 간 다음에야 알게 되는가? 이런 질문들에 대한 답이 없다면, 조용히 진행 중인 미래의 장애를 하나 가지고 있는 셈이다.
그래서 나는 점점 더 타입 시스템을 정합성 증명이라기보다 운영 보조 도구로 생각하게 되었다. 그 가치가 단지 특정 종류의 오류를 배제하는 데만 있는 것은 아니다. 물론 그것도 한다. 진짜 가치는 그것이 그 코드를 쓴 사람이 떠난 뒤에도 살아남는 형태로 제도적 지식을 인코딩한다는 데 있다. 빠르게 성장하는 회사에서는 사람이 떠나고, 팀을 옮기고, 휴가나 육아휴직을 가고, 새로 합류한다. 이런 이동이 계속되면 사람들이 머릿속으로 알고 있던 것들은 어딘가에 적어두지 않는 한 그들과 함께 문밖으로 나간다. 이상적으로는 그것을 컴파일러가 읽을 수 있는 형태로 적어두어야 한다. 컴파일러는 평균적인 위키 페이지보다 훨씬 더 규율이 있기 때문이다.
이것은 코드 너머로도 확장된다. 나는 안정성 엔지니어링 팀의 일원으로서 기능과 제품이 프로덕션에서 어떻게 동작할지를 끊임없이 조사한다. 이것은 제품 개발 속도를 늦추기 위해서가 아니다. 그 기능을 출시하는 팀과 협력해, 문제가 생겼을 때 그 여파를 감당할 준비가 되어 있도록 하고, 가능하다면 그 여파를 흥분되는 일이 아니라 지루한 일로 만들기 위해서다. 우리는 이런 질문을 한다. 이것이 실패하면 폭발 반경은 어디까지인가? 어떤 작업은 멱등적이어야 하며, 어떻게 보장할 것인가? 롤백은 어떤 모습인가? 진행 중이던 작업은 어떻게 되는가? 어떤 시스템이 실패를 흡수하고, 어떤 시스템이 그것을 증폭시키는가? 핵심은 중요한 결정들이 이미 되돌리기 비싼 상태가 된 다음에 출시를 감사하는 것이 아니라, 충분히 이른 시점에 대화를 해서 설계 자체를 바꾸게 만드는 데 있다.6
우리의 철학을 평이하게 말하면 이렇다. 우리는 품질 경찰이 아니다. 우리는 고장 난 기능의 여파를 처리하느라 새벽 4시에 깨고 싶지 않도록 도와주고 싶은 사람들이다. 대단히 이념적인 입장이라기보다, 그냥 사람들을 돕고 싶을 뿐이다.
그렇다면 이런 관점에서, 우리는 Haskell을 어떻게 프로덕션에서 작동하게 만드는가?
내 뜨거운 주장 하나: Haskell에 대한 첫 번째이자 가장 중대한 오해는 순수성이 이 언어가 본질적으로 가진 것 이라기보다, 당신의 인터페이스가 강제하는 것이라는 점이다.
속을 들여다보면 Haskell은 순수한데도 부작용을 수행하는 마법 기계가 아니다. bytestring, text, vector의 모든 "순수한" 함수 뒤에는 가변 할당, 버퍼 쓰기, unsafe coercion, 그리고 주니어 엔지니어의 사이드 프로젝트에서 발견하면 당신을 놀라게 할 다른 동작들로 가득한 작고 유쾌한 지옥도가 숨어 있다. ST 모나드 뒤에는 제자리 돌연변이와 계산 내부에서 관찰 가능한 부작용이 있다. 그것이 받아들여질 수 있는 이유는 그 부작용들이 경계를 넘을 수 없도록 캡슐화되어 있기 때문이다.
runST :: (forall s. ST s a) -> a
rank-2 타입(즉, 타입 s가 괄호 안에서만 스코프를 가지며 밖으로 탈출할 수 없는 타입)인 runST는 계산 내부에서 만들어진 가변 참조가 타입 s로 태그되어 있기 때문에 바깥으로 새어 나갈 수 없도록 보장한다. 내부적으로는 온갖 명령형 헛짓거리가 일어날 수 있다. 외부적으로는 함수가 순수하다. 경계 바깥 세상은 그 돌연변이를 전혀 보지 못하고, 결과만 받는다.
내 생각에, 이것은 더 큰 차원에서 아주 훌륭한 설계 원칙이다. 어떤 스코프 안에서든 임의로 위험한 작업을 허용할 수 있다. 단, 그 스코프의 출구 타입이 충분히 좁아서 그 위험이 밖으로 새어나갈 수 없게만 하면 된다. 이 원칙은 프로덕션의 모든 곳에 적용된다. 데이터베이스 계층은 내부적으로 connection pooling, retry 로직, 가변 상태를 사용한다. 캐시는 동시성 가변 맵을 쓴다. HTTP 클라이언트에는 아마 circuit breaker, pooled connection, 그리고 작은 시정부 하나쯤은 운영할 수 있을 정도의 bookkeeping이 들어 있을 것이다. 인터페이스가 오용을 막을 정도로 충분히 단단하고, 경계가 유지되기만 한다면 이 모든 것은 문제가 아니다.
프로덕션에서 목표는 흔히 돌연변이를 완전히 피하는 것이 아니다. 대부분의 현실 시스템에서는 그건 진지한 제안이 아니다. 목표는 돌연변이를 가두고, 그 가둠을 읽기 쉽게 만들고, 실제로 가둬진 채로 있는지 검증하는 것이다. 종종 올바른 질문은 "이것은 순수한가?"가 아니라 "불순성이 어디에 있고, 코드베이스의 얼마만큼이 그것을 알아도 되는가?"다.
3개월 전에 Haskell을 배운 신입 엔지니어에게는 "순수성은 유지하려 애쓰는 경계다"라는 설명이 "Haskell은 순수하다"보다 훨씬 더 유용하다. 전자는 모듈을 설계하러 앉았을 때 무엇을 해야 하는지를 알려준다. 후자는 대체로 거기 앉아 심오한 척만 한다.
이렇게 경계 중심으로 순수성을 보는 관점은 Haskell 프로덕션 엔지니어링 전반에서 반복해서 나타나는 더 일반적인 패턴으로 이어진다. 위험한 것들은 울타리 안에 가두고, 신중히 노출하고, 오용하기 어렵게 만들면 견딜 만해진다. 돌연변이도 그렇고, retry, 트랜잭션, 상태 기계, 분산 워크플로, 타입 수준 장치도 그렇다. 뒤이어 나오는 대부분의 이야기는 사실 같은 아이디어가 모자만 바꿔 쓴 것에 불과하다.
큰 코드베이스에는 정합성이 어떤 작업을 특정 순서로 수행하거나, 본 작업과 눈에 보이는 직접적 연관이 없는 어떤 단계를 반드시 포함하는 데 달려 있는 패턴이 있다.
"모든 트랜잭션 뒤에는 감사 로그를 flush하는 걸 잊지 마세요."
"이 엔드포인트를 호출하기 전에 항상 feature flag를 확인하세요."
"알림은 데이터베이스 트랜잭션 안에서 enqueue해야지, 그 뒤에 하면 안 됩니다."
이런 것들이 바로 운영 전승 지식의 주문들이다. 그것들은 위키 페이지, 온보딩 문서, 반쯤 잊힌 디자인 리뷰, 그리고 지금은 세 팀이나 떨어져 있고 목요일까지 일정이 꽉 찬 시니어 엔지니어의 기억 속에 산다. 공격적으로 채용하는 회사에서는 이런 부족 지식의 반감기가 놀라울 정도로 짧다. 엔지니어가 떠나면 주문도 희미해진다. 마감이 다가오면 가장 먼저 생략되는 것도 이런 것들이다. 신입이 들어오면 그 주문이 존재한다는 사실조차 알 방법이 없는 경우가 많다. 핵심 불변식이 9개월 전 Slack 스레드에 살아 있는 것만큼 "견고한 시스템 설계"를 잘 보여주는 일도 드물다.7
Haskell은 이 주문들을 타입에 인코딩해서 잊어버릴 수 없게 만들 수 있는 도구를 준다. 내 돈을 걸라면, 이것이야말로 이 언어가 프로덕션 엔지니어링 조직에 제공하는 단일 가장 가치 있는 점이다.
실제 패턴을 단순화한 예를 생각해보자. 특정 부작용(알림 보내기, 이벤트 발행)이 데이터베이스 쓰기와 트랜잭션적으로 함께 일어나야 한다. 이전도 아니고, 이후도 아니고, 별도 트랜잭션 안도 아니다. 함께, 아니면 전혀 아니어야 한다.
순진한 접근은 사람들에게 올바른 함수를 쓰라고 말하는 것이다.
-- 이걸 써 주세요, 다른 건 말고
writeWithEvents :: Transaction -> [Event] -> IO ()
-- 이건 직접 쓰지 마세요 (하지만 막을 수는 없습니다)
writeTransaction :: Transaction -> IO ()
publishEvents :: [Event] -> IO ()
이건 초보 수준의 엔지니어링이다. 될 때까지는 된다. 그리고 "안 되는 때"는 대개 금요일 오후에 찾아온다. 위키 페이지를 쓴 사람은 휴가 중이고, 나머지 모두는 그 위키 페이지가 사실상 구조 부재를 막아주고 있었다는 사실을 실시간으로 깨닫고 있다.
더 나은 접근은 타입을 재구성해서, 작업을 커밋 하는 유일한 방법이 이벤트 발행을 포함하는 경로를 통하도록 만드는 것이다.
data Transact a -- opaque; cannot be run directly
record :: Transaction -> Transact ()
emit :: Event -> Transact ()
-- Transact를 실행하는 *유일한* 방법: 원자적으로 commit하고 publish한다
commit :: Transact a -> IO a
이제 주문은 방 안의 유일한 문이다. 할 수 있는 다른 일이 없으니 잊을 수가 없다. 타입 시스템이 당신의 이벤트에 대해 특별히 심오한 것을 증명한 것은 아니다. 대신 훨씬 더 실용적인 일을 해냈다. 올바른 운영 절차를 가장 저항이 적은 경로로 만든 것이다.
이 차이는 중요하다. 프로덕션에는 정리를 원하지 않는 곳이 많다. 우리는 평범하고 바쁜 엔지니어가 다른 합리적인 일 열두 가지를 동시에 처리하려다가 실수로 잘못된 일을 하기 어렵게 만드는 설계를 원한다. 여기서 컴파일러는 단지 논리를 검사하는 것이 아니다. 제도적 기억을 보존해, 날카로운 경계를 가진 인터페이스로 바꾸고 있다.
신입이 들어와 "트랜잭션은 어떻게 작성하나요?"라고 물으면 타입 시스템이 답해준다. 시니어 엔지니어가 떠나도 그 답은 남는다. 제도적 지식은 누군가가 아름답게 문서화했기 때문이 아니라, 물론 그런 문서가 있으면 좋지만, 누군가가 컴파일러가 강제할 수 있는 형태로 그것을 인코딩했기 때문에 살아남는다. 다시 말해, 컴파일러는 평균적인 위키보다 운영 전승 지식을 더 잘 보관하며, 시스템의 현실이 바뀌어도 업데이트를 잊어버릴 가능성도 더 적다.
위의 패턴, 즉 올바른 운영 절차가 유일한 절차가 되도록 타입을 구성하는 방식은 단일 트랜잭션 내부에서는 잘 작동한다. 안타깝게도 금융 시스템은 단일 트랜잭션 안에 머물 의무를 느껴본 적이 없다.
그 안에는 여러 단계, 여러 서비스, 여러 실패 모드에 걸친 프로세스가 가득하다. 결제를 보내고, 파트너의 확인을 기다리고, 원장을 갱신하고, 고객에게 알리고, 취소를 처리하고, 타임아웃을 처리하고, 파트너는 yes라고 했지만 워커가 답을 기록하기 전에 죽은 경우를 처리하고, 네트워크가 잠시 더 높은 존재의 차원으로 들어가 버려서 아무 말도 하지 않은 경우를 처리한다. 어느 단계에서 실패하든, 당신은 자신이 어디까지 왔는지, 무엇이 이미 일어났는지, 무엇이 아직 남았는지를 알아야 한다. 상태가 필요하다. retry가 필요하다. timeout이 필요하다. 멱등성이 필요하다. 그리고 이 모든 것이 프로세스 크래시와 배포를 넘어 계속 작동해야 한다. 그러다 보면 "그냥 비즈니스 로직"으로 시작한 것이 운영상의 공통 관심사를 제각각 한 번씩 다시 구현한 코드 덩어리로 눈 깜짝할 사이에 불어난다.
예전 Mercury에서는 이런 프로세스들을 cron job과 백그라운드 워커가 구동하는 데이터베이스 기반 상태 기계로 조정했고, retry 로직과 timeout 처리는 코드베이스 곳곳에 흩어져 있었다. 작동은 했다. 동시에, 불발탄 해체에 가까운 경계심을 요구하기도 했다. 깨지기 쉬웠고, 추론하기 어려웠으며, 운영 장애의 불균형하게 큰 몫을 차지하는 원천이었다.
Temporal은 우리의 durable execution 프레임워크이고, 이것을 도입한 것은 우리가 내린 더 나은 인프라 결정 중 하나였다. 워크플로를 평범한 순차 코드로 작성하면, 플랫폼이 모든 단계를 이벤트 히스토리에 기록한다. 워커가 워크플로 도중 죽으면, 다른 워커가 결정론적인 접두사를 재생해 상태를 복원하고, 멈췄던 지점부터 이어서 실행한다. retry, timeout, cancellation, error handling은 각 팀이 서툴게 재구현하는 대신 플랫폼이 제공한다.
나는 Temporal을 Frankenstein의 괴물이라고 생각한다. 좋은 의미에서다. 훌륭한 부품들로 조립되었고, 믿기 어려운 노력으로 생명을 얻었으며, 그것에 겁먹는 많은 사람들보다 더 영리하다. 어떤 플랫폼은 native하게 가지는 durable history, replay, determinism 같은 것들을, 원래는 그런 걸 할 줄 모르고 태어난 런타임 위에 볼트로 붙여놓는다. 우리 대부분은 회사를 Erlang으로 다시 쓰지 않을 것이다. Temporal은 나머지 우리를 위한 의수다. 약간 미친 듯하지만 아주 효과적인 방식으로, 평범한 언어들에게도 비슷한 운영적 장점들을 얻을 기회를 준다.
이것은 흔히 Haskell에 귀속되는 미덕들과도 잘 맞물린다. Temporal 워크플로는 중요한 의미에서 이벤트 히스토리 위의 순수 함수다. Temporal Workflow에는 결정론 요구사항이 있다. 재생된 워크플로는 원본과 같은 명령 시퀀스를 생성해야 한다. 이것은 Haskell이 순수 코드에 강제하는 제약과 정확히 같다. 같은 입력이면 같은 출력이다. 부작용은 _activity_로 격리되는데, 이것은 워크플로에서의 IO에 해당한다. 워크플로는 조율하고, activity는 실행한다. 순수한 core / 불순한 shell 모델에 대해 생각해본 적이 있다면, 이것은 그 모델을 순전히 규율에 의존하지 않고 플랫폼이 분리 강제를 해주는 형태다.
우리는 Temporal용 Haskell SDK인 hs-temporal-sdk를 만들고 오픈소스로 공개했다. 이것은 공식 Core SDK(Rust, FFI 경유)를 감싸고, 워크플로, activity, worker를 정의하기 위한 Haskell다운 API를 제공한다.
나는 Temporal의 Replay 컨퍼런스에서 우리의 도입 패턴에 대한 발표를 한 적이 있는데, 짧게 말하면 이렇다. Temporal 덕분에 우리는 깨지기 쉬운 cron job 체인과 데이터베이스 기반 상태 기계를, 플랫폼이 조정을 담당하는 durable workflow로 교체할 수 있었다. 운영상의 개선은 상당했다. 스프린트 계획 중 즉흥적으로 짜 넣은 실패 의미론을 가진 손수 만든 분산 상태 기계를 지우고, 그것을 그런 의미론이 처음부터 정해져 있는 무언가로 바꾸는 기분 좋은 경험은 아무리 강조해도 부족하다.
이 역시 다른 의상을 입은 적응 역량이다. 워커 크래시, 프로세스 재시작, 장기 실행 조정을 겪고도 자기 위치를 잃지 않는 시스템은, 운영자에게 더 많은 지렛대를 주고 장애 중에 풀어야 할 미스터리를 더 적게 남기는 시스템이다.
프로덕션 시스템이 커질수록 내가 자주 보는 실수 하나는 호출하는 시스템의 성격이 도메인 모델 안으로 새어 들어오게 만드는 것이다.
우리에게는 HTTP 상태 코드 예외를 던지고, 그것을 프런트엔드 사용자에게 그대로 반환하는 코드가 있다. 이것은 처음 작성될 당시에는 말이 됐다. 왜냐하면 그 코드는 HTTP 요청 핸들러 안에서 실행됐기 때문이다. 그런데 모든 성장하는 코드베이스가 그렇듯, 그 코드의 일부가 추출되어 재사용되었다. 이제 그것은 cron job에서도 돌고, 큐 기반 백그라운드 워커에서도 돌고, Temporal workflow에서도 돈다. 그런데도 무언가 잘못되면 여전히 StatusCodeException 409 "Conflict"를 던진다. 이것은 cron job이 하기에 완전히 제정신이 아닌 일이다. cron job에는 409를 기다리는 호출자가 없다. 아무도 그 상태 코드를 읽지 않는다. 오류는 원래의 추상화가 자신의 전송 계층과 결합되어 있었기 때문에 그대로 시스템 전체로 전파된 것이다.
해결책은 개념적으로 단순하다. 도메인 오류를 도메인 타입으로 모델링하라. 잔액 부족으로 실패한 결제는 402가 아니라 InsufficientFunds여야 한다. 중복 요청은 409가 아니라 DuplicateRequest여야 한다. 이런 것들은 비즈니스 로직이 패턴 매칭하고, 재시도 전략을 정하고, 의미 있게 로그를 남기고, 맥락에 따라 다르게 처리할 수 있는 대상이다.
그다음 각 경계에서 얇은 변환 계층을 작성한다.
data PaymentError
= InsufficientFunds
| DuplicateRequest RequestId
| PartnerTimeout Partner
toHttpError :: PaymentError -> HttpResponse
toHttpError InsufficientFunds = err402 "Insufficient funds"
toHttpError (DuplicateRequest _) = err409 "Duplicate request"
toHttpError (PartnerTimeout _) = err502 "Partner unavailable"
toWorkerStrategy :: PaymentError -> WorkerAction
toWorkerStrategy InsufficientFunds = Fail "Insufficient funds"
toWorkerStrategy (DuplicateRequest _) = Skip
toWorkerStrategy (PartnerTimeout _) = RetryWithBackoff
새로운 이야기는 아니다. 그런데 실제로는 놀랄 만큼 자주 건너뛰어진다. 어떤 코드든 첫 번째 버전은 하나의 맥락을 위해 쓰이고, 나중에 세 군데에서 더 호출될 것이라는 사실을 깨달을 즈음이면, 누군가 그 상태 코드 예외를 비즈니스 로직의 일부로 잡기 시작했기 때문에 그것들이 사실상 구조를 떠받치고 있게 되고, 아무도 그것을 리팩터링하고 싶어 하지 않기 때문이다.
이 분리를 일찍 할수록 비용은 적다. 늦게 할수록 결과 동작은 더 기괴해진다. 결국 cron job이 Sentry를 향해 409를 던지고, 백그라운드 워커가 HTTP 전용 예외를 비즈니스 의미로 해석하는 지경에 이른다. 추상화가 자기 containment를 벗어났다는 사실을 알려주는 방식이 바로 그런 것이다.
이것은 순수성과 같은 원리다. 다만 운영 언어가 아니라 도메인 언어로 표현되었을 뿐이다. 당신의 transport concern은 가장자리에 속해야 한다. 도메인 모델은 웹 핸들러, CLI, cron job, 백그라운드 워커, workflow engine 중 어디에서 호출되든, 결혼식 자동차 뒤에 매단 깡통처럼 HTTP 상태 코드를 질질 끌고 다닐 필요 없이 살아남아야 한다.
이제 방금 하라고 말한 일을 너무 많이 하지 말라고 말하는 부분이다.
불변식을 타입에 인코딩하는 것은 강력하다. 동시에 비싸기도 하다. 런타임 비용이 아니라 인지적 오버헤드, 도입되는 경직성, 그리고 나중에 요구사항이 바뀌었을 때 바꾸기 어려워지는 비용이 크다. 그리고 요구사항은 반드시 바뀐다. 만약 당신 회사가 그렇지 않다면, 그 비결도 알고 싶고 주식 코드도 알고 싶다.
당신이 타입 시스템 안으로 밀어 넣는 모든 불변식은, 앞으로 그 코드를 만질 모든 엔지니어에게 제약이 된다. 그 제약을 어기면 데이터 손실, 금전 오류, 규제 문제, 또는 어떤 불쌍한 사람의 호출기 울림으로 이어진다면 그 비용은 정당화된다. 하지만 그 제약이 "우리가 지금은 우연히 이렇게 하고 있을 뿐이다", 혹은 "종속 타입에 대한 글을 읽었고, 내 권한 로직에 반드시 적용해야 한다" 같은 것이라면, 운영상 아무런 이익 없이 코드베이스를 더 바꾸기 어렵게 만든 것일 가능성이 높다. 다음으로 그것을 마주치는 사람은 타입을 리팩터링하느라 일주일을 쓰거나, 더 가능성 높은 시나리오로는 당신이 막고 싶었던 것보다 더 나쁜 우회로를 찾아낼 것이다.
여기에는 스펙트럼이 있고, 프로덕션 코드베이스는 그 위에서 정직하게 살아야 한다.
한쪽 끝에는 모든 것을 인코딩하는 경우가 있다. 당신의 타입은 도메인의 충실한 모델이다. 불법 상태는 표현 불가능하다. 비즈니스 규칙 하나를 바꾸려면 50개 모듈에 걸쳐 타입 변경을 실로 꿰어 넣어야 해서 리팩터링에 몇 주가 걸린다. 신입 엔지니어는 타입 시그니처를 바라보며 도대체 무슨 잘못을 저질렀기에 이런 일을 겪는지 생각하고, 조용히 상담사와 커리어 옵션에 대해 논의하기 시작한다. 당신은 대성당을 지었다. 대성당은 아름답다. 동시에 비싸고, 차갑고, 배관 공사를 얼마나 빨리 개보수할 수 있는지로 유명한 건축물도 아니다.
반대쪽 끝에는 아무것도 인코딩하지 않는 경우가 있다. 당신의 타입은 String, IO (), 최악의 경우 Dynamic이다. 어길 계약이 없으니 코드는 바꾸기 쉽다. 시스템이 돌아가는 이유는 그것을 만든 사람들이 아직 주변에 있고, 그 문자열들이 무슨 뜻인지 기억하기 때문이다. 그들이 떠나면 시스템도 작동을 멈추고, 왜 그런지 아는 사람은 없다. 당신은 텐트를 지었다. 텐트는 유연하고, 이동 가능하며, 특정한 날씨 조건에서는 하늘에 대해 아주 직접적인 방식으로 배울 기회를 제공한다. 물론 이것이 많은 Haskell 개발자들이 애초에 Haskell로 도망쳐 오는 이유 중 하나이기도 하다. 바로 이런 접근이 만들어내는 고통을 피하려고 말이다.
적절한 지점은 그 중간 어딘가다. 내가 유용하다고 생각하는 몇 가지 휴리스틱은 다음과 같다.
침묵하는 손상을 막아주는 불변식은 타입에 인코딩하라. 위반해도 즉시 오류가 나지 않고 잘못된 데이터가 만들어지는 경우라면(이벤트 없이 커밋된 트랜잭션, 감사 로그 없이 처리된 결제, 그럴듯해 보이지만 의미적으로는 불가능한 상태 전이) 타입에 넣어라. 침묵하는 실패의 피드백 루프는 너무 길어서 인간의 성실함에 기대기 어렵다.
크게 실패하는 불변식은 런타임 체크를 써라. 위반 시 즉각적이고 명백한 오류가 난다면(500 응답, assertion 실패, JSON 경계에서의 타입 불일치) 좋은 오류 메시지를 가진 런타임 체크로 충분할 수 있다. 프로덕션 전에, 혹은 아주 빨리 잡을 수 있다.
도메인 전체를 타입으로 모델링하려는 충동을 억제하라. 당신의 도메인은 지저분하다. 예외 케이스가 있고, 경과 조항이 있고, 서로 모순되는 규칙이 있고, 2018년으로 거슬러 올라가며 누구도 완전히 이해하지 못하는 특정 고객 셋을 위한 특수 동작이 있다. 타입 시스템은 깔끔함을 원한다. 당신의 비즈니스는 그것을 제공하지 않는다. 앞으로도 절대 제공하지 않을 것이다.
타입은 컴파일러만이 아니라 팀을 위한 것임을 기억하라. 컴파일러는 많은 도구 중 하나일 뿐이다. 테스트, 문서, 코드 리뷰, 예제, 플레이북 역시 모두 합쳐져 방어 심층 구조를 이룬다. 목표는 타입 검사기와의 논쟁에서 이기는 것이 아니다. 목표는 올해 Haskell을 배운 사람들까지 포함한 인간 팀이 운영하고, 확장하고, 유지보수할 수 있는 시스템을 만드는 것이다.
그렇다고 해도, 강한 타입 수준 장치가 정확히 필요한 경우가 분명히 있다. 우리 내부 라이브러리 중에는 타입이 정말로 털복숭이처럼 복잡한 것들이 있다. GADT, type family, 상태 전이를 추적하는 phantom type 같은 것들 말이다. 이런 경우는 대개 잘못되면 돈이 엉뚱한 곳으로 가거나 규제 불변식이 깨지는 메커니즘들이다. 여기서 복잡성은 절대적으로 필수적이다.
지속 가능하게 이런 일을 하려면 핵심은 우리가 그 복잡성을 캡슐화한다 는 점이다. 타입 수준 상태 기계를 구현한 모듈은 대개 그것을 깊이 이해하는 소수의 작성자가 있고, 이상적으로는 철저한 테스트 스위트도 있다. 반면 그것을 사용하는 모듈에는 표면 API가 다섯 개 정도의 평범한 함수와 평범한 타입처럼 보인다. 다른 팀의 제품 엔지니어는 그 함수들을 호출할 수 있고, 그 아래에서 잘못된 상태로 트랜잭션을 커밋할 수 없게 보장하는 작은 타입 수준 정리 증명기가 돌고 있다는 사실을 몰라도 되고 신경 쓸 필요도 없다. 증명 부담은 경계 안에서 해소되어야지, 경계를 넘어 새어 나가면 안 된다.
이 역시 순수성과 같은 containment 원리의 상위 버전이다. 복잡성 그 자체는 괜찮다. 그것이 가치 있는 무언가를 사주기 때문이다. 문제가 되는 것은 그 복잡성이 모듈 경계를 넘어, 그런 복잡성을 떠맡겠다고 한 적 없는 사람들이 유지보수하는 코드 안으로 스며드는 경우다. 우리는 이것을 다른 어느 곳보다 코드 리뷰에서 많이 잡아낸다. 누군가 자신이 소유하지 않은 모듈을 건드리는 PR을 열었는데, diff가 컴파일러가 소리 지르는 것을 멈추게 하려고 옆 파일에서 복사해온 타입 애너테이션으로 가득하다면, 대개 추상화가 friendly fire의 한 형태가 되었다는 신호다.
신뢰성이 적응 역량에 관한 것이라면, introspection은 그것을 사오는 방법 중 하나다. 운영자는 볼 수 없는 것을 이해할 수 없다. 팀은 내부가 불투명한 시스템에 적응할 수 없다. 관측 가능성은 마지막에 뿌리는 장식이 아니다. 그것은 소프트웨어 설계 표면의 일부다.
이것은 Haskell에서 특히 중요하다. Haskell에는 monkey patching이 없기 때문이다. 런타임에 라이브러리 안으로 들어가서 HTTP 클라이언트를 타이밍을 기록하는 버전으로 바꾸거나, 데이터베이스 호출을 OpenTelemetry span을 내보내는 것으로 갈아끼울 수 없다. 물론 이것은 Haskell만의 문제는 아니다. Rust도 근본적으로 같은 제약을 가진다. monkey patching도 없고, runtime method swizzling도 없고, 언어 차원의 "여기 잠깐 끼어들게요" 같은 탈출구도 없다. orphan rule 때문에 자신이 소유하지 않은 타입에 trait implementation을 추가하는 것조차 막힌다. 차이가 있다면 Rust 생태계는 tower middleware 패턴으로 어느 정도 수렴했지만, Haskell 생태계는 아직 여러 접근법으로 갈라져 있다는 점이다. 제약은 같다. 질문은 생태계가 관례적인 탈출구를 제공하느냐, 아니면 각 팀이 하나씩 즉흥적으로 만들어야 하느냐이다.
따라서 라이브러리가 기능을 구체적인 top-level 함수 집합으로만 노출한다면, 그것을 계측할 수 있는 선택지는 제한적이다. 새 모듈에서 그 함수들을 감싸고, 아무도 원래 모듈 대신 당신의 모듈을 import하는 것을 잊지 않기를 바라는 수밖에 없다. 말해두자면, 희망은 그렇게 강력한 아키텍처 패턴이 아니다.
어떤 언어에도 일반화할 수 있는 요점 하나는 이것이다. 볼 수 없는 것은 운영할 수 없다. 라이브러리가 비싼 작업을 하면서도 측정할 hook을 제공하지 않아서 trace에 커다란 불투명한 구멍이 생긴다면, 무엇이 느린지는 대시보드가 알려줄 때가 아니라 고객이 불평할 때 알게 된다. 그리고 대시보드가 그것을 알려주는 시점이 고객 불평 이후라면, 당신은 이미 그 설계 결정에 대한 이자를 내고 있는 셈이다.
내가 가장 자주 집어 드는 해결책은 함수 레코드다. 구체 함수들로 가득한 모듈을 노출하는 대신, 그 함수들을 필드로 가진 레코드를 노출하는 것이다. 그러면 호출자는 나머지를 건드리지 않고 개별 함수를 감싸거나, 계측하거나, mock으로 바꾸거나, 교체할 수 있다. 나는 이것을 Embracing Flexibility in Haskell Libraries에서 길게 쓴 적이 있는데, 짧게 말하면 이렇다.
-- 구체 모듈은 아무런 지렛대를 주지 않는다:
sendRequest :: Request -> IO Response
-- 함수 레코드는 그것을 전부 준다:
data HttpClient = HttpClient
{ sendRequest :: Request -> IO Response
, getManager :: IO Manager
}
이 레코드가 있으면 sendRequest를 타이밍 계측으로 감싸서 새 HttpClient를 반환할 수 있다. 테스트를 위해 장애를 주입할 수 있다. 구현을 mock으로 바꿀 수 있다. retry, tracing, 요청 재작성, tenant별 동작, 혹은 이번 분기 프로덕션이 새로 발견해낸 다른 어떤 cross-cutting concern이든 추가할 수 있다. 이 모든 것을 런타임에, 라이브러리 소스 코드를 건드리지 않고 할 수 있다. WAI는 type Middleware = Application -> Application으로 이것을 제대로 해냈다. 동작의 조합 가능한 변환은 매우 다양한 시스템 전반에서 엄청나게 유용하고, 시스템은 거의 언제나 모든 cross-cutting need를 미리 예쁘게 정리해서 내주지 않는다.
이 패턴 안에는 더 주목받아야 할 멋진 대수적 성질이 숨어 있다. 약속하건대 가짜 콧수염을 붙이고 카테고리 이론을 슬쩍 들여오는 게 아니다. 정말 실용적인 이야기다. middleware와 interceptor 타입은 거의 항상 Semigroup와 Monoid 인스턴스를 가질 수 있다. WAI의 Middleware는 Application -> Application인데, 이는 endomorphism이고, endomorphism은 composition 아래에서 id를 항등원으로 갖는 monoid를 이룬다. 각 필드가 자체적으로 endomorphism(또는 비슷한 형태의 continuation-passing function)인 interceptor hook의 레코드는 fieldwise Semigroup 인스턴스를 거의 공짜로 얻는다. a <> b는 각 필드를 독립적으로 compose하고, mempty는 모든 필드가 항등인, 즉 호출을 그대로 통과시키는 레코드다.
이렇게 되면 조합은 엔지니어링 문제가 아니라 거의 비문제가 된다. tracing interceptor와 timeout interceptor와 task queue rewriting interceptor를 결합하기 위한 맞춤 배선을 쓸 필요가 없다. 그냥 mconcat 하면 된다.
appTemporalInterceptors =
mconcat
[ retargetingInterceptor
, otelInterceptor
, sentryInterceptor
, sqlApplicationNameInterceptor
, loggingContextInterceptor
, statementTimeoutInterceptor
, teamNameInterceptor
, clientExceptionInterceptor
, workflowTypeNameInterceptor
]
각 interceptor는 서로 고립된 채, 자기만의 모듈 안에서, 오직 하나의 관심사만 생각하면 되는 사람에 의해 정의된다. 개별 interceptor는 mempty에서 시작해 하나 이상의 필드를 override하는 식으로 만들어지고, 나머지는 모두 그대로 통과된다. 조합은 그냥 (<>)다. 숨겨진 배선은 없다. hook의 형태에 합의하는 것 이상의 coordination tax도 없다. 순서는 리스트 안에서 명시적이다. 새로운 cross-cutting concern은 요소 하나를 더 append하면 추가된다. 기존 interceptor는 건드릴 필요가 없다.
이것이 Monoid 패턴이 가장 실용적인 모습으로 나타나는 방식이다. 이 추상화는 우아함을 위해 존재하는 것이 아니다. 물론 우아함은 기분 좋은 부작용이다. 그것은 "서로 독립적인 N개의 cross-cutting concern을 결합하라"는 운영 과제를, 구성상 trivially correct하게 만들어 주기 때문에 존재한다. middleware 타입이 mempty와 (<>)를 지원한다면, 열다섯 명의 엔지니어에게 하나씩 쓰라고 맡겨도 그 조각들은 자연스럽게 조합된다.
효과 시스템(effectful, polysemy, fused-effects, cleff 등)은 또 다른 경로를 제공한다. 사용 가능한 연산을 설명하는 effect type을 정의하고, 호출 지점에서 바꿔 끼울 수 있는 interpreter를 제공하는 것이다. 프로덕션용 하나, 테스트용 하나, 프로덕션 interpreter를 tracing으로 감싼 하나 같은 식이다. reinterpretation 기능은 특히 좋다. effect를 가로채 metric을 기록하거나 지연을 주입한 뒤, 실제 핸들러로 다시 보내면 된다. 나는 fused-effects 문서에서 fault injection과 observability를 위해 이것을 사용하는 가이드를 쓴 적이 있는데, 핵심 아이디어는 어떤 효과 시스템에도 옮겨갈 수 있다. 트레이드오프는 효과 시스템이 장치를 추가한다는 점이다. 타입 수준 effect 리스트, handler stack, 때로는 사나운 타입 오류들 말이다. 반면 함수 레코드는 신입도 반나절이면 패턴을 이해할 정도로 단순하다. 둘 다 작동한다. 중요한 것은 하나를 골라 충분히 일관되게 사용해서, 당신의 운영 가능성 이야기가 그 자체로 고고학 발굴이 되지 않게 하는 것이다.
내게 canonical한 긍정 사례는 persistent 라이브러리다. 그 SqlBackend 타입은 함수 레코드다. connPrepare, connInsertSql, connBegin, connCommit, connRollback 등이 있다. 내가 persistent용 OpenTelemetry 계측을 구현했을 때, 관련 필드를 감싸기만 해서 모든 데이터베이스 작업 주위에 tracing span을 추가할 수 있었다. fork도 필요 없었다. 소스 변경도 거의 없었다. 코드 몇 줄로 데이터베이스 계층 전체에 대한 가시성을 확보했다. 좋은 운영용 escape hatch란 바로 이런 모습이다.
이렇게 되어 있지 않은 라이브러리들이 우리에게 가장 큰 운영상 고통을 안긴다. Mercury에서는 Hackage에 올라와 있는 웹 API 클라이언트 바인딩을 거의 사용하지 않는다. 그것들이 꼭 형편없이 작성되었기 때문은 아니다. 어떤 것들은 꽤 좋다. 문제는 계측할 수 없는 코드를 신뢰할 수 없다는 데 있다. 서드파티 바인딩이 구체 함수로 HTTP 호출을 한다면, tracing을 추가할 방법도 없고, 우리 SLO에 맞춘 timeout을 주입할 방법도 없고, 테스트에서 파트너 장애를 시뮬레이션할 방법도 없으며, trace의 400ms 빈 구간을 설명할 방법도 없다. 눈을 가늘게 뜨고 이론을 세우는 수밖에 없다. 그래서 우리는 직접 쓴다. 초기 작업량은 더 크지만, 우리가 쓰는 클라이언트는 처음부터 그렇게 만들었기 때문에 관측 가능하도록 구성된다.
사람들이 늘 말하지는 않는, 조금 더 완만한 생태계 비용도 있다. 당신이 의존하는 몇몇 라이브러리들은 정확히 말해 버려진 것은 아니다. 버려졌다는 표현은 너무 깔끔하다. 그것들은 여전히 현역으로 쓰이고 있고, 생태계에 구조적으로 중요하며, 계속 돌아갈 만큼은 유지보수되고 있고, 그것을 개선하는 일이 풀타임 직무인 누군가가 명확히 소유하고 있지도 않다.
이런 현상에는 몇 가지 이유가 있다. 현대 Haskell 생태계의 큰 부분을 만든 사람들 중 일부는, 아주 합리적으로도, 다른 일로 옮겨갔다. 예를 들어 널리 쓰이는 라이브러리와 프레임워크를 만든 몇몇 prolific author들은 관심의 큰 부분을 Rust 쪽으로 옮긴 것처럼 보인다. 그들의 라이브러리는 여전히 작동하고, 어느 정도 지원도 받고, 사용자도 있다. 하지만 stewardship 모델은 점점 불분명해진다. 빠르게 움직이는 프로덕션 팀이 말하는 의미의 "책임자"가 분명하지 않다. 그러면 경험을 통해 더 나은 설계를 배웠다 하더라도, breaking change를 만드는 데 망설임이 생기는 것도 이해할 만하다.
이런 분산된 stewardship은 기술적 결과를 낳는다. 오래된 인터페이스가 계속 남는다. 라이브러리는 여전히 기능한다는 의미에서는 안정적이지만, 관측 가능성, 경계 설계, 운영 가능성에 대한 더 새로운 아이디어를 적극적으로 받아들인다는 의미에서는 그다지 살아 있지 않다. 또한 이제 당연히 기대하고 싶은 발전을 따라가지 못할 수도 있다. 예를 들어 http-client는 아직도 직접적으로는 HTTP/1.1만 지원하는데, 꽤 쓸 만하다가도 어느 순간 더는 그렇지 않게 된다. 우회는 가능하다. 하지만 생태계의 일부는 적극적으로 진화하는 제품이라기보다 공공 인프라에 더 가깝게 느껴진다. 유용하고, 튼튼하고, 약간 개보수에 저항적인 그런 것 말이다.
이것은 자원봉사 유지보수자들에 대한 불평이 아니다. 단지 더 작은 생태계 위에 진지한 시스템을 지을 때 따라오는 주변적 위험 중 하나일 뿐이다. 그 코드는 버려진 것이 아닐 수 있다. 그저 다소 묵묵하게 서서, 누군가 충분한 문맥과 충분한 하위 호환 파손 감수 의지를 가지고 열차가 계속 다니는 동안 역사를 개조해주기를 기다리고 있을 뿐일 수 있다.
만약 당신이 Haskell 라이브러리를 쓰고 있다면, escape hatch를 남겨두라. 함수 레코드든, effect type이든, callback이든, 무엇이든 소비자가 코드를 수정하지 않고 동작을 주입할 수 있게 해주는 무언가를 제공하라. Haskell의 타입 시스템은 제약을 강제하는 데 훌륭하다. 하지만 조심하지 않으면 시스템을 너무 단단히 봉인해서, 그것을 운영해야 하는 사람들이 안을 들여다볼 수 없게 만들 수도 있다. 운영적으로 불투명한 완벽한 추상화는 프로덕션에서는 그냥 사용할 수 없다.
가능하다면 hs-opentelemetry-api를 의존성으로 추가하고, 패키지에 계측을 넣는 것을 고려해 달라. 이 API 패키지는 좋은 시민이 되도록 설계되어 있다. 우리는 breaking change에 보수적이고, 애플리케이션이 OpenTelemetry SDK를 초기화하지 않으면 완전히 inert하다. 성능 오버헤드는 최소 수준이고, 사용자의 애플리케이션에서 예기치 않은 예외나 로그도 발생시키지 않는다. 솔직히 말해 현재 의존성 footprint가 우리가 원하는 만큼 작지는 않으며, 그 점은 적극적으로 개선 중이다. 그래도 핵심 IO 작업 주위에 span 몇 개만 있어도, 당신의 라이브러리를 프로덕션에서 돌리는 사람들에게는 실제로 큰 차이를 만든다.
그리고 제발 부탁인데, 라이브러리 코드에서 직접 로그를 찍지 말아 달라. 로깅 프레임워크를 import해서 stdout이나 stderr에 쓰지 말라. 로깅 callback을 제공하거나, logger를 매개변수로 받거나, 호출자가 원하는 곳으로 라우팅할 수 있도록 로그 메시지를 데이터 타입으로 노출하라. 로그가 어디로 갈지 라이브러리가 결정하는 순간, 그것은 애플리케이션에 속해야 할 운영 환경에 대한 결정을 대신 내려버린 것이다. Mercury에서는 로그를 구조화된 파이프라인을 통해 observability stack으로 보낸다. 라이브러리가 stderr에 직접 쓰면 그 메시지들은 그 모든 것을 우회해버리고, 우리는 원래 JSON line 스트림이어야 할 곳에 불쑥 덤프된 그것들을 처리하기 위한 맞춤 배선을 써야 한다. 지구에서 누구의 유한한 시간을 쓰기에도 너무 어처구니없는 방식이다. callback은 당신에게 거의 비용이 들지 않는다. 운영 방법은 애플리케이션이 결정하게 두라.
라이브러리 작성자들에게 하나 더. .Internal 모듈을 노출하는 것도 고려해보라. 이것은 다소 논쟁적인 조언이다. 다른 이들은 .Internal 모듈이 사용자들이 의존하는 암묵적 API 표면을 만들어, 라이브러리 내부 리팩터링을 더 어렵게 만든다고 합리적으로 주장해왔다. 그 우려는 정당하다. 하지만 그것은 당신이 public API를 정확히 올바르게 설계했고, 사용자가 겪을 모든 사용 사례를 예상했다는 상당한 자신감 위에 서 있다. 내 경험상, 그런 자신감은 대체로 정당화되지 않는다.
명시적인 안정성 경고("이 모듈의 API는 minor version 사이에서도 예고 없이 바뀔 수 있습니다")가 달린 잘 문서화된 .Internal 모듈은, 사용자가 패키지를 fork해서 자기 코드베이스에 vendor한 뒤 당신의 upstream 수정 사항을 영영 받지 못하는 갈라진 사본을 유지보수하게 되는 대안보다 훨씬 낫다. Haskell 생태계의 최고 라이브러리들은 이미 이것을 하고 있다(containers, text, unordered-containers). 제공 비용은 거의 없고, "이 라이브러리를 fork해야 해"를 "불안정한 모듈 하나를 import해야 해"로 바꿔준다. 종종 모두에게 더 나은 상황이다.
여기서 주의할 점 하나는, internal module을 노출하면 소비자들이 조용히 후드를 열고 필요한 것을 꺼내 쓰느라 피드백이 당신에게 돌아오지 않을 수 있다는 것이다. 때로는 괜찮다. 때로는 그것이 public API의 빈 구멍들이 계속 빈 채로 남는 이유가 된다. 아무도 굳이 말해주지 않기 때문이다.
모든 프로덕션 Haskell이 아름다운 것은 아니다.
unsafePerformIO는 당신이 매일 의존하는 라이브러리들 안에서 쓰이고 있다. bytestring과 text 라이브러리는 내부적으로 그것을 사용해 가변 버퍼를 할당하고, 그 안에 쓰고, 결과를 freeze한다. 타입은 생성 중에 무슨 일이 있었는지 아무 말도 하지 않는다. containment는 관례, 신중한 추론, 코드 리뷰로 유지된다. 당신은 어떤 프로덕션 Haskell 코드베이스에서든 이것을 만나게 될 것이다. 그리고 타입 안전한 대안이 받아들일 수 없는 성능 비용이나 복잡성을 요구하는 순간, 당신도 이것을 쓰게 될 것이다. 그럴 때는 정직하라. 타입이 검사하지 않는 불변식을 문서화하라. 적절히 불편해하라. 그리고 타입 안전한 대안이 실용적이 되었는지 주기적으로 다시 검토하라. 프로덕션 Haskell은 타협의 부재가 아니다. 그것은 타협을 규율 있게 가둬두는 일이다.
그다음은 테스트 상황이다. Hackage의 Haskell 라이브러리 중 놀랄 만큼 많은 수가 테스트가 거의 없거나 아예 없다. 암묵적인 주장, 때로는 끔찍할 만큼 노골적인 주장까지 포함해, 말하자면 컴파일만 되면 동작한다는 것이다. 이것은 작은 순수 코드와 단단한 타입에서는 때때로 사실이다. 그리고 초기에 Haskell을 경험하는 사람에게는 흔한 감각이기도 하다. 불법 상태를 표현 불가능하게 만들고, 컴파일러가 놀라울 정도로 많은 것을 잡아주면, 잠시나마 평범한 소프트웨어 실패를 초월한 것 같은 느낌이 든다.
이 감정은 신뢰해서는 안 된다.
IO가 많은 코드, 외부 시스템과 상호작용하는 코드, 또는 흥미로운 버그가 구조 가 아니라 의미론 속에 사는 코드에서는 거의 절대 사실이 아니다. 타입은 함수가 Either ParseError Transaction을 반환한다고 말해줄 수 있다. 하지만 amount 필드를 센트로 파싱하는지 달러로 파싱하는지는 말해주지 못한다. 파트너 API가 생략된 필드를 null 필드와 다르게 해석하는지 여부도 말해주지 못한다. 윤년의 정확히 하나의 타이밍 윈도우에서 retry 로직이 이중 청구를 만드는지도 말해주지 못한다. 프로덕션 맥락에서 당신은 이런 라이브러리 위에 시스템을 쌓고 있고, 그 라이브러리들의 테스트되지 않은 가정까지 함께 물려받고 있다. 알아둘 가치가 있고, 당신 층에서 integration test로 보완할 가치도 있다.
Haskell의 다른 못생긴 부분들도 나타난다. orphan instance, 문맥상 total하다고 맹세하는 partial function, 도달 불가능하다고 약속하는 error 호출, 어색한 FFI wrapper, 손수 만든 예외 계층 구조 장난질, 이상적인 추상화가 현실의 추상화와 칼싸움을 벌이다 진 자리들 말이다. 이런 것들은 쌓인다. 프로덕션에서 Haskell을 운영한다는 것은 살아 있는 시스템을 돌보는 일이고, 살아 있는 시스템은 타협을 축적한다. 당신이 할 수 있는 일은 코드 리뷰, 문서, 예제, 테스트를 통해 규율을 강제하여, 모든 타협이 어디에 있는지, 왜 그렇게 되었는지, 그것을 제거하면 무엇이 깨지는지를 알고 있는 것이다. 목표는 도덕적 순수성이 아니다. 목표는 장애가 났을 때 시스템의 가정 절반이 구전 전통으로만 존재했다는 사실을 발견하지 않도록 하는 것이다.
누군가 프로덕션 시스템에 Haskell을 고려할 때마다 나오는 질문이 있다. 그럴 가치가 있는가?
첫날에는 아니다.
첫날에는 더 느리다. 지금의 Haskell 생태계에는 Next.js나 Rails처럼 배터리 포함형 hot-reloading 개발 환경을 책상 위에 던져주는 힘이 없다. IHP 같은 프로젝트가 언젠가는 거기에 도달할 수 있겠지만, 결국 당신에게 필요한 라이브러리는 없거나, 있더라도 누군가 한 사람이 여가 시간에 유지보수하고 있는 경우가 많다. 오류 메시지는 때때로 무엇이 문제인지 정확히 알지만 그것을 당신에게 설명해야 한다는 사실을 깊이 분개하는 광인의 장문처럼 읽힌다.
하지만 채용 문제는 과장되어 있다. 우리 CTO인 Max Tagher는 백엔드 Haskell 엔지니어가 Mercury 전체에서 가장 채용하기 쉬운 역할이라고 공개적으로 말한 바 있다.8 시장이 제공하는 것보다 Haskell 일자리에 대한 수요가 더 많기 때문에, 일반적인 채용 역학이 뒤집힌다. Haskell에 대한 관심은 기본적인 개발자 품질의 꽤 괜찮은 대리 지표로 작동한다(Paul Graham의 Python Paradox를 추상화 사다리 한 단계 위 언어에 적용한 것), 그리고 일에 무관심한 사람이 아니라 정말 그 일을 하고 싶어 하는 사람들을 끌어들인다. 우리는 Haskell 경험이 깊은 사람도, 경험이 전혀 없는 사람도 채용하고, 후자 그룹을 6주에서 8주 안에 생산적으로 만드는 교육 프로그램을 갖추고 있다. 채용 풀 문제는 내일까지 Haskell 전문가 100명이 필요한 경우에는 실제 문제다. 그러나 좋은 제너럴리스트를 채용하고 가르치는 데 투자할 의향이 있다면 훨씬 덜 현실적인 문제다.
아무도 경고해주지 않는 채용 위험은 풀의 크기가 아니다. 풀의 기질 이다.
Haskell은 이상주의자를 끌어들인다. 이것은 대체로 장점이다. 정합성을 신경 쓰고, 추상화를 신중히 생각하며, 논문을 재미로 읽고, 다른 생태계가 순전히 지쳐서 몇 년 전에 받아들인 가정들을 다시 따져보려는 사람들을 얻게 된다. 하지만 통제되지 않은 이상주의는 프로덕션의 부채가 된다. 새로운 관계 대수의 타입 수준 인코딩으로 데이터베이스 계층을 다시 쓰고 싶어 하는 엔지니어는 기능을 출하하는 데 도움이 되지 않는다. 일회성 스크립트에서 Text 대신 String을 썼다고 코드를 merge하지 않으려는 엔지니어는 마감에 도움이 되지 않는다. 지난주에 읽은 논문 스타일로 전체 재작성을 주장하는 기회로 모든 설계 토론을 만드는 엔지니어는, 아무리 똑똑해도, 팀을 느리게 만든다.
실용주의 문화를 적극적으로 길러야 한다. Haskell은 강력한 도구를 준다. 그것을 항상 전부 쓰는 것은 실용주의가 아니다. 자기 만족이다. 타입 시스템은 전동 공구이지 종교가 아니며, 프로덕션 시스템은 이미 충분히 좋은 해법이 있는 문제에 대해 새로운 메커니즘을 발명할 핑계로 모든 기능을 대할 장소가 아니다.
보상은 나중에 오지만, 실제로 온다. 동적 타입 코드베이스에서는 몇 주 걸릴 리팩터링이 몇 시간 만에 끝나는 것을 볼 때다. 컴파일러가 변경을 모든 호출 지점에 관통시켜, 무엇을 놓쳤는지 정확히 말해주기 때문이다. Haskell에서의 기계적 리팩터링은 직접 경험하기 전에는 전달하기 어려운 특별한 즐거움이 있다. 타입 하나를 바꾸면, 컴파일러가 적응이 필요한 모든 위치의 완전한 목록을 건네준다. 하나씩 체계적으로 처리한다. 검색도, 추측도, 존재하는 줄도 잊고 있던 모듈 안의 호출 지점을 놓쳤는지 걱정할 일도 없다. 그리고 컴파일이 되면 끝이다. 이것은 아마 완전함 이 아니라 정말 완전함 처럼 느껴지는 몇 안 되는 프로그래밍 경험 중 하나다. 신입이 모듈의 타입 시그니처를 읽고 누구에게 묻지 않아도 계약을 이해하는 순간에도 그것이 보인다. 엔지니어 수가 50명에서 수백 명으로 늘어난 회사에서는, 이것은 편의가 아니라 생존 전술이다. 불가능한 상태가 정말로 불가능해서 프로덕션 장애가 발생하지 않는 순간에도 그것이 보인다. 누군가 바쁘게 일하다가 가능성이 낮다고만 여겼던 것이 아니라 말이다.
우리는 이 투자가 수년이 아니라 수개월 단위로 보상된다는 것을 발견했다. 특히 금융 서비스에서는 더욱 그렇다. 데이터 무결성 버그의 비용은 사용자 불만이 아니라 규제 지적사항과 타인의 돈으로 측정되기 때문이다. 타입 시스템이 이런 위험을 없애주지는 않는다. 하지만 그것을 실수로 도입하기 더 어렵게 만드는 도구를 제공하고, 빠르게 성장하는 코드베이스에서는 위험의 대부분이 바로 그 "실수로"에 살고 있다.
우리는 수년 동안 Haskell을 프로덕션에서 돌려왔다. 규모도 크고, 실수의 대가가 고객과 우리 모두에게 막대한 영역에서 말이다. 고통이 없었던 것은 아니다. 어떤 고통은 언어 탓이었고, 어떤 것은 생태계 탓이었고, 상당수는 우리 자신의 탓이었다. 하지만 시스템은 작동했고, 팀이 성장하고 변화하는 동안에도 계속 작동한다. 내 경력에서 내가 내렸던 모든 기술 선택에 대해 그렇게 말할 수 있는 것은 아니다.
열여섯 살 때 나를 Haskell에 매혹시킨 것은 어떤 버그들은 불가능하게 만들 수 있다는 약속이었다. 나는 여전히 그 전제를 사랑한다. 하지만 지금 내가 이해하는 것은 더 깊은 프로덕션 가치가 더 넓고, 어쩌면 더 흥미롭다는 점이다. Haskell은 어렵게 얻은 운영 지식을, 그것을 발견한 사람들이 집에 갔거나, 팀을 옮겼거나, 회사를 떠난 뒤에도 보존하게 해준다. 위험한 기계 주위에 단단한 경계를 그을 수 있게 해준다. 안전한 길을 쉬운 길로 만들 수 있게 해준다. 조직이 계속 바뀌는 와중에도 작동해야 하는 시스템에서, 이것은 학문적 우아함을 넘어 단단한 엔지니어링 지렛대를 제공한다.
프로덕션에 Haskell을 고려하고 있다면, 이것이 그 모습에 대한 현실적인 그림을 주었기를 바란다. 은탄환도 아니고, 도덕적 성전도 아니고, Haskell 숙련도가 넓은 스펙트럼에 걸친 팀이 다룰 때조차 진정으로 강력한 도구 집합이라는 그림 말이다.
Ian Duncan은 Mercury의 Stability 팀 엔지니어로, Mercury의 Temporal SDK, OpenTelemetry 계측, 그리고 자기가 안 보고 있을 때 프로덕션이 픽 쓰러져 죽지 않도록 만드는 신뢰성 인프라 작업을 하고 있다. 그는 2014년부터 다양한 함수형 프로그래밍 언어로 직업적으로 코드를 써왔다. 가족과 함께 헤이그에 살고 있으며, 커피, 파워 메탈, 시스템 아키텍처에 대해 강한 의견을 가지고 있다. Bluesky와 iankduncan.com에서 그를 찾을 수 있다.
1 2023년 3월, Silicon Valley Bank는 미국 역사상 두 번째로 큰 은행 파산으로 기록된 붕괴를 겪었다. Mercury는 5일 만에 8,700개가 넘는 신규 고객과 20억 달러의 예금을 얻었다. 스타트업 생태계 전체가 동시에 자기 돈을 당신 쪽으로 옮기기로 결정하는 것만큼 인프라를 스트레스 테스트하는 일도 드물기 때문이다. "SVB's collapse drove 26K customers to Mercury in 4 months" (TechCrunch, 2023)를 보라. 그중 95%가 남았다.
2 이건 내가 지금까지 적어도 서너 회사에서 실제로 일어나는 걸 본 일이다!
3 복잡한 시스템에서 실패를 연구하는 것과 성공을 연구하는 것의 구분은 Erik Hollnagel의 Safety-I and Safety-II (Ashgate, 2014)의 핵심 논지다. 전통적인 접근("Safety-I")은 일이 잘못되지 않도록 막는 데 초점을 맞춘다. 대안인 ("Safety-II")은 일이 보통 왜 잘 되는지를 이해하고, 성공 조건이 유지되도록 보장하는 데 초점을 맞춘다.
4 복원력 있는 시스템의 기반으로서의 "적응 역량"은 복원력 엔지니어링 문헌, 특히 David Woods의 작업에서 온 개념이다. Resilience Engineering: Concepts and Precepts (Ashgate, 2006)와 Woods의 "Four Concepts for Resilience and the Implications for the Future of Resilience Engineering" (Reliability Engineering & System Safety, 2015)를 보라.
5 Patrick McKenzie (patio11), "What Working At Stripe Has Been Like" (2020). 충분히 빠르게 성장하는 회사에서는 동료의 대다수가 항상 신입이 되며, 따라서 코드나 문서에 적혀 있지 않은 지식은 사실상 비밀 지식이 된다.
6 우리의 프로세스는 Betsy Beyer 외의 Site Reliability Engineering (O'Reilly, 2016) 32-33장에서 설명되는 Google의 production readiness review 모델의 영향을 받았다. 우리의 변형은 gatekeeping보다 협업적 리뷰를 더 강조한다.
7 덧붙이자면, Mercury의 Slack 메시지는 이제 몇 년쯤 지나면 삭제되는 수명을 가진다. 그래서 큰 조직에서는, 더 영속적인 곳에 적어두지 않으면 깊은 전승 지식이 결국 완전히 증발하기 시작하는 시점이 오게 된다.
8 Max Tagher, "Haskell in Production: Mercury" (Serokell, 2022) 및 "Haskell in Mercury" (Functional Futures, 2024).
*Mercury는 핀테크 회사이며 FDIC 보험 가입 은행이 아닙니다. 은행 서비스는 Choice Financial Group과 Column N.A.를 통해 제공되며, 둘 다 FDIC 회원사입니다. (작성 시점 기준.)
Haskell Language Server 2.13.0.0 release ›
© 2026 The Haskell Programming Language's blog