2026년 3월 31일

동시성 제어의 핵심: 락, 뮤텍스, 세마포어 마스터하기

130
동시성 제어의 핵심: 락, 뮤텍스, 세마포어 마스터하기

동시성 제어의 핵심: 락, 뮤텍스, 세마포어 마스터하기

동시성 제어의 핵심: 락, 뮤텍스, 세마포어 마스터하기

안녕하세요, 10년차 소프트웨어 엔지니어이자 기술 교육자입니다. 현대 소프트웨어 시스템은 대부분 동시에 여러 작업을 처리하는 멀티스레드 또는 멀티프로세스 환경에서 동작합니다. 웹 서버는 수많은 사용자 요청을 동시에 처리하고, 백그라운드 작업은 여러 스레드에서 병렬로 실행됩니다. 이러한 환경에서 가장 중요한 도전 과제 중 하나는 바로 '동시성 제어'입니다. 여러 작업이 공유 자원에 동시에 접근할 때 발생할 수 있는 문제를 방지하고 데이터 무결성을 지키는 것은 시스템의 안정성과 신뢰성을 결정하는 핵심 요소입니다.

오늘은 이 동시성 제어의 가장 기본적인 도구이자 핵심 개념인 **락 (Lock), 뮤텍스 (Mutex), 세마포어 (Semaphore)**에 대해 초중급 개발자분들이 쉽게 이해할 수 있도록 깊이 있게 다뤄보겠습니다. 이 개념들은 면접에서 단골 질문이며, 실무에서 예측 불가능한 버그를 해결하거나 성능 문제를 최적화할 때 반드시 알아야 할 지식입니다.

1. 개념 소개: 동시성 제어, 왜 필요한가?

1. 개념 소개: 동시성 제어, 왜 필요한가?

정의 및 탄생 배경

**동시성 제어(Concurrency Control)**는 여러 개의 독립적인 실행 단위(스레드, 프로세스)가 동시에 공유 자원(메모리, 파일, 데이터베이스 등)에 접근할 때, 이 자원의 일관성과 무결성을 유지하기 위한 메커니즘을 의미합니다.

초기 컴퓨터 시스템은 한 번에 하나의 작업만 순차적으로 처리했습니다. 하지만 하드웨어 발전과 함께 CPU 코어 수가 늘어나고, 운영체제가 여러 프로그램을 동시에 실행하는 멀티태스킹 기능을 제공하면서 상황이 복잡해졌습니다. 하나의 프로그램 내에서도 여러 작업을 병렬로 처리하기 위해 '스레드' 개념이 도입되었고, 이제는 여러 스레드가 동일한 프로세스 내에서 메모리 공간을 공유하며 작업하는 것이 일반적이 되었습니다.

문제는 여기서 발생합니다. 여러 스레드가 동시에 같은 메모리 위치에 있는 변수를 읽고 쓰려고 할 때, 예상치 못한 결과가 나올 수 있습니다. 이를 **경쟁 조건(Race Condition)**이라고 부르며, 시스템의 동작을 예측 불가능하게 만들고 심각한 데이터 손상을 초래할 수 있습니다.

왜 중요한가?

경쟁 조건의 가장 흔한 예시는 은행 계좌 잔액입니다. A라는 계좌에 1000원이 있다고 가정해 봅시다. 두 개의 스레드가 동시에 이 계좌에서 각각 100원씩 인출하려 합니다.

  • 스레드 1: 잔액(1000)을 읽고, 100원을 인출하여 (900)을 계산합니다.
  • 스레드 2: (스레드 1이 잔액을 업데이트하기 전에) 잔액(1000)을 읽고, 100원을 인출하여 (900)을 계산합니다.
  • 스레드 1: 계산된 900원을 잔액에 씁니다.
  • 스레드 2: 계산된 900원을 잔액에 씁니다.

결과적으로 계좌 잔액은 800원이 되어야 하지만, 두 스레드 모두 900원으로 업데이트하여 최종 잔액이 900원이 되는 치명적인 문제가 발생합니다. 이처럼 동시성 제어가 없으면 데이터 무결성이 깨지고, 시스템은 신뢰할 수 없는 상태가 됩니다. 락, 뮤텍스, 세마포어는 이러한 문제를 해결하여 데이터의 일관성을 보장하고, 시스템의 예측 가능한 동작을 유지하는 데 필수적인 도구입니다.

2. 핵심 원리 설명: 공유 자원 보호막

2. 핵심 원리 설명: 공유 자원 보호막

동시성 제어의 핵심은 **임계 영역(Critical Section)**을 보호하는 것입니다. 임계 영역이란, 공유 자원에 접근하는 코드 블록으로, 한 번에 하나의 스레드/프로세스만 이 영역을 실행하도록 보장해야 합니다. 락, 뮤텍스, 세마포어는 이 임계 영역에 대한 접근을 제어하는 역할을 합니다.

락 (Lock) / 뮤텍스 (Mutex)

은 가장 기본적인 동시성 제어 메커니즘입니다. 특정 코드 블록(임계 영역)에 진입하기 전에 락을 획득하고, 영역을 벗어날 때 락을 해제합니다. 락이 이미 다른 스레드에 의해 획득된 상태라면, 해당 스레드는 락이 해제될 때까지 대기합니다.

**뮤텍스(Mutex)**는 'Mutual Exclusion'의 줄임말로, 상호 배제를 의미합니다. 즉, 한 번에 하나의 스레드/프로세스만이 공유 자원에 접근할 수 있도록 보장하는 동기화 도구입니다. 뮤텍스는 락의 한 종류로 생각할 수 있으며, 특히 '소유권' 개념이 있습니다. 즉, 뮤텍스를 획득한 스레드만이 뮤텍스를 해제할 수 있습니다.

비유: 뮤텍스는 마치 하나의 화장실 문에 달린 잠금장치와 같습니다.

  1. 누군가 화장실에 들어가려면 문을 잠가야 합니다 (뮤텍스 획득).
  2. 문이 이미 잠겨 있다면, 다른 사람은 문이 열릴 때까지 기다려야 합니다 (뮤텍스 대기).
  3. 화장실을 다 사용하면 문을 열고 나옵니다 (뮤텍스 해제).
  4. 문은 한 번에 한 명만 잠글 수 있으며, 잠근 사람만이 열 수 있습니다.

동작 방식:

  • acquire(): 뮤텍스를 획득합니다. 이미 획득된 상태라면 다른 스레드가 해제할 때까지 블록됩니다.
  • release(): 뮤텍스를 해제합니다. 대기 중인 다른 스레드가 있다면 그 중 하나가 뮤텍스를 획득합니다.

다이어그램 (텍스트 설명):

+-----------------+                      +-----------------+
|     Thread A    |                      |     Thread B    |
+--------+--------+                      +--------+--------+
         |                                         |
         |      (공유 자원 접근 전)                |
         |  -> Mutex.acquire() (획득 성공)         |
         |                                         |
         |   (임계 영역 진입 - 공유 자원 사용)       |
         |                                         |
         |  -> Mutex.release() (해제)              |
         |                                         |
         |      (다른 작업 수행)                   |
         |                                         |
+--------+--------+                      +--------+--------+

만약 Thread A가 acquire()하여 임계 영역에 있다면, Thread B가 acquire()를 호출하면 Thread A가 release()할 때까지 대기합니다.

세마포어 (Semaphore)

세마포어는 뮤텍스보다 좀 더 일반화된 동시성 제어 도구입니다. 세마포어는 '자원의 개수'를 관리하며, 동시에 여러 스레드가 공유 자원에 접근할 수 있도록 허용합니다. 세마포어는 내부적으로 카운터 변수를 가지고 있으며, 이 카운터는 동시에 자원에 접근할 수 있는 스레드의 최대 개수를 나타냅니다.

비유: 세마포어는 주차장의 빈 공간 카운터와 같습니다.

  1. 주차장에 10개의 빈 공간이 있다고 가정합니다 (세마포어 카운터 = 10).
  2. 차가 한 대 들어오면 빈 공간이 하나 줄어듭니다 (세마포어 wait() 또는 acquire() 연산, 카운터 감소).
  3. 차가 한 대 나가면 빈 공간이 하나 늘어납니다 (세마포어 signal() 또는 release() 연산, 카운터 증가).
  4. 빈 공간이 0이라면, 새로 들어오는 차는 공간이 생길 때까지 기다려야 합니다.

동작 방식:

  • wait() (또는 acquire()): 세마포어 카운터를 1 감소시킵니다. 카운터가 0 이하면 스레드는 블록됩니다.
  • signal() (또는 release()): 세마포어 카운터를 1 증가시킵니다. 대기 중인 스레드가 있다면 그 중 하나를 깨웁니다.

세마포어의 종류:

  • 이진 세마포어(Binary Semaphore): 카운터가 0 또는 1만 가질 수 있는 세마포어입니다. 이는 뮤텍스와 유사하게 동작하여 상호 배제를 제공합니다.
  • 계수 세마포어(Counting Semaphore): 카운터가 0 이상의 정수 값을 가질 수 있는 세마포어입니다. 특정 개수만큼의 자원에 대한 동시 접근을 제어할 때 사용됩니다.

다이어그램 (텍스트 설명):

                     +---------------------------------+
                     | Semaphore (초기값 N)            |
                     |   - available_resources = N     |
                     +---------------------------------+
                                      |
       +-------------------------------------------------------------+
       |                                                             |
+------v------+                 +------v------+                 +------v------+
|   Thread A  |                 |   Thread B  |                 |   Thread C  |
+-------------+                 +-------------+                 +-------------+
      |                             |                             |
      | Semaphore.acquire()         | Semaphore.acquire()         | Semaphore.acquire()
      | (available_resources--)     | (available_resources--)     | (available_resources--)
      |                             |                             |
      |   (공유 자원 사용)            |   (공유 자원 사용)            |   (공유 자원 사용)
      |                             |                             |
      | Semaphore.release()         | Semaphore.release()         | Semaphore.release()
      | (available_resources++)     | (available_resources++)     | (available_resources++)
      |                             |                             |
+-------------+                 +-------------+                 +-------------+

만약 available_resources가 N개인데 N개의 스레드가 동시에 acquire()했다면, N+1번째 스레드는 acquire() 호출 시 대기하게 됩니다.

3. 코드 예제 (Python)

Python의 threading 모듈은 락과 세마포어를 포함한 다양한 동기화 도구를 제공합니다.

예제 1: 뮤텍스 (Lock)를 사용한 공유 카운터 보호

여러 스레드가 공유 변수를 증가시킬 때 발생하는 경쟁 조건을 threading.Lock으로 해결하는 예제입니다.

import threading
import time

shared_counter = 0
# 뮤텍스 객체 생성
# Lock은 acquire()와 release() 메소드를 가짐.
# with 문을 사용하면 락 획득/해제를 자동으로 처리하여 실수를 줄일 수 있음.
lock = threading.Lock() 

def increment_counter():
    global shared_counter
    for _ in range(100_000): # 각 스레드가 10만 번 카운터 증가
        # 락 획득: 이 블록 안의 코드는 한 번에 한 스레드만 실행 가능
        with lock: 
            current_value = shared_counter
            # 문맥 전환(context switch)이 일어날 수 있는 지점.
            # 락이 없다면 여기서 다른 스레드가 먼저 current_value를 읽고
            # 잘못된 결과를 초래할 수 있음.
            time.sleep(0.000001) # 의도적으로 경쟁 조건 발생 가능성 높임
            shared_counter = current_value + 1
        # with 블록을 벗어나면 락이 자동으로 해제됨

if __name__ == "__main__":
    threads = []
    num_threads = 5 # 5개의 스레드 생성

    for _ in range(num_threads):
        thread = threading.Thread(target=increment_counter)
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join() # 모든 스레드가 종료될 때까지 대기

    print(f"최종 카운터 값: {shared_counter}")
    # 예상 결과: 5 * 100,000 = 500,000

# 락이 없을 때의 실행 결과 (주석 처리된 lock 관련 코드 제거):
# 최종 카운터 값: 387421 (매번 다를 수 있음)

# 락이 있을 때의 실행 결과:
# 최종 카운터 값: 500000

예제 2: 세마포어를 사용한 동시 작업 수 제한

특정 리소스에 동시에 접근할 수 있는 스레드 수를 제한하는 예제입니다. 예를 들어, DB 커넥션 풀이나 네트워크 소켓 연결 수 등을 제어할 때 유용합니다.

import threading
import time
import random

# 최대 3개의 스레드만 동시에 '리소스 사용' 함수에 접근 가능
# Semaphore(3)은 카운터가 3으로 초기화된 세마포어 생성
semaphore = threading.Semaphore(3) 

def access_resource(thread_id):
    print(f"스레드 {thread_id}: 리소스 접근을 시도합니다.")
    
    # 세마포어 획득: 카운터가 0이면 대기, 아니면 카운터 감소 후 진입
    with semaphore:
        print(f"스레드 {thread_id}: 리소스를 사용 중입니다. (남은 허용치: {semaphore._value})")
        # 실제 리소스 사용 로직
        time.sleep(random.uniform(1, 3)) # 1~3초 동안 리소스 사용
        print(f"스레드 {thread_id}: 리소스 사용을 완료했습니다.")
    # with 블록을 벗어나면 세마포어 카운터가 자동으로 증가 (release)

if __name__ == "__main__":
    threads = []
    num_threads = 10 # 10개의 스레드 생성

    for i in range(num_threads):
        thread = threading.Thread(target=access_resource, args=(i,))
        threads.append(thread)
        thread.start()
        time.sleep(0.1) # 스레드 시작 간격을 두어 동시에 시도하는 것처럼 보이게 함

    for thread in threads:
        thread.join() # 모든 스레드가 종료될 때까지 대기

    print("모든 스레드가 리소스 사용을 완료했습니다.")

# 실행 결과 (예시):
# 스레드 0: 리소스 접근을 시도합니다.
# 스레드 0: 리소스를 사용 중입니다. (남은 허용치: 2)
# 스레드 1: 리소스 접근을 시도합니다.
# 스레드 1: 리소스를 사용 중입니다. (남은 허용치: 1)
# 스레드 2: 리소스 접근을 시도합니다.
# 스레드 2: 리소스를 사용 중입니다. (남은 허용치: 0)
# 스레드 3: 리소스 접근을 시도합니다. (대기)
# 스레드 4: 리소스 접근을 시도합니다. (대기)
# ...
# (스레드 0, 1, 2 중 하나가 리소스 사용 완료 후)
# 스레드 X: 리소스 사용을 완료했습니다.
# 스레드 3: 리소스를 사용 중입니다. (남은 허용치: 0)
# ...

위 예시에서 semaphore._value는 내부적으로 세마포어의 현재 카운터 값을 보여줍니다. with semaphore: 블록에 진입할 때 카운터가 감소하고, 블록을 벗어날 때 카운터가 증가하는 것을 확인할 수 있습니다.

4. 실무 적용 사례

락, 뮤텍스, 세마포어는 다양한 소프트웨어 시스템에서 동시성을 제어하는 데 사용됩니다.

  • 데이터베이스 시스템: 여러 트랜잭션이 동시에 동일한 데이터에 접근할 때, 데이터의 일관성을 유지하기 위해 로우(Row) 레벨 락, 테이블(Table) 레벨 락 등을 사용합니다.
  • 운영체제 커널: 운영체제 커널 내부에서는 수많은 스레드가 동시에 실행되며 시스템 자원에 접근합니다. 이때 커널의 중요한 자료구조(예: 프로세스 목록, 메모리 관리 테이블)에 대한 접근을 보호하기 위해 뮤텍스나 세마포어를 광범위하게 사용합니다.
  • 멀티스레드 웹 서버/애플리케이션: 웹 서버에서 공유 캐시, 세션 데이터, 통계 정보 등 여러 스레드가 동시에 읽고 쓰는 데이터에 대한 접근을 보호하기 위해 락을 사용합니다.
  • 리소스 풀 관리: 데이터베이스 커넥션 풀, 스레드 풀 등 제한된 수의 리소스를 여러 클라이언트/스레드가 공유해야 할 때, 세마포어를 사용하여 동시에 리소스를 사용할 수 있는 수를 제한하고 효율적으로 관리합니다.
  • 분산 시스템의 분산 락: 여러 서버에 걸쳐 실행되는 분산 시스템에서는 단일 서버 내의 락으로는 충분하지 않습니다. Redis, Apache ZooKeeper 같은 분산 코디네이션 서비스를 이용하여 분산 락을 구현하여, 여러 서버가 공유하는 자원에 대한 동시 접근을 제어하기도 합니다. (이 글의 주 초점은 아니지만, 개념의 확장성을 보여줍니다.)

5. 자주 하는 실수와 해결법

동시성 제어 도구를 잘못 사용하면 시스템에 더 큰 문제를 야기할 수 있습니다.

5.1. 교착 상태 (Deadlock)

정의: 두 개 이상의 스레드가 서로가 가지고 있는 락을 기다리면서 영원히 대기하는 상태를 말합니다. 예시: 스레드 A는 락1을 획득하고 락2를 기다립니다. 스레드 B는 락2를 획득하고 락1을 기다립니다. 해결법:

  • 자원 할당 순서 지정: 모든 스레드가 락을 획득할 때 동일한 순서로 획득하도록 강제합니다. (예: 항상 락1 -> 락2 순서로 획득)
  • 타임아웃 설정: 락을 획득할 때 타임아웃을 설정하여 무한 대기를 방지합니다. 타임아웃 발생 시 획득했던 락을 모두 해제하고 재시도합니다.
  • 락 사용 범위 최소화: 락을 잡고 있는 시간을 최소화하여 다른 스레드의 대기 시간을 줄입니다.

5.2. 활동 상태 (Livelock)

정의: 교착 상태와 유사하게 아무 작업도 진행되지 않지만, 스레드들이 계속해서 상태를 바꾸며 서로 양보하려 시도하는 상황입니다. 교착 상태처럼 멈춰 있지는 않지만, 유의미한 진행이 없습니다. 예시: 두 사람이 좁은 길에서 마주쳤을 때, 서로 비켜주려다가 계속 같은 방향으로 움직여 결국 아무도 지나가지 못하는 상황. 해결법: 재시도 시점에 무작위 지연(random backoff)을 추가하여 동시에 같은 동작을 반복할 확률을 줄입니다.

5.3. 기아 상태 (Starvation)

정의: 특정 스레드가 자원을 영원히 획득하지 못하고 계속 대기하는 상태입니다. 우선순위가 낮은 스레드가 우선순위가 높은 스레드에 밀려 계속 실행되지 못하는 경우가 대표적입니다. 해결법: 공정한 스케줄링 정책을 사용하거나, 락 획득 시 우선순위를 고려하는 메커니즘을 도입합니다.

5.4. 락을 너무 넓게 잡거나 잊는 경우

  • 너무 넓게 잡는 경우 (Coarse-grained locking): 임계 영역이 너무 커서 락을 획득하는 시간이 길어지면, 다른 스레드들이 오랫동안 대기하게 되어 동시성이 저해되고 성능이 떨어집니다. 필요한 최소한의 코드 블록에만 락을 적용해야 합니다 (Fine-grained locking).
  • 락 해제를 잊는 경우: acquire()는 호출했지만 release()를 호출하지 않으면, 해당 락은 영원히 해제되지 않아 다른 스레드들이 영원히 대기하는 교착 상태와 유사한 상황이 발생할 수 있습니다. Python의 with 문처럼 락의 생명주기를 자동으로 관리해주는 기능을 적극 활용하는 것이 좋습니다.

6. 더 공부할 리소스 추천

동시성 제어는 컴퓨터 과학의 핵심 분야 중 하나이며, 깊이 있는 이해를 위해서는 운영체제와 병렬 프로그래밍에 대한 학습이 필수적입니다.

  • 운영체제 관련 서적:
    • "Operating System Concepts" (공룡책): 동시성, 스레드, 프로세스 동기화 등 운영체제의 기본적인 개념을 매우 상세하게 다룹니다. 이론적인 기반을 튼튼히 다지는 데 최고의 자료입니다.
  • 프로그래밍 언어 공식 문서:
    • Python threading 모듈 문서: Lock, Semaphore 외에도 RLock (재귀 락), Condition, Event 등 다양한 동기화 프리미티브에 대한 설명을 제공합니다.
    • Java java.util.concurrent 패키지 문서: 자바는 동시성 프로그래밍을 위한 매우 강력하고 다양한 도구를 제공합니다. ReentrantLock, Semaphore, CountDownLatch, CyclicBarrier 등 고급 동기화 개념을 학습하는 데 좋습니다.
    • Go 언어의 sync 패키지 및 CSP (Communicating Sequential Processes): Go는 Mutex, Semaphore와 같은 전통적인 동기화 도구 외에도 채널(Channel)을 이용한 CSP 모델을 통해 동시성을 안전하게 다루는 독특하고 강력한 방법을 제공합니다.
  • 온라인 강의 및 튜토리얼:
    • 각 언어별 동시성 프로그래밍 튜토리얼이나 Coursera, Udacity 등에서 제공하는 병렬 프로그래밍 관련 강의를 찾아보는 것도 좋습니다.

동시성 제어는 처음에는 어렵게 느껴질 수 있지만, 실제 시스템의 견고함과 성능을 결정하는 매우 중요한 요소입니다. 오늘 배운 락, 뮤텍스, 세마포어의 개념을 확실히 이해하고 실습을 통해 숙달한다면, 여러분은 더욱 안정적이고 효율적인 소프트웨어를 개발하는 데 한 걸음 더 나아갈 수 있을 것입니다.