멀티스레드의 이해와 구현 방법
멀티스레딩은 하나의 프로세스 내에서 여러 실행 흐름을 동시에 처리하는 기술입니다. Java에서 스레드를 생성하고 실행하는 방법은 크게 세 가지로 나뉩니다.
1. Thread 클래스 상속
가장 기본적인 방법으로, Thread 클래스를 상속받아 run() 메서드를 오버라이드합니다.
public class WorkerThread extends Thread {
@Override
public void run() {
for (int i = 0; i < 50; i++) {
System.out.println(getName() + " - 작업 중: " + i);
}
}
}
public class ThreadMain {
public static void main(String[] args) {
WorkerThread t1 = new WorkerThread();
WorkerThread t2 = new WorkerThread();
t1.setName("프로세서-A");
t2.setName("프로세서-B");
t1.start();
t2.start();
}
}
2. Runnable 인터페이스 구현
다중 상속이 불가능한 Java의 특성상 가장 권장되는 방식입니다. 작업 내용과 스레드 실행 객체를 분리할 수 있습니다.
public class TaskRunner implements Runnable {
@Override
public void run() {
String currentName = Thread.currentThread().getName();
for (int i = 0; i < 50; i++) {
System.out.println(currentName + " 실행 단계: " + i);
}
}
}
public class RunnableMain {
public static void main(String[] args) {
TaskRunner task = new TaskRunner();
Thread thread1 = new Thread(task, "스레드-1");
Thread thread2 = new Thread(task, "스레드-2");
thread1.start();
thread2.start();
}
}
3. Callable 및 Future 활용
스레드 실행 결과(리턴값)를 받아야 하거나 예외 처리가 필요할 때 사용합니다.
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class SumTask implements Callable<Integer> {
@Override
public Integer call() throws Exception {
int total = 0;
for (int i = 1; i <= 100; i++) {
total += i;
}
return total;
}
}
public class CallableMain {
public static void main(String[] args) throws ExecutionException, InterruptedException {
SumTask task = new SumTask();
FutureTask<Integer> futureTask = new FutureTask<>(task);
Thread t = new Thread(futureTask);
t.start();
// 결과 대기 및 출력
Integer result = futureTask.get();
System.out.println("계산 결과: " + result);
}
}
주요 스레드 제어 메서드
setName(String name): 스레드에 식별 가능한 이름을 부여합니다.currentThread(): 현재 실행 중인 스레드 객체를 참조합니다.sleep(long ms): 지정된 밀리초 동안 현재 스레드를 일시 정지시킵니다.setDaemon(boolean on): 스레드를 데몬 스레드로 설정합니다. 주 스레드가 종료되면 데몬 스레드도 자동 종료됩니다.join(): 특정 스레드가 종료될 때까지 현재 스레드를 대기시킵니다.yield(): 다른 스레드에게 CPU 실행 권한을 양보합니다.
스레드 안전(Thread Safety)과 동기화
여러 스레드가 공유 자원에 동시에 접근할 때 데이터 정합성 문제가 발생할 수 있습니다. 이를 해결하기 위해 동기화 메커니즘을 사용합니다.
1. synchronized 코드 블록 및 메서드
public class InventoryManager implements Runnable {
private int stock = 100;
@Override
public void run() {
while (true) {
synchronized (InventoryManager.class) {
if (stock > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
stock--;
System.out.println(Thread.currentThread().getName() + " 출고 완료. 남은 재고: " + stock);
} else {
break;
}
}
}
}
}
2. Lock 인터페이스 (ReentrantLock)
보다 세밀한 제어가 가능한 명시적 잠금 방식입니다. 반드시 finally 블록에서 unlock()을 호출해야 합니다.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class SecureCounter extends Thread {
private static int count = 0;
private static final Lock lock = new ReentrantLock();
@Override
public void run() {
while (true) {
lock.lock();
try {
if (count < 100) {
Thread.sleep(10);
count++;
System.out.println(getName() + " 카운트: " + count);
} else {
break;
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
}
대기 및 호출 메커니즘 (Producer-Consumer)
스레드 간 효율적인 통신을 위해 wait()와 notifyAll()을 사용하거나, BlockingQueue를 활용할 수 있습니다.
BlockingQueue를 활용한 구현
ArrayBlockingQueue를 사용하면 명시적인 동기화 코드 없이도 안전한 데이터 전달이 가능합니다.
import java.util.concurrent.ArrayBlockingQueue;
public class Producer extends Thread {
private ArrayBlockingQueue<String> queue;
public Producer(ArrayBlockingQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
while (true) {
try {
queue.put("데이터");
System.out.println("생성자가 데이터를 생성하여 큐에 넣었습니다.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class Consumer extends Thread {
private ArrayBlockingQueue<String> queue;
public Consumer(ArrayBlockingQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
while (true) {
try {
String data = queue.take();
System.out.println("소비자가 '" + data + "'를 처리했습니다.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
스레드 풀과 성능 최적화
스레드 생성과 소멸에 따르는 오버헤드를 줄이기 위해 스레드 풀을 사용합니다. 시스템의 자원을 효율적으로 관리하기 위해 최대 병렬 처리 가능 수를 확인하는 것이 중요합니다.
// 현재 시스템의 사용 가능한 프로세서 수 확인
int coreCount = Runtime.getRuntime().availableProcessors();
System.out.println("활성 코어 수: " + coreCount);
스레드 풀의 적정 크기는 CPU 집약적인 작업인지, I/O 집약적인 작업인지에 따라 달라집니다. CPU 집약적 작업은 코어 수 + 1 내외, I/O 집약적 작업은 코어 수의 수 배까지 설정하는 것이 일반적입니다.