레이스 컨디션이란
🏎️ 운영체제에서 말하는 '레이스 컨디션 (Race Condition)', 한 번에 정리하자
1. 들어가며: 왜 '동시에'만 하면 위험할까?
우리는 보통 프로그램을 작성할 때 "A 함수 호출하고, 그다음 B 함수 호출하면 되겠지"라고 생각하기 쉽습니다. 하지만 운영체제, 특히 여러 프로세스·스레드가 동시에 실행되는 환경에서는 그 순서가 꼬이거나, 중간 상태가 다른 실행 흐름에게 그대로 노출될 수 있습니다.
이때 등장하는 개념이 바로 **레이스 컨디션(Race Condition)**입니다.
레이스 컨디션은 단순히 "동시성 버그"를 뜻하는 말이 아니라, 실행 순서나 타이밍에 따라 결과가 달라지고, 그 결과 예상치 못한 잘못된 상태가 만들어지는 현상을 말합니다.
이 글에서는 레이스 컨디션이 왜 생기는지, 어떻게 동작하는지, 어떤 예시로 나타나고, 어떻게 막는지를 최대한 세세하게 풀어보겠습니다.
2. 레이스 컨디션의 핵심 정의
2.1 직관적인 정의
레이스 컨디션이란 둘 이상의 실행 흐름(프로세스, 스레드, 인터럽트 핸들러 등)이 공유 자원을 다룰 때, 실행 순서나 타이밍에 따라 최종 결과가 달라지고, 특정 조건에서는 잘못된 결과가 발생하는 현상입니다.
여기서 중요한 포인트는 두 가지입니다.
-
누가 이기느냐가 결과가 된다
여러 흐름이 "경쟁"하며 공유 자원을 건드리기 때문에, 어느 쪽이 먼저 실행되느냐에 따라 값이 달라집니다. -
잘못된 상태가 실제로 발생한다
단순히 "결과가 다를 수 있다"에서 끝나지 않고, 데이터의 정합성이 깨지거나 시스템 상태가 unsafe해지는 경우가 많습니다.
2.2 조건: 왜 아무 때나 생기지 않는가?
레이스 컨디션이 생기려면 보통 아래와 같은 조건이 겹칩니다.
- **공유 자원(shared resource)**이 존재한다.
- 여러 실행 흐름이 그 자원을 읽고/쓰거나, 상태를 확인하고/변경한다.
- 그 과정이 원자적으로(atomic) 처리되지 않는다.
- 실행 순서나 타이밍이 예측 불가능하거나 보장되지 않는다.
즉, "공유 자원을 여러 주체가 건드리는데, 그 작업이 쪼개질 수 있고, 순서도 마음대로 바뀔 수 있을 때" 레이스 컨디션의 위험이 생깁니다.
3. 동시성, 원자성, 그리고 크리티컬 섹션
레이스 컨디션을 제대로 이해하려면 동시성 프로그래밍의 기본 개념들을 같이 봐야 합니다.
3.1 동시성과 병렬성
- 동시성(concurrency): 여러 작업이 진행 중인 듯한 구조. 실제로는 시간상으로 번갈아 실행될 수도 있습니다.
- 병렬성(parallelism): 실제로 여러 흐름이 동시에 실행됨.
운영체제에서는 스레드, 프로세스, 인터럽트, 커널 작업 등 다양한 수준이 동시에 진행됩니다. 이 "동시에"가 바로 레이스 컨디션의 무대가 됩니다.
3.2 원자성(Atomicity)
어떤 연산이 중간 상태 없이 한 번에 완료되면 원자적이라고 합니다.
원자적이지 않은 연산은 다른 실행 흐름이 그 중간에 끼어들 수 있습니다.
예를 들어 "값을 읽어서 1 증가시킨 뒤 다시 저장"하는 연산은,
- 읽기
- 증가
- 저장
이 3개 작업이 분리되어 있기 때문에 원자적이지 않습니다. 만약 같은 작업을 원자적으로 한다면 '읽은다음증가시킨다음저장' 이라는 작업을 원자적으로 만들어야함. 이 사이사이에 다른 흐름이 같은 변수를 읽거나 쓰면, 결과가 달라질 수 있습니다.
3.3 크리티컬 섹션(Critical Section)
크리티컬 섹션은 공유 자원을 접근하거나 변경하는 코드 영역입니다.
이 영역은 다음 원칙을 만족해야 안전하게 동작합니다.
- 한 번에 하나의 실행 흐름만 진입해야 하거나,
- 진입 순서나 상호 배제가 보장되어야 하거나,
- 최소한 예상된 상태를 깨지 않도록 설계되어야 합니다.
레이스 컨디션은 주로 이 크리티컬 섹션이 제대로 보호되지 않을 때 나타납니다.
4. 레이스 컨디션의 대표 패턴
레이스 컨디션은 단순히 "변수 하나 두고 동시 접근"만 있는 게 아닙니다. 패턴으로 보면 훨씬 이해가 쉽습니다.
4.1 체크-액트(Check-Then-Act) 패턴
가장 흔한 레이스 패턴 중 하나입니다.
- "A 조건이 참이면 어떤 작업을 한다"
- 그런데 A가 참인지 확인한 뒤, 실제로 작업을 수행하기 사이에 다른 흐름이 상태를 바꿔버릴 수 있습니다.
이 경우 확인 시점에는 유효해 보였는데, 실제 실행 시점에는 이미 무효가 되어 있을 수 있습니다.
결과적으로:
- 이미 처리된 일을 다시 처리하거나
- 존재하지 않는 자원을 사용하려 하거나
- 권한과 상태가 불일치하는 상황이 생길 수 있습니다.
4.2 리드-모디파이-라이트(Read-Modify-Write) 패턴
공유 변수를 읽고, 수정한 뒤, 다시 쓰는 패턴입니다.
이 패턴은 정말 자주 등장하고, 레이스 컨디션도 매우 잘 생깁니다.
예를 들어 카운터를 1 증가시키는 작업을 생각해봅시다.
이 작업은 실제로
- 현재 값을 읽고
- 1을 더하고
- 새 값을 저장
하는 세 단계로 나뉩니다.
이 사이사이에 다른 스레드가 같은 값을 읽고 같은 값으로 증가시켜 저장하면, 증가 횟수가 실제보다 적어질 수 있습니다.
이건 단순히 "값이 좀 틀릴 수 있다" 수준을 넘어, 실행 횟수 합계가 보존되지 않는 문제로 이어집니다.
4.3 테스트-액션-Time-of-check to Time-of-use (TOCTOU)
보안이나 파일 처리에서 자주 언급되는 패턴입니다.
어떤 대상의 상태를 확인한 뒤, 그 확인 결과가 여전히 유효하다고 가정하고 실제 사용하는 구조입니다.
문제는 확인 시점과 사용 시점 사이의 틈입니다.
이 틈에 상태가 바뀌면, 확인은 통과했지만 실제 사용은 안전하지 않은 상황이 발생할 수 있습니다.
4.4 상태 기반 의사결정
어떤 플래그나 상태를 보고 다음 행동을 결정하는 경우에도 레이스가 생길 수 있습니다.
예를 들어 "작업 중인지 여부"를 플래그로 검사한 뒤 실제 작업을 시작하는 구조에서,
검사와 작업 시작 사이에 다른 흐름이 플래그를 바꾸거나 작업을 조작하면 의도하지 않은 중복 실행이나 불일치가 생길 수 있습니다.
5. 예시로 보는 레이스 컨디션
이제 구체적인 예시를 여러 층위로 살펴보겠습니다.
운영체제/시스템 수준의 이야기이므로, 너무 실생활 느낌만 가지 말고 실행 흐름의 간섭을 중심으로 읽는 것이 좋습니다.
5.1 예시 1: 은행 계좌 동시 출금
가장 이해하기 쉬운 사례입니다.
계좌에 100만 원이 있고, 두 사람이 거의 동시에 60만 원씩 출금하려고 한다고 합시다.
올바른 결과라면 둘 중 하나는 실패해야 할 텐데, 레이스 컨디션이 있으면 둘 다 성공해서 잔고가 음수가 될 수도 있습니다.
핵심 흐름은 이렇습니다.
- 스레드 A의 읽기: 잔액 100만 원
- 스레드 B의 읽기: 잔액 100만 원
- 스레드 A 계산: 100 - 60 = 40만 원
- 스레드 B 계산: 100 - 60 = 40만 원
- 스레드 A가 40만 원 저장
- 스레드 B가 40만 원 저장
이 경우 최종 잔액은 40만 원이 되고, 실제로는 120만 원이 출금된 셈이 됩니다.
여기서 문제는 잔액 확인과 출금 반영이 하나의 원자적 작업이 아니었다는 점입니다.
각 스레드가 잔액을 읽고, 조건을 판단하고, 다시 쓰는 사이에 서로의 작업을 덮어버린 것이죠.
이런 레이스 컨디션은 금융뿐 아니라 재고, 예약, 쿠폰, 좌석 배정 같은 곳에서도 똑같이 위험합니다.
5.2 예시 2: 공유 카운터 증가
카운터를 여러 스레드가 동시에 증가시키는 상황을 생각해봅시다.
레이스 컨디션이 있으면 예상보다 적은 횟수가 기록될 수 있습니다.
예를 들어 1을 증가시키는 코드가 다음과 같은 단계로 이루어져 있다고 합시다.
- count 값을 읽는다
- 읽은 값에 1을 더한다
- 그 값을 count에 다시 저장한다
스레드 A와 B가 거의 동시에 이 작업을 수행하면,
- 둘 다 같은 초기 값을 읽고
- 각자 1을 더한 뒤
- 마지막에 저장한 값만 남을 수 있습니다
즉, 증가가 두 번 일어났는데도 카운터는 1만 증가할 수 있습니다.
이런 문제는 특히 누적 통계, 요청 횟수, 이벤트 발생 횟수, 작업 완료 횟수 같은 곳에서 치명적입니다.
숫자가 틀리면 로깅, 모니터링, 과금, 재시도 판단 등이 모두 흔들릴 수 있습니다.
5.3 예시 3: 파일 생성과 존재 여부 확인 사이의 틈
파일을 만들기 전에 "이미 있는지"를 확인하고, 없으면 생성하는 코드를 생각해봅시다.
언뜻 보면 안전해 보이지만, 확인과 생성 사이에 다른 프로세스나 스레드가 끼어들 수 있습니다.
예를 들어,
- A가 파일 존재 여부를 확인함: 없음
- B가 파일 존재 여부를 확인함: 없음
- A가 파일 생성
- B가 파일 생성 시도
이런 순서가 되면 B는 이미 생성된 파일을 다시 다루게 될 수 있고, 경우에 따라 덮어쓰거나 충돌이 발생하거나, 권한과 경로가 꼬일 수 있습니다.
이 문제는 확인 시점과 사용 시점이 분리되어 있다는 점에서 생깁니다.
운영체제 수준에서는 파일, 디렉터리, 심볼릭 링크, 공유 메모리, 장치 노드 등 다양한 자원에서도 유사한 구조가 나타날 수 있습니다.
5.4 예시 4: 싱글톤 초기화와 동시 접근
프로그램에서 " 객체는 하나만 존재해야 한다"는 싱글톤 패턴을 생각해봅시다.
락을 적절히 쓰지 않으면, 처음 생성 시점에 여러 스레드가 동시에 "아직 생성되지 않았으니 새로 만들자"고 판단할 수 있습니다.
그 결과
- 객체가 두 번 생성되거나
- 일부만 초기화된 상태의 객체가 노출되거나
- 여러 흐름이 서로 다른 인스턴스를 참조하게 될 수 있습니다
이건 단순히 "객체 수가 늘어난다"를 넘어서, 상태의 일관성을 깨뜨릴 수 있습니다.
예를 들어 설정값, 연결 풀, 캐시, 전역 자원이 싱글톤으로 관리되는데 초기화 레이스가 생기면, 이후 동작 전체가 불안정해질 수 있습니다.
5.5 예시 5: 커널/하드웨어 수준의 레이스
운영체제 커널이나 low level 시스템 코딩에서도 레이스 컨디션은 중요합니다.
예를 들어,
- 어떤 장치나 자원을 사용 중인지 표시하는 플래그
- 할당/해제 순서
- 인터럽트 핸들러와 정상 코드 사이의 접근
- 여러 코어에서 동시에 접근하는 공유 구조체
이런 경우 단순히 "변수 하나"의 문제가 아니라, 하드웨어 상태, 메모리 순서, 캐시, 인터럽트 타이밍까지 얽힌 복잡한 레이스가 생길 수 있습니다.
커널에서는 특히 "잠깐 확인하고, 잠깐 쓰고, 잠깐 해제하는" 작업들이 많기 때문에,
이 작업들이 원자적으로 보호되지 않으면 시스템 전체의 안정성이 흔들릴 수 있습니다.
6. 레이스 컨디션이 왜 까다로운가
레이스 컨디션은 디버깅이 특히 어렵습니다. 그 이유는 다음과 같습니다.
6.1 재현이 어렵다
실행 순서나 인터럽트 타이밍, 스케줄링, 시스템 부하, 코어 수, 캐시 상태 등에 따라 현상이 달라질 수 있습니다.
어떤 환경에서는 잘 되다가, 어떤 환경에서는 터지는 식이라서 "왜 안 되는지"를 특정하기 어렵습니다.
6.2 버그가 간헐적이다
레이스 컨디션은 항상 실패하는 버그가 아니라, 특정 경쟁 조건에서만 실패하는 경우가 많습니다.
그래서 평소에는 멀쩡해 보이다가, 부하가 걸리거나 배포가 바뀌거나 코어 수가 달라졌을 때 갑자기 나타납니다.
6.3 원인 지점이 멀리 떨어져 있다
문제를 일으키는 흐름과 실제로 잘못된 상태가 관찰되는 지점이 다를 수 있습니다.
예를 들어 A의 타이밍이 B의 실패를 유발할 수도 있고,
그 결과가 한참 뒤의 출력이나 장애로 이어질 수도 있습니다.
6.4 결과가 미묘하다
값이 조금 틀리는 정도부터, 데이터 손상, 중복 처리, 누락, 무한 루프, 교착 상태 비슷한 현상까지 다양하게 나타날 수 있습니다.
그래서 "레이스 컨디션이다"라고 진단하기까지도 시간이 걸리고, 정확한 원인을 찾는 데도 노력이 필요합니다.
7. 레이스 컨디션을 막는 생각의 방향
레이스 컨디션을 완전히 피하려면 "모든 공유 접근을 의심하는 태도"가 필요합니다.
현실적으로는 아래 원칙들을 조합해서 설계합니다.
7.1 공유 자체를 줄이기
가장 강력한 방법은 애초에 충돌을 줄이는 것입니다.
- 가능한 한 스레드/프로세스별 로컬 상태를 사용한다
- 공유가 꼭 필요한지 먼저 검토한다
- 읽기 전용으로 만들 수 있으면 안전하게 만들 수 있는지 검토한다
- 데이터 소유권을 한쪽으로 몰아서 동시에 건드리지 않게 설계한다
공유를 줄이면 보호할 대상도 줄어듭니다.
7.2 원자성 확보하기
여러 단계로 나뉜 작업을 하나의 원자적 작업처럼 다룰 수 있어야 합니다.
방법으로는
- 원자적 연산(atomic operation) 사용
- 락으로 크리티컬 섹션 보호
- 특정 자원을 원자적으로 처리할 수 있는 시스템 호출이나 자료구조 활용
- 변경 단위가 하나의 작업으로만 보이도록 설계
중요한 것은 "겉보기에 한 줄"이라고 해서 원자적인 것은 아니라는 점입니다.
실제로 내부가 여러 단계로 나뉘어 있으면 레이스가 생길 수 있습니다.
7.3 체크-액트 분리하지 않기
상태를 확인하고, 그다음 행동하는 구조는 특히 위험합니다.
가능하면 확인과 행위를 분리 하지 말고,
확인+행위가 한 원자적 단위 안에 들어가도록 해야 합니다.
또는 아예 "확인 후 행동"이 아니라 "상태를 직접 변경하며 결과를 반환"하는 방식으로 바꿀 수 있으면 더 안전합니다.
7.4 불변성과 초기 설계
변하지 않아도 되는 것은 변하지 않게 만드는 것이 좋습니다.
예를 들어
- 초기화 이후에는 변경되지 않는 구조
- 읽기 전용으로 공유되는 데이터
- 복사본을 이용해 독립적으로 처리하는 방식
불변성은 레이스 컨디션을 줄이는 데 매우 강력합니다.
여러 흐름이 동시에 읽어도 안전하면, 보호할 필요가 크게 줄어듭니다.
7.5 식별과 테스트
레이스 컨디션을 막으려면 설계와 코드뿐 아니라 검증도 중요합니다.
- 공유 자원 접근 지점 표시
- 크리티컬 섹션 식별
- 동시성 테스트, 부하 테스트
- 반복 실행으로 간헐적 실패 확인
- 정적 분석, 동시성 관련 경고 확인
다만 테스트만으로는 모든 레이스를 찾기 어렵기 때문에, 설계 단계에서 가능성을 줄이는 것이 더 중요합니다.
8. 단순한 "동시 접근" 과 레이스 컨디션의 차이
여기서 한 번 헷갈리기 쉬운 지점을 정리하고 넘어가겠습니다.
모든 동시 접근이 레이스 컨디션인 것은 아닙니다.
예를 들어 여러 스레드가 같은 값을 읽기만 한다면, 일반적으로 데이터가 바뀌지 않으므로 충돌이 적을 수 있습니다.
레이스 컨디션이 되는 핵심은 공유 상태가 바뀌고, 그 변경 과정이 원자적이지 않으며, 그 결과 상태의 정합성이 깨질 수 있다는 데 있습니다.
즉, 중요한 질문은 "여러 흐름이 닿느냐"가 아니라
**"그 흐름이 상태를 건드리고, 그 건드림이 안전하게 보호되느냐"**입니다.
9. 운영체제 맥락에서 특히 중요한 이유
운영체제는 수많은 실행 흐름이 서로 자원을 나누는 무대입니다.
- 프로세스 간 자원 공유
- 스레드 간 메모리 공유
- 커널 내부 자료구조
- 인터럽트와 정상 실행 흐름의 공존
- 멀티코어 환경에서의 동시 접근
이런 환경에서는 "내 코드만 잘 짜면 된다"가 아니라,
커널, 라이브러리, 하드웨어, 스케줄링, 인터럽트 타이밍까지 함께 고려해야 합니다.
그래서 운영체제 수준의 레이스 컨디션은 단순히 응용 프로그램 버그를 넘어,
시스템 전체의 안정성과 보안, 자원 관리 로직에 직접 영향을 줄 수 있습니다.
10. 정리: 레이스 컨디션을 보는 관점
레이스 컨디션을 한 문장으로 말하면 이렇습니다.
여러 실행 흐름이 공유 자원을 다룰 때, 실행 순서나 타이밍에 따라 결과가 달라지고, 그로 인해 의도하지 않은 잘못된 상태가 생기는 현상
이걸 막으려면 결국 세 가지가 중요합니다.
- 공유 자원을 정확히 파악하고,
- 접근이 원자적이고 안전하게 보호되도록 설계하며,
- 체크-액트 분리나 비원자적 갱신 같은 위험 패턴을 피한다
레이스 컨디션은 "운 나쁘게 순서를 타서 생기는 버그"가 아니라,
타이밍 의존적인 상태 변경 구조 자체에서 나오는 문제라는 점을 이해하면 훨씬 잘 보입니다.
동시성은 성능을 높여주지만, 동시에 책임도 따라옵니다.
그 책임의 핵심이 바로 공유를 어떻게 다룰 것인가, 그리고 변경을 어떻게 원자적으로 만들 것인가입니다.
11. 마무리
레이스 컨디션은 운영체제, 시스템 프로그래밍, 동시성 소프트웨어를 다루는 사람이라면 계속 마주치게 되는 주제입니다.
한 번에 완벽히 이해하기는 어렵지만,
"누가 언제 무엇을 건드리느냐"와 "그 작업이 쪼개질 수 있느냐"라는 관점으로 보면 예시가 훨씬 선명하게 보입니다.
공유 카운터, 은행 계좌, 파일 생성 체크, 싱글톤 초기화, 커널 자원 관리 같은 예시를 떠올리면서,
내가 쓰는 코드에서도 확인-변경-저장이 분리되어 있지 않은지, 공유 상태가 보호되고 있는지를 자주 점검해보면 좋습니다.
결국 레이스 컨디션과의 싸움은 거창한 기술 하나보다,
공유를 최소화하고, 변경 단위를 안전하게 만들고, 위험한 패턴을 피하는 습관에서 시작됩니다.