Java Thread 6부 — 실무에서 사용하는 Thread
지금까지 Thread의 기본 개념부터 동시성 문제, synchronized, Lock, Atomic, volatile까지 봤다.
이제 마지막으로 봐야 할 건 하나다.
그래서 실무에서는 Thread를 어떻게 써야 할까?결론부터 말하면, 실무에서는 new Thread()를 직접 만드는 일이 많지 않다.
대부분은 ThreadPool을 사용한다.
왜냐하면 Thread는 만들고 버리는 비용이 크고,
무작정 많이 만들면 오히려 서버가 느려지기 때문이다.
1. 실무에서는 ThreadPool을 사용한다
Thread를 직접 만들면 이런 코드가 된다.
Thread thread = new Thread(() -> {
System.out.println("작업 실행");
});
thread.start();학습용으로는 좋다.
하지만 실무에서 요청이 들어올 때마다 이렇게 Thread를 새로 만들면 위험하다.
요청 1개 → Thread 1개 생성
요청 1000개 → Thread 1000개 생성
요청 10000개 → Thread 10000개 생성Thread가 많아지면 CPU는 실제 작업보다 Thread를 바꿔 끼우는 데 시간을 더 많이 쓴다.
이걸 컨텍스트 스위칭 비용이라고 한다.
그래서 실무에서는 Thread를 미리 만들어두고 재사용한다.
이 구조가 ThreadPool이다.
ThreadPool 기본 예제
Java에서는 ExecutorService를 사용한다.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Main {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < 10; i++) {
int taskId = i;
executor.submit(() -> {
System.out.println("작업 실행: " + taskId);
});
}
executor.shutdown();
}
}이 코드는 Thread를 5개만 만들어두고 10개의 작업을 나눠서 처리한다.
Thread는 5개
작업은 10개
남는 작업은 대기열에서 기다림이게 실무 Thread 처리의 기본이다.
2. ThreadPool 설계의 핵심
ThreadPool을 설계할 때 가장 중요한 질문은 이거다.
이 작업은 CPU를 많이 쓰는가?
아니면 대기 시간이 많은가?이 기준에 따라 Thread 개수가 달라진다.
3. CPU Bound 작업
CPU Bound는 CPU 계산이 많은 작업이다.
예를 들면 이런 것들이다.
이미지 변환
대량 계산
암호화 / 복호화
압축
복잡한 알고리즘 처리이런 작업은 CPU를 계속 사용한다.
그래서 Thread를 너무 많이 만들어도 빨라지지 않는다.
CPU 코어가 8개인데 Thread를 100개 만든다고 100배 빨라지지 않는다.
오히려 컨텍스트 스위칭만 늘어서 느려질 수 있다.
CPU Bound 작업은 보통 이렇게 잡는다.
Thread 개수 ≈ CPU 코어 수예를 들어 CPU 코어가 8개라면 ThreadPool 크기도 8개 근처에서 시작하는 게 좋다.
int processors = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(processors);핵심은 이거다.
CPU를 많이 쓰는 작업은 Thread를 많이 만든다고 해결되지 않는다.4. I/O Bound 작업
I/O Bound는 기다리는 시간이 많은 작업이다.
예를 들면 이런 것들이다.
DB 조회
외부 API 호출
파일 읽기 / 쓰기
네트워크 통신
크롤링이런 작업은 CPU가 계속 바쁘지 않다.
DB 응답을 기다리거나, 외부 서버의 응답을 기다리는 시간이 많다.
그래서 CPU Bound보다 Thread를 더 많이 둘 수 있다.
CPU Bound → 코어 수 근처
I/O Bound → 코어 수보다 많이 가능예를 들어 외부 API 호출이 많은 서비스라면 ThreadPool 크기를 20, 50, 100처럼 더 크게 잡을 수도 있다.
하지만 무작정 크게 잡으면 안 된다.
Thread가 너무 많아지면 메모리 사용량이 늘고, 대기 작업이 쌓이고, 장애가 더 크게 번질 수 있다.
실무에서는 대기열도 중요하다
ThreadPool에서 Thread 개수만큼 중요한 게 있다. 바로 Queue다.
Thread가 모두 바쁘면 새 작업은 Queue에서 기다린다.
요청 들어옴
→ 사용 가능한 Thread 있음
→ 바로 실행
요청 들어옴
→ 모든 Thread가 바쁨
→ Queue에서 대기문제는 Queue가 무한히 커지는 경우다.
요청이 계속 들어오는데 처리가 느리면 Queue에 작업이 끝없이 쌓인다.
결국 메모리가 터질 수 있다.
그래서 실무에서는 보통 이런 기준이 필요하다.
Thread 개수 제한
Queue 크기 제한
거부 정책 설정
타임아웃 설정
모니터링단순히 ThreadPool을 만드는 것보다
“얼마나 받을 것인가”를 정하는 게 더 중요하다.
5. Executors만 믿으면 안 된다
Executors.newFixedThreadPool()은 편하다.
하지만 내부적으로 대기열이 매우 크게 잡힐 수 있어서 트래픽이 몰리면 작업이 계속 쌓일 수 있다.
실무에서는 ThreadPoolExecutor로 직접 설정하는 경우가 많다.
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class Main {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10,
20,
60L,
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.submit(() -> {
System.out.println("작업 실행");
});
executor.shutdown();
}
}각 값은 이런 의미다.
corePoolSize = 10
기본적으로 유지할 Thread 수
maximumPoolSize = 20
최대 Thread 수
keepAliveTime = 60초
초과 생성된 Thread가 놀고 있을 때 유지할 시간
workQueue = 100
대기 가능한 작업 수
rejectedExecutionHandler
더 이상 받을 수 없을 때의 처리 방식이런 식으로 제한을 둬야 서비스가 과부하 상황에서 무너지지 않는다.
6. 데드락
Thread를 사용하다 보면 가장 무서운 문제 중 하나가
데드락이다.
데드락은 서로가 가진 자원을 기다리다가 영원히 멈추는 상태다.
예를 들어보자.
Thread A는 lock1을 잡고 lock2를 기다린다.
Thread B는 lock2를 잡고 lock1을 기다린다.둘 다 상대방이 잠금을 풀어야 진행할 수 있다.
하지만 둘 다 기다리고만 있다.
결과적으로 프로그램이 멈춘다.
데드락 예제
public class Main {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: lock1 획득");
synchronized (lock2) {
System.out.println("Thread 1: lock2 획득");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2: lock2 획득");
synchronized (lock1) {
System.out.println("Thread 2: lock1 획득");
}
}
});
t1.start();
t2.start();
}
}이 코드는 운이 나쁘면 멈출 수 있다.
t1은lock1을 잡고lock2를 기다린다.t2는lock2를 잡고lock1을 기다린다.서로가 서로를 기다리는 상태가 된다.
데드락을 피하는 방법
가장 기본적인 방법은 잠금 순서를 통일하는 것이다.
synchronized (lock1) {
synchronized (lock2) {
// 작업
}
}모든 Thread가 항상 lock1 → lock2 순서로 잠금을 얻으면
서로 반대로 물고 기다리는 상황을 줄일 수 있다.
정리하면 이렇다.
잠금 순서를 통일한다
잠금 범위를 줄인다
중첩 잠금을 조심한다
tryLock()으로 무한 대기를 피한다실무에서는 데드락이 한 번 터지면 재현이 어렵고 원인 찾기도 힘들다.
그래서 처음부터 잠금 설계를 단순하게 가져가는 게 중요하다.
7. ThreadLocal
ThreadLocal은 Thread마다 독립적인 값을 저장할 때 사용한다.
일반 변수는 여러 Thread가 공유할 수 있다.
하지만 ThreadLocal에 저장된 값은 각 Thread마다 따로 관리된다.
public class UserContext {
private static final ThreadLocal<String> currentUser = new ThreadLocal<>();
public static void set(String userId) {
currentUser.set(userId);
}
public static String get() {
return currentUser.get();
}
public static void clear() {
currentUser.remove();
}
}사용 예시는 이렇다.
UserContext.set("user-1");
String userId = UserContext.get();
UserContext.clear();ThreadLocal은 어디에 쓰일까?
실무에서는 요청 단위 정보를 저장할 때 자주 사용한다.
현재 로그인 사용자
요청 ID
트랜잭션 정보
로깅 추적 ID
인증 정보예를 들어 웹 요청 하나가 들어왔을 때
그 요청을 처리하는 Thread에 사용자 정보를 저장해둘 수 있다.
그러면 메서드마다 userId를 계속 파라미터로 넘기지 않아도 된다.
ThreadLocal 사용 시 가장 중요한 것
반드시 정리해야 한다.
try {
UserContext.set("user-1");
// 작업 처리
} finally {
UserContext.clear();
}왜냐하면 ThreadPool에서는 Thread가 재사용되기 때문이다.
요청이 끝났는데 ThreadLocal 값을 지우지 않으면 다음 요청이 이전 요청의 값을 볼 수 있다.
이건 매우 위험하다.
A 사용자 요청 처리
→ ThreadLocal에 A 저장
→ 값 제거 안 함
→ 같은 Thread가 B 사용자 요청 처리
→ B 요청에서 A 값이 보일 수 있음그래서 ThreadLocal은 편하지만 항상 remove()까지 한 세트로 생각해야 한다.
8. Virtual Thread 간단 소개
최근 Java에서는 Virtual Thread가 등장했다.
Virtual Thread는 기존 OS Thread보다 훨씬 가볍게 사용할 수 있는 Thread다.
기존 Thread는 OS Thread와 연결되어 있어서 많이 만들면 부담이 크다.
반면 Virtual Thread는 JVM이 훨씬 가볍게 관리한다.
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread thread = Thread.startVirtualThread(() -> {
System.out.println("Virtual Thread 실행");
});
thread.join();
}
}또는 Executor 형태로 사용할 수도 있다.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Main {
public static void main(String[] args) {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
System.out.println("Virtual Thread 작업 실행");
});
}
}
}Virtual Thread는 특히 I/O Bound 작업에 강하다.
DB 요청
API 호출
파일 I/O
네트워크 대기이런 작업은 기다리는 시간이 많기 때문에 가벼운 Virtual Thread와 잘 어울린다.
Virtual Thread가 모든 문제를 해결하지는 않는다
Virtual Thread가 나왔다고 해서 동시성 문제가 사라지는 것은 아니다.
공유 데이터를 동시에 수정하면 여전히 Race Condition이 발생한다.
락을 잘못 잡으면 여전히 데드락이 생길 수 있다.
ThreadLocal도 여전히 조심해야 한다.
Virtual Thread는 Thread를 가볍게 만들어준다.
하지만 동시성 설계를 대신해주지는 않는다.특히 CPU Bound 작업에서는
Virtual Thread를 많이 만든다고 CPU가 늘어나는 게 아니다.
CPU 계산이 많은 작업은 여전히 CPU 코어 수의 한계를 받는다.
9. 언제 Thread를 직접 만들지 말아야 할까?
실무에서 다음 상황이라면 new Thread()를 직접 만드는 것을 피하는 게 좋다.
요청마다 Thread를 새로 만드는 경우
반복적으로 많은 작업을 처리하는 경우
작업 개수를 예측하기 어려운 경우
서버 애플리케이션에서 비동기 작업을 처리하는 경우
Thread 종료와 예외 처리를 직접 관리하기 어려운 경우이런 경우에는 직접 Thread를 만들지 말고
ThreadPool이나 Executor를 사용하는 게 맞다.
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.submit(() -> {
System.out.println("작업 실행");
});또는 I/O Bound 중심의 최신 Java 환경이라면 Virtual Thread도 고려할 수 있다.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
System.out.println("I/O 작업 실행");
});
}그럼 Thread를 직접 만들어도 되는 경우는?
아예 쓰지 말라는 뜻은 아니다.
다음처럼 단순한 학습, 테스트, 작은 도구에서는 괜찮다.
Thread 동작을 학습할 때
간단한 예제 코드를 만들 때
짧게 실행되는 테스트 프로그램을 만들 때
Thread 자체를 직접 제어해야 하는 특수한 경우하지만 서버, 배치, 크롤러, API 처리처럼 작업이 계속 들어오는 구조라면 이야기가 다르다.
이때는 직접 Thread를 만들기보다 Executor 기반으로 설계하는 게 안전하다.
10. 실무 기준으로 정리
Thread를 실무에서 사용할 때는 이 기준만 기억해도 좋다.
작업을 직접 실행하지 말고 Executor에 맡긴다.
CPU Bound 작업은 CPU 코어 수 근처로 제한한다.
I/O Bound 작업은 대기 시간이 많으므로 더 많은 동시성을 줄 수 있다.
ThreadPool은 Thread 수뿐 아니라 Queue 크기도 제한한다.
공유 데이터는 synchronized, Lock, Atomic으로 보호한다.
상태 변경을 다른 Thread가 봐야 한다면 volatile을 고려한다.
ThreadLocal은 반드시 remove()까지 처리한다.
락을 여러 개 잡을 때는 데드락을 의심한다.
Virtual Thread는 I/O 작업에 강하지만 만능은 아니다.마무리
Thread는 단순히 동시에 실행하는 기술이 아니다.
실무에서 Thread는 성능, 안정성, 장애 대응과 직접 연결된다.
Thread를 너무 적게 쓰면 느리다.
Thread를 너무 많이 쓰면 서버가 무너진다.
락을 잘못 쓰면 데이터가 깨지고, 락을 더 잘못 쓰면 프로그램이 멈춘다.
그래서 Thread를 잘 쓴다는 건 Thread를 많이 만든다는 뜻이 아니다.
필요한 만큼만 실행하고,
공유 데이터는 안전하게 보호하고,
작업량이 몰려도 시스템이 버티게 설계하는 것.이게 실무에서 Thread를 사용하는 핵심이다.
Java Thread 시리즈의 결론은 간단하다.
Thread는 직접 다루는 기술이 아니라,
제어하고 설계해야 하는 자원이다.