멀티쓰레딩이란?
멀티쓰레딩은 하나의 프로세스 내에서 여러 쓰레드를 생성해 동시에 작업을 처리하는 프로그래밍 기법입니다. 멀티프로세싱과 달리 같은 메모리 공간을 공유하므로 더 가벼운 병렬 처리가 가능합니다.
각 쓰레드는 독립적인 실행 흐름을 가지면서도 코드, 데이터, 힙 메모리를 공유하므로 효율적인 자원 활용과 빠른 데이터 교환이 가능합니다. 특히 I/O 대기 시간이 긴 작업이나 CPU 집약적 작업을 분리하여 전체 응용 프로그램의 성능을 크게 향상시킬 수 있습니다.
핵심 개념
1. 쓰레드의 구조와 메모리 공유
각 쓰레드는 고유한 스택과 프로그램 카운터를 가지지만, 힙 메모리와 코드 영역은 같은 프로세스의 다른 쓰레드들과 공유합니다.
public class ThreadMemoryExample {
private static int sharedCounter = 0; // 힙에 저장, 모든 쓰레드가 공유
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
int localVar = 100; // 스택에 저장, 쓰레드 고유
for (int i = 0; i < 1000; i++) {
sharedCounter++; // 공유 변수 접근
}
});
Thread thread2 = new Thread(() -> {
int localVar = 200; // 다른 쓰레드의 localVar와 독립
for (int i = 0; i < 1000; i++) {
sharedCounter++;
}
});
thread1.start();
thread2.start();
}
}
2. 동기화와 경쟁 상태 해결
공유 자원에 대한 동시 접근으로 발생하는 경쟁 상태를 해결하기 위해 동기화 메커니즘을 사용해야 합니다.
public class SynchronizedExample {
private int counter = 0;
private final Object lock = new Object();
// synchronized 키워드 사용
public synchronized void incrementWithSync() {
counter++;
}
// 명시적 락 사용
public void incrementWithLock() {
synchronized (lock) {
counter++;
}
}
// 원자적 연산 사용
private final AtomicInteger atomicCounter = new AtomicInteger(0);
public void incrementAtomic() {
atomicCounter.incrementAndGet();
}
}
3. 쓰레드 풀을 통한 효율적 관리
직접 쓰레드를 생성하는 대신 쓰레드 풀을 사용하면 자원을 효율적으로 관리할 수 있습니다.
public class ThreadPoolExample {
public static void main(String[] args) {
// 고정 크기 쓰레드 풀
ExecutorService executor = Executors.newFixedThreadPool(4);
// 작업 제출
for (int i = 0; i < 10; i++) {
final int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId +
" executed by " + Thread.currentThread().getName());
// 시간이 걸리는 작업 시뮬레이션
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
}
}
4. 비동기 프로그래밍과의 결합
CompletableFuture를 활용해 멀티쓰레딩과 비동기 프로그래밍을 결합할 수 있습니다.
public class AsyncMultithreadingExample {
public CompletableFuture<String> processDataAsync(String data) {
return CompletableFuture
.supplyAsync(() -> {
// 데이터 전처리 (별도 쓰레드에서 실행)
return preprocessData(data);
})
.thenApplyAsync(preprocessed -> {
// 메인 처리 (다른 쓰레드에서 실행)
return processMainLogic(preprocessed);
})
.thenApplyAsync(result -> {
// 후처리 (또 다른 쓰레드에서 실행)
return postProcess(result);
});
}
private String preprocessData(String data) {
// CPU 집약적 전처리 작업
return data.toUpperCase();
}
private String processMainLogic(String data) {
// 메인 비즈니스 로직
return "Processed: " + data;
}
private String postProcess(String result) {
// 결과 후처리
return result + " [DONE]";
}
}
정리
| 구분 | 장점 | 주의사항 |
|---|---|---|
| 성능 | 병렬 처리로 응답성 향상, CPU 멀티코어 활용 | 과도한 쓰레드 생성 시 오버헤드 증가 |
| 자원 공유 | 메모리 공유로 빠른 데이터 교환 | 경쟁 상태와 데드락 위험 |
| 컨텍스트 스위칭 | 프로세스 대비 가벼운 전환 비용 | 동기화 오버헤드 발생 가능 |
| 구현 복잡도 | 단일 프로세스 내 간단한 통신 | 디버깅과 테스트의 어려움 |
- 핵심 원칙: 적절한 동기화로 데이터 무결성 보장
- 성능 최적화: 쓰레드 풀 활용과 작업 단위 최적화
- 안정성 확보: 데드락 방지와 예외 처리 철저히 구현