Skip to content

필사 모드: 동시성 완전 가이드: 공유 상태를 줄여 나가는 순서

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며

이 블로그에는 동시성을 다룬 글이 이미 두 편 있습니다. 동시성 vs 병렬성은 두 개념의 차이를 정리한 개념 글이고, 분산 락 패턴 비교는 어떤 인프라로 락을 구현할지 비교한 글입니다. 이 글은 그 둘 사이의 빈자리를 채웁니다. 애플리케이션을 설계하는 사람이 실제로 밟는 작업 순서, 즉 공유 상태를 없앨 수 있으면 없애고, 없앨 수 없으면 범위를 좁히고, 그래도 남는 것을 방어하고 테스트하는 순서를 다룹니다.

데이터베이스 격리 수준의 내부 동작(MVCC, 스냅샷 격리)은 다루지 않습니다. "언제 애플리케이션이 아니라 저장소에 맡겨야 하는가"라는 경계선까지만 짚고 그 안쪽은 데이터베이스 카테고리로 넘깁니다.

한 줄로 줄이면 이렇습니다. 동시성 설계의 대부분은 잠금을 잘 거는 기술이 아니라 잠글 것을 먼저 줄이는 기술입니다.


1. 동시성 버그가 특별한 이유 — 재현되지 않는 실패

1-1. 실패가 입력이 아니라 타이밍의 함수다

일반 버그는 입력의 함수입니다. 같은 입력이면 같은 실패가 나오고, 그래서 재현 케이스와 회귀 테스트를 만들 수 있습니다. 동시성 버그는 스케줄링의 함수입니다. 같은 입력, 같은 코드, 같은 서버인데 스레드가 어느 순간 선점되었는지에 따라 결과가 갈립니다. 재현 케이스는 확률적으로만 존재합니다.

1-2. 관측이 현상을 바꾼다

로그 한 줄을 추가하면 문제가 사라지는 일이 흔합니다. 로그는 I/O이고, I/O는 스레드를 양보시켜 경합 창의 폭을 바꿉니다. 디버거는 더 심해서, 브레이크포인트가 사실상 전역 락처럼 동작합니다. 그래서 동시성 문제는 관찰로 좁혀 나가기 어렵고, 코드를 읽고 불변식을 따지는 정적 추론의 비중이 훨씬 큽니다.

1-3. 테스트 통과는 부재의 증거가 아니다

경합 창이 100나노초라면 로컬에서 1,000번 돌려도 그 창에 들어갈 확률은 낮습니다. 반대로 초당 수천 번 호출되는 프로덕션에서는 하루 안에 반드시 들어갑니다. 동시성에서는 테스트 결과보다 설계 논증이 먼저입니다. "이 코드는 왜 안전한가"를 문장으로 쓸 수 있어야 하고, 테스트는 그 논증이 무너진 순간을 잡는 그물입니다.

1-4. 실패가 조용히 남는다

동시성 버그는 예외를 던지고 끝나지 않습니다. 잔액은 맞는데 거래 내역이 하나 없고, 재고는 0인데 주문은 두 건 들어와 있습니다. 발견은 며칠 뒤 정산에서 이뤄지고 그때는 원인 시점의 로그가 이미 사라져 있습니다. 실무 체크 질문은 하나입니다. "이 함수가 정확히 같은 순간에 두 번 실행되면 무엇이 깨지는가?"


2. 0단계: 공유 상태를 없앨 수 있는가

첫 단계는 잠금 선택이 아니라 잠글 대상을 줄이는 것입니다. 비용이 낮은 순서대로 정리한 사다리입니다.

0단계  공유하지 않는다     불변 데이터 / 값 복사 / 순수 계산
1단계  소유권을 옮긴다     한 번에 한 주체만 소유, 메시지로 전달
2단계  범위를 좁힌다       요청 스코프 · 스레드 로컬 · 태스크 로컬
3단계  키로 쪼갠다         같은 키는 항상 같은 워커/파티션으로
4단계  남은 것만 잠근다    원자성 경계 정의 → 잠금 → 테스트

2-1. 불변 데이터와 범위 축소

읽기만 하는 데이터는 몇 개의 스레드가 접근하든 안전합니다. 설정이나 라우팅 테이블처럼 가끔 바뀌고 자주 읽히는 값은 제자리에서 고치지 말고 새 객체를 만들어 참조를 통째로 교체합니다. 교체는 참조 하나의 쓰기로 끝나고, 읽던 쪽은 예전 스냅샷을 계속 봅니다. 다만 컬렉션만 불변이고 원소가 가변이면 아무것도 보장되지 않습니다.

  • 요청 스코프: 요청 동안만 사는 객체는 공유되지 않습니다. 편의로 싱글턴 캐시에 얹는 순간 공유 상태가 됩니다.
  • 스레드 로컬: 추적 ID나 트랜잭션 컨텍스트에 적합하지만, 스레드 풀에서 재사용될 때 이전 요청 값이 남는 누수가 잦으므로 정리 경로가 필수입니다.
  • 소유권 이전: 버퍼를 채워 넘긴 뒤 자신은 더 이상 만지지 않는 패턴입니다. 규율로만 지켜지므로 리뷰에서 명시적으로 확인해야 합니다.

2-2. 키 기반 파티셔닝

가장 실용적인 축소 기법입니다. 상태를 전역으로 두지 않고 키로 쪼갠 뒤 같은 키는 항상 같은 워커로 보냅니다. 계정별 잔액, 주문별 상태 머신처럼 자연 키가 있는 도메인에 잘 맞습니다. 키 안에서는 락 없이 순차 처리가 되고 키 사이에는 경합이 없습니다.

대가도 분명합니다. 핫 키가 생기면 그 키의 처리량이 전체 병목이 되고, 파티션 수를 바꾸는 리밸런싱 중에는 같은 키가 두 워커에 동시에 붙는 구간이 생깁니다. 그 구간을 위한 임대 확인과 멱등 처리가 함께 필요합니다.

2-3. 논쟁: 메시지 전달 vs 공유 메모리와 잠금

현장의 의견이 갈리는 지점입니다. 어느 한쪽이 정답이라고 말하는 글은 의심하는 편이 좋습니다.

  • 메시지 전달(액터, CSP) 쪽: 상태를 한 주체가 독점하고 나머지는 메시지로만 요청하므로 데이터 레이스가 구조적으로 사라집니다. 소유자가 하나라 추론이 쉽습니다.
  • 공유 메모리와 잠금 쪽: 메시지 전달은 레이스를 없애는 것이 아니라 형태를 바꿀 뿐이라고 봅니다. 요청 순서 뒤바뀜, 메일박스 무한 증가, 응답 대기 중 교착은 그대로입니다. 여러 액터에 걸친 불변식은 결국 조정 프로토콜을 손으로 구현하게 만듭니다.
  • 실제 갈림 축: 불변식이 한 엔티티 안에 갇히는가 여러 엔티티에 걸치는가, 런타임이 어느 쪽을 저비용으로 제공하는가, 팀이 스택 트레이스로 디버깅하는가 메시지 로그로 디버깅하는가.

불변식이 한 엔티티에 갇히면 메시지 전달이, 여러 엔티티를 묶어야 하면 트랜잭션 경계가 있는 저장소가 유리합니다. 취향이 아니라 불변식의 모양에 달린 문제입니다.


3. 데이터 레이스의 정의와 메모리 모델

3-1. 정의와 DRF-SC

Go 메모리 모델은 데이터 레이스를 "a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses"로 정의하고, 프로그래머의 의무를 "Programs that modify data being simultaneously accessed by multiple goroutines must serialize such access"로 못 박습니다.

그 대가로 주는 보상이 중요합니다. 같은 문서는 "In the absence of data races, Go programs behave as if all the goroutines were multiplexed onto a single processor"라고 말합니다. 흔히 DRF-SC라고 부르는 성질입니다. 레이스를 하나도 남기지 않으면 명령어 재배치나 캐시 가시성 같은 하드웨어 이야기를 고려하지 않아도 됩니다. 반대로 하나라도 남기면 그 프로그램의 동작에 대한 어떤 직관도 보장되지 않습니다.

3-2. "정수 하나 읽는 것"이 위험한 이유

동기화 없는 읽기는 흔히 "값이 조금 오래된 것뿐"이라고 변명됩니다. 실제 가능한 결과는 더 넓습니다. 컴파일러가 반복문 안의 읽기를 레지스터로 끌어올리면 갱신을 영원히 못 봅니다. 재배치 때문에 다른 스레드에서는 플래그가 먼저 켜지고 데이터가 나중에 쓰인 것처럼 보일 수 있습니다. 워드 크기를 넘는 값은 절반만 갱신된 채 읽히기도 합니다.

// 예시 — 동기화 없는 플래그는 종료를 보장하지 않는다
var done bool          // 여러 고루틴이 접근하지만 보호되지 않음
go func() { work(); done = true }()
for !done {            // 컴파일러가 done을 레지스터에 올릴 수 있다
}

3-3. 원자적 연산은 원자성을 보장하지 않는다

가장 잦은 오해입니다. 원자적 타입은 한 연산의 원자성만 보장합니다. 연산을 이어 붙이면 그 사이가 열립니다.

안전한 것   counter.Add(1)                 하나의 원자적 연산
위험한 것   if counter.Load() < limit {    읽기와 쓰기 사이가 열려 있다
                counter.Add(1)
            }

check-then-act 레이스라고 부릅니다. 여기서 중요한 구분이 나옵니다. 데이터 레이스가 없어도 레이스 컨디션은 남습니다. 위 코드는 모든 접근이 원자적이라 데이터 레이스는 없지만 "한계를 넘지 않는다"는 불변식은 깨집니다. 레이스 디텍터는 전자를 잡고 후자는 잡지 않습니다.

레이스 디텍터는 실행된 경로에서 실제로 발생한 레이스만 잡으므로, 커버리지가 넓은 통합 테스트와 함께 CI에서 돌릴 때 값어치가 생깁니다.


4. 원자성 경계 정하기

4-1. 불변식을 먼저 문장으로 적는다

임계 구역은 줄 수가 아니라 불변식 단위로 정합니다. 먼저 지켜야 할 문장을 적습니다. "잔액은 음수가 될 수 없다", "예약 좌석 수의 합은 총좌석을 넘지 않는다", "결제 완료를 거치지 않고 배송 중으로 갈 수 없다". 그다음 각 문장이 잠시라도 깨지는 구간을 찾으면 그 구간 전체가 하나의 경계입니다.

예시 — 잔액 차감의 원자성 경계

  ├─ 현재 잔액 읽기   ┐
  ├─ 출금액과 비교    │ 이 네 단계 사이에 다른 주체가 잔액을
  ├─ 잔액에서 차감    │ 읽거나 바꾸면 불변식이 깨진다
  └─ 새 잔액 쓰기     ┘

4-2. 경계를 넓히거나 좁힐 때의 대가

너무 좁으면 불변식이 깨집니다. 이건 정확성 문제라 협상 대상이 아닙니다. 너무 넓으면 처리량이 떨어집니다. 이건 성능 문제라 측정하고 협상할 수 있습니다. 그래서 먼저 넓게 잡아 정확성을 확보하고, 측정으로 병목이 확인된 다음에만 좁힙니다. 반대로 하면 정확성 결함을 성능 최적화라는 이름으로 심게 됩니다.

4-3. 임계 구역 안에서 하면 안 되는 일

임계 구역의 길이는 곧 다른 모든 주체의 대기 시간입니다.

  • 네트워크 호출과 디스크 I/O: 지연이 통제되지 않습니다. 외부 API 하나가 느려지면 그 락에 걸린 전 스레드가 함께 멈춥니다.
  • 다른 락 획득: 락을 쥔 채 다른 락을 잡는 순간 교착 가능성이 생깁니다. 불가피하면 5-3의 순서 규칙이 필수입니다.
  • 사용자 콜백 호출: 콜백 안에서 무슨 일이 벌어질지 알 수 없고, 재진입해 같은 락을 다시 잡는 사고가 흔합니다.

패턴으로 줄이면 락 안에서는 메모리만 만지고 계산과 I/O는 밖에서 한다가 됩니다. 필요한 데이터를 락 안에서 복사해 나와 밖에서 처리하고, 반영이 필요하면 다시 짧게 잠급니다. 이때 두 번째 잠금 시점에는 상태가 바뀌었을 수 있으므로 재검증이 필요합니다.


5. 잠금 — 비관적 vs 낙관적, 그리고 잠금 순서

5-1. 비관적 잠금과 낙관적 잠금

비관적 잠금은 먼저 잠그고 일합니다. 재고 차감이나 좌석 배정처럼 경합이 실제로 잦고 재시도가 사용자에게 보이는 작업에 맞습니다. 대가는 대기, 교착 가능성, 락 유지 시간이 정하는 처리량 상한입니다. 획득 타임아웃은 선택이 아니라 필수입니다. 무한 대기는 장애를 교착으로 바꿔 진단을 어렵게 만듭니다.

낙관적 잠금은 잠그지 않고 진행한 뒤 반영 시점에 "내가 읽은 이후로 바뀌지 않았는가"를 확인합니다.

-- 예시 — 버전 컬럼을 쓰는 낙관적 잠금
UPDATE accounts
   SET balance = balance - 1000,
       version = version + 1
 WHERE id = 42
   AND version = 7;      -- 영향받은 행이 0이면 충돌 → 재시도

충돌이 드물고 읽기가 압도적으로 많을 때 유리하지만, 충돌이 잦아지면 재시도가 폭증해 오히려 느려집니다. 재시도 상한과 백오프가 필요하고, 재시도할 때는 반드시 데이터를 다시 읽어야 합니다. 예전 스냅샷으로 다시 시도하면 영원히 실패합니다.

5-2. HTTP 경계에서의 같은 문제

클라이언트가 값을 읽고 사용자가 수정하는 사이 다른 사용자가 이미 바꿔 놓는 상황을 lost update 문제라고 합니다. RFC 6585는 428 Precondition Required를 정의하면서 이 코드가 "the origin server requires the request to be conditional"을 뜻하며 lost update 문제를 막기 위한 것이라고 설명합니다. 서버는 조건 없는 갱신을 428로 거절하고, 클라이언트는 검증자를 조건부 헤더에 실어 다시 보냅니다.

PUT /accounts/42 HTTP/1.1
If-Match: "v7"

# 서버 상태가 이미 v8이면 412 Precondition Failed
# 조건 헤더가 아예 없으면 428 Precondition Required

5-3. 잠금 순서 — 교착 예방의 유일한 실용적 규칙

A를 쥔 스레드가 B를 기다리고 B를 쥔 스레드가 A를 기다리면 둘 다 영원히 멈춥니다. 실무에서 통하는 예방책은 하나입니다. 모든 락에 전역 순서를 부여하고 항상 그 순서로만 획득합니다.

락 계층 예시 (위에서 아래로만 획득 가능)

  1. 테넌트 락   2. 계정 락   3. 주문 락   4. 캐시 샤드 락

  규칙: 3을 쥔 상태에서 2를 잡을 수 없다.
        같은 계층 안에서는 키를 정렬한 순서로 획득한다.

같은 계층의 락 두 개를 잡아야 하면(계좌 이체가 전형적입니다) 키를 정렬해 작은 쪽부터 잡습니다. 이 규칙 하나로 순환 대기가 구조적으로 불가능해집니다. 획득 타임아웃은 교착을 영구 정지 대신 실패로 드러내 주지만 감지 수단일 뿐 예방책이 아닙니다.

5-4. 읽기/쓰기 락의 함정

읽기가 많으면 유리해 보이지만 실측 없이 도입하면 손해인 경우가 많습니다. 임계 구역이 짧으면 부가 비용 때문에 상호 배제 락보다 느려지고, 읽기가 끊이지 않으면 쓰기가 밀리는 기아가 생깁니다. 읽기 락을 쥔 채 쓰기 락으로 승격하려는 코드는 대표적인 교착 패턴입니다.


6. 단일 프로세스를 넘어서면: DB 트랜잭션과 분산 락의 경계

프로세스가 하나에서 둘이 되는 순간 언어가 제공하는 락은 아무것도 보장하지 않습니다. 인스턴스가 각자의 뮤텍스를 잡고 같은 행을 동시에 갱신합니다. 오토스케일링이 처음 켜진 날 드러나는 대표적 사고입니다.

6-1. 순서: 트랜잭션 → 조건부 갱신 → 분산 락

  1. 하나의 데이터베이스 트랜잭션으로 끝나는가? 끝난다면 거기서 끝냅니다. 검증된 동시성 제어가 이미 저장소 안에 있습니다.
  2. 제약 조건이나 조건부 갱신으로 표현되는가? 유니크 제약과 WHERE version = ? 형태의 조건부 갱신은 락 없이 정확성을 만듭니다. "중복 생성 방지"는 대부분 락이 아니라 유니크 제약의 문제입니다.
  3. 여러 저장소나 외부 시스템에 걸치는가? 그때 비로소 분산 락이 등장합니다.

3번으로 곧장 뛰어드는 설계가 흔하지만 대부분은 1번이나 2번에서 끝납니다.

6-2. 분산 락이 보장하지 않는 것

분산 락은 상호 배제를 보장하지 않습니다. 확률을 낮출 뿐입니다. 락은 보통 TTL 임대이므로 작업이 TTL보다 오래 걸리면 만료되고 다른 인스턴스가 같은 락을 얻는데, 원래 인스턴스는 자기가 쥐고 있다고 믿고 계속 씁니다. GC 일시 정지나 컨테이너 스로틀링으로 수 초 멈춰도 결과는 같고, 애플리케이션 코드로는 감지할 방법이 없습니다. TTL 계산이 노드별 시계에 의존하면 시계 드리프트가 그대로 정확성 문제가 됩니다.

그래서 분산 락은 정확성의 최후 방어선이 아니라 경합 감소 수단입니다. 최후 방어선은 저장소 쪽에 둡니다. 갱신 시점에 버전을 검사하는 조건부 갱신이거나, 두 번 들어와도 결과가 같아지는 멱등 설계입니다.

6-3. 멱등성은 동시성 설계의 일부다

재시도가 있는 시스템은 결국 같은 작업을 두 번 실행합니다. RFC 9110은 멱등성을 "the intended effect on the server of multiple identical requests is the same as for a single request"로 정의하고 GET·HEAD·PUT·DELETE·OPTIONS·TRACE를 멱등 메서드로 규정합니다. POST는 멱등이 아닙니다. 그래서 POST로 만드는 자원에는 별도의 멱등 키가 필요합니다.

  • 클라이언트가 요청마다 고유한 멱등 키를 만들어 보냅니다.
  • 서버는 그 키로 결과를 저장하고 같은 키가 다시 오면 저장된 결과를 그대로 돌려줍니다.
  • 키 저장은 실제 작업과 같은 트랜잭션 안에서 이뤄져야 합니다. 아니면 그 사이 실패에서 중복이 남습니다.
  • 같은 키가 동시에 두 번 도착할 수 있으므로 키 테이블의 유니크 제약이 실질적인 방어선입니다.

절차는 멱등성과 재시도에 더 자세히 정리되어 있습니다.


7. 큐와 백프레셔 — 대기열을 어디에 세울 것인가

7-1. 큐는 이미 여러 겹으로 존재한다

큐를 둘지 말지 고민하기 전에 인정할 사실이 있습니다. 소켓 수신 버퍼, 스레드 풀 작업 큐, 커넥션 풀 대기열, 데이터베이스 락 대기열이 전부 큐입니다. 설계 문제는 "큐를 둘 것인가"가 아니라 어느 큐가 한계에 먼저 도달하고 그때 무엇을 할 것인가입니다.

요청 → [소켓 버퍼] → [스레드 풀 큐] → [커넥션 풀 대기] → [DB 락 대기] → 처리

  각 단계마다 상한과 거절 정책이 있어야 한다.
  상한이 없는 단계가 하나라도 있으면 그곳이 지연과 메모리의 구멍이 된다.

7-2. 무한 큐는 장애를 지연 시간으로 바꾼다

상한이 없으면 과부하는 실패가 아니라 지연 증가로 나타납니다. 문제는 그 지연이 이미 의미가 없어진 요청에까지 붙는다는 점입니다. 클라이언트는 30초 전에 타임아웃하고 재시도했는데 서버는 아무도 기다리지 않는 요청을 계속 처리합니다.

Google SRE 도서의 연쇄 장애 장은 이 구조를 "a failure that grows over time as a result of positive feedback"으로 정의합니다. 과부하가 재시도를 낳고 재시도가 과부하를 키우는 양의 피드백입니다. 같은 장은 다층 재시도의 곱셈 효과도 지적합니다. 세 계층이 각각 4회씩 재시도하면 사용자 동작 한 번이 64번의 시도가 됩니다.

7-3. 유한 큐와 거절 정책

  • 상한을 정한다: 큐 길이 상한은 대략 "허용 대기 시간 ÷ 평균 처리 시간"입니다. 목표가 200밀리초이고 처리에 20밀리초 걸리는 워커가 10개면 상한은 대략 100입니다.
  • 빨리 거절한다: SRE 도서는 무한정 큐잉 대신 조기 거절(예: 503 반환)을 권합니다. 거절은 실패가 아니라 보호입니다.
  • 오래된 항목을 버린다: 큐 진입 시각을 기록하고 클라이언트 타임아웃을 넘긴 항목은 꺼내는 즉시 폐기합니다.
  • 재시도를 통제한다: SRE 도서는 "Always use randomized exponential backoff when scheduling retries"라고 못 박고, 프로세스당 분당 60회 같은 재시도 예산 예시를 들며 "Don't retry a given request indefinitely"라고 명시합니다. 영구 오류와 재시도 가능한 오류는 코드로 구분해 영구 오류는 절대 재시도하지 않습니다.
  • 역방향으로 신호를 보낸다: 진짜 백프레셔는 거절이 아니라 생산자를 늦추는 것입니다. 유한 버퍼의 블로킹 쓰기, 크레딧 기반 흐름 제어가 이에 해당합니다.

재시도 정책이 성공 확률에 미치는 영향은 재시도 확률 계산기로 시뮬레이션할 수 있습니다.

7-4. 논쟁: async/await vs 스레드

실행 모델 선택에도 합의된 정답이 없습니다.

  • 비동기 쪽: 연결마다 스레드를 잡지 않으므로 I/O 대기가 많은 워크로드에서 메모리와 컨텍스트 스위치 비용이 훨씬 낮습니다. 수만 개의 동시 연결이 목표면 선택지가 좁습니다.
  • 스레드 쪽: 스택 트레이스가 온전하고 디버거가 그대로 동작하며 블로킹 라이브러리를 그대로 씁니다. 비동기는 함수 색깔 문제를 만들어 코드베이스 전체에 전염되고, 이벤트 루프에서 CPU를 오래 쓰는 코드 한 줄이 전체를 멈춥니다.
  • 실제 갈림 축: 워크로드가 I/O 중심인가 CPU 중심인가, 런타임이 경량 스레드를 제공하는가, 의존 생태계가 어느 쪽인가, 팀의 디버깅 도구가 어느 쪽에 준비되어 있는가.

중요한 것은 두 모델 모두 공유 상태 문제를 없애 주지 않는다는 점입니다. 단일 스레드 이벤트 루프도 await 지점에서 다른 태스크가 끼어들 수 있어서, await를 사이에 둔 check-then-act는 여전히 깨집니다. 실행 모델을 바꿔도 2장부터 5장까지의 작업은 그대로 남습니다.


8. 동시성 코드를 테스트하는 법

8-1. 결정적으로 만들 수 있는 부분을 분리한다

가장 효과적인 기법은 "동시에 돌려 놓고 운을 비는" 테스트를 줄이는 것입니다. 상태 전이 로직을 순수 함수로 뽑아내면 그 부분은 결정적으로 테스트되고 동시성 표면은 얇아집니다.

8-2. 경합 창을 인위적으로 벌린다

무작위 실행에 기대는 대신 원하는 인터리빙을 강제합니다. 배리어나 래치를 테스트 전용 훅으로 심어 두 스레드가 정확히 그 지점에서 만나게 합니다.

예시 — 두 갱신이 반드시 겹치게 만드는 테스트

  스레드 A: 읽기 ──────┐(배리어 대기)──────→ 쓰기
  스레드 B: 읽기 ──────┘(배리어 해제)──────→ 쓰기

  기대: 두 번째 쓰기가 충돌로 거절된다.
  확률이 아니라 구조로 lost update를 재현한다.

8-3. 반복 실행, 레이스 디텍터, 속성 기반 테스트

동시성 테스트는 반복 횟수를 올려 돌립니다. CI에서 1회, 야간 작업에서 수백 회 같은 이중 구성이 현실적입니다. 레이스 디텍터는 별도 잡으로 켜고 통합 테스트와 함께 돌립니다. 무작위성이 있으면 시드를 로그에 남깁니다. 재현의 유일한 단서입니다.

동시 연산의 결과가 어떤 순차 실행 순서로도 설명 가능해야 한다는 성질은 속성으로 표현하기 좋습니다. 무작위 연산 시퀀스를 동시에 돌린 뒤 순차 모델과 대조하면 손으로 짠 케이스보다 훨씬 넓은 인터리빙을 훑습니다(속성 기반 테스트 실전).

8-4. 플래키 테스트를 무시하지 않는다

간헐적 실패를 재실행으로 넘기는 습관은 동시성 결함을 은폐하는 가장 흔한 경로입니다. 플래키 테스트는 대개 둘 중 하나입니다. 테스트가 잘못 작성됐거나, 진짜 경합을 발견한 것입니다. 확인 전에 재시도 설정으로 덮지 않는 규칙이 필요합니다.


9. 실패 모드 카탈로그

실패 모드증상전형적 원인1차 대응
데드락스레드 영구 정지, CPU 0%락 획득 순서 불일치전역 잠금 순서, 획득 타임아웃
라이브락CPU는 바쁜데 진척 없음충돌 후 전원이 동시에 양보·재시도백오프에 지터, 재시도 상한
기아특정 작업만 계속 밀림읽기 우선 락, 우선순위 없는 큐공정성 옵션, 대기 시간 기반 승격
lost update나중 저장이 먼저 저장을 덮음읽고-수정-쓰기 사이 보호 없음버전 검사 조건부 갱신, If-Match
이중 처리결제·메일이 두 번 나감재시도 + 비멱등 연산멱등 키, 유니크 제약
팬텀 응답응답이 다른 요청의 결과요청 스코프 값을 공유 객체에 저장컨텍스트 전파 점검, 스레드 로컬 정리
스레드 풀 고갈전면 지연, 헬스체크 실패풀 내부에서 같은 풀을 기다림풀 분리, 블로킹 호출 격리
커넥션 풀 고갈DB 대기 급증트랜잭션 안에서 외부 호출임계 구역에서 I/O 제거
캐시 스탬피드만료 직후 원본 폭주동일 키가 동시에 만료만료 시각 지터, 단일 비행 갱신
순서 역전이벤트가 뒤바뀌어 도착키 없는 병렬 소비키 기반 파티셔닝, 버전 기반 무시

반복되는 패턴이 보입니다. 항목 대부분은 공유 범위를 줄였다면 애초에 존재하지 않았을 문제입니다. 2장의 사다리를 한 칸 더 내려가는 것이 이 표를 외우는 것보다 값이 큽니다. 지연 폭증이 보인다면 커넥션 풀 크기서킷 브레이커 패턴도 함께 점검하세요.


퀴즈: 실력을 확인해 보세요

퀴즈 1: 모든 공유 변수를 원자적 타입으로 바꿨는데도 재고가 음수가 됩니다. 무엇을 잘못했나?

정답: 원자적 연산은 한 연산의 원자성만 보장합니다. 재고를 읽어 검사한 뒤 차감하는 것은 두 연산이라 그 사이가 열려 있습니다.

설명: 데이터 레이스와 레이스 컨디션은 다른 문제입니다. 접근이 모두 원자적이면 데이터 레이스는 사라지지만 "재고는 0 미만이 될 수 없다"는 불변식은 검사와 차감을 한 덩어리로 묶어야 지켜집니다. 조건부 원자 연산으로 합치거나, 하나의 임계 구역 또는 하나의 조건부 갱신문으로 묶어야 합니다. 레이스 디텍터가 조용하다는 사실이 정확성의 증거는 아닙니다.

퀴즈 2: 인스턴스를 1대에서 4대로 늘린 날부터 중복 주문이 생깁니다. 무엇을 먼저 확인하나?

정답: 중복 방지 로직이 프로세스 내부의 락이나 인메모리 캐시에 의존하고 있는지 확인합니다.

설명: 단일 인스턴스에서는 언어의 뮤텍스가 사실상 전역 조정자 역할을 했습니다. 인스턴스가 늘면 각자 자기 뮤텍스를 잡으므로 그 보장이 통째로 사라집니다. 대응은 분산 락을 먼저 도입하는 것이 아니라 저장소 쪽에 유니크 제약이나 조건부 갱신을 두는 것입니다. 분산 락은 임대 만료와 프로세스 정지 때문에 상호 배제를 보장하지 못합니다.

퀴즈 3: 락 경합이 심해 임계 구역을 줄이려 합니다. 가장 먼저 밖으로 뺄 것은?

정답: 임계 구역 안의 네트워크 호출과 디스크 I/O입니다.

설명: 임계 구역의 길이는 다른 모든 대기자의 대기 시간이고, I/O는 그중 통제되지 않는 부분입니다. 외부 API가 10밀리초에서 2초로 느려지면 그 락에 걸린 모든 스레드가 2초씩 밀립니다. 데이터를 락 안에서 복사해 나와 밖에서 I/O를 하고 반영할 때 다시 짧게 잠급니다. 두 번째 잠금 시점에는 상태가 바뀌었을 수 있으므로 재검증이 필요합니다.

퀴즈 4: 과부하 시 응답 시간이 30초까지 늘지만 오류율은 0%입니다. 좋은 상태인가?

정답: 아닙니다. 상한 없는 큐가 있다는 신호이며 이미 아무도 기다리지 않는 요청을 처리하고 있을 가능성이 큽니다.

설명: 오류율 0%와 응답 30초의 조합은 과부하를 거절하지 않고 쌓고 있다는 뜻입니다. 클라이언트는 그전에 타임아웃하고 재시도하므로 서버는 폐기해도 되는 작업을 처리하며 새 요청까지 밀어냅니다. 큐에 상한을 두고 초과분을 즉시 거절하며, 진입 시각을 기록해 타임아웃이 지난 항목은 꺼내는 즉시 버려야 합니다.

퀴즈 5: 동시성 테스트가 500회 중 1회 실패합니다. 다음 행동으로 적절한 것은?

정답: 재실행으로 넘기지 않고 테스트 자체의 결함인지 실제 경합인지 판정하는 것이 먼저입니다.

설명: 간헐적 실패는 확률이 낮은 인터리빙에서만 드러나는 진짜 결함의 유일한 신호일 수 있습니다. 실패 당시의 시드를 기록하고 배리어로 그 인터리빙을 강제해 결정적 테스트로 바꿔 봅니다. 재현되면 실제 결함이고, 안 되면 테스트의 타이밍 가정이 잘못된 것입니다. 판정 전에 재시도 설정으로 덮으면 그 신호는 프로덕션에서 다시 나타납니다.


마치며

동시성 설계에서 가장 많이 쓰이는 도구는 뮤텍스가 아니라 삭제입니다. 공유하지 않아도 되는 것을 공유하지 않고, 전역이 아니어도 되는 것을 전역에 두지 않고, 키로 쪼갤 수 있는 것을 쪼개고 나면 실제로 잠가야 할 대상은 놀랄 만큼 줄어듭니다.

남은 것은 순서를 지키면 됩니다. 불변식을 문장으로 적고, 그 문장이 깨지는 구간을 원자성 경계로 정하고, 경계 안에서는 메모리만 만지고, 프로세스 밖으로 나가면 저장소의 제약 조건과 멱등성을 최후 방어선으로 두고, 큐마다 상한과 거절 정책을 붙입니다. "아마 괜찮을 겁니다"는 설계가 아닙니다. 왜 안전한지를 한 문단으로 쓸 수 없다면 그 코드는 아직 완성되지 않았습니다.


참고 자료

  • The Go Memory Model — go.dev — 데이터 레이스의 정의, 동시 접근 직렬화 의무, 레이스가 없을 때의 순차 일관성(DRF-SC) 문장을 인용했습니다. 2026-08-15 확인.
  • RFC 6585: Additional HTTP Status Codes — IETF — 428 Precondition Required가 조건부 요청을 요구해 lost update 문제를 막는다는 설명을 인용했습니다. 2026-08-15 확인.
  • RFC 9110: HTTP Semantics — IETF — 멱등성의 정의와 멱등 메서드 목록, POST가 멱등이 아니라는 점을 인용했습니다. 2026-08-15 확인.
  • Google SRE Book — Addressing Cascading Failures — 연쇄 장애의 정의, 무작위 지수 백오프 권고, 재시도 예산 예시, 다층 재시도의 곱셈 효과, 조기 거절 권고를 인용했습니다. 2026-08-15 확인.
  • 공유 상태 축소 사다리, 락 계층 예시, 9장의 실패 모드 표는 위 자료에 나오는 것이 아니라 이 글에서 정리한 절차입니다.

이어서 읽기

완전 가이드 시리즈

현재 단락 (1/149)

이 블로그에는 동시성을 다룬 글이 이미 두 편 있습니다. [동시성 vs 병렬성](/blog/2026-07-03-concurrency-vs-parallelism)은 두 개념의 차이를...

작성 글자: 0원문 글자: 12,026작성 단락: 0/149