Java 멀티스레드 | Thread, Runnable, Executor
이 글의 핵심
스레드를 요청마다 새로 만들면 금방 자원이 바닥나고, 공유 카운터를 동기화 없이 올리면 결과가 매번 달라집니다. 스레드 풀을 써야 하는 이유와 Future.get()이 호출 스레드를 블로킹하는 함정, synchronized 범위를 좁게 잡아야 하는 이유를 짚어 안전한 멀티스레드 코드의 기본 틀을 잡게 합니다.
들어가며
Thread·Executor·synchronized 등으로 여러 실행 흐름을 다룹니다. 공유 객체에 대한 접근 순서를 정하지 않으면 데이터가 깨질 수 있으므로, 락·원자 변수·동시성 유틸과 함께 읽는 것이 좋습니다.
C++ std::thread·mutex와 OS 스레드 + 락이라는 큰 그림이 비슷해 비교하기 좋으며, Go 고루틴처럼 스레드보다 가벼운 단위를 쓰는 언어와는 설계 선택이 다릅니다.
Java의 전통적인 Thread는 운영체제 스레드와 1:1로 대응합니다. 스레드 하나마다 별도의 스택 메모리(기본 수백 KB~1MB 수준)가 잡히고, 생성과 컨텍스트 스위칭에 커널이 관여하므로 수천 개를 만들면 메모리와 스케줄링 비용이 빠르게 커집니다. 이 글의 흐름이 “스레드를 직접 만드는 법”에서 “스레드 풀에 작업을 맡기는 법”으로 넘어가는 이유가 여기에 있습니다. Java 21부터는 JVM이 관리하는 가벼운 가상 스레드(virtual thread)도 정식 기능이 되었는데, 마지막 절에서 간단히 비교합니다.
스레드를 만드는 두 가지 방법
방법 1: Thread 상속
class MyThread extends Thread {
private String name;
public MyThread(String name) {
this.name = name;
}
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(name + ": " + i);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Main {
public static void main(String[] args) {
MyThread t1 = new MyThread("Thread-1");
MyThread t2 = new MyThread("Thread-2");
t1.start();
t2.start();
}
}
run()에 작업을 정의하고 start()로 실행하는 구조입니다. 초보자가 가장 많이 하는 실수는 t1.run()을 직접 호출하는 것인데, 이렇게 하면 새 스레드가 만들어지지 않고 main 스레드에서 일반 메서드처럼 순서대로 실행됩니다. 오류가 나지 않으니 알아채기 어렵고, 출력이 항상 Thread-1이 끝난 뒤 Thread-2가 나온다면 이 실수를 의심해 보세요. start()는 한 번만 호출할 수 있어서, 같은 스레드 객체에 두 번 호출하면 IllegalThreadStateException이 납니다.
실행 결과의 출력 순서는 매번 달라질 수 있습니다. 두 스레드 중 어느 쪽이 먼저 CPU를 얻을지는 OS 스케줄러가 정하기 때문입니다. main 메서드가 먼저 끝나도 프로그램은 종료되지 않는데, 일반 스레드(사용자 스레드)가 모두 끝나야 JVM이 종료되기 때문입니다. 반대로 setDaemon(true)로 만든 데몬 스레드는 다른 사용자 스레드가 끝나면 작업 도중이라도 함께 종료됩니다.
catch (InterruptedException e) { e.printStackTrace(); }는 예제에서 흔히 보이지만 좋은 처리가 아닙니다. 인터럽트는 “하던 일을 멈춰 달라”는 요청인데, 예외를 잡는 순간 스레드의 인터럽트 상태가 지워지므로 이렇게 삼키면 반복문이 계속 돌아 요청이 무시됩니다. 작업을 끝내야 한다면 Thread.currentThread().interrupt()로 상태를 복원한 뒤 return하거나 break하는 것이 관례입니다.
방법 2: Runnable 구현 (권장)
class MyRunnable implements Runnable {
private String name;
public MyRunnable(String name) {
this.name = name;
}
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(name + ": " + i);
}
}
}
// 사용
Thread t1 = new Thread(new MyRunnable("Thread-1"));
Thread t2 = new Thread(new MyRunnable("Thread-2"));
t1.start();
t2.start();
// 람다로 더 간결하게
Thread t3 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
System.out.println("Thread-3: " + i);
}
});
t3.start();
Runnable을 권장하는 이유는 “작업”과 “실행 수단”을 분리하기 때문입니다. Thread를 상속하면 클래스가 이미 Thread의 자식이 되어 다른 클래스를 상속할 수 없고, 작업 로직이 스레드 객체에 묶여 스레드 풀 같은 다른 실행 방식에 넘기기 어렵습니다. Runnable로 정의한 작업은 new Thread(...)에도, 다음 절의 ExecutorService에도 그대로 넘길 수 있습니다.
Runnable은 추상 메서드가 run() 하나뿐인 함수형 인터페이스라 t3처럼 람다로 바로 쓸 수 있습니다. 람다 안에서 바깥 지역 변수를 쓰려면 그 변수가 사실상 final이어야 합니다. 반복문의 i를 람다 안에서 바로 쓰면 “local variables referenced from a lambda expression must be final or effectively final” 컴파일 오류가 나는데, 다음 절 예제의 final int taskId = i;가 이 문제를 피하는 방법입니다.
ExecutorService로 스레드 풀 쓰기
스레드 풀
import java.util.concurrent.*;
public class ExecutorExample {
public static void main(String[] args) {
// 고정 크기 스레드 풀
ExecutorService executor = Executors.newFixedThreadPool(3);
for (int i = 0; i < 10; i++) {
final int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId + " by " +
Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
executor.shutdown();
try {
executor.awaitTermination(1, TimeUnit.MINUTES);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
작업 10개를 스레드 3개가 나눠 처리합니다. 출력의 스레드 이름을 보면 pool-1-thread-1~3만 반복해서 나오는데, 스레드를 작업마다 새로 만들지 않고 끝난 스레드가 대기열의 다음 작업을 가져가기 때문입니다. 각 작업이 1초씩 걸리므로 전체는 약 4초(3+3+3+1개씩)가 걸립니다. 스레드 생성 비용을 아끼는 것뿐 아니라 동시에 실행되는 개수에 상한을 두는 것이 스레드 풀의 더 중요한 역할입니다. 요청마다 스레드를 만드는 서버는 트래픽이 몰리면 스레드 수가 무한정 늘어 OutOfMemoryError: unable to create native thread로 죽을 수 있지만, 풀은 초과 작업을 대기열에 쌓아 둡니다.
shutdown()은 새 작업을 더 받지 않되 이미 제출된 작업은 끝까지 실행하고, awaitTermination()은 그 완료를 지정 시간만큼 기다립니다. shutdown()을 빠뜨리면 풀의 스레드가 새 작업을 기다리며 계속 살아 있어서 main이 끝나도 프로그램이 종료되지 않습니다. 처음 스레드 풀을 쓸 때 “프로그램이 안 끝난다”는 증상의 대부분이 이것입니다. awaitTermination은 시간 안에 끝났는지를 boolean으로 돌려주므로, false라면 shutdownNow()로 실행 중인 작업에 인터럽트를 보내는 식으로 처리합니다. Java 19부터는 ExecutorService가 AutoCloseable이라 try-with-resources로 쓰면 블록 끝에서 종료와 대기를 함께 해 줍니다.
Executors.newFixedThreadPool은 편하지만 내부 대기열에 크기 제한이 없습니다. 작업이 처리 속도보다 빨리 들어오면 대기열이 끝없이 쌓여 메모리가 부족해질 수 있어서, 운영 코드에서는 ThreadPoolExecutor를 직접 생성해 크기가 정해진 ArrayBlockingQueue와 거부 정책을 지정하기도 합니다. 풀 크기는 CPU 계산 위주 작업이면 코어 수 근처, 네트워크·파일 대기가 많은 작업이면 그보다 크게 잡는 것이 일반적입니다.
submit()에 넘긴 작업에서 예외가 나면 콘솔에 아무것도 찍히지 않는다는 점도 알아 둬야 합니다. 예외는 반환된 Future 안에 저장되고 get()을 호출해야 ExecutionException으로 드러납니다. 예제처럼 Future를 버리면 작업이 조용히 실패하므로, 결과가 필요 없더라도 작업 안에서 예외를 잡아 로그를 남기거나 execute()를 써서 스레드의 기본 예외 처리기로 전달되게 하세요.
Future와 Callable
import java.util.concurrent.*;
public class FutureExample {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
// Callable: 반환값 있음
Callable<Integer> task = () -> {
Thread.sleep(1000);
return 42;
};
Future<Integer> future = executor.submit(task);
System.out.println("작업 진행 중...");
// 결과 대기
Integer result = future.get(); // 블로킹
System.out.println("결과: " + result);
executor.shutdown();
}
}
Runnable.run()은 반환값이 없고 체크 예외도 던질 수 없습니다. Callable.call()은 값을 반환하고 Exception을 던질 수 있어서, 위 람다 안에서 Thread.sleep()을 try-catch 없이 쓸 수 있습니다. submit()은 작업을 대기열에 넣고 즉시 Future를 돌려주므로 “작업 진행 중…”이 먼저 출력되고, future.get()에서 작업이 끝날 때까지 main 스레드가 멈춥니다.
작업 안에서 던진 예외는 get()에서 ExecutionException으로 감싸져 나오고, 원래 예외는 getCause()로 꺼냅니다. Future로는 “끝나면 이어서 이것을 하라”는 조합을 표현하기 어려워서, 결과를 받아 다음 작업으로 넘기는 흐름이 많다면 CompletableFuture의 thenApply, thenCompose, allOf를 쓰는 편이 읽기 쉽습니다.
synchronized로 공유 데이터 보호하기
synchronized 메서드
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
public class Main {
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("Count: " + counter.getCount()); // 2000
}
}
count++는 한 줄이지만 실제로는 “값 읽기 → 1 더하기 → 쓰기” 세 단계입니다. 두 스레드가 같은 값을 동시에 읽고 각자 1을 더해 쓰면 증가 하나가 사라지는데, 이것이 경쟁 상태(race condition)입니다. synchronized를 지우고 반복 횟수를 100만 번 정도로 늘려 실행해 보면 결과가 2000000보다 작고 실행할 때마다 달라지는 것을 확인할 수 있습니다. 1000번 정도에서는 스레드 하나가 순식간에 끝나 버려 우연히 정답이 나오기도 하는데, 이렇게 “테스트에서는 되는데 운영에서 가끔 틀리는” 것이 동시성 버그가 찾기 어려운 이유입니다.
synchronized 메서드는 그 객체(this)의 모니터 락을 잡아 한 번에 한 스레드만 들어오게 합니다. getCount()까지 synchronized인 이유는 원자성보다 가시성 때문입니다. 락 없이 읽으면 다른 스레드가 쓴 최신 값이 CPU 캐시 등의 이유로 보이지 않을 수 있고, 같은 락을 잡고 읽어야 이전에 락을 풀며 쓴 값이 보장됩니다. 예제에서는 join()이 이미 가시성을 보장하지만, 스레드가 실행 중일 때 값을 읽는 코드라면 이 차이가 드러납니다.
단순한 카운터라면 락 대신 AtomicInteger의 incrementAndGet()을 쓰는 편이 간결하고, 경쟁이 심한 상황에서는 LongAdder가 더 빠릅니다. 락은 여러 필드를 함께 일관되게 바꿔야 할 때 필요합니다.
synchronized 블록
class BankAccount {
private int balance = 0;
private final Object lock = new Object();
public void deposit(int amount) {
synchronized (lock) {
balance += amount;
}
}
public void withdraw(int amount) {
synchronized (lock) {
if (balance >= amount) {
balance -= amount;
}
}
}
public int getBalance() {
synchronized (lock) {
return balance;
}
}
}
synchronized 블록은 락을 잡을 객체와 범위를 직접 정합니다. this 대신 외부에 공개되지 않는 private final Object lock을 쓰는 이유는, this로 잠그면 이 객체를 가진 다른 코드가 synchronized (account)로 같은 락을 잡아 의도치 않게 대기하거나 교착 상태를 만들 수 있기 때문입니다. 필드를 final로 두는 것도 중요한데, 락 객체가 바뀌면 스레드마다 다른 객체를 잠가 동기화가 무의미해집니다.
withdraw에서 잔액 확인과 차감이 같은 블록 안에 있다는 점이 핵심입니다. getBalance()로 확인한 뒤 따로 차감하면 확인과 차감 사이에 다른 스레드가 먼저 출금해 잔액이 음수가 될 수 있습니다(“check-then-act” 문제). 각각의 메서드가 동기화되어 있어도 조합한 동작은 원자적이지 않습니다.
반대로 락 범위가 너무 넓으면 병렬 실행의 의미가 사라집니다. 락 안에서 네트워크 호출이나 파일 쓰기를 하면 그동안 다른 스레드가 모두 기다리므로, 공유 상태를 바꾸는 최소한의 코드만 블록에 넣습니다. 두 계좌 사이 이체처럼 락을 두 개 잡아야 한다면 모든 스레드가 같은 순서(예: 계좌 번호 오름차순)로 잡아야 교착 상태를 피할 수 있습니다. 타임아웃이 있는 잠금이나 읽기·쓰기 락이 필요하면 java.util.concurrent.locks의 ReentrantLock, ReadWriteLock을 씁니다.
스레드 풀로 여러 파일을 병렬 다운로드하기
import java.util.concurrent.*;
import java.util.*;
public class ParallelDownloader {
public static void main(String[] args) throws Exception {
List<String> urls = Arrays.asList(
"https://example.com/file1.txt",
"https://example.com/file2.txt",
"https://example.com/file3.txt"
);
ExecutorService executor = Executors.newFixedThreadPool(3);
List<Future<String>> futures = new ArrayList<>();
for (String url : urls) {
Future<String> future = executor.submit(() -> {
System.out.println("다운로드 시작: " + url);
Thread.sleep(1000); // 다운로드 시뮬레이션
return "완료: " + url;
});
futures.add(future);
}
for (Future<String> future : futures) {
System.out.println(future.get());
}
executor.shutdown();
}
}
모든 작업을 먼저 submit()한 뒤 결과를 모으는 구조가 중요합니다. 반복문 안에서 submit() 직후 바로 get()을 호출하면 작업 하나가 끝날 때까지 다음 작업을 제출하지 못해 순차 실행과 다를 바 없어집니다. 위 코드는 세 다운로드가 동시에 진행되어 약 1초 만에 끝납니다.
다만 결과를 futures 리스트 순서대로 기다리기 때문에, 첫 번째 파일이 가장 늦게 끝나면 이미 끝난 두세 번째 결과도 그때까지 출력되지 않습니다. 끝난 순서대로 처리하려면 ExecutorCompletionService를 쓰거나, CompletableFuture.supplyAsync(...).thenAccept(...)로 완료 시점에 처리를 연결합니다. 실제 다운로드라면 get(30, TimeUnit.SECONDS)처럼 타임아웃을 두고, 하나가 실패해도 나머지 결과는 살릴 수 있게 ExecutionException을 작업별로 처리해야 합니다. 또 get()이 예외를 던지면 shutdown()에 도달하지 못하므로 finally에서 종료하는 것이 안전합니다.
이런 I/O 대기 위주 작업은 Java 21의 가상 스레드와 잘 맞습니다. Executors.newVirtualThreadPerTaskExecutor()를 쓰면 작업마다 가상 스레드를 하나씩 만들어도 부담이 적어서, 풀 크기를 고민하지 않고 수천 개의 다운로드를 동시에 기다릴 수 있습니다. 가상 스레드는 블로킹 호출에서 OS 스레드를 양보하기 때문입니다. 반면 CPU 계산 위주 작업은 코어 수 이상으로 빨라지지 않으므로 기존 고정 크기 풀이 여전히 적합합니다.
스레드 API 요약
- Thread: 스레드 생성, start()로 실행
- Runnable: 작업 정의, 람다 사용 가능
- ExecutorService: 스레드 풀, 재사용
- Future/Callable: 반환값 있는 작업
- synchronized: 동기화, 데이터 무결성
다음 단계
같이 보면 좋은 글
- Java 시작하기 | JDK 설치부터 Hello World까지
- Java 변수와 타입 | 기본 타입, 참조 타입, 형변환
- Java 입출력 | File, BufferedReader, NIO
- Swift 에러 처리 | do-catch, throw, Result
자주 묻는 질문 (FAQ)
Q. Future.get()을 호출하면 프로그램이 멈추는 이유는 무엇인가요?
A. get()은 작업이 끝날 때까지 호출한 스레드를 블로킹하므로, 작업이 오래 걸리거나 끝나지 않으면 그 자리에서 계속 기다립니다. get(1, TimeUnit.SECONDS)처럼 타임아웃을 주고 TimeoutException을 처리해야 무한 대기를 막을 수 있습니다. 여러 작업을 제출했다면 모두 먼저 submit한 뒤에 결과를 모아야 병렬 실행의 이점을 살릴 수 있습니다.