Multithreading
멀티스레딩
하나의 프로세스에서 여러 스레드를 동시에 실행하여 병렬 처리를 구현하는 프로그래밍 기법입니다. CPU 활용률을 극대화하고 응답성을 향상시키지만, 동기화 문제에 주의가 필요합니다.
멀티스레딩
하나의 프로세스에서 여러 스레드를 동시에 실행하여 병렬 처리를 구현하는 프로그래밍 기법입니다. 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은 여러 스레드가 공유 자원에 동시 접근할 때 실행 순서에 따라 결과가 달라지는 문제입니다. 예를 들어 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로 재사용하는 게 오버헤드를 줄일 수 있어요."
두 스레드가 서로의 락을 기다리며 영원히 블로킹. 락 획득 순서를 일관되게 유지하거나, tryLock()으로 타임아웃 설정. 데드락 감지 도구(JConsole, VisualVM) 활용.
공유 변수 접근 시 동기화 누락으로 데이터 불일치 발생. 모든 공유 자원 접근에 Lock 사용, 가능하면 불변 객체 또는 Thread-local 변수 활용.
CPython은 한 번에 하나의 스레드만 Python 바이트코드 실행. CPU-bound 작업은 multiprocessing 모듈 사용, I/O-bound 작업은 threading 또는 asyncio 활용.
스레드 풀로 스레드 재사용, 공유 상태 최소화, 불변 객체 선호, concurrent 패키지의 thread-safe 자료구조 사용. 가능하면 async/await나 액터 모델 고려.