# [09-02] 스레드 (Thread)

이용성·2026년 3월 30일
post-thumbnail

스레드는 프로세스 내부의 실행 흐름으로, 가벼운 프로세스라고도 불립니다.


🎯 스레드란 무엇인가

스레드 (Thread)의 정의

스레드는 프로세스 내에서 실행되는 독립적인 실행 흐름입니다.

쉬운 비유:

프로세스 = 식당
스레드 = 식당 직원

단일 스레드 식당:
- 주방장 1명
- 혼자서 주문 받고, 요리하고, 서빙
- 손님이 많으면 느림

멀티 스레드 식당:
- 주방장 3명
- 같은 주방(메모리) 공유
- 같은 재료(자원) 공유
- 동시에 여러 요리 가능
- 빠름!

핵심:
같은 공간과 자원을 공유하면서 독립적으로 일함

프로세스 내의 스레드

프로세스:
┌─────────────────────────────────┐
│           프로세스             │
│  메모리 (공유):                │
│  ┌───────────────────────────┐  │
│  │ 코드 영역                │  │
│  ├───────────────────────────┤  │
│  │ 데이터 영역 (전역 변수)    │  │
│  ├───────────────────────────┤  │
│  │ 힙 (동적 할당)            │  │
│  └───────────────────────────┘  │
│  스레드별 독립:                 │
│  ┌─────────┐ ┌─────────┐        │
│  │스레드 1 │ │스레드 2  │        │
│  │        │ │         │        │
│  │스택     │ │스택     │        │
│  │레지스터  │ │레지스터 │        │
│  │PC      │ │PC       │        │
│  └─────────┘ └─────────┘        │
└─────────────────────────────────┘

공유: 코드, 데이터, 힙
독립: 스택, 레지스터, PC (프로그램 카운터)

* 프로그램 카운터(Program Counter, PC) : CPU 내부 레지스터 중 하나로,
   다음에 실행할 명령어의 주기억장치 주소를 가리키는 포인터

🔄 프로세스 vs 스레드

핵심 차이

프로세스:
- 독립적인 실행 단위
- 자신만의 메모리 공간
- 무거움 (생성/전환 비용 큼)
- 안전함 (격리됨)

스레드:
- 프로세스 내부의 실행 흐름
- 메모리 공간 공유
- 가벼움 (생성/전환 비용 작음)
- 위험함 (공유로 인한 문제 가능)

상세 비교

def process_vs_thread_comparison():
    """
    프로세스 vs 스레드 비교
    """
    
    # ===== 메모리 =====
    """
    프로세스:
    - 독립적인 메모리 공간
    - 프로세스 A의 변수를 B가 접근 불가
    - 안전하지만 통신 어려움
    
    스레드:
    - 같은 메모리 공간 공유
    - 스레드 A의 전역 변수를 B가 접근 가능
    - 통신 쉽지만 동기화 필요
    """
    
    # ===== 생성 비용 =====
    """
    프로세스:
    - 생성: 느림 (새 메모리 공간 할당)
    - 전환: 느림 (컨텍스트 스위칭 비용 큼)
         * Context Switching: CPU가 현재 작업(프로세스/스레드)을 중단하고 
            다른 작업으로 전환하기 위해, 이전 작업의 상태(문맥)를 저장하고
            새 작업의 상태를 불러오는 핵심 기술
    
    스레드:
    - 생성: 빠름 (메모리 공유)
    - 전환: 빠름 (같은 주소 공간)
    """
    
    # ===== 통신 =====
    """
    프로세스 간 통신 (IPC):
    - 파이프, 소켓, 공유 메모리 등 필요
    - 복잡하고 느림
    
    스레드 간 통신:
    - 전역 변수, 힙 공유
    - 간단하고 빠름
    """
    
    # ===== 안전성 =====
    """
    프로세스:
    - 한 프로세스가 죽어도 다른 프로세스 무관
    - 격리되어 안전
    
    스레드:
    - 한 스레드가 죽으면 전체 프로세스 죽음
    - 공유로 인한 버그 가능
    """

비교 표

┌──────────────┬─────────────┬─────────────┐
│ 구  분      │  프로세스    │   스레드    │
├──────────────┼─────────────┼─────────────┤
│ 메모리       │ 독립        │ 공유       │
│ 생성 속도    │ 느림        │ 빠름       │
│ 전환 속도    │ 느림        │ 빠름       │
│ 통신         │ 어려움(IPC) │ 쉬움(공유) │
│ 안전성       │ 높음        │ 낮음       │
│ 자원 사용    │ 많음        │ 적음       │
│ 디버깅       │ 쉬움        │ 어려움     │
└──────────────┴─────────────┴─────────────┘

언제 사용?
프로세스: 독립성, 안정성 중요
스레드: 속도, 자원 효율 중요

🚀 멀티스레딩

단일 스레드 vs 멀티 스레드

import time
import threading

# ===== 단일 스레드 =====
def single_thread_example():
    """
    단일 스레드로 작업 처리
    
    특징:
    - 순차적 실행
    - 한 번에 하나씩
    - 느림
    """
    print("=== 단일 스레드 ===")
    start = time.time()
    
    # 작업 1
    print("작업 1 시작")
    time.sleep(1)  # 1초 걸리는 작업 시뮬레이션
    print("작업 1 완료")
    
    # 작업 2
    print("작업 2 시작")
    time.sleep(1)
    print("작업 2 완료")
    
    # 작업 3
    print("작업 3 시작")
    time.sleep(1)
    print("작업 3 완료")
    
    end = time.time()
    print(f"총 시간: {end - start:.2f}초\n")
    # 예상: 약 3초

# ===== 멀티 스레드 =====
def worker(task_id, duration):
    """
    작업을 수행하는 워커 함수
    
    각 스레드가 이 함수를 독립적으로 실행
    
    task_id: 작업 번호
    duration: 작업 시간 (초)
    """
    print(f"작업 {task_id} 시작")
    time.sleep(duration)  # 작업 시뮬레이션
    print(f"작업 {task_id} 완료")

def multi_thread_example():
    """
    멀티 스레드로 작업 처리
    
    특징:
    - 병렬 실행
    - 동시에 여러 작업
    - 빠름
    """
    print("=== 멀티 스레드 ===")
    start = time.time()
    
    # 스레드 3개 생성
    threads = []
    
    for i in range(1, 4):  # 작업 1, 2, 3
        # threading.Thread: 새 스레드 생성
        # target: 스레드가 실행할 함수
        # args: 함수에 전달할 인자 (튜플)
        thread = threading.Thread(
            target=worker,
            args=(i, 1)  # (task_id, duration)
        )
        threads.append(thread)
        
        # 스레드 시작
        thread.start()
        # 주의: start()는 스레드를 시작만 하고 바로 반환
        # 스레드는 백그라운드에서 실행됨
    
    # 모든 스레드가 끝날 때까지 대기
    for thread in threads:
        # join(): 이 스레드가 끝날 때까지 기다림
        thread.join()
    
    end = time.time()
    print(f"총 시간: {end - start:.2f}초\n")
    # 예상: 약 1초 (병렬 실행)

# 실행
single_thread_example()  # 약 3초
multi_thread_example()   # 약 1초

출력:

=== 단일 스레드 ===
작업 1 시작
작업 1 완료
작업 2 시작
작업 2 완료
작업 3 시작
작업 3 완료
총 시간: 3.00초

=== 멀티 스레드 ===
작업 1 시작
작업 2 시작
작업 3 시작
작업 1 완료
작업 2 완료
작업 3 완료
총 시간: 1.00초

실용적인 예시: 웹 크롤러

*웹 크롤러(web crawler): 조직적, 자동화된 방법으로 웹(web)을 탐색하는 컴퓨터 프로그램

import threading
import time
import requests  # pip install requests

def download_page(url):
    """
    웹페이지 다운로드
    
    네트워크 I/O: CPU가 놀고 있음 → 스레드 사용하기 좋은 경우!
    
    url: 다운로드할 URL
    """
    try:
        print(f"다운로드 시작: {url}")
        response = requests.get(url, timeout=5)
        print(f"다운로드 완료: {url} ({len(response.text)} bytes)")
    except Exception as e:
        print(f"오류: {url} - {e}")

def single_thread_crawler(urls):
    """
    단일 스레드 크롤러
    
    순차적으로 하나씩 다운로드
    """
    print("=== 단일 스레드 크롤러 ===")
    start = time.time()
    
    for url in urls:
        download_page(url)
    
    print(f"총 시간: {time.time() - start:.2f}초\n")

def multi_thread_crawler(urls):
    """
    멀티 스레드 크롤러
    
    동시에 여러 페이지 다운로드
    """
    print("=== 멀티 스레드 크롤러 ===")
    start = time.time()
    
    threads = []
    
    for url in urls:
        thread = threading.Thread(target=download_page, args=(url,))
        threads.append(thread)
        thread.start()
    
    # 모든 다운로드 완료 대기
    for thread in threads:
        thread.join()
    
    print(f"총 시간: {time.time() - start:.2f}초\n")

# 사용 예시
urls = [
    'https://www.example.com',
    'https://www.python.org',
    'https://www.github.com',
]

# single_thread_crawler(urls)  # 순차: 느림
# multi_thread_crawler(urls)   # 병렬: 빠름

🔐 스레드 안전성 (Thread Safety)

공유 자원 문제

스레드들이 같은 메모리를 공유하므로, 동시에 같은 데이터를 수정하면 문제가 발생합니다.

import threading

# 공유 변수
counter = 0

def increment_unsafe():
    """
    안전하지 않은 증가
    
    문제:
    여러 스레드가 동시에 counter를 수정
    → 경쟁 상태(Race Condition)
    → 예상과 다른 결과
    """
    global counter
    
    # 이 코드는 실제로 3단계:
    # 1. counter 값 읽기
    # 2. 1 증가
    # 3. counter에 쓰기
    #
    # 스레드 A와 B가 동시에 실행하면:
    # A: 읽기(0) → 증가(1) → 쓰기(1)
    # B: 읽기(0) → 증가(1) → 쓰기(1)
    # 결과: 1 (2가 되어야 하는데!)
    
    for _ in range(100000):
        counter += 1

def race_condition_demo():
    """
    경쟁 상태 시연
    """
    global counter
    counter = 0
    
    print("=== 경쟁 상태 (Race Condition) ===")
    
    # 스레드 2개 생성
    thread1 = threading.Thread(target=increment_unsafe)
    thread2 = threading.Thread(target=increment_unsafe)
    
    # 동시 실행
    thread1.start()
    thread2.start()
    
    # 완료 대기
    thread1.join()
    thread2.join()
    
    print(f"예상값: 200000")
    print(f"실제값: {counter}")
    print(f"차이: {200000 - counter}\n")
    # 실행할 때마다 결과가 다름!
    # 예: 187234, 193451, 198732 등

# 실행
race_condition_demo()

왜 문제가 발생하는가?

counter += 1은 원자적(atomic)이지 않음!

실제로는:
1. LOAD counter (메모리 → 레지스터)
2. ADD 1
3. STORE counter (레지스터 → 메모리)

타이밍 예시:
시간   | 스레드 A          | 스레드 B          | counter
------|------------------|------------------|--------
t0    | LOAD (0)         |                  | 0
t1    |                  | LOAD (0)         | 0
t2    | ADD 1 → (1)      |                  | 0
t3    |                  | ADD 1 → (1)      | 0
t4    | STORE (1)        |                  | 1
t5    |                  | STORE (1)        | 1

결과: 1 (2가 되어야 하는데!)

해결: Lock 사용

import threading

counter = 0
lock = threading.Lock()  # 자물쇠 생성

def increment_safe():
    """
    안전한 증가
    
    Lock 사용:
    - 한 번에 한 스레드만 실행
    - 다른 스레드는 대기
    - 안전하지만 느림
    """
    global counter
    
    for _ in range(100000):
        # Lock 획득 (자물쇠 잠그기)
        lock.acquire()
        
        try:
            # 임계 영역 (Critical Section)
            # 한 번에 한 스레드만 실행
            counter += 1
        finally:
            # Lock 해제 (자물쇠 풀기)
            # finally: 예외가 발생해도 반드시 실행
            lock.release()

def safe_increment_demo():
    """
    안전한 증가 시연
    """
    global counter
    counter = 0
    
    print("=== Lock 사용 (안전) ===")
    
    thread1 = threading.Thread(target=increment_safe)
    thread2 = threading.Thread(target=increment_safe)
    
    thread1.start()
    thread2.start()
    
    thread1.join()
    thread2.join()
    
    print(f"예상값: 200000")
    print(f"실제값: {counter}")
    print(f"차이: {200000 - counter}\n")
    # 항상 200000!

# 더 간단한 방법: with 문
def increment_safe_with():
    """
    with 문으로 Lock 사용
    
    장점:
    - 자동으로 acquire/release
    - 예외 처리 불필요
    - 코드 간결
    """
    global counter
    
    for _ in range(100000):
        with lock:       # with 문: 자동으로 lock.acquire()와 lock.release()
            counter += 1

# 실행
safe_increment_demo()

💡 실무 팁

언제 스레드를 사용하는가?

def when_to_use_threads():
    """
    스레드 사용이 좋은 경우 vs 나쁜 경우
    """
        # ===== 좋은 경우: I/O 바운드 =====
    """
    I/O 바운드: CPU가 놀고 있는 작업
        * I/O Bound: 프로세스가 진행될 때, I/O Wating 시간이 많은 경우로, 
          파일 쓰기, 디스크 작업, 네트워크 통신을 할 때 주로 나타나며 
          작업에 의한 병목(다른 시스템과 통신할 때 나타남)에 의해 작업 속도가 결정
    
    예시:
    - 파일 읽기/쓰기
    - 네트워크 요청
    - 데이터베이스 쿼리
    - 사용자 입력 대기
    
    이유:
    I/O 대기 중에 다른 스레드가 일할 수 있음
    """
        # ===== 나쁜 경우: CPU 바운드 =====
    """
    CPU 바운드: CPU를 계속 쓰는 작업
        * CPU Bound: 프로세스가 진행될 때, CPU 사용 기간이 I/O Wating 보다 많은 경우로,
          주로 행렬 곱이나 고속 연산을 할 때 나타나며 CPU 성능에 의해 작업 속도가 결정
    
    예시:
    - 복잡한 계산
    - 이미지 처리
    - 비디오 인코딩
    - 암호화/복호화
    
    이유:
    Python은 GIL(Global Interpreter Lock)로 인해 한 번에 한 스레드만 Python 코드 실행
    → CPU 바운드에서는 멀티스레딩 효과 없음
    → 대신 multiprocessing 사용!
    """

스레드 수는 얼마나?

import os
import threading

def how_many_threads():
    """
    적절한 스레드 수
    """  
    # CPU 코어 수 확인
    cpu_count = os.cpu_count()
    print(f"CPU 코어: {cpu_count}개")
    
    # 현재 활성 스레드 수
    active = threading.active_count()
    print(f"활성 스레드: {active}개")
    
    """
    가이드라인:
    
    I/O 바운드:
    - 스레드 수 = CPU 코어 수 × 2 ~ 10
    - 대부분 대기하므로 많이 만들어도 OK
    
    CPU 바운드:
    - 스레드 수 = CPU 코어 수
    - 더 많이 만들면 오버헤드만 증가
    
    실제:
    - 테스트해보고 결정
    - 너무 많으면 컨텍스트 스위칭 비용
    - 너무 적으면 자원 낭비
    """

how_many_threads()

디버깅 팁

import threading

def thread_debugging_tips():
    """
    스레드 디버깅
    """    
    # 스레드 이름 지정
    def worker():
        name = threading.current_thread().name
        print(f"[{name}] 작업 중...")
    
    thread = threading.Thread(
        target=worker,
        name="Worker-1"  # 이름 지정
    )
    thread.start()
    thread.join()
    
    # 데몬 스레드
    """
    데몬 스레드:
    - 백그라운드 작업용
    - 메인 스레드 종료 시 자동 종료
    - 로그, 모니터링 등에 사용
    """
    def daemon_worker():
        while True:
            print("백그라운드 작업...")
            threading.Event().wait(1)
    
    daemon = threading.Thread(
        target=daemon_worker,
        daemon=True  # 데몬으로 설정
    )
    daemon.start()
    # 메인 종료하면 daemon도 자동 종료

thread_debugging_tips()

🎯 핵심 정리

스레드

정의:
프로세스 내부의 실행 흐름

특징:
- 메모리 공유
- 가볍고 빠름
- 통신 쉬움
- 동기화 필요

프로세스 vs 스레드

프로세스:
독립적, 무거움, 안전, 통신 어려움

스레드:
공유, 가벼움, 위험, 통신 쉬움

선택:
독립성/안정성 → 프로세스
속도/효율 → 스레드

멀티스레딩

장점:
- I/O 대기 중 다른 작업 가능
- 반응성 향상 (UI가 멈추지 않음)
- 자원 효율

단점:
- 경쟁 상태 (Race Condition)
- 디버깅 어려움
- 데드락 가능
   *교착 상태(Deadlock): 운영체제에서 2개 이상의 프로세스가 서로 상대방이 가진 자원을 기다리며
                        무한히 대기하여, 결과적으로 어떤 작업도 진행되지 못하고 멈추는 현상

스레드 안전성

문제:
여러 스레드가 같은 데이터 동시 수정
→ 예상치 못한 결과

해결:
Lock 사용
→ 한 번에 한 스레드만 실행
→ 안전하지만 느려짐

실무 가이드

I/O 바운드: 스레드 ✓
CPU 바운드: 프로세스 (multiprocessing)

스레드 수:
I/O: CPU 코어 × 2~10
CPU: CPU 코어 수

항상 테스트!

🔗 다음 글에서는

[09-03] 컨텍스트 스위칭 (Context Switching)

  • 컨텍스트 스위칭의 정의: CPU가 작업을 전환하는 과정
  • 왜 필요한가: 멀티태스킹의 핵심 메커니즘
  • 어떻게 동작하는가: PCB 저장/복원 과정
  • 성능 영향: 오버헤드와 최적화 방법

이전 글: [09-01] 프로세스
다음 글: [09-03] 컨텍스트 스위칭
시리즈: P1. Computer Science

profile
AI 전문가를 꿈꾸는 도전자

0개의 댓글