Mercury는 주석 등을 제외한 약 200만 줄의 Haskell 코드베이스로 30만 개 이상 기업에 뱅킹 서비스를 제공하며, 2025년에 2,480억 달러 거래량과 연환산 매출 6억 5,000만 달러를 처리함
Mercury의 Haskell 활용 가치는 순수성 자체보다 운영 지식을 API와 타입에 담고, 위험한 동작을 좁은 경계 뒤에 두며, 안전한 경로를 쉬운 경로로 만드는 데 있음
신뢰성은 실패를 모두 막는 것이 아니라 시스템이 변동을 흡수하는 능력으로 다뤄지며, 타입 시스템은 오류 클래스를 배제하고 제도적 지식을 컴파일러가 강제하는 문서처럼 남겨줌
Mercury는 금융 업무 흐름의 재시도, 타임아웃, 취소, 크래시 복구를 위해 Temporal을 durable execution 프레임워크로 쓰고, Haskell SDK hs-temporal-sdk를 오픈소스로 공개함
Haskell의 프로덕션 가치는 모든 것을 타입에 넣는 데 있지 않고, 데이터 손실·금융 오류·규제 문제로 이어지는 불변식은 타입으로 보호하되 복잡성은 캡슐화하고 테스트·문서·코드 리뷰와 함께 운영하는 데 있음
Mercury의 Haskell 운영 규모와 신뢰성 관점
Mercury는 주석 등을 제외하고 약 200만 줄 규모의 Haskell 코드베이스를 운영함
Mercury는 30만 개 이상의 기업에 뱅킹 서비스를 제공하는 핀테크 회사이며, 2025년에 2,480억 달러 거래량과 연환산 매출 6억 5,000만 달러를 처리함
직원은 약 1,500명이고, 엔지니어링 조직은 주로 범용 개발자를 채용하며 대부분은 입사 전 Haskell을 써본 적이 없음
이 시스템은 빠른 성장, SVB 위기로 5일 만에 20억 달러 신규 예금이 유입된 상황, 규제 심사, 대규모 금융 시스템의 일반적·비일반적 상황을 거치며 수년간 동작해 옴
신뢰성은 실패 방지가 아니라 변동 흡수 능력
전통적 신뢰성 접근은 실패를 열거하고, 검사와 테스트를 추가하고, 버그를 찾는 데 집중하지만 이것만으로는 충분하지 않음
Mercury는 신뢰성을 시스템이 변동을 흡수하는 능력으로 다룸
시스템이 우아하게 성능 저하를 겪을 수 있어야 함
운영자가 시스템을 이해하고 조정할 수 있어야 함
아키텍처가 올바른 일을 쉽게, 잘못된 일을 어렵게 만들어야 함
빠르게 성장하는 조직에서는 새로 합류한 엔지니어가 모듈을 읽고 이해할 수 있는지, 데이터베이스가 느릴 때 서비스가 함께 무너지는지, 인터페이스 오용을 컴파일러가 잡는지가 실제 운영 질문이 됨
타입 시스템은 단순한 정확성 증명보다 운영 보조 장치에 가까움
특정 오류 클래스를 배제함
작성자가 떠난 뒤에도 제도적 지식을 컴파일러가 읽을 수 있는 형태로 남김
위키보다 일관되게 강제되는 문서 역할을 함
Mercury의 안정성 엔지니어링은 제품 개발을 늦추는 품질 경찰이 아니라, 기능이 깨졌을 때의 영향을 설계 초기부터 다루는 협업 방식임
실패 시 폭발 반경
멱등성이 필요한 작업과 방식
롤백 형태
진행 중 작업의 처리
실패를 흡수하는 시스템과 증폭하는 시스템을 미리 따짐
순수성은 언어의 속성이 아니라 인터페이스 경계
Haskell의 순수성은 내부에 부작용이 전혀 없다는 뜻이 아니라, 인터페이스가 부작용의 누출을 막는 경계를 만든다는 의미에 가까움
bytestring, text, vector 같은 라이브러리의 순수 함수 뒤에는 가변 할당, 버퍼 쓰기, unsafe coercion 같은 내부 구현이 존재함
ST 모나드는 계산 안에서 관찰 가능한 제자리 변경과 부작용을 사용하지만, runST의 rank-2 타입이 내부에서 만든 가변 참조의 탈출을 막음
runST :: (forall s. ST s a) -> a
내부에서는 명령형 동작이 가능하지만, 외부에는 결과만 나오며 변경 가능 상태는 경계 밖으로 새지 않음
이 원칙은 운영 시스템 전반에 적용됨
데이터베이스 계층은 내부적으로 연결 풀링, 재시도, 가변 상태를 사용할 수 있음
캐시는 동시성 가변 맵을 사용할 수 있음
HTTP 클라이언트는 회로 차단기, 연결 풀, 많은 장부 관리를 가질 수 있음
핵심은 위험한 동작을 좁은 인터페이스로 감싸 오용을 어렵게 만드는 것임
실제 시스템에서 목표는 변경을 완전히 피하는 것이 아니라, 변경이 어디에 있는지 명확히 하고 코드베이스 중 누가 그것을 알아야 하는지 제한하는 것임
올바른 일을 쉬운 일로 만들기
대형 코드베이스에서는 정확성이 특정 순서나 보이지 않는 추가 단계에 의존하는 패턴이 자주 생김
트랜잭션 후 감사 로그를 flush해야 함
엔드포인트 호출 전 feature flag를 확인해야 함
알림 enqueue를 데이터베이스 트랜잭션 안에서 해야 함
이런 운영 지식이 위키, 온보딩 문서, 과거 디자인 리뷰, Slack 스레드, 일부 시니어 엔지니어의 기억에만 있으면 빠르게 사라짐
Haskell은 이런 절차를 타입으로 인코딩해 잊을 수 없게 만들 수 있음
나쁜 방식은 올바른 함수를 쓰라고 부탁하되 우회 경로를 남겨두는 것임
-- Please use this one, not the other one
writeWithEvents :: Transaction -> [Event] -> IO ()
-- Don't use this directly (but we can't stop you)
writeTransaction :: Transaction -> IO ()
publishEvents :: [Event] -> IO ()
더 나은 방식은 작업을 실행하는 유일한 경로가 이벤트 발행을 포함하도록 타입을 재구성하는 것임
data Transact a -- opaque; cannot be run directly
record :: Transaction -> Transact ()
emit :: Event -> Transact ()
-- The *only* way to execute a Transact: commit and publish atomically
commit :: Transact a -> IO a
여기서 타입 시스템은 이벤트에 관한 깊은 정리를 증명하기보다, 올바른 운영 절차를 가장 쉬운 경로로 만듦
새 엔지니어가 트랜잭션 작성법을 물으면 타입 시그니처와 공개 API가 답을 제공하고, 시니어 엔지니어가 떠나도 지식이 남음
지속 실행과 Temporal
금융 시스템의 업무 흐름은 단일 트랜잭션 안에 머물지 않음
결제 전송
파트너 승인 대기
원장 업데이트
고객 알림
취소와 타임아웃 처리
파트너는 성공했지만 워커가 기록 전 죽은 경우
네트워크 문제로 응답이 없는 경우
이런 흐름에는 상태, 재시도, 타임아웃, 멱등성, 프로세스 크래시와 배포를 넘어 지속되는 실행이 필요함
Mercury는 과거에 데이터베이스 기반 상태 머신, cron 작업, 백그라운드 워커, 코드 곳곳의 재시도와 타임아웃 처리로 이런 프로세스를 조정함
동작은 했지만 취약했고 이해하기 어려웠으며 운영 사고의 불균형한 원인이 됨
Temporal은 Mercury의 durable execution 프레임워크로, 워크플로를 일반적인 순차 코드처럼 작성하고 플랫폼이 각 단계를 이벤트 히스토리에 기록함
워커가 워크플로 중간에 크래시하면 다른 워커가 결정적 prefix를 replay해 상태를 재구성하고 중단 지점부터 계속함
재시도, 타임아웃, 취소, 오류 처리는 각 팀이 따로 재구현하는 대신 플랫폼이 제공함
Temporal workflow는 이벤트 히스토리에 대한 순수 함수와 비슷한 성격을 가짐
replay된 workflow는 원래와 같은 명령 시퀀스를 만들어야 함
이 결정성 요구는 순수 코드의 같은 입력·같은 출력 제약과 닮아 있음
부작용은 workflow의 IO에 해당하는 activity로 격리됨
Mercury는 Temporal의 공식 Core SDK를 Rust FFI로 감싼 Haskell SDK hs-temporal-sdk를 만들고 오픈소스로 공개함
전송 계층 관심사는 가장자리에 있어야 하며, 도메인 모델은 웹 핸들러, CLI, cron 작업, 백그라운드 워커, workflow 엔진 어디서 호출돼도 HTTP 상태 코드를 끌고 다니지 않아야 함
타입 인코딩의 비용과 적정선
불변식을 타입에 넣는 것은 강력하지만 인지 비용, 경직성, 요구사항 변경 시 어려움을 만든다는 비용이 있음
위반이 데이터 손실, 금융 오류, 규제 문제, 호출 대기 사고로 이어진다면 타입 인코딩 비용이 정당화됨
단지 현재 방식이 그렇다거나, 타입 수준 기법을 적용해 보고 싶다는 이유라면 코드베이스를 바꾸기 어렵게 만들 가능성이 큼
너무 많이 인코딩하는 쪽
불법 상태가 표현 불가능하고 도메인이 타입으로 충실히 모델링됨
비즈니스 규칙 변경이 50개 모듈을 관통하는 타입 변경으로 이어져 리팩터링이 길어짐
새 엔지니어가 타입 시그니처를 이해하기 어려워짐
아무것도 인코딩하지 않는 쪽
타입이 String, IO (), 최악의 경우 Dynamic에 가까워짐
코드는 바꾸기 쉽지만 계약이 없고, 의미는 기존 작성자의 기억에 의존함
작성자가 떠나면 시스템이 왜 동작하지 않는지 알기 어려워짐
유용한 기준
조용한 손상을 막는 불변식은 타입에 넣는 편이 좋음
이벤트 없이 커밋된 트랜잭션
감사 로그 없이 처리된 결제
겉보기엔 가능하지만 의미적으로 불가능한 상태 전이
크게 실패하는 불변식은 좋은 오류 메시지를 가진 런타임 검사로 충분할 수 있음
500 응답
assertion 실패
JSON 경계의 타입 불일치
전체 도메인을 타입으로 모델링하려는 욕구는 억제해야 함
도메인에는 예외, 과거 호환 규칙, 서로 충돌하는 규칙, 특정 고객용 특수 동작이 존재함
타입은 컴파일러만이 아니라 팀을 위한 도구임
테스트, 문서, 코드 리뷰, 예제, 플레이북과 함께 방어층을 구성해야 함
Mercury 내부에는 GADT, type family, 상태 전이를 추적하는 phantom type 같은 복잡한 타입 수준 장치를 쓰는 라이브러리도 있음
잘못되면 돈이 잘못 이동하거나 규제 불변식이 깨지는 메커니즘에서는 이런 복잡성이 필요함
핵심은 복잡성을 캡슐화하는 것임
타입 수준 상태 머신을 구현하는 모듈은 소수의 깊이 이해한 작성자와 충분한 테스트를 가져야 함
사용하는 쪽의 API는 일반적인 타입을 가진 몇 개 함수처럼 보여야 함
product engineer가 내부의 타입 수준 증명 장치를 모르고도 안전하게 호출할 수 있어야 함
코드 리뷰에서 다른 모듈을 만지는 PR이 컴파일러를 달래기 위해 복사한 타입 주석으로 가득하다면 추상화가 경계를 넘어 새고 있다는 신호임
내성 가능성을 위한 설계
신뢰성이 적응 능력이라면 내성 가능성은 그 능력을 얻는 방식 중 하나임
운영자는 볼 수 없는 것을 운영할 수 없고, 팀은 내부가 불투명한 시스템에 적응하기 어려움
Haskell에는 monkey patching이 없어 런타임에 라이브러리 내부 HTTP 클라이언트를 바꾸거나 데이터베이스 호출을 OpenTelemetry span을 내는 함수로 교체하기 어려움
Rust도 같은 제약을 갖지만 Rust 생태계는 tower 미들웨어 패턴에 수렴한 반면, Haskell 생태계는 여러 접근으로 나뉘어 있음
라이브러리가 구체적인 최상위 함수 묶음만 노출하면, 계측하려면 새 모듈로 감싸고 사람들이 원래 모듈 대신 그 모듈을 import하길 기대해야 함
함수 레코드
가장 자주 쓰는 해법은 구체 함수 대신 함수 레코드를 노출하는 것임
-- A concrete module gives you no leverage:
sendRequest :: Request -> IO Response
-- A record of functions gives you all of it:
data HttpClient = HttpClient
{ sendRequest :: Request -> IO Response
, getManager :: IO Manager
}
이 방식이면 sendRequest를 타이밍 계측으로 감싸 새 HttpClient를 반환할 수 있음
테스트용 fault injection, mock 교체, 재시도, tracing, 요청 rewrite, tenant별 동작 같은 횡단 관심사를 런타임에 추가할 수 있음
WAI의 type Middleware = Application -> Application처럼 동작 변환을 합성 가능하게 만드는 패턴이 운영상 매우 유용함
Monoid로 합성되는 인터셉터
미들웨어와 interceptor 타입은 대개 Semigroup과 Monoid 인스턴스를 가질 수 있음
WAI의 Middleware는 endomorphism이고, endomorphism은 합성과 id 아래 monoid를 형성함
interceptor hook 레코드는 필드별로 합성할 수 있어 tracing, timeout, task queue rewrite 같은 관심사를 별도 배관 없이 mconcat으로 합칠 수 있음
appTemporalInterceptors =
mconcat
[ retargetingInterceptor
, otelInterceptor
, sentryInterceptor
, sqlApplicationNameInterceptor
, loggingContextInterceptor
, statementTimeoutInterceptor
, teamNameInterceptor
, clientExceptionInterceptor
, workflowTypeNameInterceptor
]
각 interceptor는 독립 모듈에서 한 가지 관심사만 다루고, mempty에서 필요한 필드만 override하며, 순서는 리스트에 명시됨
이펙트 시스템
effectful, polysemy, fused-effects, cleff 같은 effect system도 다른 경로를 제공함
사용 가능한 연산을 effect 타입으로 정의하고 production, testing, tracing용 interpreter를 호출 지점에서 바꿀 수 있음
effect를 가로채 메트릭 기록이나 지연 주입 후 실제 handler로 다시 보낼 수 있음
단점은 타입 수준 effect list, handler stack, 까다로운 타입 오류 같은 장치가 추가된다는 것임
함수 레코드는 새 엔지니어가 오후 하나면 이해할 수 있을 만큼 단순함
persistent의 긍정적 예
persistent의 SqlBackend는 connPrepare, connInsertSql, connBegin, connCommit, connRollback 같은 함수 레코드임
OpenTelemetry 계측을 추가할 때 관련 필드를 감싸 모든 데이터베이스 작업에 tracing span을 붙일 수 있었음
fork 없이, 거의 소스 변경 없이 데이터베이스 계층 가시성을 확보함
운영상 어려운 라이브러리
Mercury는 Hackage에 공개된 웹 API 클라이언트 바인딩을 거의 쓰지 않음
서드파티 바인딩이 구체 함수로 HTTP 호출을 수행하면 tracing, SLO에 맞춘 timeout, 파트너 장애 시뮬레이션, trace의 400ms 공백 설명이 어려워짐
그래서 직접 클라이언트를 작성하고 처음부터 관측 가능하게 만듦
작은 생태계의 비용
일부 Haskell 라이브러리는 버려진 것은 아니지만, 명확히 책임지고 빠르게 개선하는 주체가 없는 공공 인프라처럼 남아 있음
오래된 인터페이스가 유지되고, 관측 가능성·경계 설계·운영성에 관한 새로운 설계를 받아들이는 속도가 느릴 수 있음
http-client는 직접적으로 HTTP/1.1만 지원하며, 충분히 쓸 만하지만 특정 시점에는 우회가 필요할 수 있음
패키지 작성자를 위한 운영상 요구
라이브러리 작성자는 사용자가 소스 수정 없이 동작을 주입할 수 있도록 함수 레코드, effect 타입, callback 같은 탈출구를 제공해야 함
hs-opentelemetry-api를 의존성으로 추가하고 핵심 IO 작업 주변에 span을 두는 것만으로도 production에서 라이브러리를 운영하는 사용자에게 도움이 됨
API 패키지는 breaking change에 보수적이며, 애플리케이션이 OpenTelemetry SDK를 초기화하지 않으면 inert하게 동작하도록 설계됨
성능 오버헤드는 최소화되어 있고, 사용자 애플리케이션에서 예기치 않은 예외나 logging을 발생시키지 않음
의존성 footprint는 아직 원하는 만큼 작지 않으며 개선 작업 중임
라이브러리 코드에서 직접 로그를 쓰면 안 됨
logging framework를 import해 stdout이나 stderr에 직접 쓰는 대신 callback, logger parameter, 호출자가 라우팅할 수 있는 로그 메시지 데이터 타입을 제공해야 함
로그가 어디로 가는지는 애플리케이션의 운영 환경에 속한 결정임
Mercury는 구조화 로그 파이프라인을 observability stack으로 보내며, 라이브러리가 stderr에 직접 쓰면 JSON lines 스트림과 별도 배관이 필요해짐
.Internal 모듈 노출도 고려할 수 있음
사용자가 내부 API에 의존해 refactor가 어려워질 수 있다는 우려는 타당함
하지만 공개 API가 모든 사용 사례를 이미 맞혔다는 확신은 드물게만 정당화됨
명시적 안정성 경고가 있는 .Internal 모듈은 사용자가 패키지를 fork하고 vendoring하는 것보다 나을 수 있음
containers, text, unordered-containers는 Haskell 생태계에서 이런 방식을 쓰는 좋은 예임
다만 사용자가 조용히 내부 모듈을 써서 필요한 것을 해결하면 공개 API 결함에 대한 feedback이 줄어들 수 있음
타입에 넣지 않는 것들
프로덕션 Haskell에도 아름답지 않은 부분이 존재함
unsafePerformIO는 일상적으로 의존하는 라이브러리 내부에서 쓰임
bytestring과 text는 내부적으로 가변 버퍼를 할당하고, 쓰고, freeze해 결과를 만듦
타입은 생성 중 어떤 일이 있었는지 말하지 않음
경계는 관례, 신중한 reasoning, 코드 리뷰로 유지됨
타입 안전한 대안이 성능이나 복잡성 비용을 지나치게 만들면 직접 이런 타협을 작성할 수도 있음
타입이 확인하지 않는 불변식을 문서화해야 함
불편함을 유지하고, 타입 안전한 대안이 실용적이 됐는지 주기적으로 재검토해야 함
production Haskell은 타협 부재가 아니라 타협의 규율 있는 격리임
Hackage의 많은 Haskell 라이브러리에는 테스트가 적거나 없음
“컴파일되면 동작한다”는 생각은 작은 순수 코드와 강한 타입에서는 가끔 맞을 수 있음
IO-heavy 코드, 외부 시스템 연동, 구조보다 의미에 버그가 있는 코드에는 거의 맞지 않음
타입은 Either ParseError Transaction을 반환한다는 것은 말할 수 있지만 다음은 말할 수 없음
amount 필드를 센트로 파싱하는지 달러로 파싱하는지
파트너 API가 생략된 필드와 null 필드를 다르게 해석하는지
재시도 로직이 윤일의 특정 타이밍 창에서 이중 과금을 일으키는지
production에서는 이런 라이브러리 위에 시스템을 만들며, 검증되지 않은 가정을 물려받으므로 자기 계층의 integration test로 보완해야 함
orphan instance, 문맥상 total이라고 믿는 partial function, 도달 불가능하다고 약속한 error, 어색한 FFI wrapper, 수작업 exception hierarchy 같은 타협도 누적됨
목표는 도덕적 순수성이 아니라, 모든 타협이 어디에 있고 왜 만들어졌으며 제거하면 무엇이 깨지는지 코드 리뷰, 문서, 예제, 테스트로 알 수 있게 하는 것임
Haskell을 프로덕션에서 쓸 가치
Haskell은 첫날부터 빠른 선택은 아님
현재 생태계는 Next.js나 Rails처럼 batteries-included hot-reloading 개발 환경을 즉시 제공하지 못함
필요한 라이브러리가 없거나, 있어도 한 사람이 spare time에 유지할 수 있음
오류 메시지가 매우 난해할 때가 있음
채용 문제는 과장되어 있음
Mercury CTO Max Tagher는 backend Haskell engineer가 Mercury 전체에서 가장 채용하기 쉬운 역할이라고 공개적으로 말한 바 있음
Haskell 일자리에 대한 수요가 공급보다 많아 일반적인 채용 역학이 뒤집힘
Mercury는 Haskell 경험이 깊은 사람과 전혀 없는 사람을 모두 채용하며, 후자는 6~8주 교육 프로그램으로 생산성을 갖추게 함
내일 Haskell 전문가 100명이 필요하다면 채용 풀 문제는 현실적이지만, 좋은 범용 개발자를 뽑아 가르칠 의지가 있다면 덜 현실적임
더 큰 채용 리스크는 풀의 크기가 아니라 성향임
Haskell은 정확성과 추상화에 신경 쓰고 논문을 즐겨 읽으며 기존 가정을 의심하는 이상주의자를 끌어들임
이 강점이 통제되지 않으면 production 책임이 될 수 있음
데이터베이스 계층을 새로운 타입 수준 관계대수 인코딩으로 다시 쓰려 하거나, throwaway script의 String 대신 Text를 쓰지 않았다고 merge를 거부하거나, 모든 설계를 최신 논문식 total rewrite로 끌고 가는 태도는 팀을 느리게 함
production Haskell에는 실용주의 문화가 필요함
타입 시스템은 전동 공구이지 종교가 아님
이미 좋은 해법이 있는 문제를 새 메커니즘 발명 기회로 삼는 것은 production에 맞지 않음
수익은 시간이 지나며 나타남
동적 타입 코드베이스에서 몇 주 걸릴 리팩터링이, 타입 변경 후 컴파일러가 모든 call site를 알려줘 몇 시간에 끝날 수 있음
새 엔지니어가 타입 시그니처를 읽고 모듈의 계약을 이해할 수 있음
불가능한 상태가 실제로 표현 불가능해서 production incident가 발생하지 않을 수 있음
Mercury는 투자 회수가 몇 년이 아니라 몇 달 단위로 나타난다고 봄
특히 금융 서비스에서는 데이터 무결성 버그 비용이 사용자 불만이 아니라 규제 지적과 타인의 돈으로 측정됨
타입 시스템이 위험을 제거하지는 않지만, 빠르게 성장하는 코드베이스에서 실수로 위험을 도입하기 어렵게 만드는 도구를 제공함
Haskell의 production 가치는 은탄환이나 도덕적 운동이 아니라, 다양한 Haskell 숙련도를 가진 팀도 위험한 장치를 경계 안에 두고, 운영 지식을 보존하며, 안전한 경로를 쉬운 경로로 만들 수 있게 하는 강력한 도구 집합에 있음