TECH 으로 돌아가기
TECH HACKER NEWS 어제 8분 읽기 62 READS

수렴만으로는 부족하다: Automerge로 살아있는 힙을 병합할 때의 함정

수렴만으로는 부족하다: Automerge로 살아있는 힙을 병합할 때의 함정
SOURCE IMAGE · HACKER NEWS

여러 사용자가 같은 데이터를 동시에, 심지어 오프라인 상태에서 고쳐도 나중에 자동으로 하나로 합쳐지는 협업 도구는 이제 흔하다. 그 배후에는 CRDT(충돌 없는 복제 데이터 타입) 계열 기술이 있고, 그중 Automerge는 대표적인 오픈소스 라이브러리다. Ink & Switch의 Livelymerge(LM) 프로젝트는 이 아이디어를 극단까지 밀어붙인다. Dan Ingalls, Peter van Hardenberg, Alex Warth가 참여한 이 프로젝트는 Lively Kernel 계열의 실행 시스템 전체—모든 객체, 클래스, 메서드가 담긴 힙(heap)—를 하나의 Automerge 문서로 만든다. 그러면 여러 사용자가 같은 객체 메모리를 공유하며 동시에 프로그래밍하고, 병합은 Automerge가 '공짜로' 처리해 준다는 구상이다. 이번 글은 그 구상이 부딪히는 근본적인 한계를 정면으로 다룬다.

수렴은 정답을 보장하지 않는다

Automerge가 약속하는 것은 '수렴(convergence)'이다. 두 클라이언트가 서로의 변경을 주고받으면 반드시 동일한 최종 상태에 도달한다. 문제는, 그렇게 도달한 상태가 프로그램이 감당할 수 있는 상태라는 보장은 없다는 점이다. 문서, 할 일 목록, 스케치처럼 흔한 응용에서는 Automerge의 병합이 대체로 우리가 원하는 대로 작동한다. 그러나 실행 중인 프로그램의 힙을 포인터까지 포함해 통째로 병합하는 것은 전혀 다른 이야기다.

글쓴이는 아주 단순한 예로 이를 보여준다. 각 노드가 next 프로퍼티로 다음 노드를 가리키는 1→2→3→4 연결 리스트가 있고, 두 클라이언트가 이를 동시에 수정한다. Automerge는 각 클라이언트의 변경을 하나의 트랜잭션으로 보고, 한쪽 트랜잭션의 쓰기를 모두 적용한 뒤 다른 쪽을 적용한 것처럼 병합한다. 두 순서 중 하나를 결정론적으로 골라 양쪽이 같은 결과를 얻는다. 어느 쪽도 건드리지 않은 프로퍼티는 살아남고, 둘 다 쓴 프로퍼티(예: 3.next)는 나중에 적용된 쪽이 이긴다. 결과적으로 양쪽은 완벽히 같은 상태로 수렴한다. Automerge는 잘못한 것이 없다.

의도가 아니라 결과를 재생한다

그런데도 리스트는 망가진다. 핵심은 병합이 '의도(intent)'가 아니라 '결과로 남은 쓰기(writes)'를 재생한다는 데 있다. 클라이언트 B가 '3과 4를 맞바꾼다'는 조작을 계산할 때 근거로 삼은 것은 원래의 1→2→3→4였다. 하지만 병합 이후에는 그 상태가 더 이상 존재하지 않는다. 만약 B의 조작이 A의 변경 뒤에 실제로 다시 실행되었다면 전혀 다른, 정상적인 쓰기를 만들어냈을 것이다. Automerge가 재생하는 것은 이미 컴파일된 낮은 수준의 쓰기일 뿐이고, '모든 노드는 정확히 한 번 나타난다, 순환이 없다, 리스트는 끝이 있다' 같은 프로그래머의 불변식은 Automerge가 볼 수 있는 어디에도 기록되어 있지 않다. 이것이 '수렴만으로는 부족하다'는 말의 뜻이다.

특정 사례는 피할 수 있다. next 포인터로 리스트를 직접 만들지 않고, 병합 의미가 잘 정의된 Automerge 내장 배열을 쓰면 동시 삽입·삭제가 기대대로 섞인다. LM의 그래픽 프레임워크 Morphic도 이 방식을 적극 활용한다. 화면의 모든 것이 다른 morph를 담을 수 있는 morph이고, 각 morph의 하위 목록은 Automerge 배열이라 두 사용자의 동시 추가가 자연스럽게 병합된다. 문제는 이런 타입들을 조합하는 순간 다시 무너진다는 것이다. 병합은 트랜잭션 단위로 순서를 정하되 그 안의 쓰기는 여전히 맹목적으로 재생하므로, 두 개 이상의 프로퍼티나 객체에 걸친 불변식은 전혀 보이지 않는다. 힙 자체가 문서이고 사용자가 원하는 자료구조를 자유롭게 만드는 LM에서 이는 예외적 상황이 아니라 상시적 위험이다.

조작을 병합한다는 아이디어

다만 저자들은 이 문제가 예상보다 덜 자주 터졌다고 말한다. 내장 타입에 최대한 기대고, 서로 어긋날 수 있는 중복 표현을 피하는 신중한 프로그래밍으로 실사용에서 꽤 견고한 시스템을 만들 수 있었다는 것이다. 물론 신중한 프로그래밍은 해법이 아니라 해법을 기다리는 동안의 임시방편이다. 자연스러운 방향은, 프로그래머가 실제로 관심을 두는 추상화 아래의 원시 객체 그래프가 아니라 '의도' 수준에서 병합하는 것이다. 여기서 의도란 '3.next를 null로 설정'도, 한 단계 위인 '3과 4를 맞바꿈'도 아니고, 추상 타입 수준의 언어—'이 값을 리스트에서 제거', '이 값을 저 값 뒤에 삽입'—를 뜻한다.

Automerge는 이미 내장 타입에 한해 정확히 이렇게 동작한다. 리스트 변경은 포인터 쓰기가 아니라 삽입·삭제 연산으로 기록되며, 그래서 동시 편집이 잘 병합된다. 여기서 나온 착상은, 프로그래머가 정의한 자료구조가 스스로를 list형·map형·counter형이라 선언하고 읽기·쓰기를 그 타입의 인터페이스로 통과시키면, 문서에는 낮은 수준의 쓰기 대신 타입의 고수준 어휘가 연산으로 기록되도록 하자는 것이다. 병합은 타입마다 한 번 정의된 의미에 따라 그 연산들을 합치는 일이 된다. Kleppmann 등이 복제 트리를 위해 만든 move 연산은 '재부모화가 절대 순환을 만들지 않는다'는 불변식을 병합에 직접 심어 이런 접근이 엄밀하게 가능함을 보여준다. 그 밑바탕 기법은 모든 클라이언트의 연산을 타임스탬프 같은 단일 결정론적 순서로 재생하면서 각 연산이 불변식을 어기는지 검사해 위반하는 것은 건너뛰는 방식으로, 놀랄 만큼 넓게 일반화된다.

이 영역을 파고드는 것은 LM만이 아니다. Martin Kleppmann, Vincent Liu, Owen Lynch 등이 만드는 Coln은 스키마·질의·마이그레이션을 위한 표현력 있는 언어를 갖춘 병합 가능 데이터베이스로, '이것은 이중 연결 리스트다' 같은 제약을 선언하게 한다. 그리고 위반에 단호한 입장을 취한다. 병합이 제약을 깨뜨린다면 병합 자체를 거부하고, 데이터를 제약을 만족하는 상태로 되돌리는 일은 사용자나 AI의 몫으로 남긴다. 실무자 입장에서 정리하면, CRDT는 만능 병합기가 아니다. 애플리케이션의 불변식을 병합 계층이 알 방법이 없다면 수렴은 조용히 깨진 상태로 수렴할 뿐이다. 당장 쓸 수 있는 지침은 두 가지다. 병합 의미가 검증된 내장 타입에 최대한 기대고, 서로 모순될 수 있는 중복 표현을 애초에 만들지 않는 것. 그 너머의 진짜 해법은 결과가 아니라 의도를 기록하고 병합하는 데 있으며, 아직은 열린 문제로 남아 있다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://www.inkandswitch.com/livelymerge/notebook/lm-02/
SHARE
처리 중...