종이 회로 디버그 실험실: 인덱스 카드와 실로 동시성 버그를 시뮬레이션하기
인덱스 카드와 실로 만든 ‘종이 회로’ 실험실을 통해 동시성, 레이스 컨디션, 그리고 실제 세계에서의 대응 전략(락, 트랜잭션, 멱등 키, 레이트 리미팅 등)을 직관적이고 구체적으로 이해하는 방법을 소개합니다.
종이 회로 디버그 실험실: 인덱스 카드와 실로 동시성 버그를 시뮬레이션하기
동시성(concurrency) 버그는 악명 높게 잡기 어렵습니다. 아주 짧은 타이밍 창에만 나타나고, 특정 부하 상황에서만 재현되며, 로그를 추가하는 순간 사라져 버리기도 합니다. 많은 개발자는 직관적인 설명이 아닌, 난해한 버그 리포트나 실제 서비스 장애를 통해 처음 레이스 컨디션(race condition)을 접하곤 합니다.
종이 회로 디버그 실험실(Paper Circuit Debug Lab) 은 여기에 정면으로 도전합니다. 도구는 의외로 단순합니다. 바로 인덱스 카드와 실입니다. 스레드, 공유 상태, 스케줄링을 물리적인 시스템으로 바꿔서, 추상적인 동시성 개념을 눈에 보이고, 디버깅 가능하며 — 무엇보다 — 기억에 남도록 만듭니다.
이 글에서는 이 실험실이 무엇인지, 어떻게 협력형 멀티태스킹(cooperative multitasking) 을 모델링하는지, 어떻게 레이스 컨디션 을 드러내는지, 그리고 그 통찰이 락(locks), 트랜잭션(transactions), 멱등 키(idempotency keys), 레이트 리미팅(rate limiting) 같은 현실 세계의 전략과 어떻게 연결되는지 살펴봅니다. 또, 체계적인 동시성 버그 탐지와 더 나은 프로그래밍 모델을 강조하는 [LPSZ08] 같은 연구 흐름과도 연결해 봅니다.
왜 종이로 동시성을 시뮬레이션할까?
대부분의 동시성 버그는 한 가지 핵심 문제에서 비롯됩니다. 바로 머릿속에서 가능한 모든 인터리빙(interleaving)을 다 볼 수 없다는 점입니다. 단일 스레드만 가정하면 분명히 올바른 것처럼 보이는 코드가, 두 개 이상의 작업이 겹쳐서 실행되기 시작하면 무너져 버립니다.
종이 회로 디버그 실험실은 이를 다음과 같이 해결합니다.
- 프로그램을 외부로 꺼내어 물리적인 요소로 만든다
- 인덱스 카드는 코드 단계, 이벤트, 공유 리소스를 나타냅니다.
- 실은 데이터 흐름, 의존성, 또는 “누가 무엇을 쥐고 있는지”를 나타냅니다.
- 실행 순서를 명시적으로 드러낸다
- 참가자들이 카드를 한 단계씩 이동하며 코드를 따라갑니다.
- 단순한 스케줄링 규칙에 따라 차례로 턴을 가집니다.
- 팀이 인터리빙을 다시 재생하고 재배열할 수 있게 한다
- 어떤 스레드가 언제 움직이는지만 바꾸면, 전혀 다른 “역사”가 펼쳐집니다.
스케줄러를 머릿속으로 상상하는 대신, 여러분이 직접 스케줄러가 됩니다. 추상적인 문법 대신 손으로 만질 수 있는 물건을 다룹니다. 이 전환 덕분에 코드만 보고서는 얻기 어려운 직관이 열립니다.
카드로 협력형 멀티태스킹 모델링하기
이 실험실은 완전한 선점형(preemptive) 스레드 시스템이 아니라 협력형 멀티태스킹에 초점을 둡니다. 협력형 시스템에서는 태스크가 명시적으로 양보(yield) 해야만(예: I/O 대기, 타이머, 락 등) 다른 태스크가 실행됩니다.
이는 다음과 같은 많은 실시간 운영체제(RTOS) 및 임베디드 설계와 닮아 있습니다.
- 태스크는 거친(granular) 상태 머신 형태로 구성됩니다.
- 각 태스크는 잘 정의된 지점에서 자발적으로 스케줄러를 호출하거나 양보합니다.
- 어떤 태스크도 한 단계 중간에 강제로 끊기지 않습니다.
종이 모델이 동작하는 방식
간단한 실험실 구성은 다음과 같습니다.
- 각 스레드는 순서대로 놓인 한 줄의 인덱스 카드로 표현합니다(스토리보드처럼):
T1-Step1,T1-Step2,T1-Step3, …T2-Step1,T2-Step2,T2-Step3, …
- 공유 상태(예: 은행 잔고, 재고 수량)는 다음으로 표현합니다.
- 현재 값이 적힌 카드 한 장, 그리고
- 그 카드를 “쥐고” 있거나 읽고 있는 스레드에 연결된 선택적인 실
- “CPU”는 그냥 하나의 토큰입니다(동전, 마커, 특별한 카드 등).
- 이 토큰을 쥔 스레드만 “실행 중”입니다.
실행 규칙
- 스케줄러(퍼실리테이터 또는 참가자 그룹)가 CPU 토큰을 한 스레드에 건넵니다.
- 그 스레드는 다음 카드로 이동합니다.
- 단계가 계산만 하는 동작이라면, 그냥 다음 단계로 진행합니다.
- 단계가 이벤트 / I/O / 락 대기라면, 스레드는 반드시 양보(yield) 하며(즉, CPU 토큰을 반납), 대기를 표시합니다.
- 스케줄러는 실행 가능한 다른 스레드를 선택하고 이를 반복합니다.
카드 한 장은 이 모델에서 거친 의미의 원자적(atomic) 동작입니다. 카드 중간에서 스레드를 끊을 수는 없습니다.
이는 OS가 거의 모든 명령 사이에서 스레드를 끊을 수 있는 선점형 시스템보다 단순하게 설계된 것입니다. 하지만 이처럼 거친 단위만으로도, 스레드들이 상태를 공유하기 시작하면 미묘한 버그들이 그대로 드러납니다.
공유 자원과 인터리빙 시각화하기
이 실험실이 진가를 발휘하는 순간은 공유 자원과 스레드가 그것에 접근하는 순서를 다룰 때입니다.
예를 들어, Balance = 100 이라고 적힌 공유 “은행 잔고” 카드가 있다고 해 봅시다.
두 개의 스레드가 있습니다.
T1: 60 인출T2: 60 인출
각 인출 작업은 다음과 같은 카드들로 모델링합니다.
- 잔고 읽기
newBalance = balance - 60계산- 새 잔고 쓰기
- 양보 / 종료
실험실에서 잔고를 읽는 동작은 대략 다음과 같이 표현할 수 있습니다.
- 잔고 카드에서 해당 스레드의 현재 단계 카드까지 실을 옮겨, “이 스레드는 지금 값 100을 복사해서 계산에 사용 중”이라는 것을 나타냅니다.
잔고를 쓰는 동작은 다음을 포함합니다.
- 공유 잔고 카드 위의 값을 갱신합니다.
- 누가 실제로 이를 사용 중인지 보여 주도록 실을 옮기거나 제거합니다.
이제 참가자들에게 두 스레드를 스케줄링하게 합니다.
-
인터리빙 A:
T1잔고 읽기 (100)T1newBalance = 40 계산T140 쓰기T2잔고 읽기 (40)T2-20 계산T2-20 쓰기 → 잔고 부족이 제대로 드러남.
-
인터리빙 B (문제 상황):
T1잔고 읽기 (100)- 양보
T2잔고 읽기 (100)T240 계산T240 쓰기T1(이미 옛날 값 100을 들고 있던) 40 계산T140 쓰기 → 최종 잔고는 40이지만, 실제로는 120이 인출됨.
실험실에서는 어떤 스레드가 다음 카드를 진행하느냐만 바꿔서 이 스케줄들을 다시 재생(replay) 할 수 있습니다. 실이 움직이고 카드 위의 숫자가 변하는 것을 눈으로 보게 되면, 버그는 인터리빙 때문이지, 각 스레드의 로직 자체 때문이 아니라는 점이 선명하게 드러납니다.
레이스 컨디션에서 실제 버그까지
방금 시뮬레이션한 것은 전형적인 레이스 컨디션(race condition) 입니다.
둘 이상의 연산이 동일한 공유 자원에 대해 읽기·쓰기를 경쟁하듯 수행하고, 그 결과가 실제 타이밍/인터리빙에 따라 달라지는 상황.
이 패턴은 다음과 같은 곳에서 그대로 반복됩니다.
- 사용자가 “결제” 버튼을 더블 클릭할 때 발생하는 웹 폼의 이중 제출(double submit)
- 전자상거래 재고 초과 판매(overselling)
- 재시도 로직 때문에 같은 API 동작이 반복 실행되면서 생기는 중복 계정 생성 / 중복 처리
- 카운터/쿼터 업데이트가 동시에 일어나면서 잘못 집계되는 경우
협력형 모델에서는 문제가 되는 지점을 가리키기 쉽습니다. “우리는 잔고를 읽고 쓰는 사이에 양보를 허용했다.” 실험실은 이 취약한 구간을 시각적으로 눈에 확 띄게 만들어 줍니다.
이제 이를 바탕으로, 동일한 방식으로 대응 전략을 탐색할 수 있는 단계로 넘어갈 수 있습니다.
대응 전략 가르치기: 락, 트랜잭션, 멱등성, 그 밖의 것들
참가자들이 버그를 직접 본 뒤에는, 규칙이나 추가 카드를 더하는 방식으로 현실 세계의 방어 기법을 소개할 수 있습니다.
1. 락(Lock)
잔고에 대한 락 카드를 하나 추가합니다.
- 잔고를 읽거나 쓰기 전에, 스레드는 반드시:
- 락을 획득해야 합니다(락 카드에서 자신의 레인으로 실을 가져오기).
- 다른 스레드는 락이 풀릴 때까지 이를 획득할 수 없습니다.
- 락을 쥐고 있을 때에만 다음을 할 수 있습니다.
- 잔고 읽기
- 계산하기
- 새 잔고 쓰기
- 락 해제하기
인덱스 카드 기준으로 인출 시퀀스는 이렇게 바뀝니다.
- 락 획득
- 잔고 읽기
- newBalance 계산
- newBalance 쓰기
- 락 해제
이제 문제였던 인터리빙을 다시 재생해 보려 하면, 물리적인 모델이 이를 막습니다. 한 스레드가 읽기와 쓰기 사이에 다른 스레드가 끼어들 수 없습니다. 락 카드는 이미 점유되어 있기 때문입니다.
2. 트랜잭션(Transactions)
트랜잭션을 모델링하려면, 임시 업데이트(tentative update) 개념을 도입합니다.
- 스레드는 별도의 “임시 잔고(pending balance)” 카드에 먼저 값을 씁니다.
- “커밋(commit)” 단계에서만 공유 잔고 카드가 실제로 업데이트됩니다.
- 중간에 오류나 충돌이 감지되면 트랜잭션은 롤백(abort) 되어 임시 상태를 버립니다.
참가자들은 트랜잭션이 여러 단계를 하나의 더 큰 원자적 단위로 묶으면서도, 서로 충돌하지 않는 연산에 대해서는 여전히 동시성을 허용한다는 것을 눈으로 확인하게 됩니다.
3. 멱등 키(Idempotency Keys)
이중 제출과 재시도 문제를 다루기 위해, 실험실에서는 다음을 표현할 수 있습니다.
- 각 연산마다 하나씩 있는 요청 ID 카드(request ID card)
- 이미 처리된 요청들의 ID를 나열한 공유 카드, 즉 processed-requests 집합
스레드가 어떤 작업을 수행할 때마다 다음을 따르게 합니다.
- 자신의 요청 ID가 processed 집합에 있는지 확인합니다.
- 없다면, 실제 처리를 수행하고 그 ID를 집합에 추가합니다.
- 이미 있다면, 처리를 건너뛰고, 이전 결과를 그대로 반환합니다.
이 과정을 통해, 요청이 뒤죽박죽된 순서로 오더라도 멱등 키를 통해 중복 결제나 중복 동작을 막을 수 있다는 사실을 직관적으로 이해하게 됩니다.
4. 레이트 리미팅(Rate Limiting)
마지막으로 레이트 리미팅은 다음과 같이 시뮬레이션할 수 있습니다.
- 일정 개수의 토큰이 적힌 공유 토큰 버킷(token bucket) 카드
- 각 연산은 반드시 토큰 하나를 소비해야만 진행할 수 있습니다.
- 토큰은 특정 “타이머” 카드에서만 다시 채워집니다.
너무 자주 실행되는 스레드는 토큰이 떨어지면 막히게 되고, 이를 통해 레이트 리미팅이 동시 실행 수를 제한하거나, 취약한 다운스트림 시스템을 보호하는 방식을 자연스럽게 보여 줄 수 있습니다.
거친 단위 vs. 선점형 멀티스레딩
종이 회로 디버그 실험실은 거친 단위의 협력형 동시성에 초점을 둡니다.
- 인덱스 카드 한 장 단위의 동작은 원자적입니다.
- 스레드는 명시적으로 양보할 때만 멈춥니다.
이는 OS가 거의 모든 명령 사이에서 스레드를 끊을 수 있는 완전한 선점형(multithreading) 과는 대조적입니다. 선점형에서는 훨씬 더 미묘한 레이스가 발생할 수 있습니다.
하지만 이 거친 모델은 제약이 아니라 교육적 장점입니다.
- 참가자는 먼저 적은 수의, 더 큰 단계로 직관을 쌓습니다.
- 이 수준에서 인터리빙이 어떻게 버그를 일으키는지 이해한 뒤에는, 다음과 같이 설명하기 쉬워집니다.
- “선점형 시스템에서는 이 카드들이 더 잘게 쪼개진 마이크로 스텝이라고 생각해 보세요. 가능한 인터리빙 수가 기하급수적으로 폭발합니다.”
이는 [LPSZ08] 같은 연구가 강조하는 통찰과도 맞닿아 있습니다. 가능한 스케줄 수의 조합 폭발 때문에 체계적인 동시성 테스트가 어렵지만, 동시에 그 공간을 축소하거나 전략적으로 탐색하려는 연구 동기도 된다는 점입니다.
연구와 더 나은 프로그래밍 모델로의 연결
종이 회로 디버그 실험실 같은 손으로 하는 시뮬레이션은 단순한 교육용 놀이를 넘어, 동시성 전반에 대한 더 큰 노력과 맞물립니다.
-
버그 탐지와 테스트
- 이 실험실은 다양한 스케줄을 탐색하여 버그를 찾는다는 점에서, 체계적인 동시성 테스팅 도구와 유사합니다.
- 스케줄을 명시적으로 표현함으로써, 도구와 디버거에서 결정적 재실행(deterministic replay) 과 스케줄 바운딩(schedule bounding) 이 왜 중요한지에 대한 직관을 길러 줍니다.
-
프로그래밍 모델 설계
- 공유 가변 상태에서 버그가 어떻게 생기는지 눈으로 보게 되면, 자연스럽게 다음에 관심을 갖게 됩니다.
- 불변 데이터(immutable data)
- 메시지 패싱, 액터 모델(actor model)
- 트랜잭셔널 메모리(transactional memory)
- 여러분의 동시성 모델을 카드와 실로 “그려” 볼 수 있다면, 그것이 얼마나 이해하기 쉽고 견고한지를 평가할 수 있습니다.
- 공유 가변 상태에서 버그가 어떻게 생기는지 눈으로 보게 되면, 자연스럽게 다음에 관심을 갖게 됩니다.
-
교육과 팀 정렬(alignment)
- 이 실험실은 팀에 공통 언어를 제공합니다. “이 두 카드 사이에 레이스가 있다”, “여기에 락이 필요하다” 같은 표현을 자연스럽게 공유하게 됩니다.
- 디자이너, PM, QA처럼 동시성을 전공하지 않은 사람들에게도, 동시성이 미치는 영향을 설명하기 쉬워집니다.
[LPSZ08] 및 관련 연구는 동시성 버그가 널리 퍼져 있고 미묘하다는 사실을 상기시켜 줍니다. 따라서 공유된 멘탈 모델과 직관적 이해를 키워 주는 어떤 방법이든, 도구를 보완하는 유용한 접근입니다.
결론: 고위험 버그를 위한 로우테크 도구
동시성이 어려운 이유는, 그것이 눈에 보이지 않기 때문입니다. 스레드는 우리가 볼 수 없는 방식으로 서로 끼어들고, 데이터 레이스는 짧은 타이밍 창 사이에 숨어 있으며, 실제 운영 환경의 실행 경로는 우리가 머릿속에 그린 것과 종종 어긋납니다.
종이 회로 디버그 실험실은 단순한 물리적 도구 — 인덱스 카드, 실, CPU 토큰 — 를 사용해 다음을 가능하게 합니다.
- 스레드와 공유 상태를 눈에 보이게 만들고
- 특정 인터리빙이 레이스 컨디션, 이중 제출 같은 버그를 어떻게 유발하는지 드러내며
- 락, 트랜잭션, 멱등 키, 레이트 리미팅 같은 대응 전략을 실험할 수 있는 샌드박스를 제공하고
- 손으로 느낀 직관과 동시성 버그 및 프로그래밍 모델에 대한 형식적 연구 사이의 간극을 메웁니다.
복잡한 분산 시스템과 멀티코어가 일상이 된 시대에, 가장 효과적인 교육 도구 중 하나가 종이로 가득한 테이블이라는 사실은 어쩌면 역설적으로 들릴 수 있습니다. 하지만 좋은 비유의 힘은 여기서도 유효합니다. 인덱스 카드와 실을 가지고 레이스 컨디션을 한 번 디버깅해 보고 나면, 여러분은 동시성 코드 — 그리고 실제 서비스 장애 — 를 이전과는 전혀 다른 눈으로 바라보게 될 것입니다.