정적 타입과 ADT가 프로그램의 국소적 정확성을 크게 높여 주지만, 프로덕션에서의 정확성은 단일 프로그램이 아니라 여러 배포 버전이 공존하는 ‘시스템’의 성질이며, 스키마/직렬화/배포 경계에서의 버전 호환성과 의미(semantic) 드리프트를 다뤄야 한다.
메뉴
[ 2026-02-09 ]
정적 타입, 대수적 데이터 타입, 불법 상태를 표현 불가능하게 만들기: 함수형 프로그래밍 전통은 프로그램 을 추론하기 위한 놀라운 도구들을 발전시켜 왔다. 나는 10년 넘게 하스켈을 직업으로 써 왔고, 그 모든 것의 가치를 믿는다.
하지만 이런 도구들이 워낙 효과적이다 보니, 특정한 취약성이 생긴다. 우리는 때때로 프로그램에 대한 추론을 시스템 에 대한 추론과 혼동한다. 이 둘은 같은 활동이 아니며, 하나에서 뛰어나게 만드는 직관이 다른 하나로 자동 이식되지는 않는다.
이건 FP만의 문제가 아니다. 모든 프로그래밍 커뮤니티는 “프로그램”을 주요 연구 대상으로 취급한다. 하지만 FP 실천가들은 독특한 위치에 있다. 국소적(local) 정확성을 위한 도구가 너무 강력한 나머지, 시스템 수준의 성질에 대해서도 근거 없는 자신감을 갖기 쉽다. 타입 체커는 자신이 검사하는 것에 대해 솔직하다. 문제는 우리가 그 관할권이 끝나는 지점을 잊는 순간 시작된다. 모든 언어 커뮤니티는 이런 “망각”의 자체 버전을 갖고 있고, FP 커뮤니티는 그게 불필요하다고 믿게 만드는 가장 정교한 이유를 갖고 있을 뿐이다.
더 진행하기 전에 단서를 하나 달자면: 이 글은 웹 서비스, 서비스 지향 아키텍처, 그리고 그로부터 필연적으로 생겨나는 분산 시스템의 세계에 기반한다. 비디오 게임, CLI 도구, 임베디드 펌웨어를 만들고 있다면 버전 경계가 달라지고, 여기 내용의 상당 부분이 적용되지 않을 것이다. 하지만 네트워크를 통해 다른 코드와 대화하는 코드를 출시하고, 특히 시스템 전체를 한 번에 내리지 않고 변경을 배포해야 했던 적이 있다면, 이 글은 당신을 위한 것이다.
좋은 소식은, 어디를 봐야 하는지 안다면 연구 커뮤니티가 우리가 필요한 도구들을 조용히 모아 두고 있다는 점이다.
타입 이야기를 하기 전에, 내가 반복해서 주장하게 되는 한 가지를 먼저 확립하고 싶다: 모든 프로덕션 시스템은 분산 시스템이며, 당신의 모놀리식도 예외가 아니다.
서버가 두 대 이상인 웹 애플리케이션이 있다면, 당신은 분산 시스템을 갖고 있다. 백그라운드 잡 워커가 있다면, 분산 시스템이다. 크론 잡이 있다면, 분산 시스템이다. Stripe와 통신하거나, SendGrid로 이메일을 보내거나, Redis에 큐잉하거나, Postgres 리플리카에 쓰기를 한다면, 당신은 (유감스럽지만) 분산 시스템을 운영하고 있다. “모놀리식”이라는 단어는 배포 아티팩트를 설명한다. 런타임 토폴로지를 설명하지 않는다.
이게 중요한 이유는, 프로덕션에서 흥미로운 정확성 문제는 거의 항상 국소적 이라기보다 시스템적 이기 때문이다. 서로 다른 버전의 코드를 실행하는 컴포넌트 간 상호작용, 데이터베이스 상태에 대한 서로 다른 가정, 다른 곳에서 이미 부분적으로 성공한 작업을 재시도하는 동작 같은 곳에서 문제가 생긴다. 타입 시스템이 아무리 정교해도, 단일 프로그램 분석으로는 이런 문제를 잡아낼 수 없다.
대부분의 프로그래밍 언어 커뮤니티(FP 포함)는 “프로그램”을 연구 대상으로 다루는 경향이 있다. 우리는 프로그램에 대한 논문을 쓰고, 프로그램을 검증하고, 프로그램을 최적화한다. 하지만 프로덕션에서의 정확성은 프로그램의 성질이 아니다. 시스템의 성질이다. 이 사실을 명확히 보게 되면, 업계 전반의 가장 소중한 관행들 중 일부가 잘못된 고도를 겨냥하고 있는 것처럼 보이기 시작한다.
여기 중심 주장이다: 프로덕션에서 정확성의 단위는 프로그램이 아니다. 배포들의 집합이다.
하스켈 컴파일러가 프로그램이 타입이 맞다고 말할 때, 그것은 단일 아티팩트의 성질을 검증한 것이다. 하나의 바이너리, 하나의 버전, 타입과 로직의 일관된 스냅샷 하나. 이는 진정으로 가치가 있다. 하지만 프로덕션에서 그 아티팩트는 앙상블의 한 구성원일 뿐이다. 어느 순간이든 시스템은 다음을 동시에 실행하고 있을 수 있다:
정확성은 이 전체 집합이 동시에 만족해야 하는 성질이다. 타입 체커는 그중 한 요소를 검증했을 뿐이다. 요소들 간의 상호작용에 대해서는 아무것도 말해 주지 않는다. 그리고 버그는 상호작용에 숨어 있다.
이 관찰은 새롭지 않다. 구글은 F1 데이터베이스에서 이 문제를 뼈저리게 겪었고, 그 결과 스키마 진화 문헌에서 가장 중요한 논문 중 하나가 나왔다.1 그들의 통찰은, 서버들이 어느 순간에도 최대 두 개의 스키마 버전 차이만 나도록 제한할 수 있다면, 위험한 스키마 변경을 일련의 중간 상태로 분해할 수 있고 각 상태는 이웃과 쌍으로 호환된다는 것이었다. 이 프레임워크로 프로덕션 시스템에서 미묘한 버그 두 개를 찾아냈는데, 그 버그들은 시스템이 스키마를 한 번에 한 버전씩만 추론하고 있었기 때문에 존재했던 것들이었다.
프로그래밍 언어 문화는 프로그램을 하나의 단일한 것으로 취급한다. 작성하고, 컴파일하고, 배포한다. 오래된 버전은 사라지고 새 버전이 그 자리를 대신한다. 타입 시스템도 이런 모델 위에서 작동한다. 모듈 시스템도 그렇고, “코드”에 대한 당신의 정신 모델도 그렇다.
프로덕션에서는 이게 점잖은 허구다.
사소하지 않은 배포에서는 여러 버전의 코드가 동시에 실행된다. 롤링 배포는 일정 시간 창(몇 초, 몇 분, 때로는 몇 시간) 동안 구버전과 신버전이 동시에 살아서 같은 사용자를 대상으로 요청을 처리한다. 블루-그린 배포는 둘 다 존재하고 트래픽이 어느 쪽으로든 라우팅될 수 있다. 카나리 배포는 둘 다 지금 이 순간, 동시에 실제 사용자를 처리한다.
합(sum) 타입을 하나 생각해 보자:
data PaymentStatus
= Pending
| Completed
| Failed
새 버전에서 생성자를 하나 추가해 배포한다:
data PaymentStatus
= Pending
| Completed
| Failed
| Refunded
그러면 다음 몇 분 동안(시간은 배포 방식에 따라 다름) 오래된 워커가 Refunded가 들어 있는 메시지나 DB 로우를 받게 되고, 무엇을 해야 할지 모른다. JSON으로 직렬화하고 있다면, 오래된 코드는 인식할 수 없는 문자열을 보고 파싱 에러를 던진다. 타입 체커가 이걸 경고하지 않은 이유는, 타입 체커는 한 번에 한 버전만 보기 때문이다. 언어와 무관하게 사실이지만, 하스켈 예시는 아이러니가 가장 날카롭다. 철옹성 같은 보장처럼 느껴졌던 exhaustive pattern match가, 사실은 더 이상 존재하지 않는 세계에 대한 보장이었음이 드러나기 때문이다.
이것이 Protocol Buffers가 와이어에서 필드 이름 대신 숫자 태그를 쓰는 이유이고, Avro가 역직렬화 시점에 writer와 reader의 스키마 둘 다 필요로 하는 이유다.2 이는 괴상한 취향이 아니다. 생산자와 소비자가 서로 다른 버전에 있을 것이라는 근본 현실에 대한 엔지니어링적 대응이다. 직렬화 포맷이 타입 시스템이 할 수 없는 일을 하고 있다. 즉, 시간에 걸친 호환성을 추론하는 일이다.
Rolling Deploy Watch versions coexist
web-1
v1
running
web-2
v1
running
worker-1
v1
running
deploy v2 (rolling)reset
Erlang/OTP는 이 문제를 언어 수준에서 진지하게 다룬 거의 유일한 주류 플랫폼이며, 그들이 한 일을 잠시 멈춰 감상할 가치가 있다. BEAM VM은 핫 업그레이드 동안 정확히 두 버전의 모듈을 동시에 실행하는 것을 지원한다. 새 버전을 로드하면, 오래된 코드를 실행 중인 프로세스는 외부 호출을 하기 전까지 계속 실행되고, 그 시점에 전환된다. OTP의 gen_server에 있는 code_change/3 콜백은 버전 간 프로세스 상태를 마이그레이션하기 위한 명시적 훅이다. 오래된 상태와 오래된 버전 식별자를 받고, 새 상태를 반환한다. 상태 마이그레이션이 사고 후에 발견되는 땜질이 아니라, 프로그래밍 모델의 일급 요소다.
이 “두 버전” 제한이 핵심 설계 선택이다. 혼재 버전 상태 공간이 유계(bounded)라는 뜻이기 때문이다. 임의의 버전 쌍이 아니라 인접 버전 간 호환성만 추론하면 된다. 프로세스들이 여전히 첫 번째 버전을 실행 중일 때 세 번째 버전을 로드하려 하면, BEAM은 그 프로세스들을 죽인다. 엄격하지만, 문제를 다룰 만하게 만든다. 구글의 F1도 스키마 마이그레이션에서 독립적으로 같은 제약에 도달했다. 대부분의 현대 배포 시스템은 이름을 붙이지 않은 채 이를 재발견했다. 다음 배포를 시작하기 전에 롤링 배포를 끝내면, 거의 우연처럼 두 버전 창이 생기는데, 아마도 생각하지 않아도 얻는 가장 좋은 종류의 안전 성질일 것이다.3
여러 버전이 동시에 실행되는 것이 정상이라면, 코드와 데이터의 관계가 이 상황을 진짜 위험하게 만든다.
코드는 비교적 쉽게 롤백할 수 있다. 오래된 아티팩트를 다시 배포하면 된다. 하지만 ALTER TABLE ADD COLUMN을 쉽게 롤백할 수는 없고, DROP COLUMN은 절대 쉽게 롤백할 수 없다. 데이터 계층은 래칫처럼 앞으로만 움직인다. 코드 계층은 양방향으로 움직이는 것처럼 보이지만, 롤백은 저장소의 어느 커밋에도 존재하지 않았던 조합(오래된 코드, 새로운 스키마)을 만든다. 아무도 그걸 컴파일하지 않았다. 아무도 테스트하지 않았다. 어떤 타입 체커도 그걸 본 적이 없다.
나는 “항상 앞으로(roll forward)”의 편을 드는 사람인데, 이 비대칭성 때문에 그렇다. 롤백은 안전망이 있는 느낌 을 주지만, 그 결과 상태는 검증한 적도 없고 아마 고려조차 하지 않았던 상태다. 문제를 수정해서 앞으로 나아가는 편이, 테스트되지 않은 구성으로 후퇴하는 것보다 낫다. 확장-수축(expand-and-contract) 패턴(새 컬럼을 nullable로 추가, 양쪽에 쓰는 코드 배포, 백필, 새 컬럼에서 읽는 코드 배포, 옛 컬럼 삭제)은 한 번의 변경 처럼 느껴지는 일을 최소 네 번의 배포로 수행해야 한다. 이는 단순한 레시피가 아니다. 의도적으로 구성하고 테스트한 상태에만 머무르겠다는 규율이다.
The Migration Ratchet Try rolling back after a schema change
CODE
v1
3 columns
↕ can roll back
reads/writes
SCHEMA
schema v1
id
name
→ forward only
initial state migrate + deploy v2 roll back code expand (safe)contract (safe)
코드와 스키마는 v1에서 정렬되어 있다.
연구 커뮤니티는 이 일반 문제에 대해, 의외의 방향에서 온 이름을 붙여 두었다. 동적 소프트웨어 업데이트(DSU, Dynamic Software Updating) 문헌은 런타임에서 프로그램 버전 간 전환의 안전성을 연구한다. Gupta, Jalote, Barua는 1996년에 업데이트 유효성의 일반 문제는 결정 불가능(undecidable) 하다고 증명했다. 모든 가능한 프로그램과 업데이트에 대해 전환이 안전한지 알려 주는 도구를 만들 수 없다는 뜻이다.4 냉정해지는 결과다. 그렇다고 포기하자는 의미는 아니다. 발전은 도메인 특화적이고 휴리스틱해야 한다는 뜻이고, 실제 도구인 Atlas의 마이그레이션 린터나 gh-ost가 하는 일이 바로 그것이다.
확장-수축 패턴 자체는, 데이터베이스 이론 커뮤니티에서 양방향 스키마 변환(bidirectional schema transformation) 이라고 부르는 것의 운영상 구현으로 드러난다.5 그 이론은 2017년에 출판되었다. 우리 대부분은 그 이전부터 수년간 손으로 해 왔고, 시행착오와 새벽 3시 사고 회고를 통해 같은 구조에 도달했다. 학계와 산업계가 서로를 모른 채 반대 방향에서 같은 답으로 수렴한 셈이다.
데이터베이스는 적어도 데이터를 제자리에서 마이그레이션할 수 있다. 메시지 큐는 더 인내심이 많다.
모든 큐가 같은 방식으로 인내심이 있는 것은 아니다. RabbitMQ와 Sidekiq는 보통 수 초~수 분 안에 메시지를 처리한다(때로는 모니터링 대시보드로 alt-tab하기도 전에). 버전 창은 좁고, 대략 롤링 배포의 기간이다. 컨슈머가 10분 동안 한 배포 뒤처진다면, 그게 당신의 호환성 의무 범위다. 이 시스템들이 관대한 이유는 메시지가 오래 머물지 않기 때문이다. 버전 문제는 존재하지만 배포 자체와 같은 “두 버전 창”으로 제한된다.
Kafka는 다른 동물이다. 30일 보존 정책(retention policy)을 가진 Kafka 토픽에는 30일치 배포의 메시지가 들어 있다. 매일 배포한다면 직렬화 포맷 30개 버전이 한 토픽에서 공존하는 것이다. 오늘 새로 뜬 컨슈머는 그 모든 것을 역직렬화할 수 있어야 한다. Kafka를 무한 보존의 이벤트 저장소로 쓰는 팀도 있는데, 그 경우 수년 전 메시지가 있을 수 있다. 저장소의 어떤 브랜치에도 더 이상 존재하지 않는 코드가 썼고, 회사에 더 이상 없는 엔지니어가 남긴 것이다. 메시지는 남는다. 매우 인내심이 많다.
여기서 F1/Erlang의 “두 버전 차이” 가정은 완전히 깨진다. 유계 버전 창이 아니라, 무계의 고고학적 기록을 다뤄야 한다. 시스템이 지금까지 사용한 모든 직렬화 포맷이 남아 있는 기록 말이다.
실용적 대응은 잘 알려져 있다. 강한 하위 호환성 보장을 가진 직렬화 포맷 사용(Protobuf의 와이어 포맷은 메이저 버전이 바뀌어도 안정적이도록 명시적으로 설계됨), 스키마 레지스트리를 통해 쓰기 시점에 호환성 강제, 얼마나 과거까지 호환해야 하는지 제한하는 명시적 보존 정책. 하지만 많은 팀은 토픽 보존을 저장 비용 결정으로만 여기고, 호환성 결정으로 보지 않는데, 이는 실수다. 보존 정책은 버전 호환성 정책이다. 30일 보존은 “모든 배포는 30일 전 직렬화 포맷과 호환되어야 한다”를 의미한다. 무한 보존은 “모든 배포는 지금까지 존재했던 모든 직렬화 포맷과 영원히 호환되어야 한다”를 의미한다. 이는 전혀 다른 엔지니어링 제약이며, 의도적으로 선택되어야 한다.
같은 문제는, 다른 버전의 코드가 다시 읽을 수도 있는 데이터를 쓰는 어디에서나 나타난다: S3 버킷, Redis 캐시 값, 예약된 잡 페이로드. 직렬화하는 곳이면 어디든, 미래 버전이 해석해야 하는 화석 기록을 남긴다. 문제는 화석이 얼마나 오래 남느냐뿐이다.
Message Queue Time Capsule Adjust retention to see the compatibility window
60 d ago now
↑ retention start
Retention:30d
v4
v5
v6
30일 보존 = 컨슈머가 처리해야 하는 스키마 버전 3개.
메시지 큐가 버전 타임캡슐이라면, 이벤트 소싱 시스템은 버전 문제를 끌어올려 1급 원리로 만들었다.
이벤트 소싱의 약속은 함수형 프로그래밍으로 사람들을 끌어들이는 직관과 닮아 있다. 애플리케이션 상태는 DB의 가변적인 것이 아니다. 순서가 있는 불변 이벤트 시퀀스에 대한 left fold의 결과다. 이벤트는 사실이다. 이미 일어났다. 프로젝션 함수로 재생(replay)해 어떤 뷰든 도출할 수 있다. 상태는 역사에 대한 순수 함수다.
이는 아름다운 아이디어고, 무서운 귀결이 있다: 당신이 지금까지 기록한 모든 이벤트는 현재 코드로 영원히 해석 가능해야 한다.
전통적인 시스템에서는 데이터를 제자리에서 마이그레이션할 수 있다. ALTER TABLE, 백필, 그리고 끝. 옛 표현은 사라진다. 이벤트 소싱 시스템에서는 옛 표현이 핵심 이다. 이벤트 로그는 정의상 append-only다. 2019년의 PaymentInitiated 이벤트를 2026년 스키마에 맞게 다시 쓸 수 없다. 그건 일어난 일을 거짓말하는 것이다. 로그의 불변성이 가치 제안 전체다. 즉, 시스템이 사용했던 모든 이벤트 스키마 버전이, 누가 기억 하든 말든 코드베이스의 영구적 의무가 된다.
표준 대응은 업캐스팅(upcasting) 이다. 읽기 시점에 옛 이벤트를 현재 스키마로 변환한다. 프로젝션이 v1 PaymentInitiated 이벤트를 만나면, 현재 코드가 처리할 수 있는 형태를 만들어 내는 업캐스터를 거친다. 이는 실무자들이 독립적으로 도달한 Cambria의 edit lenses와 닮아 있다. 동작하고, 동시에 조용히 의무가 누적되며 계속 커진다. 새 이벤트 스키마 버전마다 새 업캐스터가 필요하다. 업캐스터는 (v1→v2→v3)처럼 합성되지만, 체인은 길어지기만 한다. 5년쯤 지나면, 프로젝션 파이프라인이 새 이벤트를 처리하는 시간보다 옛 이벤트를 업캐스팅하는 시간이 더 길어질 수도 있다. 과거가 무거워진다. 현재가 그 무게를 짊어진다.
CQRS는 이를 더 심화시킨다. 커맨드-쿼리 책임 분리(CQRS)의 핵심은 쓰기 모델과 읽기 모델이 서로 다른 표현이며, 서로 다른 시점에, 잠재적으로 서로 다른 코드 버전에 의해 업데이트된다는 것이다. 배포 중에는 이 두 측이 항상 서로 다른 버전에 있고, 그렇게 설계되어 있다. 이는 기능이지만, 이벤트 스키마가 바뀌고 읽기 측이 전체 이력을 바탕으로 프로젝션을 재구축해야 하는 순간 문제가 된다. 그 이력에는 현재 팀보다 앞선 시대의 이벤트 포맷도 포함된다.
프로젝션 재구축(rebuild)은 진실의 순간이다. “그냥 이벤트를 재생하면 된다”는 말은 이벤트 소싱의 “그냥 테스트를 돌리면 된다”에 해당한다. 원칙적으로는 맞지만, 다른 모든 것이 정돈되어 있다는 조건부 진실이다. 프로젝션 함수가 지금까지 존재했던 모든 이벤트 스키마를 처리할 수 없다면(스키마 레지스트리를 도입하기 전, 네이밍 컨벤션이 표준화되기 전, 누군가 amount가 센트가 아니라 달러라고 결정하기 전, 그 결정을 내린 엔지니어가 FAANG으로 떠나며 맥락도 함께 가져가 버리기 전) 재구축은 실패한다. 그리고 이론적으로 재구축 가능했던 읽기 모델이 실제로는 재구축 가능하지 않다는 사실을 알게 된다.
더 깊은 문제가 있다. 애그리게잇의 동작이 바뀌면(새 비즈니스 규칙, 다른 상태 전이, 변경된 검증 로직) 옛 규칙 아래 이벤트를 재생해 만들어진 모든 애그리게잇이 의심스러워진다. 이벤트는 일어난 일에 대한 사실이지만, 그 이벤트에 부여한 의미는 그것을 처리한 코드의 함수였다. 옛 비즈니스 규칙에서는 유효했던 PaymentAuthorized 이벤트가, 새 규칙에서는 거부했을 상태 전이를 의미할 수 있다. 이벤트는 바뀌지 않았다. 하지만 그것이 의미하는 것 이 바뀌었다. 이는 가장 중대한 형태의 의미(semantic) 드리프트이며, 곧 다시 돌아오겠다.
이 모든 것이 이벤트 소싱이 틀렸다는 뜻은 아니다. 감사(audit)가 중요한 도메인, 금융 시스템, 그리고 “현재 무엇인가”만큼이나 “무슨 일이 있었나”가 중요한 애플리케이션에서는 여전히 최고의 아키텍처 패턴 중 하나다. 다만 당신이 무엇에 서명하고 있는지 이해할 가치가 있다. 시스템이 만들어 낼 모든 스키마 버전과 영구적이고 되돌릴 수 없는 계약을 맺는 것이며, 그 의무는 앞으로 무한히 뻗어 간다. 버전 호환성 문제는 이벤트 소싱 시스템에 발생하는 것이 아니다. 그것이 바로 시스템 이다.
The Upcaster Chain Every schema version is permanent
(명확히 하자면: 나는 Temporal 워크플로 오케스트레이터나 Temporal JavaScript API가 아니라 시간(temporal) 데이터베이스 를 말하고 있다. 이름이 혼란스럽게 같지만 전혀 다른 것이다.)
이벤트 소싱이 “무슨 일이 있었는지 알아야 한다”에 대한 애플리케이션 레벨 응답이라면, 시간 데이터베이스는 데이터 레벨 응답이다. 양시간(bitemporal) 데이터베이스는 답이 계속 바뀌는 이유 까지 추적할 만큼 문제를 진지하게 받아들이는 응답이다.
전통적 데이터베이스는 현재 상태만 준다. 로우를 UPDATE하면 이전 값은 사라진다. SQL:2011은 두 종류의 시간 테이블을 표준화했다. 시스템 버전(system-versioned) 테이블(각 로우 버전이 저장된 시점을 자동 기록하여, 임의의 과거 시점을 질의할 수 있게 함)과 애플리케이션 시간 기간(application-time period) 테이블(현실 세계에서 사실이 참이었던 시점을 추적). 이 둘은 정말 다른 질문이다. 시스템 시간은 “시각 T에 DB는 무엇을 담고 있었나?”이고, 애플리케이션 시간은 “기간 P 동안 현실에서 무엇이 참이었나?”다.
양시간 데이터베이스는 두 축을 동시에 추적한다: 유효 시간(valid time)(현실에서 사실이 참이었던 때)과 트랜잭션 시간(transaction time)(시스템이 그것을 기록한 때). 이 구분은 정정(correction)이 늦게 들어올 때 특히 중요하다. 예를 들어 2월 5일에, 직원의 주소가 사실은 1월 15일에 바뀌었음을 알게 되었지만 오늘까지 옛 주소가 기록되어 있었다고 하자. 양시간 테이블은 둘 다 묻도록 해 준다. “1월 20일에 우리는 주소가 무엇이라고 믿었나 ?”(옛 주소; 아직 변경을 몰랐음)와 “1월 20일에 주소는 실제로 무엇이었나?”(새 주소; 변경은 이미 일어났음). 금융 보고, 보험, 의료, 규제 준수에서는 “무엇이 참이었나”와 “우리가 무엇을 알았나”의 차이가 법적 결과를 갖는다.
이는 버전 문제와 직접 관련된다. db.asOf(lastTuesday)는 본질적으로 “지난주 화요일 배포의 코드가 보던 대로 DB를 달라”는 요청이기 때문이다. 데이터 계층에서의 버전 인지 질의다.
Datomic은 이 아이디어의 가장 순수한 표현이며, 우연이 아니게도 함수형 전통에서 나왔다. Rich Hickey는 영속(persistent) 자료구조의 동기를 주는 같은 통찰—과거는 불변이니 값으로 다루라—에 기반해 Datomic을 설계했다. 사실은 datom(entity, attribute, value, transaction, added?)이고, 데이터베이스는 시간에 따라 누적되는 datom의 집합이다. 업데이트는 없다. 새 사실을 주장(assert)하고, 옛 사실은 그 부정을 주장함으로써 철회(retract)한다. 속성은 추가되지만 제거되지 않는다. 컬럼을 드롭할 수 없다. 속성을 리네임할 수 없다. (옛 속성이 더 이상 쓰이지 않게 하려면 그저 더 이상 쓰지 않고, 속성 네이밍에 영원히 엄격한 규율을 적용해야 한다.) 이는 파괴적 스키마 변경을 허용하지 않음으로써 버전 문제의 한 부류를 제거한다. 항상-앞으로 철학을 데이터 모델 자체에 적용한 것이다.
XTDB(구 Crux)는 양시간성을 더 밀어붙여, 모든 문서에 유효 시간과 트랜잭션 시간을 1급으로 둔다. 과거에 유효한 시간 범위를 가진 레코드를 삽입할 수 있고(항상 알고 있었던 척하지 않으면서 역사 데이터를 정정), 트랜잭션 시간은 정정을 수행한 시점을 기록한다.
시간/양시간 데이터베이스가 올바르게 하는 일은, 진실의 버전(version-of-truth) 문제를 데이터 모델에서 명시적으로 만든다는 점이다. 하지만 반복되는 주제가 있다: 양시간 데이터베이스가 주는 것은 데이터에 대한 타임 트래블이지, 코드에 대한 타임 트래블이 아니다. 지난주 화요일 배포가 보던 모습대로 DB를 재구성할 수는 있다. 하지만 자동으로 지난주 화요일 코드를 그 위에서 실행할 수는 없다. 역사 데이터를 해석하는 질의 함수는 지금 실행 중인 버전이다. Datomic은 amount 속성이 시간에 따라 어떤 값을 담았는지 정확히 보여 줄 것이다. 하지만 그 값이 쓰일 당시 amount가 센트를 뜻했는지 달러를 뜻했는지는 알려 주지 않는다.
Bitemporal Database Click a cell, then try different queries
T 1
T 2
T 3
T 4
T 5
T 6
T 7
T 8
V 1
V 2
V 3
V 4
V 5
V 6
V 7
V 8
Transaction Time →
↓ Valid Time (when it was true)→ Transaction Time (when recorded)
no query as-of query as-known query
노란 점은 늦은 정정이다: T6에서 우리는 V3 값이 달랐음을 알게 되었다. 전통 DB라면 옛 값이 사라진다.
불변이며 질의 가능한 데이터 이력을 갖는 것은, 없는 것보다 확실히 낫다. 하지만 이것은 문제의 데이터 절반을 해결할 뿐이다. 데이터를 해석하는 함수 자체도 버전이 붙은 아티팩트이며 조용히 진화한다는 사실—코드 절반—은 DB만으로 해결되지 않는다.
이벤트 소싱 맥락에서 잠깐 언급했지만, 별도로 다룰 가치가 있다. 어떤 도구도 잡지 못하는 “버전 문제의 버전”이기 때문이다.
지금까지 설명한 모든 것(구조적 스키마 진화, 합 타입 변화, 직렬화 호환성)은 적어도 탐지 는 가능하다. 필드가 추가/삭제되거나, 생성자가 생기거나, 타입이 바뀐다. 스키마 비교 도구는 이런 것들을 볼 수 있다.
더 교활한 문제는 타입은 같지만 의미가 바뀌는 경우다.
예를 들어 트랜잭션 레코드에 amount라는 필드가 있고 타입은 Int라고 하자. 버전 1에서는 센트를 뜻한다. 버전 2에서는 누군가 달러를 뜻하게 하자고 결정한다. 스키마는 바뀌지 않았다. 타입도 바뀌지 않았다. 어떤 diff 도구, 스키마 레지스트리, 린터도 이걸 표시하지 못한다. 하지만 버전 경계를 넘는 모든 컨슈머는 조용히 잘못된 답을 낸다. 100배로 틀린 답은 회계사를 매우 불행하게 만들고, 감사인을 분노로 끓게 만드는 종류의 틀림이다.
이는 꾸며낸 예가 아니다. 의미 드리프트는 더 미묘한 형태로 끊임없이 일어난다. “사용자가 opt-in했다”에서 “사용자가 opt-out하지 않았다”로 의미가 바뀌는 boolean(값은 같지만 기본 가정이 다름). Completed가 “결제 캡처(captured)”를 뜻하다가 비즈니스 프로세스가 진화하면서 점차 “승인(authorized)”을 뜻하게 되는 상태 enum. 항상 UTC였던 타임스탬프가 어느 한 서비스에서 로컬 타임을 쓰기 시작하는 경우. (항상 그런 서비스가 하나 있다.) 스키마는 버전 간 동일하다. 데이터는 조용히, 파국적으로 호환되지 않는다.
Semantic Drift Toggle versions to see the silent failure
v1: Producer
type Transaction = {
amount :Int
-- cents
}
writes: amount = 4999
meaning: $49.99
v2: Producer
type Transaction = {
amount :Int
-- dollars
}
writes: amount = 4999
meaning: $4999.00
Schema diff: no changes detected ✓
v1 → v1 (same version)v2 → v1 consumer (cross-version)
v1 consumer reads v1 data: amount = 4999 cents → $49.99 → €42.49 ✓
어떤 타입 시스템도 이걸 잡지 못하고, 어떤 타입 시스템도 일반적으로 이걸 잡을 수 있다고 생각하지 않는다. 모든 필드의 의도된 의미를 표현(표현 방식이 아니라)으로 인코딩해야 하기 때문이다. Liquid Haskell은 {v : Amount | v > 0} 같은 정제(refinement) 타입으로 값의 부호는 잡을 수 있어도 단위는 못 잡는다. Idris나 Agda의 종속 타입은 원칙적으로 단위를 추적할 수 있지만, 누군가 증명 의무를 작성해야 하고, 그것도 단일 버전 내부에서만 가능하다.
도움이 되는 것은, 의미적 변경을 구조적 변경만큼 진지하게 대하는 태도다. 데이터 계약의 의미를 문서화하라. 단위를 인코딩하는 newtype을 쓰라(Int가 아니라 Cents vs Dollars). 필드의 해석 을 바꾼다면 스키마가 바뀌지 않더라도 마이그레이션으로 취급하라. 버전 경계에서는 그것이 마이그레이션이기 때문이다. 이 규율은 기술적이기만 한 것이 아니라 사회적·문서적이다. 형식 검증을 중시하는 커뮤니티에는 불편할 수 있지만, 코드의 두 버전이 필드가 의미하는 것 에 대해 불일치한다면 어떤 타입 수준 기계장치도 당신을 구해 주지 못한다.
타입 시스템에 불변식을 인코딩하는 데는 곡선이 있고, 많은 커뮤니티가 수확 체감이 시작되는 지점을 체계적으로 잘못 잡고 있다고 생각한다. 충분히 표현력 있는 타입 시스템을 가진 모든 언어는 자기만의 버전을 갖는다. Java의 제네릭 토끼굴, TypeScript의 조건부 타입 미로, Rust의 라이프타임 주석 덤불. FP 버전은, 비용이 눈에 띄기 전까지 그 곡선을 꽤 멀리 내려갈 수 있을 정도로 타입 시스템이 진짜로 강력하다는 점에서 독특하다.
곡선의 왼쪽에서는 수확이 엄청나다. CustomerId와 OrderId를 혼동하지 못하게 하는 newtype, 도메인 상태를 명시적으로 모델링하는 합 타입, 핵심 비즈니스 로직에서 불법 상태를 표현 불가능하게 만들기. 이런 것은 높은 가치의 작업이다. 어떤 언어에서든 누구에게든 나는 이것을 변호할 것이다.
하지만 어느 지점부터 수확은 떨어지고 비용은 계속 오른다. 인가 상태를 추적하는 팬텀 타입, 크기가 있는 벡터를 위한 타입 수준 자연수, 프로토콜 순서를 강제하는 GADT, 정교한 권한 추적을 하는 효과 시스템. 각각 실제 문제를 해결하는 실제 기법이다. 하지만 각각 컴파일 시간을 늘리고, 에러 메시지를 더 나쁘게 만들고(GHC의 소설 길이 타입 에러는 통과의례일 뿐 장점이 아니다), 코드를 자신 있게 수정할 수 있는 엔지니어 풀을 줄이며, 프로덕션 압박 아래에서 따라가기가 더 어렵게 만든다.
내가 집중하고 싶은 비용은, 잘 논의되지 않는 것이다: 가장 강제하고 싶은 불변식은 버전 간(inter-version) 성질인데, 타입 체커는 한 번에 한 버전만 본다. “이 시스템은 배포 경계를 우아하게 처리한다.” “이 직렬화 포맷은 전방 호환된다.” “이 마이그레이션은 구버전 코드가 여전히 트래픽을 서빙하는 동안 안전하게 실행할 수 있다.” 이런 것들은 두 코드 스냅샷 간 관계의 성질이다. 타입 체커는 한 스냅샷만 본다. 프로덕션은 여러 개를 실행한다.
가장 강력한 타입 시스템에서도 이 점은 유효하다. Liquid Haskell은 당신의 코드 안에서 파싱 후 PaymentStatus가 항상 유효함을 증명할 수 있다. 하지만 지난주 화요일 배포가 직렬화한 PaymentStatus가 오늘 코드에서 파싱될 수 있는지는 증명할 수 없다. 지난주 코드가 스코프에 없기 때문이다. 종속 타입은 직렬화기와 역직렬화기가 서로 역함수임을 증명할 수 있다. 즉, 단일 버전 코덱의 라운드트립 성질. 하지만 프로덕션 질문은 이번 버전의 역직렬화기가 지난 버전의 직렬화기에 대한 좌역(left inverse)인지 여부이고, 그러려면 두 버전을 동시에 스코프에 둬야 한다.
실용적 대응은 의외로 로우테크다. 두 버전을 스스로 스코프에 유지하라. 옛 스키마 정의를 새 스키마와 나란히 코드베이스에 복사해 둬라. 옛 포맷에서 새 포맷으로의 명시적 파서를 작성하라. 테스트하라. 화려하지 않지만, 지금 프로덕션에서 실제로 실행 중인 것에 대해 검사 가능하다는 장점이 있다. 타입 체커는 v1→v2 마이그레이션 함수가 전체(total)이며 타입이 맞음을 검증할 수 있다. 다만 두 타입을 제공해 주기만 하면 된다. 이걸 잘하는 팀 대부분은 방법론이 아니라 고통스러운 경험을 통해 도달하고, 시간이 지나며 조용히 커지는 legacy/ 또는 compat/ 모듈을 갖게 된다.
Type Invariants at the Process Boundary The wire doesn't carry your types
여기서 Ink & Switch의 Cambria 프로젝트가 흥미로운 대안 모델을 제시한다.6 Cambria는 타입으로 컴파일 타임에 버전 호환성을 강제하려 하기보다, 런타임에 버전 간 데이터를 변환하는 edit lenses(합성 가능하고 되돌릴 수 있는 스키마 변환)를 사용한다. 버전 A가 버전 B로 어떻게 매핑되는지 정의하면, Cambria는 A→B와 B→C를 합성해 A→C를 만든다. 호환성은 단일 버전의 타입이 아니라 버전 간 변환 의 성질이다. 이는 근본적으로 다른 사고 방식이며, FP의 이론 전통(렌즈 및 옵틱스 문헌7)이 만드는 데 기여한 방식이기도 하다.
Alexis King의 “Parse, Don’t Validate”는 최근 FP 담론에서 가장 영향력 있는 아이디어 중 하나이며, 그럴 만하다. 런타임에 데이터를 검사하고 사용 전에 검사되길 바라기보다, 비구조적 입력을 불변식을 증명 하는 구조화된 타입으로 파싱하라. 경계에서 한 번만 검사하고, 타입이 보장을 앞으로 운반하게 하라.
이는 옳다. 하지만 불완전하다. 하나의 경계만 고려하기 때문이다. 단일 버전 안에서 프로그램의 가장자리.
프로덕션에서는 데이터가 더 어려운 경계를 끊임없이 넘나든다. 버전 간 경계다. 배포 N이 직렬화한 메시지를 배포 N+1이 역직렬화한다. 생성자가 3개인 코드가 쓴 DB 로우를 생성자가 4개인 코드가 읽는다. 오늘의 스키마가 만든 GraphQL 응답을 지난달 코드가 실행 중인 모바일 클라이언트가 소비한다. 이 경계에서는, 더 이상 코드베이스에 존재하지 않을 수도 있는 누군가의 타입에 따라 구조화된 데이터를 파싱하게 된다.
“파싱, 검증하지 말기”의 규율은 이 경계로 확장되어야 한다. 일부 도메인에서는 이미 그렇게 하고 있다.
스키마 레지스트리는 버전 경계에 적용된 “파싱, 검증하지 말기”다. Confluent 모델에서 Kafka 토픽의 모든 메시지는 스키마 ID로 태그된다. 프로듀서는 쓰기 전에 스키마를 등록한다. 컨슈머는 자기 스키마와 writer 스키마 둘 다를 가져오고, 역직렬화기는 두 스키마를 사용해 데이터를 파싱한다. 누락 필드는 기본값으로 해결하고, 모르는 필드는 건너뛰고, 호환되지 않는 변경은 크게 실패한다. 역직렬화한 뒤 데이터가 그럴듯한지 확인하는 게 아니다. 스키마 쌍을 통해 파싱 하며, 파싱 자체가 호환성을 보장한다. 검사는 모든 컨슈머에 흩어지는 것이 아니라, 스키마 등록 시점에 한 번 일어난다.
GraphQL은 이를 더 나아가게 한다. 스키마가 곧 API 계약이며, 설계상 introspectable하다. Apollo GraphOS 같은 도구는 스키마에 대해 operations checks를 수행한다. 배포 전에, 프로덕션 트래픽에서 수집한 실제 클라이언트 쿼리와 비교해 어떤 클라이언트가 깨질지 정확히 알려 준다.8 오픈소스 스키마 레지스트리인 GraphQL Hive는 페더레이션 스키마의 조합(composition) 검사를 수행하고, 수집된 operation에 실제로 등장하는 경우에만 필드 제거를 파괴적(breaking) 변경으로 표시하는 조건부 파괴적 변경 탐지를 할 수 있다. GraphQL Inspector는 두 스키마 버전을 diff하고, 각 변경을 breaking/dangerous/safe로 분류한다.
이것이 “파싱, 검증하지 말기”의 버전 경계판이다. 스키마 변경이 하위 호환인지 배포 후에 기도하는 대신, 배포 전에 증명한다. 스키마 diff가 파싱이다. 호환성 체크가 타입 체크다. 레지스트리가 타입 시스템이다.
이 접근의 한계를 솔직히 말할 가치가 있다. 최근 10,000개의 operation에 대한 체크는 지금 활성인 클라이언트에 대해서만 말해 준다. 18개월 전 모바일 앱 버전처럼 업데이트하지 않은 사용자가 다음 주 화요일 깨어나 최근 트래픽에 전혀 없는 형태의 요청을 보내는 경우는 알려주지 못한다. 1년 보존 토픽에 6개월 동안 얌전히 있던 Kafka 메시지를, 당신의 반짝이는 새 코드가 소비할 때도 알려주지 못한다. 이런 도구들은 휴리스틱이고, 좋은 휴리스틱이지만 휴리스틱이다. 흔한 케이스는 잘 커버하고 롱테일은 전혀 못 한다. 롱테일을 위해서는 여전히 명시적인 버전 인지 파싱과, 아무도 참조하지 않는 것이 확실하지 않은 필드를 절대 제거하지 않는 규율이 필요하다.
같은 패턴은 Buf를 통한 Protobuf(4단계 엄격성에서 53개 규칙을 강제), 스키마 레지스트리의 7가지 호환성 모드를 통한 Avro, 그리고 Atlas 같은 도구를 통한 DB 스키마(실행 전에 파괴적 변경을 SQL 마이그레이션에서 린팅)에서도 나타난다. 이 도구들은 모두 같은 일을 한다. 런타임(사고로 드러남)에서의 호환성 체크를 빌드 타임(CI 실패로 드러남)으로 옮기는 것이다. 이는 King이 말한 “검증→파싱”의 이행을 한 단계 위로 적용한 것이다.
Schema Registry Pick a change, pick a compatibility mode
PROPOSED SCHEMA CHANGE
add optional field remove field add enum value
COMPATIBILITY MODE
BACKWARD FORWARD FULL
New schema can read old data (upgrade consumers first)
Select a schema change to see the compatibility check.
검사는 등록 시점에 한 번 일어난다. 모든 컨슈머 곳곳에 흩어져 있지 않다.
스키마 레지스트리 패턴은 중요한 사실을 드러낸다: 지금 어떤 버전이 실행 중인지 식별할 수 있고, 인접 버전 간 호환성을 단언할 수 있다면, 통일 이론을 기다리지 않고도 실질적으로 멀티 버전 정확성을 달성할 수 있다.
필요한 것을 생각해 보자.
첫째, 모든 경계 아티팩트(직렬화된 메시지, API 응답, DB 마이그레이션)는 버전 메타데이터로 태그되어야 한다. 스키마 레지스트리는 직렬화 포맷에 대해 이를 한다. DB 마이그레이션 도구는 스키마에 대해 이를 한다(마이그레이션 히스토리 자체가 버전 로그다). API 게이트웨이는 HTTP 트래픽에 태그를 붙일 수 있다. 대부분의 시스템은 이미 이 정보를 갖고 있다. 다만 연결하지 않을 뿐이다.
둘째, 호환성 함수가 필요하다. 어떤 경계 아티팩트의 버전 A와 버전 B가 주어졌을 때 호환되는가? Confluent 스키마 레지스트리가 Avro/Protobuf에 대해 계산하는 것, GraphQL Inspector가 GraphQL 스키마에 대해 계산하는 것, Buf가 Protobuf 정의에 대해 계산하는 것, Atlas가 SQL 마이그레이션에 대해 계산하는 것이 바로 이것이다. 각 도구는 한 종류의 경계를 커버한다.
셋째(대부분 팀이 놓치는 부분), 실제로 무엇이 실행 중인지 알아야 한다. 웹 서버, 백그라운드 워커, 크론 잡에 어떤 코드 버전이 배포되어 있나? DB는 어떤 스키마 버전인가? Kafka 토픽에는 어떤 메시지 스키마가 비행 중(in-flight)인가?
여기서 서비스 메시(service mesh)가 마케팅을 넘는 이유로 흥미로워진다. Istio와 Linkerd는 서비스 사이에 앉아 모든 요청을 관찰한다. 어떤 서비스 버전이 어떤 파드에서 실행되는지 안다. 카나리 배포를 위해 버전별로 트래픽을 분할하고, 헤더 기반 라우팅을 하고, 선언된 호환 버전 사이로만 트래픽이 흐르게 강제할 수 있다. Argo Rollouts나 Flagger 같은 점진적 전달(progressive delivery) 도구(에러율이 치솟으면 자동 롤백)와 결합하면, 버전 비호환성을 네트워크 계층에서 감지·완화하는 피드백 루프를 얻는다.
제한은 서비스 메시가 서비스 간 HTTP/gRPC 경계만 본다는 점이다. DB, 메시지 큐, 크론 잡은 보지 못한다. 버전 인벤토리의 한 조각이지 전체 그림은 아니다. 하지만 “무슨 버전이 실행 중인가”를 런타임 시스템의 1급 개념으로 다루는 것이 실용적임을 보여 주고, 우리가 가진 것 중 가장 근접한 예다.
세 가지(버전 태그, 호환성 함수, 런타임 버전 인벤토리)가 모두 있으면, 매 배포 전에 실제로 중요한 질문에 답할 수 있다: “내가 지금 배포하려는 버전은 현재 실행 중인 모든 버전과 호환되는가?” “컴파일되나?”도, “테스트가 통과하나?”도 아니다. “지금 실제로 존재하는 배포들의 집합이 주어졌을 때, 이것을 추가하는 게 안전한가?”다.
아직 모든 경계 타입을 동시에 아우르는 통합 도구는 없다. (이걸 읽으며 “스타트업 각이네”라고 생각했다면, 제발 그렇게 하라.) 하지만 구성요소는 존재한다. 오케스트레이터에 실행 중인 이미지 태그를 질의하고, 마이그레이션 히스토리를 스키마 레지스트리와 대조하고, GraphQL 스키마를 수집된 클라이언트 operation과 diff하고, Buf 호환성 체크를 돌리는 배포 파이프라인은 오늘날에도 만들 수 있다. 이는 연구가 아니라 엔지니어링 작업이다.
Deploy Compatibility Check Run a deploy check against the ensemble
CURRENTLY RUNNING
Web Servers
v42
Workers
v41
Database
migration #187
Kafka Topics
3 schema versions
GraphQL Schema
hash abc123
deploy v42→v43 (safe)deploy v42→v43 (unsafe)
"컴파일되나?"도 아니고 "테스트가 통과하나?"도 아니다. "지금 실행 중인 것들을 기준으로, 이게 안전한가?"
구성(compositional) 관점에서 내가 이걸 흥미롭게 보는 이유는, 배포의 호환성은 각 경계에 대한 호환성의 논리곱(conjunction)이기 때문이다. 각 경계는 고유의 호환성 함수를 가진다. 전체는 독립된 체크들의 곱(product)이다. 이는 FP 훈련을 받은 두뇌가 구조를 식별하고 활용하는 데 강점이 있는 바로 그 종류다. 우리는 타입 체커에서 눈을 떼고 그것을 보기만 하면 된다.
지금까지는 멀티 버전 공존을 관리해야 할 문제로 다뤘다. 몇몇 프로젝트는 더 급진적인 질문을 던졌다. 아예 문제가 아니게 만들 수는 없을까?
Unison은 가장 원칙적인 접근을 한다. 코드는 이름이 아니라 AST 해시로 저장된다. 함수를 바꾸면 새 해시를 만들고, 옛 해시는 여전히 존재하며, 옛 정의를 가리키며, 여전히 동작한다. 옛 버전에 의존하는 코드들은 당신이 업데이트를 명시적으로 전파하기 전까지 옛 것을 계속 쓴다. 전통적인 의미의 배포가 없다. 옛 함수와 새 함수는 내용 주소 지정(content-addressed) 저장소에서 서로 다른 값이기 때문에, 구성상 공존한다.
이는 내가 설명한 문제를 놀라울 정도로 많이 제거한다. 롤링 배포가 혼재 버전 불일치를 만들 수 없는데, 롤링할 것이 없기 때문이다. 각 호출자는 자신이 빌드될 때 기준이었던 정확한 해시에 고정된다. 버전 호환성 질문은 “이 호출자가 새 해시를 참조하도록 업데이트되었는가?”가 되고, 이는 시간 조정 문제가 아니라 그래프 도달성(reachability) 문제가 된다.
Dark는 인프라 측면에서 유사한 아이디어를 추구했다. 에디터와 런타임이 통합되어, 비행 중(in-flight)이던 옛 HTTP 요청은 도착 당시 존재하던 코드 버전에서 계속 실행되었다. Dhall은 더 좁지만 중요한 조각(설정)에서, 전체성(totality)과 내용 주소 지정을 통해 설정 버전 간의 진정한 동등성 체크를 제공한다. 세 경우 모두 패턴은 같다: 코드(또는 설정)를 불변이고 내용 주소 지정 가능하게 만들어, “새 버전 배포”가 옛 버전을 파괴하거나 변경하지 않게 하라. 버전 공존은 경쟁 조건이 아니라 자료구조가 된다.
이에는 플라톤적 의미에서 정답 처럼 느껴지는 유혹이 있다. 하지만 만능열쇠는 아니다.
가장 근본적인 한계는 이 글 전체를 괴롭히는 것이다: 의미 드리프트는 해시를 신경 쓰지 않는다. amount * exchangeRate를 계산하는 내용 주소 지정 함수는 같은 AST 해시를 영원히 유지한다. amount의 비즈니스 의미가 센트에서 달러로 바뀌면, 함수는 구조적으로 동일하고 의미적으로는 틀리다. 내용 주소 지정은 코드 의 참조 무결성을 보장한다. 의미 의 참조 무결성은 보장하지 않는다. 조용한 대재앙은 의미에서 일어난다.
더 실용적으로: 코드는 불변일지 몰라도 데이터베이스는 아니다. 특정 해시에 고정된 Unison 함수도 다른 모든 버전과 같은 Postgres, 같은 Kafka 토픽, 같은 Redis 클러스터를 읽고 쓴다. 옛 해시에 고정된 옛 코드는, 스키마가 그 밑에서 마이그레이션된 DB에 대해 충실히 실행될 것이다. 내용 주소 지정은 순수 계산의 버전 문제는 해결하지만 부수효과에는 아무것도 하지 못한다. 그리고 프로덕션 시스템은 불편하게도 대부분이 부수효과다.
내용 주소 지정은 해결하기보다 감추는 일관성(coherence) 문제도 있다. 서비스 A가 공유 라이브러리의 hash-X에, 서비스 B가 hash-Y에 고정되어 있고, 두 해시가 서로 다른 메시지 와이어 포맷 가정을 내장하고 있다면, 전과 같은 버전 비호환성이 있다. 다만 각 서비스가 내부적으로는 일관되기 때문에 더 보기 어려울 뿐이다. 불일치는 해시 사이 공간, 데이터가 무엇을 의미하는지에 대한 암묵 계약에 있다.
Content-Addressed Code Old hashes persist. Side effects don't.
이런 프로젝트들 중 주류 채택에 성공한 것은 없다. 이유는 기술적이기도 하고 중력적이기도 하다. 기존 도구/라이브러리/배포 인프라/채용 파이프라인(아직 Unison 경험을 요구하는 구인 공고를 찾기란…)은 코드가 파일에 있고, 아티팩트로 컴파일되며, 새 아티팩트로 옛 아티팩트를 교체함으로써 배포된다는 가정을 한다. 하지만 아이디어는 계속 되살아난다. Nix와 Guix는 패키지에 내용 주소 지정을 사용한다. Docker 이미지 레이어는 내용 주소 지정이다. Git도 내용 주소 지정 저장소다. “불변이고 주소 지정 가능한 값이, 가변 이름보다 추론하기 쉽다”는 통찰은 함수형 프로그래머에게 매우 익숙해야 한다. 결국 이것은 순수 함수에 대한 논증이기 때문이다. 우리는 배포 아티팩트 자체에는 이를 일관되게 적용하지 못했고, 적용하려 하면 어려운 부분이 코드가 아니었다는 사실을 깨닫는다. 코드가 닿는 모든 것이 어려웠다.
좌절스럽고 동시에 희망적인 점이 있다. 내가 묘사한 문제들에 대한 지적 도구 상자는 이미 존재한다. 다만 서로 충분히 대화하지 않는 커뮤니티에 흩어져 있거나, 어떤 이유로든 지식/툴링을 공유하지 않는 거대 기업 안에 사일로로 갇혀 있다.
“유효한 (코드 버전, 스키마 버전, 데이터 포맷 버전) 튜플 공간은 무엇이고, 이 공간의 어떤 지점에서도 항상 안전한 상태로 도달할 수 있는가?”라는 질문은 구성적 질문이다. 대수적 구조, 불변식, 법칙을 만족하는 변환에 대한 질문이다. 가장 유망한 연결고리들을 간단히 스케치해 보자.
점진적 타입(gradual typing)을 버전 호환성의 모델로. 대응은 놀랄 정도로 깊다. Siek와 Taha의 consistency 관계 (2006)는 반사적(reflexive)이고 대칭(symmetric)이지만 추이적(transitive)이지 않은데, 이는 서로 다른 정밀도의 타입 간 호환성을 모델링하는 방식이 버전 타입 간 호환성과 정확히 닮아 있다. Max New와 Amal Ahmed는 서로 다른 정밀도의 타입 사이 캐스트가 embedding-projection pair를 이룬다는 것을 보였다. 더 정밀한 타입에서 덜 정밀한 타입으로 갔다가 다시 돌아오면 항등(identity)이라는 뜻이다.9 이는 버전 마이그레이션의 수학적 구조다. 데이터를 더 일반적인 스키마로 widen하고 다시 narrow해도 손실이 없어야 한다.
세션 타입(session types)을 API 버저닝으로.Session types는 통신 당사자 사이의 합법적인 메시지 시퀀스를 모델링하며, 서브타이핑 규칙은 API 진화와 거의 완벽히 매핑된다. 더 많은 요청 타입을 받아들이는 서버는 하위 호환이고, 더 적은 응답 변형만을 약속하는 서버는 안전하다. 최근 연구는 멀티파티 프로토콜에서 이 호환성 체크가 다항 시간에 결정 가능함을 보여 준다. 아직 실용 API 버저닝 도구는 이 이론을 쓰지 않는다(Buf의 53개 breaking change 규칙은 원칙적일 수 있는 부분이 임의적이다). 하지만 기반은 준비되어 있다.
다중 언어 의미론을 다중 버전 의미론으로. Amal Ahmed의 그룹은 서로 다른 타입 시스템을 가진 다른 언어의 컴포넌트로 구성된 프로그램을 연구해 왔다. Patterson과 Ahmed의 linking types (SNAPL 2017)는 다른 능력을 가진 컴포넌트와 링크될 수 있는 지점을 주석으로 표기할 수 있다. 눈을 가늘게 뜨고 보면, 서로 다른 코드 버전은 서로 다른 타입 시스템을 가진 다른 언어이며, 배포 경계는 외부 함수 인터페이스(FFI)다.10
Spivak의 범주론적 데이터 마이그레이션. David Spivak은 범주론을 사용해 스키마 진화를 형식화했다. 데이터베이스 스키마는 작은 범주(small category)이고, 인스턴스는 집합 값을 갖는 함자(functor)이며, 스키마 사상(morphism)은 세 개의 수반(adjoints) 데이터 마이그레이션 함자(Σ, Δ, Π)를 유도하며, 이들은 구성상 합성이 가능하다.11 스키마 마이그레이션은 범주를 이루고, 합성은 잘 정의됨이 보장된다. 배포를 가로지르는 마이그레이션 시퀀스를 추론할 때 원하는 정확한 성질이다.
새벽 3시에 배포가 실패하고 PagerDuty가 밤을 망치려 할 때, 이런 연구가 당신을 구해 주지는 못한다는 것을 안다. 하지만 언어와 무관하게 시스템을 만드는 방식을 어떻게 바꿔야 하는지에 대한 전환점을 가리킨다.
스냅샷이 아니라 앙상블을 위해 설계하라. 도메인 타입을 모델링할 때 “이 타입이 올바른가?”만 묻지 말고 “이 타입이 진화할 수 있는가?”를 묻자. 생성자를 추가하면 옛 컨슈머 역직렬화가 깨질까? 필드를 제거하면 이전 배포가 크래시할까? 단일 프로그램의 가장자리뿐 아니라 모든 버전 경계에서 “파싱, 검증하지 말기”를 적용하라.
경계를 명시적이고 기계적으로 검사 가능하게 만들라. 스키마를 등록하라. CI에서 GraphQL 타입을 diff하라. SQL 마이그레이션을 린트하라. 모든 경계 아티팩트(API 스키마, 메시지 포맷, DB 스키마, 워크플로 정의)는 버전 관리되고, 배포 파이프라인의 일부로 호환성 체크가 되어야 한다.12
“불순한 껍질(impure shell)”에 투자하라. 재시도, 타임아웃, 연결 관리, 서킷 브레이킹, 그레이스풀 셧다운, 에러 복구를 처리하는 부분이 시스템이 현실과 만나는 지점이다. 잘 구조화된 FP 애플리케이션에서 이 로직은 종종 순수 코어보다 덜 설계 관심을 받는 바깥 “불순” 레이어에 있다. 하지만 이 코드는 시스템이 버전 전환을 우아하게 처리하는지, 아니면 무너지는지를 결정한다. 도메인 모델링에 가져오는 것과 같은 엄격함을 받을 가치가 있다.
배포 시점 호환성 체크로 나아가라. 개별 도구는 존재한다. 마이그레이션 안전성을 위한 Atlas, Protobuf 호환성을 위한 Buf, 스키마 체크를 위한 Apollo GraphOS 또는 GraphQL Hive, 워크플로 버저닝을 위한 Temporal13, 라이브러리 API를 위한 cargo-semver-checks14. 부족한 것은 오케스트레이션이다. 실행 중인 것을 질의하고, 모든 경계에서 호환성을 체크하고, 예/아니오를 주는 파이프라인 단일 단계. 이는 오늘날 오프더셸프 부품으로 달성 가능하다.
당신의 모놀리식을 그것답게 대하라. 서버가 여러 대이고, 백그라운드 워커나 크론 잡, 서드파티 통합이 있다면(그리고 당신은 그렇다), 당신은 분산 시스템을 운영 중이다. 팀이 이 현실을 더 빨리 내면화할수록, 단일 일관 프로그램이라는 기분 좋은 허구가 아니라 런타임 환경의 현실을 반영하는 아키텍처 결정을 더 빨리 내리게 된다.
FP 커뮤니티는 수십 년 동안 프로그램을 놀라운 정밀도로 추론하기 위한 도구를 만들어 왔다. 그 작업은 매우 가치 있고 매혹적이며, 매일 나를 더 행복한 코더로 만든다. 모든 언어 커뮤니티는 우리가 국소적 정확성을 대하는 진지함을 배울 이득이 있다. 내가 말하는 것은 그것을 버리거나 가치를 깎아내리자는 게 아니다. 다만 많은 가장 어려운 문제들이 실제로 존재하는 레벨로 시선을 들어 올리고, 그 문제들 중 얼마나 많은 것이 프로그램을 어떤 언어로 썼는지와 무관하게 똑같이 보이는지, 솔직하게 알아차리자는 것이다.
빠진 것은 종합(synthesis)이다. 타입 정의, 직렬화 포맷, 마이그레이션 히스토리, 배포 토폴로지를 받아 특정 배포 시퀀스가 안전한지 알려 주는 통합 도구는 아직 없다. 공평하게 말하면, 문제의 폭을 생각할 때 그런 걸 정말 만들 수 있는지도 모르겠다.
하지만 이론이나 반짝이는 스타트업이 고쳐 주기를 기다릴 필요는 없다. “파싱, 검증하지 말기”는 올바른 직관을 주었다. 우리는 그것을 모든 버전 경계에 적용하기만 하면 된다. 스키마 호환성 체크, API 계약 diff, DB 마이그레이션 린트 도구는 이미 존재한다. 빠진 조각은 그것들을 배포 시스템과 연결해, 실제로 중요한 질문에 답하는 것이다. “지금 실행 중인 모든 것을 고려할 때, 이 배포는 안전한가?”
프로그램은 정확성의 단위가 아니다. 배포들의 집합이 단위다. 타입 체커의 관할권은 단일 아티팩트의 경계에서 끝나고, 프로덕션은 서로 다른 빈티지(vintage)의 아티팩트 앙상블이다. 각자는 자신이 컴파일될 때의 타입에 충실하고, 각자는 이웃과 충돌할 잠재력이 있다. 그 앙상블을 추론하기 위한 도구는 생각보다 가까이에 있다. 다만 당신이 손을 뻗어 오던 도구들이 아닐 뿐이다.
Rae 외, “Online, Asynchronous Schema Change in F1” (VLDB 2013). 이 논문은 인덱스 생성에 대한 4상태 전이(없음 → delete-only → write-only → public)를 정의하며, 인접한 각 쌍은 공존해도 안전하다. 이는 분산 시스템에서 멀티 버전 스키마 정확성에 대한 결정적 정형 모델로 남아 있다. ↩
Confluent의 스키마 레지스트리는 이를 7가지 호환성 모드로 형식화한다: BACKWARD, FORWARD, FULL 및 그 TRANSITIVE 변형. BACKWARD는 “컨슈머를 먼저 업그레이드할 수 있다”, FORWARD는 “프로듀서를 먼저 업그레이드할 수 있다”, FULL은 “어떤 순서로든 업그레이드 가능”을 의미한다. 이는 배포 순서 문제에 직접 매핑된다. ↩
Kubernetes는 인프라 측면에서 같은 통찰로 수렴 중이다. KEP-4330은 마이너 버전 롤백을 포함한 2단계 업그레이드를 위한 “emulated version”을 도입하고, KEP-4020은 업그레이드 중 안전한 API 라우팅을 위한 mixed-version 프록시를 추가한다. 하지만 둘 다 Erlang의 code_change/3처럼, 버전 간 상태 전이를 위한 명시적 프로그래머 작성 함수를 제공하지는 않는다. 그것은 어떤 플랫폼도 제공하지 못한 가장 솔직한 인터페이스로 남아 있다. ↩
Gupta, Jalote, Barua, “A Formal Framework for On-line Software Version Change” (IEEE TSE, 1996). 이후 Stoyle, Hicks 외는 DSU를 위한 타입 시스템 (TOPLAS 2007)을 개발해, 업데이트가 수용되면 결과 프로그램이 버전 경계를 가로질러 타입-정확함을 보장했다. Hayden 외는 (VSTTE 2012) 옛 버전과 새 버전을 결합한 “병합 프로그램(merged program)”을 통해 DSU 정확성의 첫 자동 검증을 달성했다. ↩
InVerDa 시스템 (Herrmann 외)은 이를 BiDEL로 정식화했다. PRISM 프레임워크의 스키마 수정 연산자를 양방향이며 관계적으로 완전(relationally complete)하게 확장한 양방향 DB 진화 언어다. 하나의 DB에서 여러 스키마 버전이 공존하되 모든 버전이 같은 데이터셋에 접근할 수 있게 하며, 문헌에서 멀티 버전 스키마 공존에 대한 가장 강한 형식 보장을 제공한다. ↩
Litt, van Hardenberg, Henry, “Cambria: Schema Evolution in Distributed Systems with Edit Lenses” (PaPoC 2021). 이 시스템은 Automerge CRDT와 통합되며, writer 스키마로 raw write를 저장하고 읽기 시점에 번역한다. ↩
Foster, Greenwald, Moore, Pierce, Schmitt의 렌즈에 대한 기념비적 연구 (POPL 2005, TOPLAS 2007)가 대수적 기반을 확립했다. Hofmann, Pierce, Wagner는 어느 방향도 특권을 갖지 않는 대칭 렌즈(symmetric lenses) (POPL 2011)로 이를 확장했고, 이는 양방향 버전 마이그레이션을 직접 모델링한다. ↩
Apollo의 operations checks는 최대 10,000개의 서로 다른 과거 operation에 대해 실행된다. GraphQL Hive와 GraphQL Inspector는 오픈소스 생태계에서 유사 기능을 제공하며, Hive는 버전 히스토리와 페더레이티드 그래프의 조합 검증을 갖춘 완전한 스키마 레지스트리를 제공한다. ↩
New, Ahmed, “Graduality from Embedding-Projection Pairs” (ICFP 2018). Wadler와 Findler의 blame tracking (ESOP 2009)은 Blame 정리를 통해 이를 확장한다. 서로 다른 정밀도의 타입 사이 캐스트에서 실패가 발생하면, 덜 정밀하게 타입된 부분에 책임을 귀속한다. 버전 경계에서 어느 쪽이 비호환을 유발했는지 식별하는 데 직접 적용 가능하다. ↩
Patterson, Ahmed의 의미론적 건전성 프레임워크 (PLDI 2022)는 서로 다른 언어의 타입 사이에 τ_A ∼ τ_B 같은 가환 관계(convertibility relation)를 두고, 변환을 구현하는 glue code를 사용한다. 교차 버전 타입 호환성에 직접 적용 가능하다. ↩
Spivak, “Functorial Data Migration” (2012). 범주론적 프레이밍은 마이그레이션 합성이 결합법칙을 만족하고 항등을 가진다는 것을 보장한다. 내가 현장에서 만난 대부분 마이그레이션 프레임워크에 대해선 그렇게 말하기 어렵다. ↩
Atlas는 40개 이상의 린트 규칙으로 자동 파괴적 변경 탐지를 수행하며, 임시 개발 DB에 변경을 시뮬레이션하는 데이터 의존 분석도 포함한다. ↩
Temporal은 멀티 버전 문제를 대부분의 오케스트레이션 시스템보다 더 진지하게 다뤘기 때문에 특별히 언급할 가치가 있다. 초기 메커니즘인 patched()는 워크플로 이벤트 히스토리에 마커를 삽입해 버전 인지 분기점으로 사용한다. 새 코드는 다른 경로를 택할 수 있고, 옛 실행은 원래 경로를 계속 간다. 동작하지만 확장성이 나쁘다. 비호환 변경마다 새 patch 호출이 필요하고 분기 로직이 누적된다. 더 흥미로운 접근은 Worker Versioning이다. 각 워커 배포에 Build ID를 부여하고, Temporal 서버가 워크플로 태스크를 올바른 워커 버전으로 라우팅하게 한다. 새 워크플로는 최신 빌드로 간다. 기존 워크플로는 시작한 빌드에서 계속된다. 이는 워크플로 레벨의 블루-그린 배포다. 구/신 워커 풀이 공존하며, 서버가 라우팅을 관리한다. 오래된 워커는 워크플로가 끝날 때까지 점진적으로 드레인할 수 있고, 장기 워크플로가 있다면 무기한 운영할 수도 있다. 버전 경계가 코드 내부의 함수별 patching이 아니라 플랫폼에서 명시적으로 관리된다. 그리고 Temporal이 하는 가장 흥미로운 일은, 버전이 불일치하면 크게 실패한다는 점이다. 새로운 워크플로 코드가 기존 실행의 이벤트 히스토리에 기록된 원래 실행과 다른 커맨드 시퀀스를 만들면, Temporal은 비결정성(nondeterminism) 에러를 발생시키고 계속 진행을 거부한다. 한편으로 워크플로가 멈추고 누군가의 호출기가 울린다. 다른 한편으로 시스템이 런타임에서 두 코드 버전이 호환되지 않음을 탐지 한 것이다. 대부분의 시스템은 이런 경우 조용히 틀린 답을 낸다. Temporal의 비결정성 에러는 어떤 의미에서 런타임 버전 호환성 단언이다. 이벤트 히스토리가 워크플로가 해야 했던 일의 명세이고, 새 코드는 그에 대해 검사된다. 발화하면 유쾌하지 않지만, 솔직하다. ↩
Predrag Gruevski는 cargo-semver-checks로 상위 1000개 Rust 크레이트 중 6분의 1이 세맨틱 버전을 최소 한 번 위반했음을 분석했다. Elm의 패키지 매니저는 릴리스 간 노출된 타입 시그니처를 비교하여 버전 분류를 자동화한 최초의 도구였다. ↩
© 2026 Ian Duncan. All rights reserved.