[에러 해결] Python Multithreading: 로깅 추가 시 사라지는 Race Condition 원인과 해결 방법

파이썬으로 멀티스레드 애플리케이션을 개발하다 보면 때때로 예측하기 어려운 미스터리한 버그에 직면할 수 있습니다. 특히 공유 자원에 접근하는 과정에서 발생하는 Race Condition은 개발자를 골치 아프게 하는 대표적인 문제입니다. 더 이상한 것은, 디버깅을 위해 로그를 추가하면 이 버그가 마법처럼 사라지는 경우가 있다는 것입니다. 이번 글에서는 이러한 ‘하이젠버그(Heisenbug)’ 현상의 원인을 파이썬 런타임 수준에서 분석하고, Race Condition을 안정적으로 해결하는 방법을 제시합니다.

1. 에러 발생 상황

개발 중인 파이썬 멀티스레드 애플리케이션에서 두 개 이상의 스레드가 공유 상태(Shared State)에 접근할 때 예상치 못한 결과가 발생하는 문제가 있었습니다. 아래 코드는 이러한 상황을 재현한 예시입니다.

import threading

counter = 0

def increment():
    global counter

    for _ in range(100000):
        value = counter
        counter = value + 1

threads = [
    threading.Thread(target=increment)
    for _ in range(2)
]

for t in threads:
    t.start()

for t in threads:
    t.join()

print(counter)

위 코드는 두 개의 스레드가 전역 변수 counter를 각각 100,000번씩 증가시키려고 합니다. 예상 결과는 200,000이지만, 실제로 실행해보면 200,000보다 작은 숫자가 출력되는 것을 볼 수 있습니다. 이는 전형적인 Race Condition의 증상입니다.

더욱 당황스러운 것은, 문제가 발생하던 코드에 아래와 같이 간단한 로깅 또는 print 문을 추가하면 Race Condition이 사라진다는 점입니다.

def increment():
    global counter

    for _ in range(100000):
        # print("incrementing") # 이 라인을 추가하면 문제가 사라진다.
        value = counter
        counter = value + 1

로깅이 타이밍을 변경한다는 것은 이해하지만, 파이썬 런타임 수준에서 정확히 어떤 일이 벌어지는지, 그리고 GIL(Global Interpreter Lock)이 작동하는 상황에서도 로깅이 스레드 스케줄링을 충분히 변경하여 Race Condition을 숨길 수 있는지에 대한 의문이 제기됩니다.

2. 명확한 발생 원인

이러한 현상은 ‘하이젠버그(Heisenbug)’라고 불리는 디버깅 효과의 일종입니다. 관찰 행위 자체가 관찰 대상의 행동을 변화시키는 양자역학의 하이젠베르크 불확정성 원리에서 이름을 따왔습니다. 파이썬 멀티스레딩 환경에서 로깅이 Race Condition을 숨기는 원리는 다음과 같습니다.

2.1. Race Condition의 본질

Race Condition은 여러 스레드가 동시에 공유 자원에 접근하고, 최소한 하나 이상의 스레드가 그 자원을 변경할 때 발생합니다. 실행 순서에 따라 결과가 달라지는 비결정적인(non-deterministic) 특징을 가집니다.

위 코드에서 counter = value + 1은 한 줄의 코드처럼 보이지만, 파이썬 인터프리터 수준에서는 여러 단계로 나뉩니다.

  1. counter의 현재 값을 읽어서 value에 저장합니다.
  2. value에 1을 더합니다.
  3. 결과 값을 다시 counter에 저장합니다.

만약 두 스레드가 거의 동시에 이 과정을 실행한다면, 다음과 같은 상황이 발생할 수 있습니다.

  • 스레드 A가 counter를 100으로 읽습니다. (value_A = 100)
  • CPU 스케줄러가 스레드 A를 일시 중지하고 스레드 B에게 제어권을 넘깁니다.
  • 스레드 B가 counter를 100으로 읽습니다. (value_B = 100)
  • 스레드 B가 value_B에 1을 더하고 counter에 101을 씁니다. (counter = 101)
  • CPU 스케줄러가 스레드 B를 일시 중지하고 스레드 A에게 제어권을 넘깁니다.
  • 스레드 A가 value_A에 1을 더하고 counter에 101을 씁니다. (counter = 101)

결과적으로 counter는 두 번 증가했어야 하지만, 한 번만 증가한 것처럼 됩니다. 이것이 바로 Race Condition 때문에 예상 결과 200,000에 미치지 못하는 이유입니다.

2.2. 로깅이 Race Condition을 숨기는 메커니즘 (GIL과 I/O)

파이썬에는 GIL(Global Interpreter Lock)이 존재하여 한 번에 하나의 스레드만 파이썬 바이트코드를 실행할 수 있도록 보장합니다. 이는 Race Condition을 완전히 없애주지는 않지만, CPython 인터프리터 수준에서 메모리 관리를 보호합니다. 그러나 GIL이 있어도 OS 스케줄러에 의해 스레드 간의 컨텍스트 스위칭은 여전히 발생할 수 있습니다.

print() 함수나 로깅 라이브러리를 통한 로깅은 본질적으로 I/O(Input/Output) 작업입니다. I/O 작업은 CPU 작업에 비해 상대적으로 오랜 시간이 걸립니다. 파이썬의 GIL은 I/O 작업 중에는 잠시 해제되는 특성을 가지고 있습니다. 즉, 한 스레드가 I/O 작업을 수행하는 동안 GIL이 해제되어 다른 스레드가 파이썬 바이트코드를 실행할 기회를 얻을 수 있습니다.

로깅 문을 추가하면:

  1. GIL 해제 및 컨텍스트 스위칭 기회 증가: I/O 작업으로 인해 GIL이 더 자주 해제되고, 이는 OS 스케줄러가 다른 스레드에게 CPU를 할당할 수 있는 기회를 증가시킵니다.
  2. 타이밍 변경: 로깅 작업 자체가 상당한 시간을 소모합니다. 이 지연 시간 때문에 Race Condition을 유발하던 임계 구역(Critical Section)에서의 미묘한 타이밍이 변경됩니다. 한 스레드가 counter를 읽고, 값을 변경하기 전에 로깅 작업을 수행하는 동안, 다른 스레드가 먼저 자신의 작업을 완료할 수 있는 시간이 생길 수 있습니다.
  3. 스레드 스케줄링 변경: 결국, 로깅으로 인한 지연과 GIL의 해제는 OS의 스레드 스케줄링 패턴을 미묘하게 변경하여, 우연히 Race Condition이 발생하지 않는 순서로 스레드들이 실행되도록 만들 수 있습니다. 즉, 원래는 겹쳤던 임계 구역의 실행 타이밍이 로깅 때문에 겹치지 않게 되어 문제가 ‘해결’된 것처럼 보이는 것입니다.

결론적으로, 로깅은 스레드의 실행 흐름에 인위적인 지연을 추가하고, GIL 해제를 유발하여 컨텍스트 스위칭의 타이밍을 변경함으로써 Race Condition을 일시적으로 숨기는 효과를 냅니다. 이는 진정한 해결책이 아니며, 언제든 환경이나 로드에 따라 다시 나타날 수 있는 불안정한 상태입니다.

3. 해결 방법 및 코드 예시

Race Condition을 근본적으로 해결하려면 스레드 동기화 메커니즘을 사용하여 공유 자원에 대한 접근을 제어해야 합니다. 가장 일반적이고 효과적인 방법은 락(Lock)을 사용하는 것입니다.

3.1. threading.Lock 사용하기

threading.Lock은 한 번에 하나의 스레드만 특정 코드 블록(임계 구역)을 실행하도록 강제하여 Race Condition을 방지합니다. 락을 획득(acquire)한 스레드만이 임계 구역에 진입할 수 있으며, 락을 해제(release)해야 다른 스레드가 락을 획득할 수 있습니다.

다음은 threading.Lock을 사용하여 counter 변수에 대한 접근을 스레드 안전하게 만든 코드 예시입니다.

import threading

counter = 0
lock = threading.Lock() # 락 객체 생성

def increment_safe():
    global counter
    for _ in range(100000):
        lock.acquire() # 락 획득
        try:
            value = counter
            counter = value + 1
        finally:
            lock.release() # 락 해제 (예외 발생 시에도 해제 보장)

# 파이썬의 with 문을 사용하면 더 깔끔하고 안전하게 락을 다룰 수 있습니다.
def increment_with_lock_context():
    global counter
    for _ in range(100000):
        with lock: # 락 획득 및 해제를 자동으로 처리
            value = counter
            counter = value + 1

threads = [
    threading.Thread(target=increment_with_lock_context) # 변경된 함수 사용
    for _ in range(2)
]

for t in threads:
    t.start()

for t in threads:
    t.join()

print(counter)

위 코드를 실행하면 이제 안정적으로 200,000이 출력되는 것을 확인할 수 있습니다. with lock: 문법을 사용하면 락 획득 및 해제를 자동으로 처리해주므로, 예외 발생 시에도 락이 올바르게 해제되어 교착 상태(Deadlock)를 방지할 수 있어 더욱 권장됩니다.

3.2. 다른 동기화 메커니즘 (참고)

  • threading.RLock (재진입 락): 동일 스레드 내에서 여러 번 획득할 수 있는 락입니다.
  • threading.Semaphore (세마포어): 동시에 N개의 스레드만 특정 자원에 접근하도록 제한할 때 사용합니다.
  • queue.Queue: 스레드 간에 데이터를 안전하게 주고받을 때 유용합니다. 공유 상태를 직접 수정하는 대신 메시지 패싱 방식으로 데이터를 처리합니다.

4. 향후 예방을 위한 팁

파이썬 멀티스레드 환경에서 Race Condition과 같은 동시성 문제를 예방하고 효과적으로 디버깅하기 위한 몇 가지 팁입니다.

4.1. 공유 상태 최소화 및 불변성 활용

멀티스레드 프로그래밍의 가장 좋은 방법은 공유 가능한 변경 가능한 상태(Shared Mutable State)를 아예 만들지 않거나 최소화하는 것입니다. 가능하면 스레드마다 독립적인 데이터를 사용하고, 데이터를 공유해야 할 때는 불변(Immutable) 객체를 사용하는 것을 고려하세요. 불변 객체는 생성 후 변경될 수 없으므로 Race Condition의 위험이 없습니다.

4.2. 처음부터 동기화 메커니즘 적용

멀티스레드 코드에서 공유 자원을 다룰 때는 항상 Race Condition의 가능성을 염두에 두고 처음부터 적절한 동기화 메커니즘(락, 세마포어 등)을 적용해야 합니다. “일단 코드를 작성하고 나중에 문제가 생기면 동기화하자”는 접근 방식은 하이젠버그와 같은 디버깅하기 어려운 버그를 유발할 수 있습니다.

4.3. 파이썬의 GIL 이해

파이썬의 GIL은 멀티스레딩이 진정한 병렬성(CPU-bound 작업의 경우)을 제공하지 못하게 하지만, I/O-bound 작업에서는 GIL이 해제되어 동시성을 확보할 수 있습니다. GIL이 Race Condition을 완전히 막아주지 않는다는 점을 명확히 인지하고 있어야 합니다. Race Condition은 여러 스레드가 파이썬 바이트코드 실행 ‘사이에’ 발생하는 스케줄링 문제에서 비롯됩니다.

4.4. 작은 단위로 테스트하고 문제 격리

멀티스레드 코드는 복잡하기 때문에 가능한 한 작은 단위로 테스트하고, 문제가 발생했을 때는 최소한의 코드로 문제를 재현할 수 있도록 격리하는 것이 중요합니다. 로깅이나 print 문을 사용하여 디버깅할 때는, 그것들이 코드의 타이밍을 변경할 수 있음을 항상 기억해야 합니다.

4.5. 신뢰할 수 있는 디버깅 도구 활용

로깅 대신 `pdb`와 같은 대화형 디버거를 사용하여 단계별로 코드를 실행하고 변수 상태를 확인하는 것이 더 효과적일 수 있습니다. 하지만 디버거 사용 자체도 실행 타이밍에 영향을 줄 수 있으므로 주의해야 합니다.

Race Condition은 멀티스레드 환경에서 피할 수 없는 난관이지만, 파이썬의 동기화 도구를 올바르게 이해하고 적용한다면 안정적이고 견고한 애플리케이션을 구축할 수 있습니다. 로깅이 문제를 숨기는 ‘하이젠버그’ 현상을 만났을 때는 당황하지 말고, 근본적인 Race Condition 해결을 위한 동기화 메커니즘 도입을 최우선으로 고려해야 합니다.

댓글 남기기