프로그래밍 공부
[자바] #26. 쓰레드 (Thread) 본문
🔍 쓰레드(Thread)란?
쓰레드는 하나의 프로그램(프로세스) 내에서 독립적으로 실행되는 작은 실행 단위이다.
Java 프로그램은 기본적으로 단일 쓰레드(main thread)로 실행되지만,
여러 개의 쓰레드를 생성하면 병렬(병행) 처리가 가능하다.
🤔 프로세스 vs 쓰레드
| 구분 | 프로세스 (Process) | 쓰레드 (Thread) |
| 실행 단위 | 독립적인 실행 단위 | 프로세스 내부의 실행 단위 |
| 메모리 공유 | X (독립적) | O (같은 프로세스 내에서 공유) |
| 생성 비용 | 높음 (OS가 새로운 프로세스를 생성) | 낮음 (프로세스 내에서 생성) |
| 통신 방식 | IPC (Inter-Process Communication) 필요 | 공유 메모리 활용 |
🎀 자바에서 쓰레드 생성 방법
(1) Thread 클래스를 상속받는 방법
class MyThread extends Thread {
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " 실행 중...");
}
}
}
public class ThreadExample {
public static void main(String[] args) {
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
t1.start(); // 쓰레드 실행
t2.start();
}
}
📌 start()를 호출해야 쓰레드가 실행되며, run()을 직접 호출하면 일반 메서드처럼 실행된다.
(2) Runnable 인터페이스 구현 방법 (권장)
class MyRunnable implements Runnable {
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " 실행 중...");
}
}
}
public class RunnableExample {
public static void main(String[] args) {
Thread t1 = new Thread(new MyRunnable());
Thread t2 = new Thread(new MyRunnable());
t1.start();
t2.start();
}
}
✔️ Runnable 방식이 더 유연하며, 다른 클래스를 상속받을 수도 있기 때문에 실무에서 더 많이 사용된다.
(3) 람다식으로 간단히 생성
public class LambdaThread {
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " 실행 중...");
}
});
t1.start();
}
}
✔️ 익명 클래스 대신 람다 표현식을 사용하여 간결하게 구현할 수 있다.
🍗 쓰레드의 주요 메서드
| 메서드 | 설명 |
| start() | 쓰레드를 시작하고 run() 메서드를 호출 |
| run() | 쓰레드가 실행할 코드 (직접 호출하지 않음) |
| sleep(ms) | 특정 시간 동안 쓰레드 일시 정지 |
| join() | 해당 쓰레드가 종료될 때까지 기다림 |
| interrupt() | 실행 중인 쓰레드를 중단 요청 |
| isAlive() | 쓰레드가 실행 중인지 확인 |
예제: join()을 사용하여 쓰레드 종료 대기
public class JoinExample {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " 실행 중...");
}
});
t1.start();
t1.join(); // t1이 끝날 때까지 main 쓰레드 대기
System.out.println("모든 작업 종료");
}
}
Thread-0 실행 중...
Thread-0 실행 중...
Thread-0 실행 중...
Thread-0 실행 중...
Thread-0 실행 중...
모든 작업 종료
t1.join();을 호출하면 메인 쓰레드는 t1이 끝날 때까지 대기한다.
즉, t1이 실행을 마칠 때까지 main 쓰레드는 멈춘 상태가 된다.
t1이 실행되면서 for 루프를 돌며 5번 출력하고, t1이 종료되면 join()이 해제된다.
t1.join();이 끝나면 main 쓰레드가 다시 진행하여 "모든 작업 종료"를 출력하고 프로그램이 종료된다.
❓ t1.join()을 사용하지 않는다면?
만약 t1.join()을 제거하면 메인 쓰레드와 t1이 동시에 실행되므로 다음과 같이 출력될 수도 있다.
Thread-0 실행 중...
모든 작업 종료
Thread-0 실행 중...
Thread-0 실행 중...
Thread-0 실행 중...
Thread-0 실행 중...
즉, "모든 작업 종료"가 먼저 출력될 수도 있으며, t1이 실행되는 동안 main 쓰레드가 종료될 수도 있다.
t1.join()을 사용하면 main 쓰레드는 t1이 종료될 때까지 기다리므로 "모든 작업 종료"가 항상 t1 실행 후에 출력된다.
⚡ 동기화 (Synchronization)
멀티쓰레드는 공유 자원을 사용할 때 데이터 충돌이 발생할 수 있다.
이를 방지하기 위해 동기화(Synchronized)를 사용한다.
동기화 블록 (synchronized 키워드)
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
public class SyncExample {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
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 예상
}
}
최종 카운트: 2000
synchronized를 사용했기 때문에, 두 개의 쓰레드가 동시에 접근해도 count 값이 안전하게 증가한다.
synchronized를 사용하지 않은 경우,
최종 카운트: 1923
최종 카운트: 1792
최종 카운트: 1850
이처럼 실행할 때마다 최종 값이 2000보다 작아질 가능성이 높다.
이유는 두 개의 쓰레드가 동시에 count++를 수행할 때,
연산이 덮어쓰기(레이스 컨디션, Race Condition)로 인해 일부 증가가 손실되기 때문이다.
💫 레이스 컨디션(Race Condition) 예시
올바른 실행 흐름
쓰레드 1: count 읽기 (100) -> 증가 (101) -> 저장
쓰레드 2: count 읽기 (101) -> 증가 (102) -> 저장
✔ 결과: count = 102 (정상 증가)
잘못된 실행 흐름 (경합 발생)
쓰레드 1: count 읽기 (100) -> 증가 (101) -> (저장 안 함)
쓰레드 2: count 읽기 (100) -> 증가 (101) -> 저장
쓰레드 1: (이제야 저장) count = 101 (잘못된 값!)
🚨 결과: count = 101 (1이 손실됨!)
이런 문제 때문에 synchronized를 사용하면 한 번에 하나의 쓰레드만 접근하도록 보호하여,
값의 손실을 방지할 수 있다.
🎂 쓰레드 풀(Thread Pool)이란?
쓰레드 풀(Thread Pool)이란 미리 일정 개수의 쓰레드를 생성해 두고, 필요할 때 재사용하는 방식이다.
새로운 쓰레드를 매번 생성하는 대신, 기존의 쓰레드를 재사용하여 성능을 최적화할 수 있다.
🤨 쓰레드 풀이 필요한 이유
(1) 쓰레드 생성 비용 절감
- 쓰레드를 매번 생성하면 CPU 및 메모리 사용량이 증가하고, 시스템이 느려질 수 있음
- 미리 쓰레드를 생성해 두면 불필요한 쓰레드 생성 비용을 줄일 수 있음
(2) 최적의 쓰레드 개수 유지
- 너무 많은 쓰레드를 생성하면 **CPU 컨텍스트 스위칭(Context Switching)**이 증가하여 성능이 저하됨
- 쓰레드 풀을 사용하면 적절한 개수의 쓰레드만 유지할 수 있음
(3) 관리가 편리함
- ExecutorService를 사용하면 쓰레드 생성, 실행, 종료까지 자동으로 관리할 수 있음
🎨 자바에서 쓰레드 풀 사용 방법
Executors 클래스 활용
Java에서는 java.util.concurrent.Executors 클래스를 사용하여 쉽게 쓰레드 풀을 생성할 수 있다.
| 쓰레드 풀 종류 | 설명 |
| newFixedThreadPool(n) | 고정된 개수(n)의 쓰레드를 유지 |
| newCachedThreadPool() | 필요할 때 새 쓰레드를 생성하고, 사용하지 않으면 삭제 |
| newSingleThreadExecutor() | 단일 쓰레드만 사용 |
| newScheduledThreadPool(n) | 일정 시간 간격으로 실행되는 작업 지원 |
newFixedThreadPool() 예제 (고정된 개수의 쓰레드)
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class FixedThreadPoolExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3); // 3개의 쓰레드 풀 생성
for (int i = 1; i <= 5; i++) {
int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId + " 실행 중... (쓰레드: " + Thread.currentThread().getName() + ")");
try {
Thread.sleep(1000); // 작업 시간 시뮬레이션
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Task " + taskId + " 완료!");
});
}
executor.shutdown(); // 모든 작업이 끝나면 종료
}
}
Task 1 실행 중... (쓰레드: pool-1-thread-1)
Task 2 실행 중... (쓰레드: pool-1-thread-2)
Task 3 실행 중... (쓰레드: pool-1-thread-3)
Task 4 실행 중... (쓰레드: pool-1-thread-1)
Task 5 실행 중... (쓰레드: pool-1-thread-2)
Task 1 완료!
Task 2 완료!
Task 3 완료!
Task 4 완료!
Task 5 완료!
✔️ 3개의 쓰레드만 생성되며, 순차적으로 작업을 처리하는 것을 확인할 수 있다.
newCachedThreadPool() 예제 (동적 쓰레드 생성)
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CachedThreadPoolExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newCachedThreadPool(); // 필요할 때만 쓰레드 생성
for (int i = 1; i <= 5; i++) {
int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId + " 실행 중... (쓰레드: " + Thread.currentThread().getName() + ")");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executor.shutdown(); // 모든 작업이 끝나면 종료
}
}
Task 1 실행 중... (쓰레드: pool-1-thread-1)
Task 2 실행 중... (쓰레드: pool-1-thread-2)
Task 3 실행 중... (쓰레드: pool-1-thread-3)
Task 4 실행 중... (쓰레드: pool-1-thread-4)
Task 5 실행 중... (쓰레드: pool-1-thread-5)
✔️ 작업이 많으면 동적으로 쓰레드를 생성하여 빠르게 처리하고, 일정 시간이 지나면 자동으로 삭제된다.
newSingleThreadExecutor() 예제 (단일 쓰레드)
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SingleThreadExecutorExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newSingleThreadExecutor(); // 단일 쓰레드
for (int i = 1; i <= 5; i++) {
int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId + " 실행 중... (쓰레드: " + Thread.currentThread().getName() + ")");
});
}
executor.shutdown();
}
}
Task 1 실행 중... (쓰레드: pool-1-thread-1)
Task 2 실행 중... (쓰레드: pool-1-thread-1)
Task 3 실행 중... (쓰레드: pool-1-thread-1)
Task 4 실행 중... (쓰레드: pool-1-thread-1)
Task 5 실행 중... (쓰레드: pool-1-thread-1)
✔️ 단일 쓰레드(pool-1-thread-1)가 순차적으로 모든 작업을 처리한다.
newScheduledThreadPool() 예제 (주기적 실행)
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class ScheduledThreadPoolExample {
public static void main(String[] args) {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
// 3초 뒤에 실행
scheduler.schedule(() -> System.out.println("3초 뒤 실행!"), 3, TimeUnit.SECONDS);
// 2초마다 반복 실행
scheduler.scheduleAtFixedRate(() -> System.out.println("2초마다 실행!"), 0, 2, TimeUnit.SECONDS);
// 4초마다 반복 실행 (이전 작업이 끝난 후 4초 뒤 실행)
scheduler.scheduleWithFixedDelay(() -> System.out.println("4초마다 실행!"), 0, 4, TimeUnit.SECONDS);
}
}
✔️ 주기적인 작업을 수행할 때 ScheduledThreadPool을 사용하면 편리하다.
shutdown()과 shutdownNow() 차이
| 메서드 | 설명 |
| shutdown() | 현재 진행 중인 작업을 완료한 후 종료 |
| shutdownNow() | 모든 작업을 즉시 중단하고 종료 |
executor.shutdown(); // 정상 종료
executor.shutdownNow(); // 강제 종료
👀 synchronized 대신 ReeantrantLock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class Counter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++; // 한 쓰레드에 의해선 실행되는 영역
} finally {
lock.unlock();
}
}
public int getCount() {
return count;
}
}
✔️ synchronized보다 더 세밀한 제어가 가능하며, try-finally 블록을 사용하여 데드락 방지가 가능하다.
'자바' 카테고리의 다른 글
| [자바] #25. NIO / NIO.2 (0) | 2025.03.03 |
|---|---|
| [자바] #24. I/O 스트림 (0) | 2025.03.01 |
| [자바] #23. 날짜/시간 관련 클래스들 (1) | 2025.02.23 |
| [자바] #22. 스트림(Stream) - 3편 (최종 연산 추가 내용) (0) | 2025.02.21 |
| [자바] #21. 스트림(Stream) - 2편 (병렬 스트림, 중간 연산 추가 내용) (0) | 2025.02.21 |