💻 프로그래밍

Multithreading

멀티스레딩

하나의 프로세스에서 여러 스레드를 동시에 실행하여 병렬 처리를 구현하는 프로그래밍 기법입니다. CPU 활용률을 극대화하고 응답성을 향상시키지만, 동기화 문제에 주의가 필요합니다.

📖 상세 설명

멀티스레딩(Multithreading)은 하나의 프로세스 내에서 여러 스레드가 동시에 실행되는 병렬 처리 기법입니다. 각 스레드는 독립적인 실행 흐름을 가지며, 프로세스의 메모리 공간(힙, 데이터 영역)을 공유합니다. 이를 통해 I/O 대기 시간에 다른 작업을 처리하거나, 멀티코어 CPU를 효율적으로 활용할 수 있습니다. 웹 서버가 동시에 수천 개의 요청을 처리하거나, GUI 애플리케이션이 백그라운드 작업 중에도 반응성을 유지하는 것이 대표적인 예시입니다.

프로세스 vs 스레드: 프로세스는 독립적인 메모리 공간을 가지며, 생성 및 컨텍스트 스위칭 비용이 높습니다. 반면 스레드는 프로세스 내에서 메모리를 공유하므로 생성 비용이 낮고(보통 프로세스의 1/10), 스레드 간 데이터 공유가 빠릅니다. 하지만 이러한 공유 특성 때문에 동기화 문제가 발생할 수 있습니다. 현대 애플리케이션은 프로세스로 분리(마이크로서비스)하고 각 프로세스 내에서 스레드를 활용하는 하이브리드 방식을 주로 사용합니다.

동기화 메커니즘: 여러 스레드가 공유 자원에 접근할 때 데이터 일관성을 보장하기 위한 도구입니다. 뮤텍스(Mutex)는 한 번에 하나의 스레드만 임계 영역에 접근하도록 보장합니다. 세마포어(Semaphore)는 카운터 기반으로 동시 접근 스레드 수를 제한합니다. 조건 변수(Condition Variable)는 특정 조건이 충족될 때까지 스레드를 대기시킵니다. Java의 synchronized, Python의 Lock, Go의 sync.Mutex 등 언어별로 구현 방식이 다릅니다.

실무 활용: 웹 서버(Tomcat, Nginx worker)는 스레드 풀로 동시 요청을 처리합니다. 데이터베이스 커넥션 풀도 스레드 풀과 유사한 패턴입니다. 데이터 처리에서는 대용량 파일을 청크로 나눠 병렬 처리하거나, MapReduce 패턴으로 분산 처리합니다. AI/ML에서는 PyTorch DataLoader가 멀티스레드로 데이터 전처리를 수행하여 GPU 활용률을 높입니다. 2025년 트렌드는 async/await 기반 비동기 프로그래밍과 멀티스레딩의 조합입니다.

💻 코드 예제

import threading
import time
from concurrent.futures import ThreadPoolExecutor

# 1. 기본 스레드 생성
def worker(name, delay):
    print(f"스레드 {name} 시작")
    time.sleep(delay)
    print(f"스레드 {name} 완료")

# 스레드 생성 및 실행
t1 = threading.Thread(target=worker, args=("A", 2))
t2 = threading.Thread(target=worker, args=("B", 1))

t1.start()
t2.start()
t1.join()  # 스레드 완료 대기
t2.join()

# 2. Lock을 사용한 동기화 (Race Condition 방지)
counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100000):
        with lock:  # 자동으로 acquire/release
            counter += 1

threads = [threading.Thread(target=increment) for _ in range(5)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"최종 카운터: {counter}")  # 정확히 500000

# 3. ThreadPoolExecutor (실무 권장 패턴)
def fetch_url(url):
    # 실제로는 requests.get(url)
    time.sleep(0.5)
    return f"{url} 완료"

urls = ["url1", "url2", "url3", "url4", "url5"]

with ThreadPoolExecutor(max_workers=3) as executor:
    # map: 순서 보장, 결과 순차 반환
    results = list(executor.map(fetch_url, urls))

    # submit: Future 객체 반환, 비동기 처리
    futures = [executor.submit(fetch_url, url) for url in urls]
    for future in futures:
        print(future.result())

# 4. Queue를 활용한 생산자-소비자 패턴
from queue import Queue

task_queue = Queue()

def producer():
    for i in range(10):
        task_queue.put(f"task_{i}")
    task_queue.put(None)  # 종료 신호

def consumer():
    while True:
        task = task_queue.get()
        if task is None:
            break
        print(f"처리: {task}")
        task_queue.task_done()
import java.util.concurrent.*;
import java.util.concurrent.locks.*;

public class MultithreadingExample {

    // 1. Thread 클래스 상속
    static class MyThread extends Thread {
        private String name;

        public MyThread(String name) {
            this.name = name;
        }

        @Override
        public void run() {
            System.out.println("스레드 " + name + " 시작");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            System.out.println("스레드 " + name + " 완료");
        }
    }

    // 2. Runnable 인터페이스 구현 (권장)
    static class MyRunnable implements Runnable {
        @Override
        public void run() {
            System.out.println("Runnable 실행: " +
                Thread.currentThread().getName());
        }
    }

    // 3. synchronized를 사용한 동기화
    static class Counter {
        private int count = 0;
        private final Object lock = new Object();

        // 메서드 전체 동기화
        public synchronized void incrementSync() {
            count++;
        }

        // 블록 동기화 (더 세밀한 제어)
        public void incrementBlock() {
            synchronized (lock) {
                count++;
            }
        }

        public int getCount() { return count; }
    }

    // 4. ReentrantLock 사용 (더 유연한 락)
    static class BetterCounter {
        private int count = 0;
        private final ReentrantLock lock = new ReentrantLock();

        public void increment() {
            lock.lock();
            try {
                count++;
            } finally {
                lock.unlock();  // 반드시 finally에서 해제
            }
        }

        // tryLock으로 데드락 방지
        public boolean tryIncrement() {
            if (lock.tryLock()) {
                try {
                    count++;
                    return true;
                } finally {
                    lock.unlock();
                }
            }
            return false;
        }
    }

    public static void main(String[] args) throws Exception {
        // 5. ExecutorService (실무 권장 패턴)
        ExecutorService executor = Executors.newFixedThreadPool(4);

        // Runnable 실행
        executor.execute(() -> System.out.println("Task 1"));

        // Callable로 결과 반환
        Future future = executor.submit(() -> {
            Thread.sleep(1000);
            return "Task 완료";
        });

        System.out.println("결과: " + future.get());  // 블로킹

        // CompletableFuture (Java 8+, 비동기 체이닝)
        CompletableFuture.supplyAsync(() -> "Hello")
            .thenApply(s -> s + " World")
            .thenAccept(System.out::println)
            .join();

        executor.shutdown();
        executor.awaitTermination(5, TimeUnit.SECONDS);
    }
}

🗣️ 실무에서 이렇게 말하세요

💬 면접에서 (Race Condition 설명)
"Race Condition은 여러 스레드가 공유 자원에 동시 접근할 때 실행 순서에 따라 결과가 달라지는 문제입니다. 예를 들어 counter++ 연산은 read-modify-write 3단계인데, 두 스레드가 동시에 read하면 하나의 증가가 손실됩니다. 해결책으로 Lock을 사용하거나, AtomicInteger 같은 원자적 연산을 활용합니다. 저는 이전 프로젝트에서 주문 재고 차감 로직에서 이 문제를 겪었고, synchronized 블록으로 해결했습니다."
💬 회의에서 (병렬 처리 전략)
"현재 배치 작업이 10시간 걸리는데, 병렬 처리로 2시간으로 줄일 수 있습니다. 데이터를 ID 범위로 5개 청크로 나누고, ThreadPoolExecutor로 병렬 처리하면 됩니다. 다만 DB 커넥션 풀 크기를 20에서 50으로 늘려야 하고, 트랜잭션 격리 레벨을 확인해야 합니다. 스레드 수는 CPU 코어 수의 2배인 16개로 시작하고, 모니터링하면서 조정하겠습니다."
💬 코드 리뷰에서
"이 코드에서 HashMap을 여러 스레드가 공유하고 있는데, HashMap은 thread-safe하지 않습니다. ConcurrentHashMap으로 교체하거나, Collections.synchronizedMap()으로 감싸야 합니다. 그리고 스레드 생성을 매번 new Thread()로 하고 있는데, ThreadPoolExecutor로 재사용하는 게 오버헤드를 줄일 수 있어요."

⚠️ 흔한 실수 & 주의사항

🔒
Deadlock (교착 상태)

두 스레드가 서로의 락을 기다리며 영원히 블로킹. 락 획득 순서를 일관되게 유지하거나, tryLock()으로 타임아웃 설정. 데드락 감지 도구(JConsole, VisualVM) 활용.

🏃
Race Condition (경쟁 상태)

공유 변수 접근 시 동기화 누락으로 데이터 불일치 발생. 모든 공유 자원 접근에 Lock 사용, 가능하면 불변 객체 또는 Thread-local 변수 활용.

🐍
Python GIL (Global Interpreter Lock)

CPython은 한 번에 하나의 스레드만 Python 바이트코드 실행. CPU-bound 작업은 multiprocessing 모듈 사용, I/O-bound 작업은 threading 또는 asyncio 활용.

올바른 접근법

스레드 풀로 스레드 재사용, 공유 상태 최소화, 불변 객체 선호, concurrent 패키지의 thread-safe 자료구조 사용. 가능하면 async/await나 액터 모델 고려.

🔗 관련 용어

📚 더 배우기