// chikrii_algorithm_strategic_labprovided as internal research — read-only
root@chikrii-lab:~/chikrii-lab/softlab-archive/concurrency-analysis.md

동시성 분석 실무 가이드: Race Condition부터 Deadlock과 성능 병목까지

평소에는 정상인데 트래픽이 몰릴 때만 잔액, 재고, 카운터 값이 틀어진다면 단순 로직 버그가 아니라 동시성 문제일 수 있습니다. 동시성 버그는 로그 한 줄만으로 원인을 찾기 어렵고, 재실행하면 사라지며, 성능 개선을 위해 넣은 비동기 처리나 캐시가 오히려 새로운 장애를 만들기도 합니다. 그래서 동시성 분석은 “락을 어디에 넣을까”보다 넓은 관점에서 공유 자원, 실행 순서, 대기 시간, 검증 방법을 함께 살피는 작업입니다.

동시성 분석에서 여러 스레드가 공유 자원에 접근하는 구조

동시성 분석이란 무엇인가

동시성 분석은 동시에 실행되는 작업들이 공유 자원, 실행 순서, 잠금, 스케줄링, 메모리 가시성 때문에 일으키는 오류와 성능 병목을 찾는 과정입니다. Oracle Java Tutorial도 concurrent software를 하나의 애플리케이션이 동시에 여러 작업을 수행할 수 있는 소프트웨어로 설명하며, Java 플랫폼이 언어와 라이브러리 차원에서 동시성 프로그래밍을 지원한다고 설명합니다.

여기서 동시성과 병렬성은 구분해야 합니다. 동시성은 여러 작업이 겹쳐 실행될 때 “어떻게 조정할 것인가”의 문제에 가깝고, 병렬성은 여러 코어에서 실제로 동시에 계산이 수행되는 실행 형태에 가깝습니다. 단일 코어에서도 스케줄러가 작업을 번갈아 실행하면 동시성 문제는 발생할 수 있습니다. 반대로 병렬 처리 성능이 좋아 보여도 공유 자원 경합이 심하면 처리량은 쉽게 떨어집니다.

동시성 문제가 디버깅하기 어려운 이유

실행 순서가 매번 달라진다

스레드, goroutine, 비동기 태스크의 실행 순서는 운영체제 스케줄러, I/O 지연, CPU 부하, GC, 네트워크 지연에 따라 달라집니다. 같은 코드를 같은 입력으로 실행해도 읽기와 쓰기의 순서가 바뀌면 결과가 달라질 수 있습니다.

작은 타이밍 차이가 결과를 바꾼다

로컬 테스트에서는 1ms 안에 끝나던 코드가 운영 환경에서는 잠깐 멈추고, 그 사이 다른 실행 흐름이 같은 값을 수정할 수 있습니다. 로그를 추가했더니 문제가 사라지는 현상도 흔합니다. 로그 출력 자체가 실행 타이밍을 바꾸기 때문입니다.

성능 개선 코드가 안정성 문제를 만들 수 있다

캐시, 배치 처리, 비동기 큐, 락 범위 축소는 성능을 높일 수 있지만, 상태 동기화가 부정확하면 stale read, 중복 처리, 순서 역전이 생깁니다. 따라서 동시성 분석은 성능 튜닝과 분리해서 볼 수 없습니다.

동시성 분석에서 자주 만나는 문제 유형

유형 핵심 증상 분석 초점
race condition 실행 순서에 따라 결과가 달라짐 타이밍 창, 공유 자원 접근 순서
data race 동기화 없이 같은 변수에 동시 접근하고 하나 이상이 쓰기 메모리 접근, happens-before
deadlock 작업이 끝나지 않고 서로 기다림 잠금 획득 순서, 순환 대기
starvation 특정 작업이 계속 실행 기회를 얻지 못함 우선순위, 큐 정책, 락 공정성
livelock 계속 움직이지만 진행이 없음 재시도 정책, 양보 로직
lock contention CPU보다 대기 시간이 커짐 임계 구역 길이, 락 분할 가능성

race condition은 공유 자원에 대해 임시적이고 배타적인 접근이 필요한데 다른 실행 흐름이 그 자원을 수정할 수 있는 타이밍 창이 있는 상황입니다. data race는 그중 메모리 접근 관점의 구체적인 형태로, Go 공식 문서는 두 goroutine이 같은 변수에 동시에 접근하고 하나 이상이 쓰기일 때 발생한다고 설명합니다. 모든 race condition이 data race인 것은 아닙니다. 예를 들어 데이터베이스에서 중복 쿠폰 발급이 발생하는 문제는 애플리케이션 메모리 data race 없이도 생길 수 있습니다.

race condition이 발생하는 실행 순서 타임라인

deadlock은 여러 스레드나 실행 구간이 필요한 lock을 서로 해제하기를 기다리면서 멈추는 상태입니다. MITRE CWE-833은 이 문제가 작업 미완료나 availability 문제로 이어질 수 있다고 설명합니다. 반면 lock contention은 완전히 멈춘 상태가 아니라, 많은 작업이 같은 락 앞에서 줄을 서느라 처리량과 응답 시간이 나빠지는 상태입니다.

두 스레드가 서로의 잠금을 기다리는 deadlock 구조

증상에서 원인까지 추적하는 동시성 분석 절차

공유 자원과 임계 구역을 먼저 찾는다

분석의 출발점은 “무엇이 공유되는가”입니다. 메모리 변수, 캐시 키, DB 레코드, 파일, 메시지 큐 offset, 세션 객체, 통계 카운터가 후보입니다. 그다음 읽기와 쓰기가 동시에 일어날 수 있는지, 한 번에 하나의 실행 흐름만 들어가야 하는 임계 구역이 어디인지 표시합니다.

실행 순서와 happens-before 관계를 추적한다

동시성 버그는 코드 줄 순서가 아니라 실제 실행 순서에서 발생합니다. mutex unlock 이후 lock, channel 송수신, future 완료, atomic 연산, 트랜잭션 commit 같은 동기화 지점이 어떤 순서를 보장하는지 확인해야 합니다. 보장되지 않는 순서에 의존한 코드가 있다면 재현이 어려운 버그의 후보입니다.

로그와 타임라인으로 사건을 재구성한다

운영 로그에는 request_id, thread 또는 goroutine 식별자, lock 획득 대기 시간, 상태 버전, 재시도 횟수, 큐 대기 시간, 트랜잭션 ID를 남기는 것이 좋습니다. 단순히 “성공/실패”만 기록하면 순서를 복원할 수 없습니다. 장애 시점 전후의 이벤트를 시간축으로 정렬하면 어떤 작업이 먼저 읽고, 누가 나중에 덮어썼는지 드러납니다.

스레드 덤프와 프로파일러로 대기 지점을 본다

Java에서는 스레드 덤프와 Java Flight Recorder, synchronized 블록, java.util.concurrent 사용 지점을 함께 봅니다. WAITING, BLOCKED 상태가 특정 락에 몰려 있으면 contention이나 deadlock 가능성이 있습니다. Go에서는 goroutine dump와 block profile, mutex profile을 활용해 채널 대기와 mutex 대기 지점을 확인합니다. Visual Studio Concurrency Visualizer 같은 도구는 스레드 타이밍, CPU underutilization, synchronization delays를 그래프와 표로 보여주는 데 유용합니다.

detector와 sanitizer는 검증 도구로 사용한다

Go의 race detector는 go test -race, go run -race, go build -race처럼 사용할 수 있습니다. 다만 실제 실행된 코드 경로에서 발생한 race만 찾기 때문에 테스트 커버리지가 부족하면 문제를 놓칠 수 있습니다. 또한 일반적인 프로그램에서 메모리 사용량이 5~10배, 실행 시간이 2~20배 증가할 수 있다고 문서는 조심스럽게 설명하므로 상시 운영 적용보다는 테스트, 스테이징, 제한된 워크로드에서 활용하는 편이 현실적입니다.

C/C++에서는 ThreadSanitizer C++ Manual이 설명하는 것처럼 -fsanitize=thread 옵션으로 컴파일하고 링크해 data race를 탐지할 수 있습니다. C++11 표준에서 data race는 undefined behavior로 취급되므로 “가끔 맞는 것처럼 보인다”는 이유로 방치하면 안 됩니다.

성능 관점에서 동시성 병목을 해석하는 법

CPU 사용률이 낮은데 응답이 느리다면 계산이 부족한 것이 아니라 대기 시간이 긴 상태일 수 있습니다. 대표 원인은 DB 커넥션 풀 고갈, 전역 mutex 경합, 큐 소비자 부족, 외부 API 응답 지연, 스레드 풀 포화입니다. 이때 CPU flame graph만 보면 원인을 놓칠 수 있으므로 wall-clock profile, blocking profile, lock wait metric을 함께 봐야 합니다.

락 경합은 처리량을 비선형적으로 낮춥니다. 임계 구역 안에서 I/O를 호출하거나, 큰 컬렉션 전체를 잠그거나, 모든 요청이 하나의 통계 카운터를 갱신하면 코어 수를 늘려도 성능이 늘지 않습니다. 반대로 락을 너무 잘게 쪼개면 잠금 순서가 복잡해져 deadlock 위험이 커질 수 있습니다. 동시성 분석은 안정성과 병렬 처리 성능 사이의 균형점을 찾는 작업입니다.

비동기 처리가 항상 빠른 것도 아닙니다. 태스크가 지나치게 잘게 나뉘면 컨텍스트 스위칭과 큐 관리 비용이 커지고, 순서 보장이 필요한 작업에서는 재정렬 비용이 추가됩니다. 스레드 수를 늘릴 때는 처리량, p95·p99 지연 시간, 큐 길이, context switch, lock wait time을 함께 측정해야 합니다.

동시성 버그를 줄이는 설계 원칙

  • 공유 상태를 줄입니다. 가능한 데이터는 요청 단위의 지역 상태로 두고, 공유해야 한다면 소유자를 명확히 정합니다.
  • 임계 구역을 작게 유지합니다. 락 안에서는 계산과 상태 변경만 수행하고, 네트워크 I/O나 긴 DB 작업은 피합니다.
  • 잠금 순서를 일관되게 유지합니다. A 다음 B를 잡는 코드와 B 다음 A를 잡는 코드가 공존하면 deadlock 후보입니다. Linux kernel lockdep 자료가 강조하는 잠금 의존성 검증 관점도 여기서 유용합니다.
  • 불변 객체와 메시지 전달을 고려합니다. 값이 바뀌지 않으면 race 가능성이 줄고, channel이나 queue로 소유권을 이동하면 공유 범위를 제한할 수 있습니다.
  • atomic, mutex, channel을 상황에 맞게 선택합니다. 단순 카운터는 atomic이 적합할 수 있지만, 여러 필드를 함께 갱신하는 불변식을 보호하려면 mutex나 트랜잭션이 더 안전합니다.

바로 적용할 수 있는 동시성 분석 체크리스트

  • 같은 데이터를 여러 스레드, goroutine, 프로세스가 읽고 쓰는가?
  • 읽기와 쓰기가 동시에 일어날 때 결과가 달라질 수 있는가?
  • 공유 자원 접근 전에 명확한 동기화 지점이 있는가?
  • 잠금 획득 순서가 코드 경로마다 다르지 않은가?
  • 락을 잡은 상태에서 외부 API, 파일, DB 같은 느린 I/O를 호출하지 않는가?
  • 타임아웃 없이 무한 대기할 수 있는 지점이 있는가?
  • 스레드 덤프에서 BLOCKED나 WAITING이 특정 자원에 몰리는가?
  • 프로파일러에서 CPU 시간보다 lock wait, queue wait, I/O wait가 더 큰가?
  • race detector나 ThreadSanitizer 실행 시 주요 코드 경로가 실제로 테스트되는가?
  • 수정 후 부하 테스트, 반복 테스트, 장애 재현 시나리오에서 p95·p99 지연 시간과 오류율이 안정적인가?

문제를 고쳤다고 판단하는 기준

동시성 버그 수정은 “테스트 한 번 통과”로 끝내면 위험합니다. 먼저 실패하던 시나리오를 재현 가능한 테스트로 만들고, 스케줄링 변동을 늘리기 위해 반복 실행과 병렬 실행을 적용합니다. 그다음 race detector나 sanitizer를 켜고 주요 경로를 통과시킵니다. 마지막으로 실제 부하와 비슷한 워크로드에서 처리량, 지연 시간, 대기 시간, 오류율을 비교해야 합니다.

수정 방식도 검토해야 합니다. 락을 추가해 데이터는 맞아졌지만 처리량이 절반으로 줄었다면 또 다른 장애의 씨앗입니다. 반대로 성능을 위해 락을 제거했다면 happens-before 관계와 메모리 가시성이 대체 수단으로 보장되는지 확인해야 합니다.

동시성 분석은 안정성과 성능을 함께 보는 일이다

동시성 분석은 단순히 락을 추가하는 작업이 아닙니다. 공유 상태가 어디에 있는지, 어떤 실행 순서를 전제로 하는지, 어느 지점에서 대기 시간이 쌓이는지, 수정 후 어떤 방식으로 검증할지를 함께 보는 과정입니다. race condition, data race, deadlock, lock contention은 서로 다른 증상을 보이지만 모두 “동시에 실행되는 작업의 조정 실패”에서 출발합니다. 운영 로그, 스레드 덤프, 프로파일러, race detector, ThreadSanitizer를 목적에 맞게 조합하면 재현이 어려운 동시성 버그도 훨씬 체계적으로 좁혀갈 수 있습니다.