경쟁 상태(Race Condition)란 무엇일까요?
경쟁 상태(레이스 컨디션, Race Condition)는 시스템의 실질적인 동작 결과가 프로세스나 스레드의 실행 순서 및 타이밍처럼 자신이 통제할 수 없는 외부 요인에 의존할 때 발생하는 현상입니다. 여러 스레드가 공유 자원에 동시에 접근해 값을 변경하려 할 때, 어느 스레드가 먼저 실행되느냐에 따라 결과가 달라지며, 그중 하나라도 바람직하지 않은 결과가 나온다면 이는 버그로 간주됩니다.
왜 보안 문제를 일으킬 수 있나요?
경쟁 상태는 비정상적인 동작에서 비롯되는 심각한 보안 취약점입니다. 작업들이 서로 다른 타이밍에 실행되면 이벤트가 정해진 순서대로 처리되지 않고 엉켜서 발생하며, 그 결과 예측하지 못한 이상 동작이 일어날 수 있습니다. 특히 경쟁 상태는 그 본질상 재현과 테스트가 매우 어렵기 때문에, 개발 단계에서 발견되지 못한 취약점이 실제 운영 환경에서 공격자에게 악용될 위험이 큽니다.
경쟁 상태의 대표적인 예시
프로그램 내부의 스레드가 예측 불가능한 순서로 실행될 때 경쟁 상태가 발생할 수 있습니다. 간단한 예로 두 입력(A, B)과 한 출력으로 불리언 값을 처리하는 AND 논리 게이트를 들 수 있습니다. A와 B가 모두 참일 때만 출력이 '참'이 되어야 하는데, 신호가 도달하는 타이밍이 어긋나면 잘못된 값이 출력될 수 있습니다.
동시성(concurrency) 처리에서도 경쟁 상태가 자주 나타납니다. 예를 들어 스레드 A가 변수 a가 null이 아닌지 검사한 직후 CPU를 빼앗겼다고 가정해 봅시다. 그 사이 스레드 B가 a = 0으로 설정하면, A가 다시 실행될 때 0으로 나누는 오류가 발생하게 됩니다. 이처럼 짧은 시간 창 사이의 연산 순서 차이가 치명적인 결함으로 이어질 수 있습니다.
경쟁 상태가 유발하는 문제
소프트웨어 경쟁 상태는 프로그램의 실행 순서나 타이밍이 스레드 또는 프로세스의 실행 방식에 의존할 때 발생합니다. 심각한(critical) 경쟁 상태는 잘못된 실행이나 소프트웨어 버그로 직결될 수 있으며, 이러한 문제의 근본 원인은 프로세스나 스레드 간에 상태를 공유한다는 점에 있습니다.
C++와 Java에서의 경쟁 상태
C++에서 경쟁 상태는 특정 이벤트들이 원하는 출력을 만들기 위해 반드시 정해진 순서로 발생해야 하는데, 모든 실행 경우에 그렇게 되지 않을 때 나타납니다. 잘못된 이벤트가 경쟁에서 이기면 프로그램은 실패합니다.
Java는 멀티스레드 언어이므로 여러 스레드가 동시에 병렬 실행되면서 경쟁 상태가 발생하기 쉬운 환경입니다. 따라서 Java 개발자라면 경쟁 상태의 위험성을 깊이 이해하고 이에 대한 대비책을 마련해야 합니다.
경쟁 상태를 피하는 방법
경쟁 상태를 방지하려면 공유 자원, 즉 여러 스레드가 함께 사용하는 자원에 대한 모든 연산이 원자적(atomic)이어야 합니다. 원자성을 확보하는 대표적인 방법은 임계 영역(critical section)을 활용하는 것입니다. 임계 영역이란 프로그램 내에서 상호 배제(mutual exclusion)가 반드시 보장되어야 하는 코드 구간을 말합니다.
락(Lock)을 활용한 접근 직렬화
일반적으로 공유 자원에 대한 접근을 직렬화(serialization)하는 것이 경쟁 상태를 피하는 가장 효과적인 방법입니다. 한 프로세스가 자원에 먼저 접근하면 해당 자원은 '잠금(lock)' 상태가 되고, 다른 프로세스는 그 프로세스가 자원을 해제할 때까지 대기해야 합니다.
Mutex와 세마포어(Semaphore)
Mutex(뮤텍스)나 세마포어를 사용하는 것도 경쟁 상태를 관리하는 좋은 방법입니다. 이러한 락은 특정 조건을 충족해야만 프로세스가 자원을 획득할 수 있도록 제한함으로써 경쟁 상태 발생 자체를 차단합니다. 그 외에도 임계 영역을 아예 제거하는 설계를 채택하는 등 다양한 방식으로 경쟁 상태를 예방할 수 있습니다.
ConcurrentHashMap에서 경쟁 상태 회피하기
가능하다면 단일 전역 락을 사용하는 HashMap/ArrayList를 구성하는 것이 안전합니다. 목록에서 전역 락을 제거하고 성능을 높이고 싶다면 ConcurrentHashMap을 사용하거나, 각 인스턴스마다 synchronized를 적용한 ArrayList를 활용해야 합니다. 또는 BlockingQueue나 ConcurrentLinkedQueue 같은 동시성 지원 큐(queue)로 전환하는 것도 훌륭한 대안입니다.
마무리
스레드 스케줄링 알고리즘의 특성상 여러 스레드가 공유 자원에 접근하는 순서를 사전에 알 수 없기 때문에, 두 개 이상의 스레드가 동시에 데이터를 읽고 수정하려 할 때 경쟁 상태는 언제든 발생할 수 있습니다. 원자성 확보, 락과 뮤텍스 활용, 동시성 지원 컬렉션 도입 등 체계적인 접근 방식을 통해 안전하고 신뢰할 수 있는 멀티스레드 환경을 구축하시기 바랍니다.