요즘 CPU 는 멀티코어다. 코어가 4개면 서로 다른 일 4개를 같은 순간에 실행할 수 있다. 그런데 내 프로그램이 일을 한 줄로만 하면 코어 하나만 쓰고 나머지 셋은 논다. 여러 코어에 일을 나눠 주는 단위가 프로세스와 스레드라서, 둘을 알아야 코어를 다 쓸 수 있다. 이 글에서 나는 두 개념의 차이부터, 스레드가 메모리를 함께 쓰기 때문에 생기는 문제와 그 해결책까지 차례로 정리한다.


프로세스와 스레드, 무엇이 다를까?

프로세스(Process)란?

프로세스는 실행 중인 프로그램이다. 더 정확히는 자기만의 메모리 공간을 가진 실행 단위다. 「메모리 공간」은 그 프로그램이 값을 넣고 꺼내는 메모리 영역이고, 「실행 단위」는 운영체제가 CPU 에 올려 돌리고 내리는 하나의 대상이라는 뜻이다.

프로세스의 특징:

  • 독립된 메모리 공간을 갖는다. 이 공간은 쓰임새에 따라 Code, Data, Heap, Stack 네 영역으로 나뉜다(프로세스 메모리 구조)
  • 운영체제가 프로세스마다 관리 기록인 PCB(Process Control Block)를 하나씩 만든다
  • 다른 프로세스와 메모리가 격리된다. 프로세스 A 는 프로세스 B 의 변수를 읽지도 고치지도 못한다
  • 생성/종료 비용이 높다. 새 프로세스를 만들 때마다 운영체제가 메모리 공간과 PCB 를 새로 마련하고, 끝날 때 다시 거둬야 하기 때문이다
  • 프로세스끼리 데이터를 주고받으려면 IPC(프로세스 간 통신)를 써야 해서 복잡하다. 메모리가 격리되어 있으니 운영체제가 마련한 통로를 거쳐야 한다. 파이프(한 프로세스가 쓴 바이트를 다른 프로세스가 차례로 읽는 통로)나 소켓(네트워크 연결의 끝) 같은 것이다

스레드(Thread)란?

스레드는 프로세스 안에서 따로 도는 실행 흐름이다. 프로세스보다 「가볍다(경량)」고 부르는데, 새로 만들 때 메모리 공간을 통째로 새로 만들지 않고 이미 있는 프로세스의 메모리를 같이 쓰기 때문이다.

스레드의 특징:

  • 프로세스의 메모리 중 Code, Data, Heap 영역을 같은 프로세스의 다른 스레드와 함께 쓴다
  • Stack 과 프로그램 카운터(PC)는 스레드마다 따로 있다. 스레드마다 지금 부르고 있는 메서드도, 다음에 실행할 줄도 다르기 때문이다
  • 운영체제가 스레드마다 관리 기록인 TCB(Thread Control Block)를 하나씩 만든다. 「이 프로세스가 쓰는 주소가 실제 메모리의 어디인지」 적은 메모리 지도 같은 것은 같은 프로세스의 스레드끼리 같으므로 PCB 에 한 벌만 있다. 그래서 TCB 는 PCB 보다 작다
  • 생성/종료 비용이 낮다
  • 스레드끼리 데이터를 주고받기가 간단하다. 같은 Heap 을 보니 객체 하나를 같이 읽고 쓰면 된다

아래 그림은 프로세스 하나 안에 스레드 셋이 있는 모양이다.

graph TB subgraph Process["프로세스 메모리 구조"] Code["Code 영역
(공유)"] Data["Data 영역
(공유)"] Heap["Heap 영역
(공유)"] Stack1["Stack 1
(Thread 1 전용)"] Stack2["Stack 2
(Thread 2 전용)"] Stack3["Stack 3
(Thread 3 전용)"] end Thread1["Thread 1
PC: 0x1234"] Thread2["Thread 2
PC: 0x5678"] Thread3["Thread 3
PC: 0x9ABC"] Thread1 -.-> Stack1 Thread2 -.-> Stack2 Thread3 -.-> Stack3 Thread1 -.-> Code Thread2 -.-> Code Thread3 -.-> Code style Code fill:#1a1a2e,stroke:#16213e,stroke-width:2px,color:#eee style Data fill:#1a1a2e,stroke:#16213e,stroke-width:2px,color:#eee style Heap fill:#1a1a2e,stroke:#16213e,stroke-width:2px,color:#eee style Stack1 fill:#0f3460,stroke:#16213e,stroke-width:2px,color:#eee style Stack2 fill:#0f3460,stroke:#16213e,stroke-width:2px,color:#eee style Stack3 fill:#0f3460,stroke:#16213e,stroke-width:2px,color:#eee style Thread1 fill:#533483,stroke:#16213e,stroke-width:2px,color:#eee style Thread2 fill:#533483,stroke:#16213e,stroke-width:2px,color:#eee style Thread3 fill:#533483,stroke:#16213e,stroke-width:2px,color:#eee style Process fill:#0a0a0a,stroke:#16213e,stroke-width:3px,color:#eee
  • PC: 0x1234 는 그 스레드의 프로그램 카운터 값, 곧 다음에 실행할 명령이 있는 메모리 위치다. 0x 는 16진수라는 표시이고 값 자체는 예시다. 세 스레드가 같은 Code 를 읽더라도 각자 다른 줄을 읽고 있으니 값이 다르다
  • 그림에는 스레드에서 Code 로만 화살표를 그렸지만, 위 목록대로 Data 와 Heap 도 세 스레드가 똑같이 함께 쓴다. 화살표를 다 그리면 그림이 엉켜서 Code 만 대표로 그린 것이다. 「(공유)」라고 적힌 칸은 전부 공유다

핵심 차이점 비교

아래 표의 「컨텍스트 스위칭」은 CPU 가 돌리던 일을 멈추고 다른 일로 갈아타는 것이다. 자세한 내용은 아래 컨텍스트 스위칭 섹션에서 다룬다. 「동기화」는 여러 스레드가 같은 데이터를 한 번에 하나씩만 건드리게 순서를 맞추는 것이고, 이것도 뒤에서 다룬다.

구분 프로세스 스레드
메모리 독립적 공유 (Stack만 독립)
통신 IPC 필요 (복잡) 메모리 공유 (간단)
컨텍스트 스위칭 느림 (높은 비용) 빠름 (낮은 비용)
안정성 높음 (격리됨) 낮음 (동기화 필요)
생성 비용 높음 낮음

안정성 줄이 왜 그런지 한 문장 붙인다. 프로세스는 메모리가 격리되어 있어서 한 프로세스가 값을 망가뜨려도 다른 프로세스는 멀쩡하다. 스레드는 같은 메모리를 쓰므로 한 스레드가 공유 데이터를 잘못 고치면 다른 스레드도 그 망가진 값을 본다. 그래서 스레드를 쓰면 동기화를 직접 챙겨야 한다.


생성부터 종료까지: 라이프사이클

스레드 라이프사이클

스레드는 만들어진 뒤 끝날 때까지 몇 가지 상태를 오간다. 아래 그림은 운영체제 관점의 일반적인 상태 그림이다.

stateDiagram-v2 [*] --> New: Thread 객체 생성 New --> Runnable: start() 호출 Runnable --> Running: 스케줄러 선택 Running --> Runnable: yield() 또는
우선순위 낮아짐 Running --> Blocked: I/O 대기
Lock 대기 Blocked --> Runnable: I/O 완료
Lock 획득 Running --> Terminated: 실행 완료
예외 발생 Terminated --> [*] style New fill:#1a1a2e,stroke:#16213e,stroke-width:2px,color:#eee style Runnable fill:#0f3460,stroke:#16213e,stroke-width:2px,color:#eee style Running fill:#533483,stroke:#16213e,stroke-width:2px,color:#eee style Blocked fill:#8b0000,stroke:#16213e,stroke-width:2px,color:#eee style Terminated fill:#2d4a2b,stroke:#16213e,stroke-width:2px,color:#eee
  • New — new Thread(...) 로 객체만 만들고 아직 돌리지 않은 상태다
  • Runnable — 돌 준비가 됐고 CPU 차례를 기다리는 상태다. 코어보다 스레드가 많으면 줄을 서야 한다
  • Running — 지금 CPU 에서 실제로 실행 중인 상태다. 줄 선 스레드 중 누구를 올릴지는 커널(컴퓨터가 켜져 있는 동안 늘 돌며 프로그램을 띄우고 CPU · 메모리를 나눠 주는 운영체제의 핵심 프로그램)의 스케줄러가 고른다
  • Running → Runnable — Thread.yield() 는 「내 차례를 잠시 양보하겠다」고 스케줄러에 알리는 메서드다. 더 높은 우선순위의 스레드가 오거나 한 번에 CPU 를 쥘 수 있는 시간(타임 슬라이스)이 다 되어 내려올 때도 이 화살표를 탄다
  • Blocked — 무언가를 기다리느라 돌 수 없는 상태다. I/O(파일 · 네트워크) 결과를 기다리거나, 다른 스레드가 쥔 자물쇠(Lock)가 풀리길 기다리는 경우다. 기다리던 것이 오면 다시 Runnable 줄로 간다
  • Terminated — run() 이 끝났거나 예외로 빠져나와 끝난 상태다

자바의 실제 상태 이름은 이 그림과 조금 다르다. JDK(자바 개발 도구와 표준 라이브러리, 그리고 자바 프로그램을 실행하는 JVM을 한 묶음으로 나눠 주는 것)의 Thread.State 에는 NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED 여섯 개가 있다. 따로 Running 이 없고, 그림의 Runnable 과 Running 을 합쳐 RUNNABLE 하나로 부른다. JDK 소스 주석은 RUNNABLE 인 스레드가 CPU 같은 운영체제 자원을 기다리는 중일 수도 있다고 적는다. 또 자바의 BLOCKED 는 「synchronized 자물쇠(모니터)를 기다리는 중」을 뜻하고(synchronized 와 모니터는 아래 Monitor 섹션에서 다룬다. 지금은 「자바가 기본으로 주는 자물쇠」로 읽으면 된다), sleep · join 으로 기다리면 WAITING 이나 TIMED_WAITING 이다. 위 그림은 운영체제 쪽 큰 그림으로 보면 된다.

Java 스레드 생성 예제:

// 1. Thread 클래스 상속
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("Thread running: " + Thread.currentThread().getName());
    }
}

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

// 사용
MyThread thread1 = new MyThread();
thread1.start();  // run()이 아닌 start() 호출!

Thread thread2 = new Thread(new MyTask());
thread2.start();

// 스레드 종료 대기
thread1.join();
thread2.join();

한 줄씩 따라간다.

  • class MyThread extends Thread — Thread 를 상속해서 새 스레드가 할 일을 run() 에 적는다. @Override는 부모의 run() 을 다시 구현했다는 표시다
  • class MyTask implements Runnable — 이번엔 상속 대신 Runnable 인터페이스의 run() 하나만 구현한다. 할 일(Runnable)과 실행 수단(Thread)을 떼어 두면 MyTask 가 다른 클래스를 상속할 자리가 남아서 이쪽을 권장한다
  • Thread.currentThread().getName() — 지금 이 줄을 실행하고 있는 스레드의 이름을 꺼낸다. 따로 이름을 안 주면 Thread-0 같은 이름이 붙는다
  • thread1.start() — 새 스레드를 띄우고, 그 새 스레드가 run() 을 실행하게 한다. 이 줄은 run() 이 끝날 때까지 기다리지 않고 바로 다음 줄로 간다
  • new Thread(new MyTask()) — Runnable 을 Thread 에 넘겨 실행 수단을 붙인다. 역시 start() 를 불러야 돈다
  • thread1.join() — join은 thread1 이 끝날 때까지 지금 스레드(여기서는 이 코드를 돌리는 메인 스레드)를 멈춰 기다리게 한다. 안 기다리면 메인 스레드가 먼저 다음 일로 넘어가서, 새 스레드가 아직 결과를 안 냈는데 그 결과를 쓰려 하는 일이 생긴다. join() 은 InterruptedException 을 던지므로 실제로는 이 코드를 throws InterruptedException 이 붙은 메서드 안에 두거나 try/catch 로 감싸야 컴파일된다

참고: GeeksforGeeks - Java Thread Creation

주의사항:

  • run() 을 직접 부르면 새 스레드가 생기지 않는다. 지금 스레드에서 보통 메서드를 부른 것과 같다
  • start() 를 부르면 JVM(내가 컴파일한 자바 프로그램을 실제로 실행해 주는 프로그램)이 새 스레드를 만들고 그 스레드에서 run() 을 실행한다
  • 스레드는 한 번 끝나면 다시 start() 할 수 없다. 같은 일을 또 하려면 새 Thread 객체를 만든다

Python 스레드 생성 예제:

이 글에는 Python 예제가 몇 개 나온다. 뒤에서 다룰 Python 만의 제약(GIL) 때문에 Python 에서는 프로세스와 스레드 중 무엇을 고르느냐가 자바보다 더 크게 갈리기 때문이다. 자바와 다른 문법은 코드 뒤에서 짚는다.

import threading
import time

def task():
    print(f"Thread running: {threading.current_thread().name}")
    time.sleep(1)
    print("Task completed")

# 스레드 생성 및 시작
thread = threading.Thread(target=task)
thread.start()

# 스레드 종료 대기
thread.join()
print("Main thread continues")
  • import threading — 자바의 import 와 같다. 스레드 기능이 든 모듈을 가져온다
  • def task(): — 메서드 정의다. 반환 타입을 안 적고, 중괄호 대신 들여쓰기로 몸통을 가른다
  • f"... {threading.current_thread().name}" — f-string이다. {} 안의 값을 문자열에 끼워 넣는다. 자바의 "..." + Thread.currentThread().getName() 과 같다
  • time.sleep(1) — 이 스레드를 1초 멈춘다. 자바의 Thread.sleep(1000) 과 같다
  • threading.Thread(target=task) — task 함수를 실행할 스레드 객체를 만든다. target= 은 이름을 붙여 인자를 넘기는 문법이다. 자바의 new Thread(new MyTask()) 자리다
  • thread.start() / thread.join() — 자바와 뜻이 같다

프로세스 라이프사이클

프로세스도 스레드와 비슷하게 생성 · 준비 · 실행 · 대기 · 종료 상태를 오간다. 프로세스가 언제 끝나는지는 언어마다 규칙이 있는데, 자바(JVM)와 Python 은 메인 스레드를 포함한 모든 non-daemon 스레드가 끝나야 프로세스를 끝낸다. non-daemon 은 「보통 스레드」이고, 그 반대인 데몬 스레드는 프로그램이 끝날 때 기다려 주지 않는 뒷일용 스레드다. 그래서 메인 스레드의 main 이 끝나도 보통 스레드 하나가 아직 돌고 있으면 프로그램은 끝나지 않는다.

from multiprocessing import Process
import time

def worker():
    print("Worker process started")
    time.sleep(1)
    print("Worker process completed")

# 프로세스 생성 및 시작
process = Process(target=worker)
process.start()

# 프로세스 종료 대기
process.join()
print(f"Process exit code: {process.exitcode}")
  • from multiprocessing import Process — multiprocessing 모듈에서 Process 클래스 하나만 가져온다
  • Process(target=worker) — 앞의 스레드 예제와 모양이 같지만, 이번에는 스레드가 아니라 새 프로세스를 만들어 그 안에서 worker 를 돌린다. 새 프로세스는 메모리가 따로라서 부모 프로세스의 변수를 고쳐도 부모에게는 안 보인다
  • process.join() — 그 프로세스가 끝날 때까지 기다린다
  • process.exitcode — 끝난 프로세스가 남긴 종료 코드다. 0 이면 잘 끝났다는 뜻이다

컨텍스트 스위칭: 왜 비용이 발생할까?

컨텍스트 스위칭(Context Switching)은 CPU 가 지금 돌리던 스레드의 상태를 저장하고, 다른 스레드의 상태를 불러와 실행하는 과정이다. 여기서 「상태」는 그 스레드가 어디까지 했는지를 다시 이어 갈 수 있게 해 주는 값들, 곧 레지스터(CPU 안의 작은 저장 칸) 값과 프로그램 카운터, 스택 위치다. 이 값을 TCB 에 적어 두었다가, 다음에 그 스레드 차례가 오면 다시 레지스터에 채워 넣고 이어서 돌린다.

컨텍스트 스위칭 발생 원인

graph TD A["CPU 실행 중
Thread A"] --> B{컨텍스트 스위칭
발생 조건} B -->|"1. 자발적"| C["I/O 대기
Lock 대기
sleep() 호출"] B -->|"2. 비자발적"| D["높은 우선순위
스레드 깨어남"] B -->|"3. 시간 소진"| E["타임 슬라이스
소진 (10ms)"] C --> F["Thread A 상태 저장
(레지스터, PC 등)"] D --> F E --> F F --> G["Thread B 상태 복원"] G --> H["CPU 실행 중
Thread B"] style A fill:#533483,stroke:#16213e,stroke-width:2px,color:#eee style B fill:#0f3460,stroke:#16213e,stroke-width:2px,color:#eee style C fill:#1a1a2e,stroke:#16213e,stroke-width:2px,color:#eee style D fill:#1a1a2e,stroke:#16213e,stroke-width:2px,color:#eee style E fill:#1a1a2e,stroke:#16213e,stroke-width:2px,color:#eee style F fill:#8b0000,stroke:#16213e,stroke-width:2px,color:#eee style G fill:#8b0000,stroke:#16213e,stroke-width:2px,color:#eee style H fill:#533483,stroke:#16213e,stroke-width:2px,color:#eee
  1. 자발적 — 스레드가 스스로 멈추는 경우다. I/O 결과를 기다리거나, 자물쇠를 기다리거나, sleep() 으로 「잠시 쉬겠다」고 한 경우다. 기다리는 동안 CPU 를 쥐고 있어 봐야 할 일이 없으니 내려놓는다
  2. 비자발적 — 잠들어 있던(기다리던) 스레드 중 우선순위가 더 높은 스레드의 기다림이 끝나 깨어나면, 스케줄러가 지금 스레드를 내리고 그 스레드를 올릴 수 있다
  3. 시간 소진 — 한 스레드가 CPU 를 쥘 수 있는 시간인 타임 슬라이스를 다 쓰면 내려온다. 그림의 10ms 는 예시 값이다. 실제 길이는 운영체제와 그때 돌 준비가 된 스레드 수에 따라 다르다

프로세스 vs 스레드 컨텍스트 스위칭 비용

먼저 같은 점부터. 어느 쪽이든 갈아타는 일은 커널이 한다. 스레드 전환도 프로세스 전환도 CPU 가 커널 모드로 들어가 커널의 스케줄러 코드를 돌려야 일어난다. 차이는 커널이 그 안에서 바꿔 끼우는 것의 양이다.

프로세스 컨텍스트 스위칭이 더 비싼 이유:

  1. 메모리 공간 전환
    • 프로세스마다 가상 메모리 주소 공간(그 프로세스가 보는 주소와 실제 메모리 자리를 잇는 지도)이 다르다. 다른 프로세스로 가면 이 지도를 통째로 바꿔 끼운다
    • TLB는 최근에 바꾼 주소 몇 개를 기억해 두는 작은 캐시인데, 지도가 바뀌면 그 기억이 맞지 않게 된다. 그래서 비우거나(플러시) 프로세스별로 구분해 둬야 하고, 비우면 한동안 주소를 바꿀 때마다 TLB 에서 답을 못 찾고 메모리에 있는 지도(페이지 표)를 직접 읽으러 간다. 메모리는 CPU 안의 TLB 보다 훨씬 멀어서 느리다
    • CPU 캐시에 남아 있던 이전 프로세스의 데이터도 새 프로세스에는 쓸모가 없다. 새 프로세스가 필요한 데이터를 다시 메모리에서 가져와 채울 때까지 느리다
  2. 더 많은 상태 정보
    • PCB 의 정보, 곧 레지스터 말고도 메모리 지도와 그 프로세스의 자원 정보까지 바꿔 끼운다
    • 파일 디스크립터(그 프로세스가 열어 둔 파일 · 소켓 번호표)와 시그널 핸들러(신호가 왔을 때 부를 함수 목록) 같은 것이 프로세스마다 따로 있다

스레드 컨텍스트 스위칭(같은 프로세스 안):

  • 같은 메모리 공간을 쓰므로 주소 지도를 바꾸지 않는다. TLB 와 캐시에 든 내용도 그대로 쓸모가 있다
  • TCB 만 저장/복원한다
  • 레지스터, 스택 포인터(지금 스레드의 Stack 에서 맨 위가 어디인지 가리키는 레지스터), PC 만 바꾼다

실무 적용: 언제 무엇을 사용할까?

Python의 GIL과 선택 전략

Python 의 GIL(Global Interpreter Lock)은 Python 코드를 실행하려는 스레드가 반드시 쥐어야 하는 자물쇠 하나다. 그래서 스레드가 여러 개여도 Python 코드를 실제로 실행하는 것은 한 순간에 하나뿐이다. 코어가 4개여도 Python 스레드 4개가 동시에 계산하지 못한다.

여기서 작업을 두 종류로 가른다(CPU 집약 · I/O 집약). CPU 가 쉬지 않고 계산하는 작업이 CPU 집약, 파일 · 네트워크 응답을 기다리는 시간이 대부분인 작업이 I/O 집약이다.

graph LR subgraph "CPU 집약 작업" CPU1["Thread 1"] -.->|GIL 경쟁| GIL1[GIL] CPU2["Thread 2"] -.->|대기| GIL1 CPU3["Thread 3"] -.->|대기| GIL1 GIL1 -.->|병목| Result1["느린 실행"] end subgraph "I/O 집약 작업" IO1["Thread 1"] -->|I/O 시 GIL 해제| Wait1[I/O 대기] IO2["Thread 2"] -->|실행 가능| GIL2[GIL] IO3["Thread 3"] -->|실행 가능| GIL2 GIL2 -->|효율적| Result2["빠른 실행"] end style CPU1 fill:#8b0000,stroke:#16213e,stroke-width:2px,color:#eee style CPU2 fill:#4d0000,stroke:#16213e,stroke-width:2px,color:#eee style CPU3 fill:#4d0000,stroke:#16213e,stroke-width:2px,color:#eee style GIL1 fill:#1a1a2e,stroke:#16213e,stroke-width:2px,color:#eee style Result1 fill:#8b0000,stroke:#16213e,stroke-width:2px,color:#eee style IO1 fill:#0f3460,stroke:#16213e,stroke-width:2px,color:#eee style IO2 fill:#0f3460,stroke:#16213e,stroke-width:2px,color:#eee style IO3 fill:#0f3460,stroke:#16213e,stroke-width:2px,color:#eee style Wait1 fill:#1a1a2e,stroke:#16213e,stroke-width:2px,color:#eee style GIL2 fill:#1a1a2e,stroke:#16213e,stroke-width:2px,color:#eee style Result2 fill:#2d4a2b,stroke:#16213e,stroke-width:2px,color:#eee
  • CPU 집약 작업(왼쪽) — 세 스레드 모두 계산하려고 GIL 을 원하지만 한 번에 하나만 쥔다. 나머지는 기다리므로 스레드를 늘려도 빨라지지 않는다
  • I/O 집약 작업(오른쪽) — 스레드가 네트워크 응답을 기다리는 동안은 Python 코드를 실행하지 않는다. 그래서 기다리기 시작할 때 GIL 을 놓고, 그 사이 다른 스레드가 GIL 을 쥐고 돈다. 기다리는 시간이 겹쳐지니 전체가 빨라진다

선택 기준:

작업 유형 선택 이유
CPU 집약적 멀티프로세스 GIL 우회, 각 CPU 코어 활용
I/O 집약적 멀티스레드 I/O 대기 시 GIL 해제, 낮은 생성 비용
혼합형 프로세스 + 스레드 프로세스 풀 + 각 프로세스 내 스레드 풀
  • CPU 집약은 왜 프로세스인가 — GIL 은 프로세스마다 하나씩 있다. 프로세스를 4개 띄우면 GIL 도 4개라서, 네 프로세스가 각자 자기 GIL 을 쥐고 네 코어에서 동시에 계산한다
  • 혼합형의 「풀」 — 스레드 풀은 미리 만들어 둔 스레드를 모아 두고 일이 오면 빌려 주는 곳이다. 일마다 새로 만들고 없애는 비용을 아낀다. 프로세스 풀은 같은 생각을 프로세스로 한 것이다

실전 예제:

import threading
import multiprocessing
import time

# CPU 집약적 작업 - 프로세스 사용
def cpu_bound_task(n):
    count = 0
    for i in range(n):
        count += i * i
    return count

# I/O 집약적 작업 - 스레드 사용
def io_bound_task(url):
    import requests
    response = requests.get(url)
    return len(response.content)

# CPU 작업: 멀티프로세스
if __name__ == '__main__':
    with multiprocessing.Pool(processes=4) as pool:
        results = pool.map(cpu_bound_task, [10000000] * 4)
    
    # I/O 작업: 멀티스레드
    urls = ['https://example.com'] * 10
    threads = []
    for url in urls:
        t = threading.Thread(target=io_bound_task, args=(url,))
        threads.append(t)
        t.start()
    
    for t in threads:
        t.join()
  • cpu_bound_task(n) — for i in range(n) 은 자바의 for (int i = 0; i < n; i++) 이다. 0 부터 n-1 까지 제곱을 더하기만 하니 계산만 하는 CPU 집약 작업이다
  • io_bound_task(url) — requests 는 HTTP 요청을 보내는 외부 라이브러리다. requests.get(url) 은 그 주소의 웹 서버에 「이 페이지를 달라」고 요청하고 응답이 올 때까지 기다린다. response.content 는 받은 본문, len(...) 은 그 길이다. 시간 대부분이 응답을 기다리는 데 쓰이니 I/O 집약 작업이다
  • if __name__ == '__main__': — 이 파일을 직접 실행했을 때만 아래를 돌린다는 뜻이다. 자바의 main 메서드 자리라고 보면 된다. multiprocessing 은 새 프로세스를 띄울 때 이 파일을 다시 읽을 수 있어서, 이 조건이 없으면 새 프로세스가 또 프로세스를 띄우는 일이 생긴다
  • with multiprocessing.Pool(processes=4) as pool: — 프로세스 4개짜리 풀을 만든다. with ... as 는 블록이 끝나면 풀을 자동으로 닫아 주는 문법으로, 자바의 try-with-resources 와 같다
  • pool.map(cpu_bound_task, [10000000] * 4) — [10000000] * 4 는 10000000 이 4개 든 리스트다. map 은 리스트의 값 하나마다 cpu_bound_task 를 부르는데, 그 일을 풀의 네 프로세스에 나눠 맡긴다. 결과 4개가 리스트로 돌아온다
  • urls = ['https://example.com'] * 10 — 같은 주소 10개가 든 리스트다
  • threading.Thread(target=io_bound_task, args=(url,)) — args= 로 함수에 넘길 인자를 준다. (url,) 는 값 하나짜리 튜플이고, 끝의 쉼표가 있어야 튜플이 된다
  • 마지막 for t in threads: t.join() — 10개 스레드가 다 끝날 때까지 기다린다

웹 서버의 멀티스레드 전략

웹 서버는 바깥에서 오는 HTTP 요청을 받아 처리하고 응답을 돌려주는 프로그램이다. 이 일은 대부분 I/O 집약적 작업(DB 쿼리 결과를 기다리거나 네트워크로 주고받는 일)이라서 멀티스레드 방식을 많이 쓴다.

Tomcat의 스레드 풀 방식:

톰캣은 자바 웹 서버다. 요청이 오면 스레드 풀에서 스레드 하나를 꺼내 그 요청을 끝까지 맡긴다. 풀 크기는 설정 파일 server.xml 에서 정한다.

<!-- Tomcat 설정 예시 (server.xml) -->
<!-- maxThreads:      최대 스레드 수 -->
<!-- minSpareThreads: 최소 유휴 스레드 수 -->
<!-- maxConnections:  최대 동시 연결 수 -->
<!-- acceptCount:     대기 큐 크기 -->
<Connector port="8080"
           maxThreads="200"
           minSpareThreads="10"
           maxConnections="10000"
           acceptCount="100"/>
  • 이 블록은 자바 코드가 아니라 XML 설정이다. XML 은 주석을 <!-- --> 로 쓰고, 태그 안에는 주석을 넣을 수 없어서 설명을 태그 위로 뺐다
  • <Connector ... /> — 커넥터는 톰캣 안에서 포트 하나를 맡는 부품이다. 그 포트로 들어온 연결을 받아 요청으로 만들어 스레드에 넘긴다. 아래 속성들이 이 커넥터의 스레드 풀 크기와 받을 연결 수를 정한다
  • port="8080" — 이 포트 번호로 오는 요청을 받는다
  • maxThreads="200" — 요청을 처리할 스레드를 최대 200개까지 만든다. 동시에 처리하는 요청이 최대 200개라는 뜻이다
  • minSpareThreads="10" — 일이 없어도 최소 10개는 미리 만들어 놓고 대기시킨다. 요청이 갑자기 와도 스레드를 새로 만드느라 늦지 않게 한다
  • maxConnections="10000" — 동시에 붙어 있을 수 있는 연결 수다. 연결은 브라우저 하나가 서버에 붙어 있는 줄 하나이고, 요청은 그 줄로 보내는 「이걸 해 달라」 한 건이다. 붙어만 있고 지금은 아무 요청도 안 보내는 연결이 많아서, 연결 수가 스레드 수보다 훨씬 커도 된다. 스레드 200개가 다 바빠도 연결은 이만큼 받아 둔다
  • acceptCount="100" — 연결 수까지 꽉 찼을 때 운영체제 쪽에서 더 기다리게 할 수 있는 연결 수다. 「대기 큐」는 차례를 기다리는 줄이다. 이것까지 넘치면 새 연결은 거절된다

장점:

  • 요청마다 스레드 하나를 붙이니, 요청 여러 개를 동시에 처리한다. 한 요청이 DB 를 기다리는 동안 다른 스레드는 다른 요청을 처리한다
  • 스레드 풀로 스레드를 재사용하므로 요청마다 스레드를 만들고 없애는 비용이 없다
  • 모든 스레드가 같은 Heap 을 보니, 서버 메모리에 둔 세션(로그인한 사용자 정보 같은 것) 저장 칸을 어느 스레드에서든 꺼낼 수 있다

Node.js의 이벤트 루프 방식:

Node.js는 서버에서 JavaScript를 돌리는 실행 환경이다. 톰캣과 반대로 내 코드는 스레드 하나에서만 돌고, 기다리는 일은 맡겨 둔 채 바로 다음 일로 넘어가는 비동기 I/O 방식을 쓴다. 맡긴 일이 끝나면 이벤트 루프가 그 일에 걸어 둔 함수를 불러 준다.

// 비동기 I/O - 스레드 블로킹 없음
const fs = require('fs');

fs.readFile('large-file.txt', (err, data) => {
    if (err) throw err;
    console.log('File read completed');
});

// I/O 대기 중에도 다른 요청 처리 가능
console.log('This runs immediately');
  • const fs = require('fs'); — 파일 기능 모듈을 가져온다. 자바의 import 자리다
  • fs.readFile('large-file.txt', (err, data) => { ... }) — 파일을 읽어 달라고 맡기면서, 다 읽으면 부를 함수를 같이 넘긴다. 이렇게 「끝나면 불러 줘」라고 넘기는 함수를 콜백이라고 한다. (err, data) => { ... } 는 자바의 람다와 같은 모양이다. 실패하면 err 에, 성공하면 읽은 내용이 data 에 들어온다
  • readFile 은 파일을 다 읽을 때까지 기다리지 않고 바로 돌아온다. 그래서 바로 다음 줄 console.log('This runs immediately') 가 먼저 찍힌다
  • 파일 읽기가 끝나면 이벤트 루프가 콜백을 불러서 File read completed 가 그다음에 찍힌다

블로킹은 일이 끝날 때까지 스레드가 다음 줄로 못 가고 멈춰 서 있는 것인데, 이 방식은 그 멈춤이 없어서 스레드 하나로도 많은 요청을 번갈아 처리한다.

비교:

방식 Tomcat (멀티스레드) Node.js (이벤트 루프)
동시성 스레드 기반 비동기 I/O 기반
확장성 스레드 수 제한 단일 스레드, 높은 동시성
CPU 작업 가능 (별도 스레드) 블로킹 (Worker 필요)
메모리 높음 (스레드당 Stack) 낮음 (단일 스레드)
  • 확장성 — 요청을 더 많이 받을 수 있게 늘리기 쉬운가다. 톰캣은 동시에 처리하는 요청 수가 스레드 수(위 설정의 200)에 묶인다
  • CPU 작업 — Node.js 에서 콜백 하나가 오래 계산하면 그 하나뿐인 스레드가 묶여서 다른 요청이 전부 밀린다. 그래서 무거운 계산은 Worker(별도 스레드를 띄우는 Node.js 기능)로 뺀다
  • 메모리 — 톰캣은 스레드마다 Stack 을 따로 잡으니 스레드가 많을수록 메모리를 더 쓴다

메모리 공유의 양날의 검

스레드가 Heap 을 함께 쓴다는 것은 장점이자 위험이다.

장점:

  • 빠른 데이터 접근 — 같은 메모리 공간이라 복사 없이 같은 객체를 본다
  • 스레드 간 통신 비용 최소화 — IPC 통로를 거칠 필요가 없다
  • 캐시 효율성 증가 — 같은 프로세스의 스레드끼리 갈아탈 때는 앞의 컨텍스트 스위칭 섹션처럼 TLB 와 캐시 내용이 그대로 쓸모 있다

단점:

  • 경쟁 상태(Race Condition) 위험 — 두 스레드가 같은 값을 동시에 고치면, 실행 순서에 따라 결과가 달라진다. 구체적인 장면은 아래 Race Condition 섹션에서 코드로 본다
  • 동기화 오버헤드 — 자물쇠를 걸고 풀고 기다리는 데 원래 일 말고 시간이 더 든다
  • 디버깅 어려움 — 실행 순서가 매번 달라서 같은 버그가 났다 안 났다 한다

베스트 프랙티스(많이 쓰는 안전한 방법):

세 방법 모두 생각은 같다 — 여러 스레드가 같은 값을 동시에 고치는 상황 자체를 만들지 않는다.

// 1. 불변 객체 사용 (공유해도 안전)
public final class ImmutableConfig {
    private final String value;
    
    public ImmutableConfig(String value) {
        this.value = value;
    }
    
    public String getValue() {
        return value;  // 변경 불가능
    }
}

// 2. 스레드 로컬 변수 사용
public class ThreadLocalExample {
    private static ThreadLocal<Integer> threadLocal = 
        ThreadLocal.withInitial(() -> 0);
    
    public void increment() {
        threadLocal.set(threadLocal.get() + 1);  // 스레드별 독립 값
    }
}

// 3. 결과만 공유 (작업 분리)
ExecutorService executor = Executors.newFixedThreadPool(4);
List<Future<Integer>> futures = new ArrayList<>();

// 각 스레드가 독립적으로 작업
for (int i = 0; i < 10; i++) {
    final int value = i;
    futures.add(executor.submit(() -> value * value));
}

// 결과만 수집
for (Future<Integer> future : futures) {
    int result = future.get();  // 안전하게 결과 수집
}

1. 불변 객체 — 불변 객체는 만든 뒤 내용을 바꿀 수 없다. 필드가 final이고 값을 바꾸는 메서드(setter)가 없다. 경쟁 상태는 「누군가 고치는 도중에 다른 스레드가 읽거나 고칠 때」 생기는데, 아무도 고치지 않으니 여러 스레드가 동시에 읽기만 해서 안전하다.

2. ThreadLocal — ThreadLocal은 변수 하나처럼 생겼지만 스레드마다 값을 따로 들고 있다. 스레드 A 가 set 한 값은 스레드 B 의 get 에 안 보인다. 각자 자기 값만 고치니 겹칠 일이 없다.

  • ThreadLocal.withInitial(() -> 0) — 처음 get 할 때의 값을 0 으로 정한다. () -> 0 은 「인자 없이 0 을 돌려주는 함수」를 짧게 쓴 람다다
  • threadLocal.set(threadLocal.get() + 1) — 지금 스레드의 값을 꺼내 1 더해 다시 넣는다. 다른 스레드의 값은 건드리지 않는다

3. 결과만 공유 — 스레드마다 서로 다른 일을 맡기고, 공유하는 것은 끝난 결과뿐이다.

  • Executors.newFixedThreadPool(4) — 스레드 4개짜리 풀을 만든다. 다루는 타입은 ExecutorService다
  • List<Future<Integer>> — Integer 결과를 나중에 받을 Future들을 담는 리스트다. Future 는 「맡긴 일의 결과를 나중에 받을 영수증」이다
  • final int value = i; — 람다 안에서는 바깥의 값이 바뀌지 않는 지역 변수만 쓸 수 있다. i 는 반복마다 바뀌므로 그대로 못 쓰고, 이번 반복의 값을 바뀌지 않는 value 에 옮겨 담아 넘긴다
  • executor.submit(() -> value * value) — 「value 의 제곱을 계산하라」는 일을 풀에 넘긴다. 풀의 스레드 하나가 꺼내 계산하고, submit 은 기다리지 않고 바로 Future 를 돌려준다
  • future.get() — 그 일이 끝날 때까지 기다렸다가 결과를 꺼낸다. 각 일은 자기 value 만 쓰고 공유 변수를 고치지 않았으니, 결과를 모으는 쪽은 메인 스레드 하나뿐이라 안전하다. get() 도 InterruptedException 등을 던지니 실제 코드에서는 처리해야 한다

동기화 메커니즘: Monitor와 Lock

공유를 피할 수 없을 때는 동기화로 「한 번에 한 스레드만」 들어가게 막는다. 자바에서 그 방법이 두 가지다 — 키워드 하나로 쓰는 synchronized 와 객체로 직접 다루는 ReentrantLock.

Monitor: Java의 내장 동기화

자바의 모든 객체에는 모니터라는 자물쇠가 하나씩 붙어 있다. JVM 이 자동으로 붙여 주는 것이라 intrinsic lock(내재 락)이라고도 부른다. 이 자물쇠를 쓰는 문법이 synchronized 키워드다. synchronized 메서드를 부르면 그 객체의 모니터를 쥐고 들어가고, 메서드가 끝나면 놓는다.

Monitor 동작 원리:

sequenceDiagram participant T1 as Thread 1 participant M as Monitor (객체) participant T2 as Thread 2 T1->>M: synchronized 메서드 호출 M->>M: 모니터 획득 Note over M: Thread 1이 모니터 소유 T2->>M: synchronized 메서드 호출 M-->>T2: 대기 (BLOCKED) T1->>T1: 메서드 실행 T1->>M: 메서드 종료 M->>M: 모니터 자동 반납 M->>T2: 모니터 획득 Note over M: Thread 2가 모니터 소유 T2->>T2: 메서드 실행 T2->>M: 메서드 종료 M->>M: 모니터 반납
  • Thread 1 이 먼저 synchronized 메서드에 들어가 모니터를 쥔다
  • Thread 2 가 같은 객체의 synchronized 메서드를 부르면, 모니터를 이미 Thread 1 이 쥐고 있으니 기다린다. 이때 상태가 앞의 라이프사이클 그림의 Blocked 이고, 자바 이름으로도 BLOCKED 다
  • Thread 1 이 메서드를 마치면 모니터를 놓고, 그제야 Thread 2 가 쥐고 들어간다

참고: GeeksforGeeks - Monitor in Java

Synchronized 키워드 사용:

class BankAccount {
    private int balance = 1000;
    
    // 메서드 레벨 동기화
    public synchronized void withdraw(int amount) {
        if (balance >= amount) {
            balance -= amount;
            System.out.println("출금: " + amount + ", 잔액: " + balance);
        }
    }
    
    // 블록 레벨 동기화 (더 세밀한 제어)
    public void deposit(int amount) {
        synchronized(this) {  // this 객체의 모니터 사용
            balance += amount;
            System.out.println("입금: " + amount + ", 잔액: " + balance);
        }
    }
}

// 사용 예
BankAccount account = new BankAccount();

Thread t1 = new Thread(() -> account.withdraw(100));
Thread t2 = new Thread(() -> account.deposit(200));

t1.start();
t2.start();
  • public synchronized void withdraw(...) — 메서드 전체를 this(이 계좌 객체)의 모니터로 감싼다. 잔액 확인과 빼기 사이에 다른 스레드가 끼어들지 못한다
  • synchronized(this) { ... } — 괄호 안 객체의 모니터를 쥐고 중괄호 블록만 실행한다. 메서드 전체가 아니라 필요한 줄만 감쌀 수 있어서 「더 세밀하다」. 괄호에 this 를 넣었으니 withdraw 와 같은 자물쇠다. 그래서 출금과 입금이 동시에 balance 를 고치지 못한다
  • new Thread(() -> account.withdraw(100)) — Runnable 을 클래스로 따로 만들지 않고, run() 에서 할 일을 람다로 바로 적었다. 앞의 MyTask 예제와 같은 일을 짧게 쓴 것이다

핵심 개념:

  • (장점) JVM 이 모니터를 자동으로 쥐고 놓는다
  • (장점) 블록 안에서 예외가 나도 빠져나오면서 자동으로 놓는다. 놓는 것을 잊을 수 없다
  • (장점) 재진입이 된다(Reentrant). 모니터를 쥔 스레드가 같은 객체의 다른 synchronized 메서드를 불러도 자기 자물쇠에 막히지 않는다
  • (단점) 타임아웃을 줄 수 없다. 모니터가 안 풀리면 끝없이 기다린다
  • (단점) 공정성을 보장하지 않는다. 오래 기다린 스레드가 먼저 들어간다는 보장이 없다

ReentrantLock: 명시적 동기화

ReentrantLock은 synchronized 가 못 하는 것(타임아웃, 공정성 선택 등)을 할 수 있는 자물쇠 객체다. 대신 쥐고 놓는 일을 내가 직접 코드로 써야 한다.

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;

class Counter {
    private int count = 0;
    private final Lock lock = new ReentrantLock();  // Fair Lock도 가능
    
    public void increment() {
        lock.lock();  // 명시적 획득
        try {
            count++;
        } finally {
            lock.unlock();  // 반드시 finally에서 해제!
        }
    }
    
    // 타임아웃과 함께 시도
    public boolean tryIncrementWithTimeout() {
        try {
            if (lock.tryLock(1, TimeUnit.SECONDS)) {  // 1초 대기
                try {
                    count++;
                    return true;
                } finally {
                    lock.unlock();
                }
            }
            return false;  // 획득 실패
        } catch (InterruptedException e) {
            return false;
        }
    }
    
    public int getCount() {
        return count;
    }
}
  • new ReentrantLock() — 공정하지 않은 자물쇠를 만든다. 주석의 「Fair Lock도 가능」은 new ReentrantLock(true) 로 만들면 오래 기다린 스레드부터 들어가는 공정한 자물쇠가 된다는 뜻이다
  • lock.lock() — 자물쇠를 쥔다. 다른 스레드가 쥐고 있으면 풀릴 때까지 기다린다
  • try { count++; } finally { lock.unlock(); } — count++ 에서 예외가 나도 finally 는 반드시 실행되니 자물쇠가 꼭 풀린다. unlock() 을 빠뜨리면 이 자물쇠를 기다리는 다른 스레드가 영원히 멈춘다. synchronized 는 이걸 자동으로 해 줬지만 여기서는 내가 챙긴다
  • lock.tryLock(1, TimeUnit.SECONDS) — 1초까지만 기다린다. TimeUnit.SECONDS 는 앞의 숫자 1 의 단위가 초라는 뜻이다. 1초 안에 쥐면 true, 못 쥐면 기다림을 포기하고 false 를 돌려준다
  • catch (InterruptedException e) — 기다리는 도중에 다른 스레드가 이 스레드에 인터럽트(「기다림을 그만둬라」는 신호)를 보내면 이 예외가 난다. 여기서는 실패로 보고 false 를 돌려준다

Synchronized vs ReentrantLock:

기능 Synchronized ReentrantLock
사용 편의성 간단 (자동 관리) 복잡 (수동 관리)
타임아웃 불가능 tryLock(timeout)
인터럽트 불가능 lockInterruptibly()
공정성 불공정 공정/불공정 선택 가능
조건 변수 wait()/notify() Condition 객체
성능 약간 빠름 약간 느림
  • 인터럽트 — synchronized 로 모니터를 기다리는 스레드는 인터럽트를 받아도 기다림을 멈추지 않는다. lockInterruptibly() 로 기다리면 인터럽트를 받는 즉시 InterruptedException 을 던지며 기다림을 그만둔다
  • 조건 변수 — 조건 변수는 「조건이 될 때까지 자물쇠를 잠시 놓고 잠들어 있다가, 누가 깨워 주면 다시 쥐고 일어나는」 장치다. synchronized 쪽은 wait()/notify(), ReentrantLock 쪽은 Condition 객체가 이 일을 한다
  • 성능 — 이 줄은 출처나 측정 조건이 적혀 있지 않다. 나는 직접 재 보지 않았다

Python의 Lock

import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    
    # 방법 1: 수동 acquire/release
    lock.acquire()
    try:
        counter += 1
    finally:
        lock.release()
    
    # 방법 2: Context Manager (권장)
    with lock:
        counter += 1  # 자동으로 acquire/release

threads = [threading.Thread(target=increment) for _ in range(100)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(f"Counter: {counter}")  # 200 (안전하게 증가)
  • global counter — 함수 안에서 바깥의 counter 를 고치겠다는 표시다. 이게 없으면 Python 은 함수 안에 새 지역 변수를 만든 것으로 본다
  • 방법 1 — lock.acquire() 로 쥐고 finally 에서 lock.release() 로 놓는다. 자바의 lock.lock() / unlock() 과 같은 모양이다
  • 방법 2 — with lock: 은 블록에 들어갈 때 쥐고 나올 때 자동으로 놓는다. 주석의 Context Manager 는 이렇게 with 와 함께 쓰여 「들어갈 때 할 일」과 「나올 때 할 일」을 알아서 해 주는 객체를 Python 에서 부르는 이름이다. 자바의 synchronized 블록처럼 놓는 것을 잊을 수 없어서 권장한다
  • [threading.Thread(target=increment) for _ in range(100)] — 스레드 100개를 담은 리스트를 한 줄로 만드는 문법이다. 자바로는 for 를 100번 돌며 list.add(new Thread(...)) 하는 것과 같다. _ 는 반복 번호를 안 쓴다는 뜻의 이름이다
  • 왜 200인가 — 스레드는 100개지만 increment() 한 번에 방법 1 에서 1, 방법 2 에서 1, 모두 2를 더한다. 100 × 2 = 200 이다. 두 방법을 비교해 보여 주려고 한 함수에 둘 다 넣은 것이다

위험한 함정: Deadlock

자물쇠로 경쟁 상태를 막으면 새 문제가 생긴다. 교착 상태(Deadlock)는 두 개 이상의 스레드가 각자 자물쇠를 하나씩 쥔 채, 상대가 쥔 자물쇠를 기다리며 영원히 멈추는 상황이다. 아래 예제에서 「자원」은 Resource 객체이고, 정확히는 그 객체의 모니터(자물쇠)다.

Deadlock 발생 예제

class Resource {
    private final String name;
    
    public Resource(String name) {
        this.name = name;
    }
    
    public synchronized void useWith(Resource other) {
        System.out.println(Thread.currentThread().getName() + 
                          ": " + name + " 획득");
        
        try {
            Thread.sleep(100);  // 시뮬레이션
        } catch (InterruptedException e) {}
        
        // 다른 자원의 락 필요!
        other.doSomething();
    }
    
    public synchronized void doSomething() {
        System.out.println(Thread.currentThread().getName() + 
                          ": " + name + " 사용");
    }
}

// Deadlock 발생!
Resource r1 = new Resource("Resource 1");
Resource r2 = new Resource("Resource 2");

Thread t1 = new Thread(() -> r1.useWith(r2), "Thread-1");
Thread t2 = new Thread(() -> r2.useWith(r1), "Thread-2");

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

// Thread-1: r1 획득 → r2 대기
// Thread-2: r2 획득 → r1 대기
// → 둘 다 영원히 대기!

단계를 따라간다.

  1. new Thread(() -> r1.useWith(r2), "Thread-1") — 두 번째 인자 "Thread-1" 은 스레드 이름이다. 출력에서 누가 찍었는지 보려고 붙였다
  2. Thread-1 이 r1.useWith(r2) 를 부른다. useWith 가 synchronized 라서 들어가는 순간 r1 의 모니터를 쥔다
  3. 같은 때 Thread-2 가 r2.useWith(r1) 를 불러 r2 의 모니터를 쥔다
  4. Thread.sleep(100) — 둘 다 0.1초 쉰다. 실제 프로그램에서 자물쇠를 쥔 채 무언가 일을 하는 시간을 흉내 낸 것이고(시뮬레이션), 이 사이에 두 스레드가 확실히 각자 하나씩 쥐게 된다. sleep 은 InterruptedException 을 던질 수 있어서 catch 를 둬야 컴파일되는데, 예제라서 비워 뒀다
  5. Thread-1 이 other.doSomething(), 곧 r2.doSomething() 을 부른다. 이것도 synchronized 라서 r2 의 모니터가 필요하다. 그런데 r2 는 Thread-2 가 쥐고 있으니 기다린다. Thread-1 은 r1 을 쥔 채로 기다린다 — useWith 가 아직 안 끝났으니 r1 을 놓지 않았다
  6. Thread-2 도 r1.doSomething() 을 부르며 r1 을 기다린다. r1 은 Thread-1 이 쥐고 있다
  7. 둘 다 상대가 놓기를 기다리지만, 상대도 기다리는 중이라 아무도 놓지 않는다. 프로그램이 그대로 멈춘다
graph LR T1["Thread 1"] -->|보유| R1["Resource 1
Lock"] T1 -.->|대기| R2["Resource 2
Lock"] T2["Thread 2"] -->|보유| R2 T2 -.->|대기| R1 R1 -.->|필요| T2 R2 -.->|필요| T1 style T1 fill:#533483,stroke:#16213e,stroke-width:2px,color:#eee style T2 fill:#533483,stroke:#16213e,stroke-width:2px,color:#eee style R1 fill:#8b0000,stroke:#16213e,stroke-width:3px,color:#eee style R2 fill:#8b0000,stroke:#16213e,stroke-width:3px,color:#eee
  • 실선 「보유」는 그 스레드가 이미 쥔 자물쇠, 점선 「대기」는 쥐려고 기다리는 자물쇠다
  • 자물쇠에서 스레드로 가는 「필요」 화살표는 「이 자물쇠가 풀리려면 저 스레드가 앞으로 가서 놓아야 한다」는 뜻이다. Resource 1 은 Thread 1 이 쥐고 있지만 Thread 2 가 기다리고, Resource 2 는 그 반대다. 화살표를 따라가면 T1 → R2 → T1 처럼 제자리로 돌아오는 고리가 생기는데, 이 고리가 교착 상태의 모양이다

Deadlock 방지 전략

교착 상태는 「서로 반대 순서로 쥐기」와 「쥔 채로 또 기다리기」가 겹쳐서 생긴다. 아래 네 방법은 각각 그 조건 하나를 깬다.

1. Lock 순서 정하기 (가장 효과적)

// 모든 스레드가 같은 순서로 락 획득
class SafeResource {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();
    
    public void safeOperation() {
        // 항상 lock1 → lock2 순서
        synchronized(lock1) {
            synchronized(lock2) {
                // 작업 수행
            }
        }
    }
}
  • new Object() — 아무 기능 없는 객체지만, 앞에서 본 대로 자바의 모든 객체에는 모니터가 하나씩 붙어 있다. 그래서 자물쇠로만 쓸 객체를 이렇게 만든다. static final 이라 모든 SafeResource 객체가 같은 자물쇠 두 개를 함께 쓴다
  • 모든 스레드가 늘 lock1 을 먼저, lock2 를 나중에 쥔다. 그러면 앞 예제처럼 「한쪽은 lock2 를 쥐고 lock1 을 기다리는」 스레드가 생길 수 없다. lock2 를 쥔 스레드는 이미 lock1 도 쥐고 있기 때문이다
  • 이 방법도 자물쇠를 겹쳐 쥔다. 겹쳐 쥐는 것 자체가 아니라 순서가 엇갈리는 것이 문제라서, 순서만 고정하면 겹쳐도 안전하다

2. Nested Lock 피하기

// 나쁜 예
synchronized void method1() {
    synchronized(otherObject) {  // Nested lock!
        // 위험
    }
}

// 좋은 예
void method1() {
    Object temp;
    synchronized(this) {
        temp = getData();
    }
    // Lock 해제 후 다른 작업
    processData(temp);
}
  • 방법 1 은 순서를 지키면 겹쳐도 된다고 했다. 그런데 otherObject 처럼 다른 객체의 자물쇠는 다른 코드가 어떤 순서로 쥐는지 내가 다 알기 어렵다. 그 순서를 보장할 수 없을 때는 아예 겹쳐 쥐지 않는 것이 이 방법이다
  • 나쁜 예 — this 의 모니터를 쥔 채로 otherObject 의 모니터를 또 쥔다. 다른 어딘가에서 otherObject → this 순서로 쥐면 앞 예제와 같은 교착 상태가 된다
  • 좋은 예 — this 의 모니터는 데이터를 꺼내는 동안만 쥐고 바로 놓는다. 그다음 일은 자물쇠 없이 한다. getData() 와 processData() 는 「공유 데이터를 꺼내는 메서드」와 「꺼낸 값으로 일하는 메서드」를 가리키는 예시 이름이라 정의를 적지 않았다

3. Timeout 사용

Lock lock1 = new ReentrantLock();
Lock lock2 = new ReentrantLock();

public boolean safeOperationWithTimeout() {
    try {
        if (lock1.tryLock(1, TimeUnit.SECONDS)) {
            try {
                if (lock2.tryLock(1, TimeUnit.SECONDS)) {
                    try {
                        // 작업 수행
                        return true;
                    } finally {
                        lock2.unlock();
                    }
                }
            } finally {
                lock1.unlock();
            }
        }
        return false;  // Deadlock 회피
    } catch (InterruptedException e) {
        return false;
    }
}
  • lock1 을 1초까지 기다려 쥐고, 쥐었으면 lock2 를 1초까지 기다린다
  • lock2 를 못 쥐면 안쪽 if 를 그냥 지나가고, 바깥 finally 에서 쥐고 있던 lock1 을 놓는다. 그리고 false 를 돌려준다
  • 교착 상태는 「쥔 채로 영원히 기다리기」였다. 여기서는 1초가 지나면 쥔 것을 놓고 물러나므로, 상대 스레드가 lock1 을 쥐고 앞으로 갈 수 있다. 그래서 영원히 멈추지 않는다
  • false 를 받은 쪽이 그다음에 무엇을 할지(잠시 뒤 다시 시도할지, 실패로 끝낼지)는 이 코드에 없다. 부르는 쪽이 정한다

4. Lock-Free 자료구조 사용

import java.util.concurrent.atomic.AtomicInteger;

// Lock 없이 안전한 증가
AtomicInteger counter = new AtomicInteger(0);

// 여러 스레드에서 동시 호출해도 안전
counter.incrementAndGet();  // 원자적 연산
  • 자물쇠를 아예 안 쓰면 교착 상태도 생길 수 없다
  • incrementAndGet() 은 1 더하고 더한 값을 돌려준다. 이 연산은 원자적이다 — 중간에 다른 스레드가 끼어들 수 없이 한 덩어리로 끝나서, 다른 스레드에게는 「아직 안 했다」나 「다 했다」 둘 중 하나로만 보인다. 자물쇠 대신 CPU 가 제공하는 한 번에 끝나는 명령을 써서 이렇게 한다

실전 예제: 안전한 멀티스레드 카운터

문제: Race Condition

앞에서 이름만 나온 경쟁 상태를 실제 코드로 본다.

// 위험한 코드!
class UnsafeCounter {
    private int count = 0;
    
    public void increment() {
        count++;  // Read-Modify-Write (비원자적!)
    }
    
    public int getCount() {
        return count;
    }
}

// 결과: 기대값 2000, 실제값 1500 (예측 불가)
UnsafeCounter counter = new UnsafeCounter();

Thread t1 = new Thread(() -> {
    for (int i = 0; i < 1000; i++) counter.increment();
});
Thread t2 = new Thread(() -> {
    for (int i = 0; i < 1000; i++) counter.increment();
});

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

System.out.println(counter.getCount());  // 2000이 아님!

두 스레드가 1000번씩, 모두 2000번 더하는데 결과가 2000 이 안 나온다. 이유는 count++ 한 줄이 실제로는 세 단계라서다.

  1. Read — 메모리에서 count 값을 읽는다
  2. Modify — 읽은 값에 1 을 더한다
  3. Write — 더한 값을 count 에 다시 쓴다

두 스레드가 겹치면 이렇게 된다.

count = 5 일 때
t1: count 를 읽는다 → 5
t2: count 를 읽는다 → 5     (t1 이 아직 안 썼다)
t1: 5 + 1 = 6 을 쓴다 → count = 6
t2: 5 + 1 = 6 을 쓴다 → count = 6

두 번 더했는데 6 이 됐다. 한 번이 사라졌다. 이런 겹침이 2000번 중 몇 번 일어나느냐에 따라 결과가 달라진다. 주석의 1500 은 그렇게 나올 수 있는 값 하나를 예로 든 것이고, 돌릴 때마다 다르다. 겹침이 한 번도 안 일어나면 2000 이 나올 수도 있다.

t1.join() 과 t2.join() 은 두 스레드가 다 끝난 뒤에 결과를 찍으려고 기다리는 줄이다. 앞에서 말했듯 실제로는 InterruptedException 처리가 필요하다.

참고: GeeksforGeeks - Atomic Variables

아래 세 해결책은 모두 이 세 단계를 다른 스레드가 끼어들 수 없는 한 덩어리로 만든다.

해결책 1: Synchronized

class SynchronizedCounter {
    private int count = 0;
    
    public synchronized void increment() {
        count++;  // 모니터로 보호됨
    }
    
    public synchronized int getCount() {
        return count;
    }
}

increment() 가 synchronized 라서 한 스레드가 세 단계를 다 마치고 모니터를 놓을 때까지 다른 스레드는 들어오지 못한다. getCount() 에도 붙인 것은 읽는 쪽도 같은 모니터를 거쳐야 다른 스레드가 쓴 최신 값을 보장받기 때문이다. 코어마다 CPU 캐시가 따로 있고 컴파일러 · CPU 가 값을 레지스터에 잠시 들고 있기도 해서, 자물쇠 없이 읽으면 다른 코어가 쓴 새 값 대신 내 쪽에 남은 옛 값을 볼 수 있다. 자바는 같은 모니터를 놓은 쪽이 쓴 값을 그 모니터를 나중에 쥔 쪽이 반드시 보게 보장한다.

해결책 2: ReentrantLock

class LockedCounter {
    private int count = 0;
    private final Lock lock = new ReentrantLock();
    
    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
    
    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

해결책 1 과 같은 일을 자물쇠 객체로 직접 한다. 쥐고(lock) 놓는(unlock) 것을 try/finally 로 내가 챙긴다.

해결책 3: AtomicInteger (최고 성능)

import java.util.concurrent.atomic.AtomicInteger;

class AtomicCounter {
    private final AtomicInteger count = new AtomicInteger(0);
    
    public void increment() {
        count.incrementAndGet();  // Lock-free!
    }
    
    public int getCount() {
        return count.get();
    }
}

incrementAndGet() 은 앞의 방지 전략 4 에서 본 원자적 연산이다. 세 단계를 CPU 명령 하나로 끝내니 끼어들 틈이 없다. 자물쇠를 안 쓰므로 기다리는 스레드를 재우고 깨우는 비용이 없어서 빠르다.

성능 비교:

방식 10,000회 증가 시간 특징
Unsafe ~5ms 잘못된 결과
Synchronized ~50ms 안전, 느림
ReentrantLock ~55ms 유연, 약간 느림
AtomicInteger ~15ms 안전, 빠름, Lock-free

이 표의 수치는 측정 환경(CPU, 스레드 수)과 측정 방법이 적혀 있지 않고, 위 예제(2,000회)와 횟수도 다르다. 나는 직접 재 보지 않았다. 「AtomicInteger 가 자물쇠 방식보다 빠르다」는 순서 정도로만 읽는다.


핵심 정리

언제 무엇을 사용할까?

프로세스 선택:

  • 맞는 경우: CPU 집약적 작업(Python 의 경우 — GIL 때문)
  • 맞는 경우: 완전한 격리가 필요할 때
  • 맞는 경우: 안정성이 최우선일 때(한 프로세스가 죽어도 다른 프로세스는 산다)
  • 안 맞는 경우: 메모리 공유가 잦을 때
  • 안 맞는 경우: 빠른 생성/종료가 필요할 때

스레드 선택:

  • 맞는 경우: I/O 집약적 작업
  • 맞는 경우: 메모리 공유가 필요할 때
  • 맞는 경우: 갈아타기가 잦아 컨텍스트 스위칭이 빨라야 할 때(작업 단위가 잘고 많을 때)
  • 맞는 경우: 웹 서버처럼 네트워크로 요청을 많이 받는 프로그램
  • 안 맞는 경우: Python 에서의 CPU 집약적 작업(GIL)

동기화 메커니즘 선택:

상황 권장 방식
간단한 동기화 synchronized
타임아웃 필요 ReentrantLock
고성능 카운터 AtomicInteger
대기/알림 패턴 wait()/notify() 또는 Condition
읽기 많음 ReadWriteLock
  • 대기/알림 패턴 — 「일감이 들어올 때까지 기다렸다가, 누가 넣으면 깨어나는」 모양이다. 앞의 조건 변수가 이 일을 한다
  • 읽기 많음 — ReadWriteLock은 읽는 스레드는 여럿이 동시에 들어가고, 쓰는 스레드가 올 때만 혼자 들어가게 하는 자물쇠다

베스트 프랙티스

  1. 공유 최소화
    • 불변 객체 사용
    • Thread Local 변수 활용
    • 결과만 공유
  2. Lock 범위 최소화
    // 나쁜 예
    synchronized void bigMethod() {
        doA();
        doB();  // 동기화 불필요한 부분도 포함
        doC();
    }
       
    // 좋은 예
    void bigMethod() {
        doA();
        synchronized(this) {
            doB();  // 필요한 부분만
        }
        doC();
    }
    

    자물쇠를 쥐고 있는 시간만큼 다른 스레드가 기다린다. 공유 데이터를 건드리는 doB() 만 감싸면, 나머지 doA() · doC() 는 여러 스레드가 동시에 돈다

  3. Deadlock 방지
    • Lock 순서 일관성 유지
    • Nested Lock 피하기
    • Timeout 설정
  4. 적절한 동시성 도구 사용
    • ExecutorService 로 스레드 풀 관리
    • CompletableFuture로 비동기 작업 — Future 처럼 결과를 담지만, 기다리지 않고 「끝나면 이걸 이어서 해라」를 붙여 둘 수 있다
    • ConcurrentHashMap 같은 동시성 컬렉션 — 여러 스레드가 동시에 넣고 꺼내도 깨지지 않게 만든 Map 이다. 보통 HashMap 은 동시에 넣으면 값이 사라질 수 있다

마무리하며

프로세스와 스레드는 소프트웨어 개발의 기본 개념이다. 맞게 고르고 맞게 쓰면 이런 것을 얻는다.

  • 성능 — 스레드나 프로세스를 여러 개 띄우면 운영체제가 그것들을 여러 코어에 나눠 올려 동시에 돌린다. 그래서 멀티코어를 다 쓴다
  • 안정성 — 동기화로 경쟁 상태와 교착 상태를 미리 막는다
  • 확장성 — 요청이 많이 몰려도(많은 트래픽) 스레드 풀이나 이벤트 루프로 나눠 처리한다

반대로 잘못 쓰면 Deadlock 과 Race Condition 처럼 재현도 어려운 버그를 만난다. 그래서 나는 먼저 「공유를 줄일 수 있나」를 보고, 공유가 남으면 그때 위의 표대로 도구를 고른다.

여러분의 프로젝트에서는 어떤 전략을 쓰고 있는지, 겪었던 동시성 문제나 해결 경험을 아래 댓글로 공유해 주면 좋겠다.


자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기