본문으로 건너뛰기

Ch 13.1 멀티 쓰레드 기초 (Thread & Runnable)

자바에서 멀티 쓰레딩(Multi-threading)은 여러 개의 작업을 동시에 수행할 수 있도록 해주는 핵심 기술입니다. 하나의 프로세스 내에서 동시에 여러 개의 흐름(Thread)을 생성하여 병렬로 작업을 처리할 수 있습니다.

1. 프로세스(Process) vs 쓰레드(Thread)

프로세스와 쓰레드는 자주 혼동되는 개념이지만 명확히 다릅니다.

구분프로세스(Process)쓰레드(Thread)
정의실행 중인 프로그램 전체프로세스 내 실행 단위
메모리독립적인 메모리 공간프로세스 메모리 공유
생성 비용높음낮음
통신 방법IPC (복잡)공유 메모리 (간단하지만 동기화 필요)
예시크롬, 워드, 게임크롬 탭 하나의 렌더링/네트워크/스크립트

멀티 프로세스: 각 프로세스가 독립 메모리를 가져 하나가 죽어도 다른 것이 살아있음 멀티 쓰레드: 메모리 공유로 효율적이지만, 동기화 문제 주의 필요

노트

자바 프로그램이 시작되면 JVM이 하나의 프로세스를 생성하고, 그 안에서 main 쓰레드가 실행됩니다.

2. 멀티쓰레딩의 장단점

장점

  • CPU 사용률 향상: 한 쓰레드가 대기 중일 때 다른 쓰레드가 CPU 사용
  • 자원 공유: 같은 프로세스 내 메모리와 자원을 공유하여 효율적
  • 응답성 향상: UI 쓰레드와 작업 쓰레드 분리로 화면이 멈추지 않음
  • 처리량 증가: 병렬 처리로 작업 시간 단축

단점

  • 동기화 문제: 공유 자원에 여러 쓰레드가 동시 접근 시 데이터 오염 가능
  • 교착 상태(Deadlock): 쓰레드들이 서로의 자원을 기다리며 무한 대기
  • 디버깅 어려움: 실행 순서가 비결정적이라 재현이 어려움
  • 컨텍스트 스위칭 비용: 너무 많은 쓰레드는 오히려 성능 저하

3. 쓰레드를 생성하는 방법

자바에서 쓰레드를 생성하는 방법은 크게 두 가지가 있습니다.

Thread 클래스 상속

class MyThread extends Thread {
private String taskName;

public MyThread(String taskName) {
this.taskName = taskName;
}

@Override
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println("[" + getName() + "] " + taskName + " - " + i + "번째");
try {
Thread.sleep(500); // 0.5초 대기
} catch (InterruptedException e) {
System.out.println(taskName + " 중단됨");
return;
}
}
System.out.println("[" + getName() + "] " + taskName + " 완료!");
}
}

public class ThreadInheritExample {
public static void main(String[] args) {
MyThread t1 = new MyThread("다운로드");
MyThread t2 = new MyThread("변환");

t1.setName("다운로드-쓰레드");
t2.setName("변환-쓰레드");

t1.start(); // 새로운 쓰레드에서 run() 실행
t2.start(); // 또 다른 새 쓰레드에서 run() 실행

System.out.println("메인 쓰레드: 두 작업 시작됨");
}
}

Runnable 인터페이스 구현 (권장)

자바는 다중 상속을 지원하지 않기 때문에, 다른 클래스를 상속받아야 하는 경우에는 Runnable 인터페이스를 구현하는 방법을 사용합니다. 실무에서는 이 방법이 더 널리 사용됩니다.

class MyRunnable implements Runnable {
private String taskName;

public MyRunnable(String taskName) {
this.taskName = taskName;
}

@Override
public void run() {
for (int i = 1; i <= 5; i++) {
System.out.println("[" + Thread.currentThread().getName() + "] "
+ taskName + " - " + i + "번째");
try {
Thread.sleep(300);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 인터럽트 상태 복원
return;
}
}
}
}

public class RunnableExample {
public static void main(String[] args) {
Runnable r1 = new MyRunnable("작업A");
Runnable r2 = new MyRunnable("작업B");

Thread t1 = new Thread(r1, "쓰레드-1");
Thread t2 = new Thread(r2, "쓰레드-2");

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

람다식으로 간단하게 (Java 8+)

public class LambdaThreadExample {
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 3; i++) {
System.out.println("람다 쓰레드 실행: " + i);
}
});

t1.start();
System.out.println("메인 쓰레드 계속 실행");
}
}
경고

run() 메서드를 직접 호출하면 새로운 쓰레드를 생성하지 않고 현재 쓰레드에서 일반 메서드처럼 실행됩니다. 반드시 start()를 호출해야 JVM이 새로운 쓰레드를 생성하고 그 안에서 run()을 실행합니다.

4. Thread 생명주기 (Life Cycle)

쓰레드는 생성부터 종료까지 다음과 같은 상태를 거칩니다.

NEW → RUNNABLE → (RUNNING) → BLOCKED/WAITING/TIMED_WAITING → TERMINATED
상태설명
NEWThread 객체 생성, 아직 start() 호출 전
RUNNABLEstart() 호출 후, 실행 대기 또는 실행 중
BLOCKEDsynchronized 블록 진입을 위해 락 대기 중
WAITINGwait(), join() 호출로 무기한 대기 중
TIMED_WAITINGsleep(n), join(n), wait(n) 으로 시간 제한 대기
TERMINATEDrun() 메서드 종료 (정상 또는 예외)
public class ThreadStateExample {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {}
});

System.out.println("생성 후: " + t.getState()); // NEW
t.start();
System.out.println("시작 후: " + t.getState()); // RUNNABLE
Thread.sleep(500);
System.out.println("슬립 중: " + t.getState()); // TIMED_WAITING
t.join();
System.out.println("종료 후: " + t.getState()); // TERMINATED
}
}

5. Thread 주요 메서드

sleep() - 일시정지

public class SleepExample {
public static void main(String[] args) {
System.out.println("시작");
try {
Thread.sleep(2000); // 2초 동안 현재 쓰레드 정지
} catch (InterruptedException e) {
System.out.println("인터럽트 발생!");
}
System.out.println("2초 후 재개");
}
}

join() - 종료 대기

public class JoinExample {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
System.out.println("작업 쓰레드: 무거운 계산 시작...");
try { Thread.sleep(3000); } catch (InterruptedException e) {}
System.out.println("작업 쓰레드: 계산 완료!");
});

worker.start();
System.out.println("메인: 작업 완료를 기다리는 중...");
worker.join(); // 작업 쓰레드가 끝날 때까지 메인 쓰레드 대기
System.out.println("메인: 작업 결과 수신, 후처리 시작");
}
}

interrupt() - 인터럽트 신호 전달

public class InterruptExample {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
try {
System.out.println("쓰레드: 긴 작업 시작 (10초)");
Thread.sleep(10000);
System.out.println("쓰레드: 완료"); // 인터럽트 시 실행 안 됨
} catch (InterruptedException e) {
System.out.println("쓰레드: 인터럽트 받아 종료");
}
});

t.start();
Thread.sleep(2000); // 2초 후
t.interrupt(); // 쓰레드에 인터럽트 신호 전달
System.out.println("메인: 인터럽트 신호 전달 완료");
}
}

기타 주요 메서드

public class ThreadMethodExample {
public static void main(String[] args) {
Thread t = new Thread(() -> {}, "내-쓰레드");

// 이름 관련
System.out.println("이름: " + t.getName()); // 내-쓰레드
t.setName("새로운-이름");

// 우선순위 (1~10, 기본 5)
System.out.println("우선순위: " + t.getPriority()); // 5
t.setPriority(Thread.MAX_PRIORITY); // 10으로 설정

// 생존 여부
t.start();
System.out.println("살아있나: " + t.isAlive()); // true

// 현재 쓰레드 참조
Thread current = Thread.currentThread();
System.out.println("현재 쓰레드: " + current.getName());
}
}

우선순위는 힌트일 뿐, OS 스케줄러가 실제 실행 순서를 결정합니다. 우선순위만으로 실행 순서를 보장할 수 없습니다.

6. 데몬 쓰레드 (Daemon Thread)

데몬 쓰레드는 일반 쓰레드(Main 쓰레드 등)의 작업을 돕는 보조적인 역할을 수행하는 쓰레드입니다. 대표적인 예로 가비지 컬렉터(GC), 자동 저장, 모니터링 등이 있습니다. 일반 쓰레드가 모두 종료되면 데몬 쓰레드도 자동으로 강제 종료됩니다.

public class DaemonThreadExample {
public static void main(String[] args) throws InterruptedException {
Thread monitor = new Thread(() -> {
while (true) {
try {
Thread.sleep(1000);
System.out.println("[모니터] 시스템 상태 점검 중... 메모리: "
+ Runtime.getRuntime().freeMemory() / 1024 + "KB");
} catch (InterruptedException e) {
return;
}
}
});

monitor.setDaemon(true); // 반드시 start() 전에 설정해야 함!
monitor.start();

System.out.println("메인 작업 시작");
for (int i = 1; i <= 3; i++) {
Thread.sleep(1500);
System.out.println("메인 작업 " + i + "/3 완료");
}
System.out.println("메인 작업 종료 - 데몬 쓰레드도 자동 종료됨");
// main 쓰레드 종료 → 데몬 쓰레드 자동 종료
}
}
경고

setDaemon(true)는 반드시 start() 호출 전에 해야 합니다. 이미 실행 중인 쓰레드를 데몬으로 변경할 수 없습니다.

7. 쓰레드 그룹 (ThreadGroup)

관련된 쓰레드들을 하나의 그룹으로 묶어서 일괄 관리할 수 있습니다.

public class ThreadGroupExample {
public static void main(String[] args) throws InterruptedException {
ThreadGroup ioGroup = new ThreadGroup("IO-Group");
ThreadGroup calcGroup = new ThreadGroup("Calc-Group");

Thread t1 = new Thread(ioGroup, () -> {
try { Thread.sleep(3000); } catch (InterruptedException e) {}
}, "IO-쓰레드-1");

Thread t2 = new Thread(ioGroup, () -> {
try { Thread.sleep(3000); } catch (InterruptedException e) {}
}, "IO-쓰레드-2");

Thread t3 = new Thread(calcGroup, () -> {
try { Thread.sleep(3000); } catch (InterruptedException e) {}
}, "계산-쓰레드-1");

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

System.out.println("IO 그룹 활성 쓰레드 수: " + ioGroup.activeCount()); // 2
System.out.println("Calc 그룹 활성 쓰레드 수: " + calcGroup.activeCount()); // 1

ioGroup.interrupt(); // IO 그룹 쓰레드 전체 인터럽트
System.out.println("IO 그룹 전체 인터럽트 완료");
}
}

8. 실전 예제: 멀티쓰레드 다운로드 시뮬레이션

여러 파일을 동시에 다운로드하는 시뮬레이션입니다. 순차 처리와 병렬 처리의 시간 차이를 비교합니다.

import java.util.ArrayList;
import java.util.List;

class FileDownloader implements Runnable {
private final String fileName;
private final int fileSizeMB;
private boolean completed = false;

public FileDownloader(String fileName, int fileSizeMB) {
this.fileName = fileName;
this.fileSizeMB = fileSizeMB;
}

@Override
public void run() {
System.out.printf("[%s] 다운로드 시작 (%dMB)%n", fileName, fileSizeMB);
try {
// MB당 100ms 걸린다고 가정
Thread.sleep(fileSizeMB * 100L);
} catch (InterruptedException e) {
System.out.println("[" + fileName + "] 다운로드 중단!");
return;
}
completed = true;
System.out.printf("[%s] 다운로드 완료!%n", fileName);
}

public boolean isCompleted() { return completed; }
public String getFileName() { return fileName; }
}

public class MultiThreadDownload {
public static void main(String[] args) throws InterruptedException {
String[][] files = {
{"spring-boot.jar", "50"},
{"mysql-connector.jar", "30"},
{"jackson-databind.jar", "20"},
{"slf4j-api.jar", "10"},
{"hibernate-core.jar", "80"}
};

System.out.println("=== 순차 다운로드 시뮬레이션 ===");
long startSeq = System.currentTimeMillis();
int totalMB = 0;
for (String[] f : files) {
int mb = Integer.parseInt(f[1]);
totalMB += mb;
System.out.printf("[%s] 다운로드 중 (%dMB)...%n", f[0], mb);
Thread.sleep(mb * 100L);
System.out.printf("[%s] 완료%n", f[0]);
}
long seqTime = System.currentTimeMillis() - startSeq;
System.out.printf("순차 완료: 총 %dMB, 소요시간 %dms%n%n", totalMB, seqTime);

System.out.println("=== 병렬 다운로드 시뮬레이션 ===");
long startPar = System.currentTimeMillis();
List<Thread> threads = new ArrayList<>();
List<FileDownloader> downloaders = new ArrayList<>();

for (String[] f : files) {
FileDownloader dl = new FileDownloader(f[0], Integer.parseInt(f[1]));
Thread t = new Thread(dl);
downloaders.add(dl);
threads.add(t);
t.start();
}

// 모든 다운로드 완료 대기
for (Thread t : threads) {
t.join();
}
long parTime = System.currentTimeMillis() - startPar;
System.out.printf("병렬 완료: 소요시간 %dms (순차 대비 약 %.1f배 빠름)%n",
parTime, (double) seqTime / parTime);
}
}

실행 결과 예시:

=== 순차 다운로드 시뮬레이션 ===
[spring-boot.jar] 다운로드 중 (50MB)...
[spring-boot.jar] 완료
...
순차 완료: 총 190MB, 소요시간 19000ms

=== 병렬 다운로드 시뮬레이션 ===
[spring-boot.jar] 다운로드 시작 (50MB)
[mysql-connector.jar] 다운로드 시작 (30MB)
...
병렬 완료: 소요시간 8000ms (순차 대비 약 2.4배 빠름)

고수 팁: 실무에서는 Thread를 직접 생성하기보다 ExecutorService(쓰레드 풀)를 사용하는 것이 권장됩니다. 쓰레드 생성/소멸 비용을 줄이고, 쓰레드 수를 제어하여 시스템 안정성을 높일 수 있습니다.

9. start() vs run() 핵심 차이

public class StartVsRunExample {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("실행 쓰레드: " + Thread.currentThread().getName());
});

// run() 직접 호출 - 메인 쓰레드에서 실행 (새 쓰레드 X)
t.run(); // 출력: 실행 쓰레드: main

// start() 호출 - 새 쓰레드 생성 후 run() 실행
t = new Thread(() -> {
System.out.println("실행 쓰레드: " + Thread.currentThread().getName());
});
t.start(); // 출력: 실행 쓰레드: Thread-1
}
}
메서드쓰레드 생성실행 주체비동기 여부
run()X현재 쓰레드 (예: main)동기 (순차 실행)
start()O새로 생성된 쓰레드비동기 (병렬 실행)