본문 바로가기
TYLER SONGBlog
블로그 목록
CS학습

Thread: 프로세스 내에서 실행 흐름을 나누는 경량 실행 단위

스레드는 하나의 프로세스 안에서 독립적으로 스케줄링되는 실행 흐름이며, 메모리 공간과 파일 디스크립터 같은 자원을 프로세스 내 다른 스레드와 공유한다. 프로세스보다 생성·전환 비용이 낮아 동시성이 필요한 프로그램에서 널리 쓰이지만, 자원을 공유하는 만큼 경쟁 조건(race condition)과 동기화 문제를 함께 안고 간다. 이 글에서는 스레드의 구조, 커

송민성7분 읽기

1. 개념

스레드(Thread)는 프로세스 내부에서 CPU에 의해 독립적으로 스케줄링되는 최소 실행 단위다. 하나의 프로세스는 최소 하나 이상의 스레드를 가지며, 프로세스 내 여러 스레드는 코드 영역, 데이터 영역, 힙(heap), 열린 파일 디스크립터 같은 자원을 공유한다. 반면 스택(stack), 레지스터 값, 프로그램 카운터(PC)는 스레드마다 독립적으로 가진다.

프로세스와 비교하면 이렇다.

항목프로세스스레드
메모리 공간독립적같은 프로세스 내 공유
생성 비용높음 (주소 공간 복제)낮음
통신 방법IPC (파이프, 소켓, 공유메모리 등)공유 변수 직접 접근
하나가 죽으면다른 프로세스에 영향 없음프로세스 전체에 영향 가능

2. 왜 사용하는가

  • 동시성 확보: I/O 대기 중에도 다른 작업을 진행해 CPU를 놀리지 않는다.
  • 자원 공유의 효율성: 프로세스 간 통신(IPC)보다 스레드 간 데이터 공유가 훨씬 빠르다. 별도의 커널 개입 없이 같은 주소 공간을 참조하기 때문이다.
  • 생성/전환 비용 절감: 프로세스를 새로 만들면 주소 공간, 파일 테이블 등을 복제해야 하지만, 스레드는 스택과 레지스터 상태만 새로 할당하면 되므로 비용이 작다.
  • 멀티코어 활용: 여러 스레드를 여러 코어에 분산 실행하면 실제 병렬 처리가 가능하다(단, 언어/런타임의 제약이 있을 수 있다. 뒤에서 GIL을 다룬다).

3. 동작 원리

TCB (Thread Control Block)

운영체제는 각 스레드마다 스레드 제어 블록(TCB)을 유지한다. TCB에는 스레드 ID, 레지스터 상태, 스택 포인터, 스케줄링 우선순위, 스레드 상태(실행/대기/준비) 등이 저장된다. 프로세스의 PCB(Process Control Block)가 여러 TCB를 참조하는 구조로 볼 수 있다.

커널 레벨 스레드 vs 유저 레벨 스레드

  • 커널 레벨 스레드(kernel-level thread): 커널이 직접 스레드를 인식하고 스케줄링한다. 시스템 콜을 통해 생성되며, 한 스레드가 블로킹(blocking)되어도 같은 프로세스의 다른 스레드는 계속 실행될 수 있다. 대신 컨텍스트 스위치 시 커널 모드 전환 비용이 든다.
  • 유저 레벨 스레드(user-level thread): 유저 공간의 라이브러리가 스케줄링을 담당한다. 컨텍스트 스위치가 빠르지만, 커널은 이 스레드들의 존재를 모르기 때문에 하나가 블로킹 시스템 콜을 호출하면 프로세스 전체가 블로킹될 수 있다.
  • 오늘날 대부분의 운영체제(Linux NPTL, Windows)는 커널 레벨 스레드를 기본으로 하며, 1:1 매핑(유저 스레드 하나당 커널 스레드 하나) 모델을 사용한다.

컨텍스트 스위치

CPU가 한 스레드에서 다른 스레드로 실행을 전환할 때, 현재 레지스터 값을 TCB에 저장하고 다음 스레드의 TCB에서 레지스터 값을 복원한다. 같은 프로세스 내 스레드 간 전환은 주소 공간(페이지 테이블)을 바꿀 필요가 없어 프로세스 간 전환보다 비용이 낮다.

GIL (Global Interpreter Lock)

CPython 인터프리터는 GIL이라는 전역 락을 두어 한 시점에 하나의 스레드만 Python 바이트코드를 실행하도록 강제한다. 이 때문에 CPU 바운드 작업은 멀티스레딩으로 실제 병렬 처리 이득을 보기 어렵고, I/O 바운드 작업(네트워크 대기, 파일 읽기 등)에서만 스레드 전환 이득을 본다.

4. 코드 예제

python
import threading import time # 공유 자원 - 락 없이 접근하면 경쟁 조건 발생 counter = 0 lock = threading.Lock() def increment(n: int) -> None: global counter for _ in range(n): with lock: # 임계 구역(critical section) 보호 counter += 1 def io_bound_task(name: str) -> None: print(f"{name} 시작") time.sleep(1) # I/O 대기를 흉내낸 블로킹 호출 print(f"{name} 종료") if __name__ == "__main__": # 1) 락으로 보호한 카운터 증가 - CPU 바운드 예제 threads = [threading.Thread(target=increment, args=(100_000,)) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print(f"최종 카운터 값: {counter}") # 400000이 정확히 나와야 정상 # 2) I/O 바운드 작업 병렬 실행 - GIL의 영향을 덜 받는 경우 start = time.time() io_threads = [threading.Thread(target=io_bound_task, args=(f"worker-{i}",)) for i in range(3)] for t in io_threads: t.start() for t in io_threads: t.join() print(f"총 소요 시간: {time.time() - start:.2f}초") # 약 1초 근처 (순차 실행이면 3초)

락 없이 counter += 1을 여러 스레드에서 동시에 실행하면 읽기-수정-쓰기(read-modify-write) 과정이 원자적(atomic)이지 않아서 값이 예상보다 작게 나올 수 있다. Lock으로 임계 구역을 보호해야 정확한 값이 나온다.

5. 시간 복잡도 또는 성능 특성

  • 스레드 생성 비용: 프로세스 생성(fork 등)보다 낮다. 주소 공간을 복제하지 않고 스택 공간만 할당하면 되기 때문이다. 정확한 수치는 운영체제와 하드웨어에 따라 다르므로 여기서는 상대적 비교만 언급한다.
  • 컨텍스트 스위치: 같은 프로세스 내 스레드 간 전환은 페이지 테이블 교체가 필요 없어 프로세스 간 전환보다 빠르다. 다만 커널 모드 진입/복귀 오버헤드는 여전히 존재한다.
  • 동기화 비용: 락(lock) 경합이 심할수록 스레드가 대기 상태로 전환되고 다시 깨어나는 비용이 커진다. 락 경합이 잦으면 스레드를 늘려도 처리량이 늘지 않고 오히려 줄어들 수 있다.
  • GIL 환경(CPython): CPU 바운드 작업은 스레드 수를 늘려도 단일 코어 성능 이상을 기대하기 어렵다. 이런 경우 multiprocessing 모듈로 프로세스를 병렬화하거나, GIL이 없는 다른 구현체(PyPy의 일부 모드, 또는 Python 3.13의 실험적 no-GIL 빌드)를 고려한다.

6. 실무 사용 사례

  • 웹 서버의 워커 스레드: Tomcat, Nginx 워커 프로세스 내부 등에서 요청마다 스레드를 할당해 동시 요청을 처리한다.
  • DB 커넥션 풀: 커넥션 풀 내부에서 여러 스레드가 커넥션을 대여/반납하며 동시 쿼리를 처리한다.
  • 백그라운드 작업 처리: 파일 업로드 처리 중 썸네일 생성, 로그 기록 등을 별도 스레드로 분리해 메인 요청 처리 흐름을 막지 않는다.
  • UI 애플리케이션: 메인 스레드(UI 이벤트 루프)와 별도로 워커 스레드에서 무거운 연산을 수행해 화면이 멈추지 않게 한다.

7. 주의할 점

  • 경쟁 조건(race condition): 여러 스레드가 락 없이 공유 자원을 동시에 수정하면 결과가 실행 순서에 따라 달라진다. 반드시 락, 세마포어(semaphore), 원자적 연산(atomic operation) 등으로 보호해야 한다.
  • 데드락(deadlock): 두 개 이상의 스레드가 서로가 가진 락을 기다리며 무한 대기하는 상황이다. 락 획득 순서를 일관되게 정하거나 타임아웃을 두어 방지한다.
  • 과도한 스레드 생성: 스레드마다 스택 메모리(리눅스 기본값은 보통 8MB 수준, 설정에 따라 다름)를 소비하므로 무분별하게 늘리면 메모리 부족과 컨텍스트 스위치 오버헤드로 오히려 성능이 나빠진다.
  • GIL을 간과한 설계: Python에서 CPU 바운드 작업에 스레드를 쓰면 기대한 병렬 성능이 나오지 않는다. 작업 특성(I/O 바운드 vs CPU 바운드)에 따라 스레드와 프로세스를 구분해서 써야 한다.
  • 스레드 안전성(thread safety): 사용하는 라이브러리나 자료구조가 스레드 안전한지 반드시 문서로 확인한다. Python의 list, dict 같은 기본 자료구조는 CPython 구현상 대부분의 단일 연산이 원자적이지만, 복합 연산(예: if key not in d: d[key] = 0)은 원자적이지 않다.

8. 핵심 정리

스레드는 프로세스 내에서 메모리와 자원을 공유하며 독립적으로 스케줄링되는 실행 흐름으로, 프로세스보다 생성과 전환 비용이 낮아 동시성 프로그래밍의 기본 단위로 쓰인다. 자원을 공유하는 대가로 경쟁 조건과 데드락 같은 동기화 문제가 따라오므로 락과 같은 동기화 도구를 정확히 이해하고 써야 한다. Python처럼 GIL이 있는 환경에서는 스레드가 I/O 바운드 작업에는 효과적이지만 CPU 바운드 작업에는 프로세스 기반 병렬 처리가 더 적합하다는 점을 구분해서 설계에 반영해야 한다.

© 2026 Tyler Song