콘텐츠로 이동

기본 개념

멀티스레드 기본 개념

왜 쓰는지

단일 스레드 프로그램은: - I/O 대기 중 전체 프로그램이 멈춤 (UI 응답 불가) - 멀티코어 CPU를 활용하지 못함 - 동시에 여러 작업을 할 수 없음

핵심: 멀티스레드는 하나의 프로세스 내에서 여러 작업을 동시에 수행하는 방식입니다.

어떻게 쓰는지

단일 스레드 vs 멀티스레드

// 1️⃣ 단일 스레드: 순차 실행
System.out.println("작업 1 시작");
Thread.sleep(2000);  // 2초 대기
System.out.println("작업 1 완료");

System.out.println("작업 2 시작");
Thread.sleep(2000);  // 2초 대기
System.out.println("작업 2 완료");

// 전체 실행 시간: ~4초
// 2️⃣ 멀티스레드: 동시 실행
new Thread(() -> {
    System.out.println("작업 1 시작");
    Thread.sleep(2000);
    System.out.println("작업 1 완료");
}).start();

new Thread(() -> {
    System.out.println("작업 2 시작");
    Thread.sleep(2000);
    System.out.println("작업 2 완료");
}).start();

// 전체 실행 시간: ~2초 (동시 진행)

프로세스 vs 스레드 메모리 구조

┌─────────────────────────────────────────┐
│ 프로세스 1                               │
├─────────────────────────────────────────┤
│ ┌─ 공유 메모리 ──────────────────────┐  │
│ │ Code Section (메서드 코드)         │  │
│ │ Data Section (정적 변수)           │  │
│ │ Heap (객체, 동적 할당)             │  │
│ └────────────────────────────────────┘  │
│                                         │
│ ┌─ 스레드 1 스택   ┐ ┌─ 스레드 2 스택┐  │
│ │ 지역 변수        │ │ 지역 변수     │  │
│ │ 반환 주소        │ │ 반환 주소     │  │
│ └────────────────┘ └─────────────────┘  │
│                                         │
└─────────────────────────────────────────┘

프로세스 2는 메모리 독립

언제 쓰는지

상황 선택 이유
I/O 대기 많음 ✅ 멀티스레드 대기 중 다른 작업 진행
UI 응답성 ✅ 멀티스레드 메인 스레드 블로킹 방지
멀티코어 활용 ✅ 멀티스레드 병렬 처리
네트워크 서버 ✅ 멀티스레드 여러 클라이언트 동시 처리
간단한 순차 작업 ❌ 단일 스레드 오버헤드 제거
CPU 집약적 ⚠️ 멀티스레드 오버헤드로 인해 느릴 수 있음

장점

장점 설명
응답성 I/O 대기 중 다른 작업 수행
리소스 활용 멀티코어 CPU 활용
공유 프로세스 내 스레드들이 메모리 공유
간편함 프로세스 간 통신(IPC)보다 간단

단점

단점 설명
복잡성 동시성 제어 어려움
동기화 필요 Race condition 방지
디버깅 어려움 재현 불가능한 버그 발생 가능
메모리 오버헤드 각 스레드마다 스택 필요
컨텍스트 스위칭 CPU 전환 비용 발생

특징

1. 멀티태스킹 vs 멀티프로세싱

┌──────────────────────────┐
│ 멀티태스킹               │
├──────────────────────────┤
│ • 소프트웨어 관점        │
│ • 단일 CPU 코어가        │
│   여러 작업을 시간대별로 │
│   번갈아 실행            │
│ • Java: 스레드 기반      │
└──────────────────────────┘

┌──────────────────────────┐
│ 멀티프로세싱             │
├──────────────────────────┤
│ • 하드웨어 관점          │
│ • 여러 CPU 코어가        │
│   진짜 동시에 실행       │
│ • Java: parallelStream   │
└──────────────────────────┘

2. 프로세스 (Process)

public class ProcessExample {
    public static void main(String[] args) throws Exception {
        // 각 프로세스는 독립적

        // 프로세스 1: 현재 Java 프로세스
        System.out.println("프로세스 1 ID: " + ProcessHandle.current().pid());

        // 프로세스 2: 별도 프로그램 실행
        Process process = new ProcessBuilder("cmd", "/c", "dir").start();
        process.waitFor();

        // 두 프로세스는 메모리 독립, 한쪽 오류가 다른쪽 영향 없음
    }
}

특징: - 각각 독립적인 메모리 (Code, Data, Heap, Stack) - 한 프로세스 오류가 다른 프로세스 영향 없음 - 프로세스 간 통신(IPC) 복잡함 - 생성/제거 비용 큼

3. 스레드 (Thread) 메모리 구조

public class ThreadMemoryExample {
    // 공유 메모리 (모든 스레드가 접근 가능)
    private static int sharedValue = 0;

    public static void main(String[] args) {
        // 스레드 1
        new Thread(() -> {
            // 지역 변수 (스레드 1 스택에만 존재)
            int localValue = 10;
            System.out.println("스레드 1: " + localValue);
            sharedValue++;  // 공유 메모리 접근
        }).start();

        // 스레드 2
        new Thread(() -> {
            // 지역 변수 (스레드 2 스택에만 존재)
            int localValue = 20;
            System.out.println("스레드 2: " + localValue);
            sharedValue++;  // 공유 메모리 접근
        }).start();

        // 스레드 1의 localValue는 스레드 2에서 접근 불가
        // 하지만 sharedValue는 둘 다 접근 가능 (위험!)
    }
}

메모리 구성: - 공유: Code, Data, Heap - 개별: Stack (각 스레드마다 별도 스택)

4. 스케줄링 (Scheduling)

CPU 시간축:
[Thread 1 실행 2ms] → [Thread 2 실행 2ms] → [Thread 3 실행 2ms] → [Thread 1 재개 2ms] ...

OS 스케줄러가 결정:
- 어느 스레드를 실행할지
- 얼마나 오래 실행할지 (Time Slice)
- 우선순위 (Priority)

Java에서의 우선순위:

Thread t1 = new Thread(() -> System.out.println("High Priority"));
t1.setPriority(Thread.MAX_PRIORITY);  // 10 (높음)

Thread t2 = new Thread(() -> System.out.println("Normal Priority"));
t2.setPriority(Thread.NORM_PRIORITY);  // 5 (보통)

Thread t3 = new Thread(() -> System.out.println("Low Priority"));
t3.setPriority(Thread.MIN_PRIORITY);  // 1 (낮음)

// 하지만 우선순위는 보증되지 않음 (OS에 따라 다름)

5. 컨텍스트 스위칭 (Context Switching)

스레드 A 상태 저장:
- CPU 레지스터 값
- Program Counter (다음 명령어 위치)
- 메모리 맵 정보
[스레드 A 중단]
[스레드 B 시작]
스레드 B 상태 복원:
- CPU 레지스터 값 로드
- Program Counter 로드
스레드 B 재개

이 과정에서 CPU 사이클 낭비 (오버헤드)
public class ContextSwitchingCost {
    public static void main(String[] args) throws Exception {
        // 단일 스레드: 빠름
        long start = System.nanoTime();
        for (int i = 0; i < 100_000_000; i++) {
            // 계산
        }
        long single = System.nanoTime() - start;

        // 멀티 스레드: 컨텍스트 스위칭 오버헤드
        start = System.nanoTime();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50_000_000; i++) {}
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50_000_000; i++) {}
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        long multi = System.nanoTime() - start;

        // CPU 집약적 작업에서는 multi가 더 느릴 수 있음
        System.out.println("단일: " + single);
        System.out.println("멀티: " + multi);
    }
}

주의할 점

⚠️ 동기화 문제: Race Condition

private static int counter = 0;

// ❌ 위험: 여러 스레드가 동시에 접근
for (int i = 0; i < 100; i++) {
    new Thread(() -> counter++).start();
}

Thread.sleep(1000);
System.out.println(counter);  // 100이 아닐 수 있음!

// 이유: counter++는 실제로는 3가지 단계
// 1. 메모리에서 값 읽기 (읽음: 5)
// 2. 1 더하기 (계산: 6)
// 3. 메모리에 쓰기 (쓰기: 6)

// 문제: 스레드 A가 읽는 중에 스레드 B가 읽으면?
// 결과: 같은 값에서 둘 다 1만 증가

❌ 데몬 스레드 주의

// ❌ 위험: 메인 스레드 종료 시 데몬도 즉시 종료
Thread daemon = new Thread(() -> {
    try {
        Thread.sleep(5000);
        System.out.println("작업 완료");  // 출력 안될 수 있음
    } catch (InterruptedException e) {
        System.out.println("중단됨");
    }
});
daemon.setDaemon(true);
daemon.start();
System.out.println("메인 끝");
// 메인 끝이 출력되고 즉시 프로그램 종료

// ✅ 올바른 방식: 데몬이 아닌 일반 스레드 사용하거나 join() 호출
Thread normal = new Thread(() -> {
    try {
        Thread.sleep(5000);
        System.out.println("작업 완료");
    } catch (InterruptedException e) {}
});
normal.start();
normal.join();  // 스레드 끝날 때까지 대기
System.out.println("메인 끝");

⚠️ 너무 많은 스레드 생성

// ❌ 위험: 스레드 메모리 폭발
for (int i = 0; i < 1_000_000; i++) {
    new Thread(() -> {
        Thread.sleep(10000);
    }).start();
}
// OutOfMemoryError 발생 (각 스레드 스택: ~1MB)

// ✅ 올바른 방식: 스레드 풀 사용
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1_000_000; i++) {
    executor.submit(() -> {
        // 작업
    });
}
executor.shutdown();

정리

항목 설명
프로세스 OS 관리, 독립 메모리, 무겁고 느림
스레드 프로세스 내, 메모리 공유, 가볍고 빠름
공유 Code, Data, Heap (위험!)
개별 Stack (안전)
스케줄링 OS가 CPU 할당 결정
컨텍스트 스위칭 상태 저장/복원 (오버헤드)

다음 단계: - Thread1.md — 스레드 생성 방법 - Thread2.md — 스레드 상태 관리 - synchronized.md — 동기화