Chrome 탭 하나가 프로세스고, 탭 안에서 JS 엔진·렌더러·네트워크 요청이 동시에 돌아간다. 이것이 스레드다. “동시에”라는 환상을 만들기 위해 OS는 컨텍스트 스위칭을 한다. 이 메커니즘을 이해하면 멀티스레딩 버그의 절반은 예방할 수 있다.

핵심 요약 (TL;DR)

프로세스(Process): OS가 프로그램에 독립적인 메모리 공간(코드·데이터·힙·스택)을 할당해 실행하는 단위. 프로세스 간 메모리는 격리됨.

스레드(Thread): 프로세스 내 실행 단위. 코드·데이터·힙은 공유, 스택은 독립. 생성 비용이 프로세스보다 낮고 통신이 쉬움.

컨텍스트 스위칭(Context Switching): CPU가 한 프로세스에서 다른 프로세스로 전환. PCB에 상태 저장·복원. 오버헤드 발생.

동기화: 여러 스레드가 공유 자원에 동시 접근하면 데이터 불일치(Race Condition) 발생 → 뮤텍스·세마포어로 제어.


프로세스 → 스레드 → 동기화 연결 구조

graph TD
    Prog["프로그램\n(디스크의 실행 파일)"]

    Prog -->|"실행 (fork/exec)"| Proc["프로세스\n독립 메모리 공간\n(코드·데이터·힙·스택)"]

    Proc --> PCB["PCB\n(Process Control Block)\n상태, PC, 레지스터,\n메모리 정보, PID"]

    Proc --> Thread1["스레드 1\n(독립 스택 + PC)"]
    Proc --> Thread2["스레드 2\n(독립 스택 + PC)"]
    Proc --> Thread3["스레드 3\n(독립 스택 + PC)"]

    Thread1 & Thread2 & Thread3 -->|"공유"| Shared["힙 + 전역 변수\n(공유 자원)"]

    Shared -->|"동시 접근"| RC["Race Condition\n데이터 불일치 위험"]

    RC -->|"해결"| Mutex["뮤텍스\n(Mutex Lock)\n상호 배제"]
    RC -->|"해결"| Sema["세마포어\n(Semaphore)\n카운팅 제어"]

    style RC fill:#fce4ec,stroke:#c62828
    style Mutex fill:#c8e6c9,stroke:#388e3c
    style Sema fill:#bbdefb,stroke:#1565c0
    style PCB fill:#fff9c4,stroke:#f9a825

PCB (Process Control Block)

OS가 프로세스를 관리하기 위해 유지하는 자료구조. 커널 영역에 존재.

PCB 구성:
┌─────────────────────────────────┐
│ PID (Process ID)                │ ← 프로세스 식별자
│ 프로세스 상태                    │ ← New/Ready/Running/Waiting/Terminated
│ PC (Program Counter)            │ ← 다음 실행할 명령어 주소
│ CPU 레지스터                    │ ← 범용 레지스터, 스택 포인터 등
│ CPU 스케줄링 정보               │ ← 우선순위, 스케줄 큐 포인터
│ 메모리 관리 정보               │ ← 페이지 테이블, 세그먼트 테이블
│ 입출력 상태                    │ ← 열린 파일 목록, 장치 할당
│ 계정 정보                      │ ← CPU 사용 시간, 시간 제한
└─────────────────────────────────┘

컨텍스트 스위칭

sequenceDiagram
    participant CPU
    participant OS_Kernel as OS 커널
    participant P1 as 프로세스 A (PCB_A)
    participant P2 as 프로세스 B (PCB_B)

    Note over CPU, P1: P1이 CPU 실행 중
    CPU->>OS_Kernel: 인터럽트 (타이머/I/O 완료)
    OS_Kernel->>P1: 현재 상태 저장\n(PC, 레지스터 → PCB_A)
    P1-->>OS_Kernel: PCB_A 저장 완료
    OS_Kernel->>OS_Kernel: 스케줄러 실행\n(다음 프로세스 선택)
    OS_Kernel->>P2: PCB_B에서 상태 복원\n(PC, 레지스터 로드)
    Note over CPU, P2: P2가 CPU 실행 시작

    Note over OS_Kernel: ⚠️ 이 전환 시간이\n컨텍스트 스위칭 오버헤드

스위칭 트리거:

  1. 타이머 인터럽트 — 시간 할당량(Time Quantum) 소진
  2. I/O 요청 — 디스크/네트워크 대기 → 다른 프로세스 실행
  3. 우선순위 높은 프로세스 도착 — 선점(Preemption)

오버헤드: 프로세스 간 스위칭은 메모리 주소 공간도 바뀌어 TLB 플러시 필요 → 스레드 간 스위칭보다 비용이 큼.


멀티스레딩 — Java 구현

기본 스레드 생성

// Thread 클래스 상속 (SRP 위반 가능성 — 기능과 실행 혼재)
class WorkerThread extends Thread {
    private final String name;

    WorkerThread(String name) { this.name = name; }

    @Override
    public void run() {
        System.out.println(name + " 시작: " + Thread.currentThread().getName());
        // 작업 수행
        try { Thread.sleep(100); } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        System.out.println(name + " 완료");
    }
}

// Runnable 구현 (권장 — 기능 분리)
public class ThreadExample {
    public static void main(String[] args) throws InterruptedException {
        // 방법 1: Runnable 구현체
        Thread t1 = new Thread(() -> {
            System.out.println("작업 1: " + Thread.currentThread().getName());
        }, "worker-1");

        // 방법 2: ExecutorService (실무 권장)
        var executor = java.util.concurrent.Executors.newFixedThreadPool(4);

        for (int i = 0; i < 10; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + ": " + Thread.currentThread().getName());
                return taskId * 2;
            });
        }

        executor.shutdown();
        executor.awaitTermination(1, java.util.concurrent.TimeUnit.MINUTES);
    }
}

동기화 — Race Condition과 해결

Race Condition 발생

// ❌ Race Condition: 두 스레드가 동시에 count++ 실행
public class RaceConditionExample {
    private static int count = 0;  // 공유 자원

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) count++;  // 읽기-증가-쓰기가 원자적이지 않음!
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) count++;
        });

        t1.start(); t2.start();
        t1.join(); t2.join();

        System.out.println("기대값: 20000, 실제: " + count);
        // 결과: 10000~20000 사이 랜덤 (매번 다름!)
    }
}

// count++ 의 실제 동작 (JVM 바이트코드):
// 1. GETFIELD count   (메모리에서 읽기)
// 2. ICONST_1        (1 준비)
// 3. IADD            (더하기)
// 4. PUTFIELD count  (메모리에 쓰기)
// T1이 1단계 후 스위치 → T2가 같은 값으로 1~4 실행 → T1이 4단계 덮어쓰기 → 1증가 손실

해결 1: synchronized

public class SynchronizedExample {
    private int count = 0;
    private final Object lock = new Object();

    // 메서드 수준 동기화 — 객체의 intrinsic lock 사용
    public synchronized void incrementMethod() {
        count++;
    }

    // 블록 수준 동기화 — 더 세밀한 제어
    public void incrementBlock() {
        // 다른 작업 (락 불필요)
        synchronized (lock) {
            count++;  // 임계 구역 (Critical Section)
        }
        // 다른 작업
    }

    public synchronized int getCount() { return count; }
}

해결 2: AtomicInteger (가장 효율적)

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicExample {
    // CAS (Compare-And-Swap) 연산으로 락 없이 원자적 연산
    private final AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();  // 원자적: 읽기-증가-쓰기가 분리 불가
    }

    public int getCount() { return count.get(); }

    // 비교 후 갱신 패턴
    public boolean conditionalUpdate(int expectedValue, int newValue) {
        return count.compareAndSet(expectedValue, newValue);
        // expectedValue와 현재값이 같을 때만 newValue로 교체
    }
}

해결 3: ReentrantLock (고급 제어)

import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.Condition;

public class ReentrantLockExample {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notEmpty = lock.newCondition();
    private final Condition notFull = lock.newCondition();
    private final int[] buffer;
    private int count, putIdx, takeIdx;

    public ReentrantLockExample(int capacity) {
        buffer = new int[capacity];
    }

    // Producer: 버퍼가 가득 찰 때까지 기다림
    public void put(int item) throws InterruptedException {
        lock.lock();
        try {
            while (count == buffer.length)
                notFull.await();              // 락 해제하고 대기
            buffer[putIdx] = item;
            putIdx = (putIdx + 1) % buffer.length;
            count++;
            notEmpty.signal();               // Consumer 깨우기
        } finally {
            lock.unlock();  // finally에서 반드시 해제
        }
    }

    // Consumer: 버퍼가 비어있을 때까지 기다림
    public int take() throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await();
            int item = buffer[takeIdx];
            takeIdx = (takeIdx + 1) % buffer.length;
            count--;
            notFull.signal();
            return item;
        } finally {
            lock.unlock();
        }
    }
}

뮤텍스 vs 세마포어

뮤텍스 (Mutex Lock):
  - 상호 배제(Mutual Exclusion)
  - 한 번에 하나의 스레드만 임계 구역 진입
  - 소유권: 획득한 스레드만 해제 가능
  - 용도: 단일 자원 보호

세마포어 (Semaphore):
  - 카운팅 기반 (초기값 N)
  - 최대 N개 스레드 동시 진입 허용
  - 소유권 없음: 다른 스레드가 해제 가능 (Signal)
  - 용도: 자원 풀 관리, Producer-Consumer

Java 세마포어:
import java.util.concurrent.Semaphore;

// DB 커넥션 풀 — 최대 10개만 동시 접근
Semaphore dbConnections = new Semaphore(10);

dbConnections.acquire();  // 카운트 감소 (0이면 대기)
try {
    // DB 작업
} finally {
    dbConnections.release();  // 카운트 증가 (대기 중인 스레드 깨움)
}

데드락 (Deadlock)

4가지 필요 조건 (코프만 조건):

1. 상호 배제 (Mutual Exclusion): 한 번에 하나의 프로세스만 자원 사용
2. 점유 대기 (Hold & Wait): 자원 가진 채로 다른 자원 대기
3. 비선점 (No Preemption): 강제로 자원 빼앗기 불가
4. 순환 대기 (Circular Wait): P1→R1→P2→R2→P1 순환

→ 4가지 중 하나라도 해제하면 데드락 불가
// ❌ 데드락 예시
Object lockA = new Object();
Object lockB = new Object();

Thread t1 = new Thread(() -> {
    synchronized (lockA) {
        System.out.println("T1: A 획득");
        try { Thread.sleep(100); } catch (InterruptedException e) {}
        synchronized (lockB) { System.out.println("T1: B 획득"); }
    }
});

Thread t2 = new Thread(() -> {
    synchronized (lockB) {           // T2는 B 먼저
        System.out.println("T2: B 획득");
        try { Thread.sleep(100); } catch (InterruptedException e) {}
        synchronized (lockA) { System.out.println("T2: A 획득"); }
    }
});
// T1: A 잠금, B 대기 / T2: B 잠금, A 대기 → 영원히 대기 (데드락)

// ✅ 해결: 항상 같은 순서로 락 획득 (순환 대기 해소)
Thread t1Fixed = new Thread(() -> {
    synchronized (lockA) {
        synchronized (lockB) { /* 항상 A → B 순서 */ }
    }
});
Thread t2Fixed = new Thread(() -> {
    synchronized (lockA) {           // T2도 A 먼저
        synchronized (lockB) { /* 항상 A → B 순서 */ }
    }
});

Deep Dive: Java 21 가상 스레드 (Virtual Thread)

// 전통적 문제: 스레드당 ~1MB 메모리 → 1000개 스레드 = 1GB
// I/O 대기 중에도 OS 스레드가 블로킹됨

// Java 21 Project Loom: 가상 스레드
// - JVM이 관리하는 경량 스레드 (수백만 개 생성 가능)
// - I/O 블로킹 시 OS 스레드(Carrier Thread)를 차지하지 않음
// - 기존 synchronized/lock API 그대로 사용

// Spring Boot 3.2+ 가상 스레드 활성화
// application.yml:
// spring:
//   threads:
//     virtual:
//       enabled: true

// 가상 스레드 생성
Thread vThread = Thread.ofVirtual()
        .name("virtual-worker")
        .start(() -> System.out.println("가상 스레드 실행"));

// ExecutorService로 가상 스레드 풀
try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
    // 요청마다 새 가상 스레드 생성 (비용 매우 낮음)
    List<Future<String>> futures = new ArrayList<>();
    for (int i = 0; i < 10000; i++) {
        final int id = i;
        futures.add(executor.submit(() -> {
            // DB 쿼리, HTTP 호출 등 I/O 블로킹 작업
            // 블로킹 시 carrier thread를 yield → 다른 가상 스레드 실행
            return fetchFromDB(id);
        }));
    }
    // 10000개 가상 스레드가 동시에 실행 가능 (메모리 수 MB)
}

// ⚠️ 가상 스레드 주의사항:
// 1. synchronized 블록 내 블로킹: carrier thread 점유 (pinning) 발생
//    → ReentrantLock 사용 권장
// 2. ThreadLocal: 가상 스레드마다 생성 → 메모리 주의
//    → ScopedValue (Java 21) 사용 권장
// 3. CPU 집약적 작업: 가상 스레드의 이점 없음 (carrier thread 점유)

실무 적용 — Spring Boot 스레드 설정

# application.yml

# Java 21 가상 스레드 (Spring Boot 3.2+)
spring:
  threads:
    virtual:
      enabled: true  # Tomcat도 가상 스레드로 전환

# 전통적 스레드 풀 (가상 스레드 비활성화 시)
server:
  tomcat:
    threads:
      max: 200       # 최대 스레드 수
      min-spare: 10  # 최소 유지 스레드

# @Async 스레드 풀 설정
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

장애 사례

사례 1: 운영 환경 데드락 (은행 계좌 이체)
  Thread A: account1 잠금 → account2 잠금 시도
  Thread B: account2 잠금 → account1 잠금 시도 → 데드락
  → 애플리케이션 응답 없음, CPU 0%, 스레드 덤프로 발견
  → 해결: 계좌 ID 오름차순으로 항상 같은 순서 잠금

사례 2: Visible 속성 누락으로 캐시 불일치
  한 스레드가 변수 수정했지만 다른 스레드는 CPU 캐시에서 이전 값 읽음
  → 해결: volatile 키워드 (JVM이 항상 메인 메모리에서 읽도록 강제)

사례 3: 가상 스레드 + synchronized 핀닝
  Spring Boot 3.2+ 가상 스레드 활성화 후 성능 저하
  → synchronized 블록에서 DB 쿼리 시 carrier thread 점유 (pinning)
  → 해결: synchronized → ReentrantLock 교체

면접 Q&A

레벨 질문 핵심 답변
🟢 기초 프로세스와 스레드의 차이는? 프로세스: 독립 메모리 공간 (코드·데이터·힙·스택), 프로세스 간 메모리 격리. 스레드: 프로세스 내 실행 단위, 코드·데이터·힙 공유, 스택만 독립
🟡 중급 컨텍스트 스위칭 오버헤드가 발생하는 이유는? CPU 레지스터와 PC를 PCB에 저장·복원하는 시간 + 캐시 무효화(TLB 플러시) + 스케줄러 실행 시간. 프로세스 간 스위칭이 스레드 간보다 비쌈 (주소 공간 전환)
🟡 중급 뮤텍스와 세마포어의 차이는? 뮤텍스: 1개 자원 상호 배제, 소유권 있음(획득한 스레드만 해제). 세마포어: N개 동시 접근 허용, 소유권 없음. DB 커넥션 풀: 세마포어(10개 동시), 임계 구역 보호: 뮤텍스
🔴 심화 데드락 4가지 조건과 방지 방법은? 상호배제·점유대기·비선점·순환대기 모두 충족 시 발생. 방지: ① 자원 순서 고정(순환대기 해소) ② try-lock(점유대기 해소) ③ 타임아웃 후 롤백 ④ 은행원 알고리즘(회피)
🔴 시니어 Java 가상 스레드가 전통 스레드 대비 장점과 제약은? 장점: 경량(수백만 생성 가능), I/O 블로킹 시 carrier thread yield. 제약: synchronized 내 블로킹 시 pinning(carrier 점유) → ReentrantLock 필요, CPU 집약 작업에는 이점 없음

정리

항목 설명
핵심 키워드 PCB, 컨텍스트 스위칭, TLB, Race Condition, 임계 구역, 뮤텍스, 세마포어, 데드락, CAS, Java 가상 스레드
연관 개념 CPU 스케줄링(FCFS/SJF/Round-Robin), 페이지 교체, 가상 메모리, IPC(파이프·소켓·공유메모리)
실무 결정 가상 스레드(Java 21): I/O 집약적 서비스, synchronized 사용 최소화, 공유 자원 → AtomicXxx 우선

레퍼런스

영상

문서 & 기사


이 포스트는 HoneyByte CS Study 시리즈의 일부입니다.