🎧 ▶ 5분 브리핑으로 듣기
▶ 오디오북 재생 (Google Drive)
NotebookLM 오디오 개요 (AI 생성)
📕 전자책 전문 읽기 동시성의 규율 · 29쪽
브라우저가 PDF를 바로 표시하지 못합니다. 새 탭에서 열기
이 글은 요약해 다시 쓴 것이고, PDF가 전문입니다.

테스트에서는 멀쩡하다가 프로덕션에서만 드물게 터지는 버그를 쫓아본 개발자, 혹은 한 주의 일정이 아무리 잡아도 안 맞는 1인 개발자에게 이 글은 쓰였습니다.

이 글에서 얻는 것은 하나의 지도입니다. 동시성 실행 시스템에서 실패의 원인은 실행자가 아니라, 실행이 멈췄다가 다시 돌아오는 자리, 즉 양도 지점의 개수입니다. 스레드 둘, await 하나 가진 단일 스레드, 달력 하나 가진 사람 한 명은 전부 같은 법칙을 따릅니다.

말하고 싶은 것은 짧습니다. “동작할 때만 동작하는” 시스템은 99퍼센트 동작하는 것이 아닙니다. 핵심은 1퍼센트가 통제되지 않는다는 점입니다. 동시성의 규율은 그 1퍼센트를 설계에서 없애는 기술이지, 테스트에서 찾는 기술이 아닙니다.

단일 스레드는 안전하지 않다 개념을 형상화한 이미지 글의 핵심 개념을 형상화합니다.

문제는 코드 순서가 아니라 실행 순서입니다.

단일 스레드 프로그램에는 하나의 보장이 있습니다. 실행 순서가 내가 쓴 순서와 같다는 것입니다. 1번 줄이 2번 줄보다 먼저 실행되고 같은 입력이면 매번 같은 결과가 나옵니다. 버그를 이유를 들어 설명할 수 있는 것도 이 보장 덕분입니다. 한 줄이 언제 실행되는지 알기 때문입니다.

동시성을 추가하는 순간 그 보장이 사라집니다. 코드는 그대로인데 실행자가 둘이라 순서가 달라지고, 같은 입력이 다른 결과를 만듭니다. 더 나쁜 것은 실패가 알리지 않는다는 점입니다. “내가 끼어들었소”라고 말하는 에러 메시지는 없습니다. 시스템은 그냥 조용히 틀린 답을 돌려줄 뿐입니다.

이것이 곧 “동작할 때만 동작하는” 시스템의 의미입니다. 99번은 잘 동작하고 순서가 다른 1번에 카운터가 1 줄어듭니다. 결제가 두 번 나갑니다. 테스트는 계속 통과합니다. 실패하는 순서는 테스트가 걷는 경로에 없기 때문입니다.

가장 단순한 코드가 어떻게 찢어지는지 기계의 눈으로 보세요. “카운터를 1 올린다”는 한 줄입니다. 기계는 이 한 줄을 세 단계로 실행합니다. 값을 읽습니다. 1을 더합니다. 새 값으로 덮어씁니다. 단계는 한 개가 아닙니다. 세 개입니다.

그러니까 버그는 “코드가 틀렸다”가 아닙니다. “세 단계 사이에 틈이 열려 있고 그 틈에 무엇이든 들어갈 수 있다”가 문제입니다. 그 틈에 들어가는 것이 무엇이냐가 다음 섹션의 주제입니다.

인터리빙: 실패가 존재할 수 있는 유일한 모양

레이스 조건은 두 동작이 서로의 사이에 끼어들었을 때만 나타나는 결함입니다. 이 끼어들기를 인터리빙이라고 부릅니다. 동작 하나는 하나 모두 정확합니다. 틀린 것은 둘이 번갈아 실행된 순서입니다.

카운터 예시의 가능한 순서를 세어 보세요. 스레드 두 개, 각 세 단계, 여섯 단계 전체입니다. 여섯 단계 중 A가 차지할 3칸을 고르는 방법은 20가지입니다. 이 장난감 사례가 만들 수 있는 모든 세계가 여기에 있습니다.

20가지 중 19가지는 아무 손해를 내지 않습니다. 1가지만 증가를 잃습니다. A가 0을 읽고 B가 0을 읽고 둘 다 1을 쓰면 결과는 1입니다. 한 번의 증가가 사라진 것입니다. 로스트 업데이트라고 부릅니다.

중요한 것은 이 예시가 이미 얼마나 작다는 점입니다. 실행자 둘, 단계 셋입니다. 실제 코드의 단계는 수십이고 실행자는 스레드 둘이 아닙니다. 수십 개의 연결입니다. 순서의 개수는 테스트와 리뷰로 열거할 수 없는 규모로 불러납니다. 실패를 나열할 수 없으니, 나열해서 찾아야 한다는 전략 자체가 성립하지 않습니다.

첫 번째 규율이 여기서 나옵니다. 순서를 열거하지 마십시오. 끼어들 틈을 없애는 것, 찢어져서는 안 되는 구간을 원자적으로 만드는 것, 둘 중 하나를 선택해야 합니다.

“드물게만 나서 괜찮다”는 답이 되지 않습니다. 그 한 가지 순서가 나타날 확률은 부하와 타이밍에 달려 있습니다. 부하가 두 배가 되는 새벽 3시에 옵니다. 그것이 일어난다는 것을 증명할 필요는 없습니다. 일어날 틈을 없앨 필요가 있는 것입니다.

the-concurrency-discipline 슬라이드 1

로크는 해결책이 아니라 규율의 시작

로크는 인터리빙 지점을 없애는 도구입니다. 로크를 쥔 동안 다른 실행은 크리티컬 섹션에 들어올 수 없고, 찢어질 수 있던 순서가 원자적이 됩니다. 로크는 “확인과 행동을 한 단위로 묶어라”를 기계가 대신 강제해주는 셈입니다.

하지만 로크는 그 자체로 새로운 실패를 만듭니다. 데드락, 경합, 이해하기 어려운 코드입니다. 구조적으로 보면 로크를 쓰는 순간 공유 상태를 하나 더 만듭니다. 누가 로크를 쥘 것인가,라는 상태입니다. 로크 사용은 면제가 아닙니다. 규율의 시작입니다.

첫 번째 규칙은 크리티컬 섹션을 작게 만든다는 것입니다. 공유 상태를 직접 건드리는 연산만 안에 두고, 계산과 입출력과 네트워크 호출은 밖으로 뺍니다. 로크를 쥔 동안 같은 로크를 원하는 모든 실행은 대기합니다. 데이터베이스 쿼리가 로크 안에 들어가면 쿼리가 끝날 때까지 섹션이 묶이고, 느린 쿼리가 시스템 전체를 기어갑니다.

로크는 병렬성을 직렬성으로 바꾸는 장치이고, 그 직렬화의 대가는 로크를 쥔 시간입니다. 그래서 첫 본능은 느린 API 호출을 밖으로 빼는 것입니다.

그런데 밖으로 빼는 순간 새로운 틈이 생깁니다. 내려놓고 다시 쥔 사이에 다른 실행이 상태를 바꿀 수 있습니다. 확인 때는 참이었지만 행동할 때는 세상이 이동합니다. 규칙은 “밖으로 빼라”가 아닙니다. “빼고 난 뒤에도 확인과 행동이 원자적인지 검증하라”가 핵심입니다.

검증이 안 되면 계속 안에 둡니다. 느린 안전한 구간이 빠른 깨진 구간보다 낫습니다. 이 트레이드오프를 매번 의식해서 선택하는 것이 크리티컬 섹션 규율의 전부입니다. 두 번째 규칙은 한 줄입니다. 예외 경로를 포함해 모든 경로가 로크를 내려놓아야 합니다. 한 경로가 잊으면 나머지 시스템이 영원히 기다립니다.

the-concurrency-discipline 슬라이드 2

단일 스레드는 안전하지 않다

지금까지 여러 스레드가 동시에 도는 이야기를 했습니다. 그런데 대부분의 시스템은 스레드를 많이 돌리지 않습니다. 웹 서버, 챗봇, 메시지 프로세서, 대부분의 자바스크립트 런타임은 실행자가 하나입니다. 그런데도 동시에 수십 개의 요청을 처리합니다.

이유는 “진행 중”인 작업이 여러 개가 될 수 있기 때문입니다. 이벤트 루프는 대기열을 갖고 작업인 네트워크, 디스크, 타이머가 끝나면 그 콜백이 대기열에 들어갑니다. 루프가 순서대로 꺼내 실행합니다. 이 루프는 곧 스케줄러입니다. 각 콜백이 언제 실행될지는 정하지만 실행하는 쪽은 하나뿐입니다.

그렇다면 인터리빙 지점은 어디입니까. 실행이 루프를 돌려주는 자리입니다. 자바스크립트에서는 await 경계, 파이썬의 asyncio에서는 await, 노드의 콜백 스타일에서는 나머지 작업을 콜백으로 넘기고 함수가 끝나는 자리입니다.

양도와 재개는 정확히 아무 실행이든 들어올 수 있는 통로입니다. 전형적인 무너짐을 보세요. 두 태스크가 공유하는 캐시가 처음 null이라고 가정합니다. 둘 다 null을 보고 둘 다 fetch를 쏘고 늦게 도착한 쪽이 덮어씁니다. fetch가 읽기라면 중복 요청 하나를 치른 손해입니다.

fetch가 쓰기라면 이야기가 달라집니다. 결제, 예약, 할당입니다. 앞 섹션의 이중 실행이 단일 스레드에서 그대로 재현됩니다. “단일 스레드는 안전하다”는 정신 모델은 여기서 틀립니다. 안전하지 않습니다. 단지 인터리빙 지점이 적을 뿐입니다.

고쳐야 할 정신 모델은 이것입니다. 안전을 결정하는 것은 실행자의 개수가 아닙니다. 양도 지점 사이에 틈이 여는 것입니까. 단일 스레드에도 yield가 있으면 check-then-act가 찢어집니다. await를 가로지르는 동안 로크를 쥔 채로 있게 하거나, 그 구간을 큐로 직렬화해야 합니다.

the-concurrency-discipline 슬라이드 3

백프레셔: 도착이 처리보다 빠를 때의 규율

이벤트 루프에는 인터리빙과 무관한 두 번째 무너짐 자리가 있습니다. 들어오는 속도가 처리보다 빠르면 어떻게 됩니까. 도착과 처리 사이의 간격은 큐이고 한계가 없는 큐는 무한히 커집니다.

메모리가 차고 지연이 늘고 결국 시스템 전체가 멈춥니다. 이것은 버그가 아닙니다. 물리입니다. 도착률이 서비스율을 이기면 큐는 자라는데, 그 성장을 멈추게 하는 장치가 없으면 아무도 멈추지 않습니다.

그러니까 규율은 큐가 차기 전에 찬 상태를 위한 정책을 정해 두는 것입니다. 표준 선택지는 네 가지입니다.

대기, 순서를 밀어 원본 순서대로 처리합니다. 드롭, 가득 차면 새로운 도착을 포기하고 포기한 사실을 기록합니다. 샘플링, 양이 과하면 핵심 부분만 남긴 채 처리율을 낮춥니다. 거부, 애초에 안 된다고 말하고 발송자에게 에러를 돌려줍니다.

고르는 것은 큐 안에 무엇이냐에 달려 있습니다. 중복되는 읽기는 드롭돼도 되지만 결제 같은 쓰기는 안 됩니다. 순서가 중요하다면 대기가 맞습니다. 네 가지의 공통점은 사전에 정한다는 것입니다. 큐가 찬 순간에 정하면 늦은데, 그 순간 큐는 이미 차 있고 시스템은 이미 실패하고 있습니다.

실전에서 가장 흔한 실수는 깊이가 없는 큐입니다. 코드는 큐가 늘 얕다고 가정하고 깊어지는 순간을 위한 가드가 없습니다. 그래서 체크는 간단합니다. 시스템의 모든 큐에 대해 “차면 어떻게 되나”를 한 줄로 답할 수 있어야 합니다. 답이 없으면 그 큐는 폭탄입니다.

1인 개발자는 동시성 실행 시스템입니다.

여기까지 코드의 이야기만 했습니다. 그런데 1인 개발자에게 동시성은 스레드 문제가 아닙니다. 1인 운영 자체가 동시성 실행 시스템입니다. 여러 작업이 동시에 도착하고 실행자는 하나이며 중복이 없고 터지면 대신할 사람이 없습니다.

무너지는 법칙은 코드와 정확히 같습니다. 시스템 공학에서 한 요소의 실패가 전체를 멈추게 하는 구성을 단일 실패 점이라고 합니다. 1인 팀에는 그런 요소가 정확히 하나 있고, 그 요소는 사람입니다. 수면, 병, 미팅, 급한 용건. 전부 프로세스 중단입니다.

중단된 동안, 끝내지 못한 일의 큐는 계속 늘어납니다. 이것은 열심히 안 해서가 아닙니다. 구조입니다. 단일 실행자 시스템은 직렬화를 피할 수 없습니다. 1인 시스템이 결정해야 하는 질문은 먼저 얼마나 많이 할 것인지입니다. 그다음 어떤 순서로, 그리고 언제 거부를 할 것인지가 핵심입니다.

앞 섹션의 큐 규율이 그대로 적용됩니다. 코드와 다른 점 하나, 여기선 큐가 눈에 보입니다. 작업 보드, 할 일 목록입니다. 보지 못하게 하면 큐는 머릿속에 있고 깊이를 재는 것이 불가능합니다. 재지 못하면 찬 상태의 정책도 정할 수 없습니다.

그래서 1인 개발자는 멀티스레드 시스템과 같은 자리에 넘어집니다. 구조가 같기 때문입니다. 공유 상태는 하나의 머리, 하나의 일정입니다. 인터리빙 지점은 중단, 메시지, 미팅입니다. 체크 후 액트는 “이게 맞다”고 확인한 뒤, 계획이 바뀌어 행동하는 것입니다. 한계 없는 큐는 머릿속입니다.

차이가 있다면 규모뿐입니다. 코드의 인터리빙은 밀리세컨드이고 사람의 인터리빙은 일 단위입니다. 법칙은 규모를 묻지 않습니다. 그래서 회고 체크리스트도 같습니다. 이번 주 계획의 어디에 무엇이 끼어들 수 있습니까. 무엇을 확인한 뒤 그 확인이 무효가 된 채 행동했습니까. 큐는 어디에 있고 차면 어떻게 됩니까.

the-concurrency-discipline 슬라이드 4

하나의 규율만 가져가려면

다시 모읍니다. 동시성 실행 시스템에서 실패의 원인은 양도 지점이지 실행자가 아닙니다. 그래서 첫 번째 체크는 코드에서 모든 양도 지점을 찾는 일입니다. await 전부, 콜백 경계 전부, 실행을 다른 쪽에 넘기는 자리 전부입니다.

각 자리에 한 질문을 던집니다. 여기 무엇이 끼어들 수 있습니까. 답이 “아무것도”라면 직관이 아니라 증거가 필요합니다. 로크, 큐, 원자 구간입니다. 답이 “무엇이든”이라면, 그리고 그 구간이 확인 후 액트라면, 그 자리는 맞는 부하만 기다리는 레이스 조건입니다.

두 번째 체크는 큐입니다. 시스템의 모든 큐에 대해 한 줄로, 차면 어떻게 되는지 답할 수 있어야 합니다. 답이 없으면 지금 정해야 합니다. 차는 순간에 정하는 것은 이미 늦습니다.

세 번째 체크는 나 자신입니다. 1인 시스템도 동시성 실행 시스템입니다. 큐를 보이게 하고 찬 상태의 정책을 두고 인터리빙 지점을 코드와 같은 언어로 리뷰합니다.

“동작할 때만 동작하는” 시스템은 99퍼센트 동작하는 것이 아닙니다. 1퍼센트가 통제되지 않는다는 것이 핵심입니다. 규율은 더 많은 테스트를 달기는 아닙니다. 순서가 달라질 수 있는 자리를 애초에 없애는 일입니다. 더 깊게 보고 싶다면, 전문 PDF가 같은 지도를 레이스 조건의 해부학부터 로크의 대가까지 더 자세히 걷습니다.

참고 자료

태그: backpressure, concurrency, event-loop, locks, race-condition, solo-developer, system-design

카테고리:

업데이트: