데이터 지향 프로그래밍 3주차 - 동시성은 커밋에서, 테스트는 데이터로

지난 모임에서는 불변 데이터와 구조적 공유로 기존 상태를 보존하면서 하나의 다음 상태를 만드는 방법을 살펴봤습니다. 하지만 여러 요청이 같은 상태에서 서로 다른 다음 상태를 계산하면, 어느 결과를 현재 상태로 채택할지는 별도의 문제로 남습니다.

세 번째 모임은 이 남은 문제에서 출발해 5장의 동시성 제어와 6장의 단위 테스트로 이어졌습니다. 두 발표는 같은 5·6장을 다루되, 한쪽은 버전 조정과 원자적 커밋에, 다른 쪽은 Git·구조적 공유·함수 호출 트리에 무게를 두었습니다.

불변 상태 다음에 남은 문제는 커밋이었습니다

5장은 변경을 두 단계로 나눕니다. 계산 단계에서는 작업을 시작할 때 읽은 상태를 바탕으로 다음 상태를 만듭니다. 기존 값을 제자리에서 고치지 않으므로 이 계산은 다른 요청과 독립적으로 진행할 수 있습니다. 실제 시스템 상태가 바뀌는 곳은 계산 결과를 현재 버전으로 채택하는 커밋 단계입니다.

커밋 과정에서는 요청을 시작할 때 읽은 기준 상태, 그 상태로 계산한 후보 상태, 커밋 직전에 다시 읽은 최신 상태를 구분합니다. 요청을 받으면 시스템이 현재 가리키는 상태를 previous로 보관하고, 요청의 변경을 적용해 아직 반영되지 않은 커밋 후보 next를 계산합니다. 계산 중 다른 요청이 먼저 커밋될 수 있으므로, 내 요청을 커밋하기 직전에 시스템 상태를 다시 읽어 current로 삼습니다. previous와 current가 같으면 계산하는 사이에 다른 변경이 없었다는 뜻이므로 next를 바로 채택할 수 있습니다.

데이터 지향 프로그래밍 3주차 - 동시성은 커밋에서, 테스트는 데이터로
previous는 작업 시작 시 읽은 기준 상태, next는 그 상태에서 계산한 커밋 후보, current는 커밋 직전에 다시 확인한 최신 상태입니다.

문제는 previous와 current가 다를 때입니다. 다른 요청이 먼저 커밋했으므로 next를 그대로 현재 상태에 덮어쓸 수는 없습니다. 단순히 버전만 비교한다면 이 요청을 충돌로 처리하고 다시 시도합니다. 여기서는 한 단계 더 나아가, 먼저 반영된 변경과 내 변경이 실제로 같은 데이터 경로를 건드렸는지 비교합니다. 두 변경의 경로가 겹치지 않으면 함께 반영하고, 겹치면 충돌로 판단해 커밋을 중지합니다.

세 버전을 비교해 바로 반영할지, 병합할지, 중지할지 결정합니다

조정은 Git의 3방향 병합에 빗대면 이해하기 쉬웠습니다. previous를 공통 기준점으로 두고 previous → current와 previous → next의 차이를 각각 구합니다. 두 차이에 포함된 정보 경로가 겹치지 않으면 현재 상태에 내 변경을 더해 병합할 수 있습니다. 같은 경로가 양쪽에 나타나면 어느 값을 택해야 할지 기계적으로 정하기 어렵기 때문에 커밋을 중지하고 다시 시도하게 합니다.

데이터 지향 프로그래밍 3주차 - 동시성은 커밋에서, 테스트는 데이터로 - 이미지 2
두 변경의 정보 경로 집합이 겹치지 않으면 병합하고, 겹치면 구조적 충돌로 판단합니다.

Git의 3방향 병합은 세 버전의 관계를 이해하는 데 도움이 되지만, 애플리케이션의 충돌 판정과 병합 절차가 Git과 동일한 것은 아닙니다. Git은 파일의 줄이나 헝크를 비교하고 충돌을 사람이 해결할 수 있습니다. 실행 중인 애플리케이션은 사람을 기다리기 어렵기 때문에, 교재의 예제는 데이터 경로가 겹치면 예외를 던져 요청 쪽에서 재시도하도록 합니다.

더 중요한 한계도 있습니다. 이 방식이 찾는 것은 구조적 충돌입니다. 서로 다른 필드를 바꿨더라도 두 결과를 합친 상태가 대출 한도나 재고 수량 같은 도메인 규칙을 깨뜨릴 수 있습니다. 경로가 겹치지 않는다는 사실만으로 의미까지 안전하다고 결론 내릴 수는 없습니다. 이런 도메인 불변식을 어느 계층에서 확인해야 하는지는 함께 공부하면서 자연스럽게 떠오른 의문이었습니다.

구조적 공유는 비교할 필요가 없는 가지를 알려 줍니다

두 상태의 차이를 매번 루트부터 끝까지 읽는다면 조정 비용이 커질 수 있습니다. 여기서 지난주에 다룬 구조적 공유가 다시 등장했습니다. 리프 하나를 바꾸면 그 값에서 루트까지 이어지는 경로만 새로 만들고, 바뀌지 않은 하위 구조는 이전 버전과 같은 객체를 계속 가리킵니다.

데이터 지향 프로그래밍 3주차 - 동시성은 커밋에서, 테스트는 데이터로 - 이미지 3
굵은 경로는 새로 만들어진 객체이고, 가운데의 공유된 가지는 두 버전이 같은 참조를 사용합니다.

데이터가 불변이라는 규율이 지켜진다면 같은 참조는 같은 내용임을 뜻합니다. 두 상태의 차이를 찾는 diffObjects 함수는 이를 이용해, 두 하위 구조의 참조가 같으면 해당 가지의 비교를 멈춥니다. 반대로 참조가 다르다고 내용까지 반드시 다른 것은 아니므로, 참조 비교는 ‘같음’을 빠르게 확인하는 단축 경로로만 사용합니다. 참조가 다르면 실제 하위 값을 계속 비교해야 합니다.

이 최적화는 구조적 공유를 지원하거나 같은 규율을 구현한 자료구조가 있을 때 힘을 발휘합니다. 모든 언어의 기본 맵이 자동으로 영속 자료구조가 되는 것은 아닙니다. 어떤 자료구조를 쓸지, 변경 경로의 복사 비용이 어느 정도인지도 실제 환경에서 함께 따져야 합니다.

불변성만으로 커밋의 원자성이 생기지는 않습니다

불변 데이터는 각 요청이 읽은 상태를 안정된 스냅샷으로 보존하지만, 여러 요청의 계산 결과를 현재 상태로 반영하는 커밋의 원자성까지 보장하지는 않습니다. A와 B가 모두 v1을 읽어 각자의 다음 상태를 계산하고, B가 먼저 v2를 커밋했다고 가정해 봅니다. A가 현재 상태가 v2로 바뀌었는지 확인하지 않은 채 v1을 바탕으로 계산한 결과를 기록하면, B가 반영한 변경은 사라집니다.

데이터 지향 프로그래밍 3주차 - 동시성은 커밋에서, 테스트는 데이터로 - 이미지 4
상태 확인과 교체가 하나의 원자적 연산이 아니면, 불변 버전을 사용해도 Lost Update가 발생합니다.

따라서 현재 상태를 읽고, 필요하면 조정한 뒤, 새 참조로 교체하는 커밋 경계에는 원자성이 필요합니다. CAS(Compare-And-Swap)나 atom, 짧은 잠금처럼 “내가 확인한 상태가 아직 현재일 때만 교체한다”는 보장이 있어야 합니다. 읽기와 계산의 범위를 크게 잠그지 않는 것이 낙관적 접근의 장점이지, 어떤 조율도 필요 없다는 뜻은 아닙니다.

교재는 불변 데이터와 낙관적 동시성 제어를 결합한 전략을 제시합니다. 충돌이 드물고 읽기가 많은 환경에서는 잠금 대기 시간을 줄일 수 있지만, 같은 데이터를 자주 갱신하는 구간에서는 재시도와 버려지는 계산이 늘어납니다. 발제에서는 이런 구간에 비관적 제어가 나을 수 있다는 점을 들어, 커밋 전략을 상황별로 바꿀 수 있는지 질문을 남겼습니다.

데이터가 입출력이 되면 테스트의 준비가 가벼워집니다

6장에서는 시스템의 동작을 조회(query)와 변경(mutation)으로 나누어 테스트합니다. 이는 DOP에만 있는 특별한 함수 집합이 아니라, 함수가 맡은 역할을 설명하기 위한 구분입니다. 조회 함수는 시스템 상태와 요청을 입력받아 상태를 바꾸지 않고 응답을 계산합니다. 시스템 상태가 맵과 배열로 표현되므로, 데이터베이스나 서버 같은 실행 환경을 준비하지 않고도 필요한 입력 데이터를 손으로 만들어 반환값과 비교할 수 있습니다.

데이터 지향 프로그래밍 3주차 - 동시성은 커밋에서, 테스트는 데이터로 - 이미지 5
순수한 계산 영역에서는 객체의 행위를 감시하기보다 반환된 값을 비교할 수 있습니다.

이 설명을 “DOP에서는 목(mock) 객체가 필요 없다”로 받아들이면 지나칩니다. 데이터 변환을 맡은 순수 함수에는 협력 객체가 적어 목(mock) 객체를 사용할 이유가 줄어듭니다. 반면 데이터베이스, 외부 API, 파일 저장소처럼 I/O가 일어나는 경계는 여전히 통합 테스트나 테스트 대역이 필요합니다. 따라서 DOP는 목(mock) 객체를 완전히 없애기보다, 테스트 대역이 필요한 I/O 경계를 더 얇게 만드는 방식에 가깝습니다.

범용 데이터를 쓴다고 테스트가 구조 변화에서 자유로워지는 것도 아닙니다. 클래스 생성 절차와의 결합은 줄지만, 함수가 기대하는 필드명이나 데이터 계약이 바뀌면 테스트 데이터와 검증 내용도 함께 바뀝니다. 테스트 데이터의 품질과 경계의 스키마가 중요해지는 이유입니다.

조회 함수를 테스트할 때는 주어진 상태와 요청에 대해 기대한 응답이 반환되는지 직접 비교합니다. 변경 함수는 현재 상태와 변경 인자를 받아 다음 상태를 계산하는 함수입니다. 실제 커밋까지 한꺼번에 실행하지 않고 계산 단계인 (state, args) → nextState만 분리해 검증할 수 있습니다. 이때 바뀌어야 할 경로뿐 아니라 보존돼야 할 중요한 값과 도메인 불변 조건도 확인해야 합니다. 일부 경로만 단언하면 테스트가 덜 깨지는 대신 예상하지 못한 부수 변경을 놓칠 수 있으므로, 부분 단언과 전체 비교의 경계는 의도에 맞춰 정해야 합니다.

호출 트리는 테스트 조합을 책임별 사례로 바꿉니다

함수 호출 트리를 그리면 테스트의 초점도 달라집니다. 말단 함수는 작은 데이터로 한 가지 계산을 수행하므로 분기별 사례를 촘촘하게 확인하기 쉽습니다. 상위 함수는 더 큰 상태를 다루지만, 하위 계산이 이미 검증됐다는 전제 아래 자신이 추가한 책임에 집중할 수 있습니다.

데이터 지향 프로그래밍 3주차 - 동시성은 커밋에서, 테스트는 데이터로 - 이미지 6
장표의 24개와 9개는 일반 법칙이 아니라, 계층마다 고유 책임을 나눠 검증했을 때 조합 수가 줄어드는 예시입니다.

‘곱셈을 덧셈으로 바꾼다’는 설명이 이 전략을 가장 기억하기 쉽게 만들었습니다. 저자 매핑의 네 경우, 제목 검색의 세 경우, 권한 처리의 두 경우를 루트에서 모두 조합하면 24개가 되지만 각 계층의 고유 책임을 나눠 확인하면 9개로 설명할 수 있습니다. 실제 필요한 수는 코드와 위험에 따라 달라집니다. 핵심은 하위 경우를 상위 테스트에서 모두 다시 조합하지 않고, 각 계층이 추가한 책임을 나눠 검증하는 데 있습니다.

그렇다고 상위 테스트를 없앨 수는 없습니다. 상위 함수가 하위 함수에 잘못된 인자를 넘기거나 호출 순서를 틀리는 버그는 말단 테스트만으로 찾기 어렵습니다. 아래에서 사용한 작은 데이터를 바깥에서 한 겹씩 감싸 재사용하고, 상위에서는 연결과 조합에 관한 대표 사례를 남기는 균형이 필요합니다.

데이터 지향 프로그래밍 3주차 - 동시성은 커밋에서, 테스트는 데이터로 - 이미지 7
상위 노드는 단순 위임일 수도 있지만, 여러 하위 로직의 조건과 순서를 정하는 오케스트레이션 책임을 가질 수도 있습니다.

두 장은 복잡성을 경계로 모으는 같은 이야기였습니다

5장과 6장은 서로 다른 주제처럼 보였지만 같은 설계 방향을 가리켰습니다. 5장은 새 상태를 만드는 계산에서 동시성의 부담을 덜어 내고, 충돌 감지와 참조 교체를 커밋 경계에 모았습니다. 6장은 계산과 외부 효과를 나누는 같은 경계를 테스트로 이어 갔습니다. 입력 데이터로 결과를 계산하는 순수 함수 영역은 ‘함수형 핵심(functional core)’, 데이터베이스나 외부 API 같은 I/O를 수행하고 계산 결과를 실제 시스템에 반영하는 영역은 ‘명령형 외곽(imperative shell)’이라 부릅니다. 계산은 핵심에 모으고 I/O가 필요한 외곽은 얇게 둘수록, 대부분의 로직을 값 비교만으로 테스트하기 쉬워집니다.

두 장의 접근을 적용할 때도 확인해야 할 조건은 남습니다. 구조적 병합은 데이터 경로의 충돌을 판별할 뿐, 병합 결과가 도메인 규칙을 만족하는지까지 보장하지 않습니다. 커밋 과정에서는 현재 상태를 확인하고 새 참조로 교체하는 절차가 원자적으로 실행되어야 합니다. 범용 맵으로 테스트 데이터를 간결하게 만들 수 있지만, 필드의 유효성과 타입 안전성은 별도로 보완해야 합니다. 다음 모임에서는 데이터 스키마를 살펴보며 이러한 검증을 어디까지 보완할 수 있는지 다시 논의하려 합니다.

이 내용을 정리하며 다음 세 가지 질문이 남았습니다.

결국 이번 주의 핵심은 복잡성을 없애는 데 있지 않았습니다. 새 상태의 계산과 경쟁, 외부 효과가 뒤섞이지 않도록 각 책임이 머물 자리를 좁히는 일이었습니다. 다음 모임에서는 그 경계를 지키는 스키마를 이어서 살펴보려 합니다.

발표를 준비하고 질문을 이어 가며 세 번째 모임을 함께 만들어 주신 모든 분께 감사드립니다.



데이터 지향 프로그래밍 3주차 - 동시성은 커밋에서, 테스트는 데이터로 - 이미지 8



작성 참고