Java에서 멀티스레딩(Multithreading)과 동시성 프로그래밍(Concurrency)은 성능 최적화와 병렬 작업을 수행하는 데 필수적인 개념이다. 하지만 잘못 사용하면 데드락(Deadlock), 레이스 컨디션(Race Condition) 같은 문제를 초래할 수 있다. 이번 포스팅에서는 멀티스레딩의 기본 개념, 스레드 생성 방법, 동기화 문제와 해결책을 다뤄보자.
멀티스레딩은 하나의 프로세스에서 여러 개의 스레드(Thread)가 동시에 실행되는 개념이다.
이를 통해 CPU 사용률을 극대화하고, 프로그램이 병렬로 실행되도록 할 수 있다.
Java에서는 스레드를 생성하는 방법이 여러 가지가 있다.
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread 실행 중: " + Thread.currentThread().getName());
}
}
public class ThreadExample {
public static void main(String[] args) {
MyThread thread = new MyThread();
thread.start(); // 스레드 시작
}
}
Thread 클래스를 상속하면 run()
메서드를 오버라이딩하여 실행할 작업을 정의할 수 있다.
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("Runnable 실행 중: " + Thread.currentThread().getName());
}
}
public class RunnableExample {
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start();
}
}
Runnable 인터페이스를 구현하면 Thread 클래스를 직접 상속하지 않고도 멀티스레딩을 사용할 수 있다. 자바는 다중 상속을 지원하지 않기 때문에, Runnable 방식이 더 유연하다.
Java 5부터는 ExecutorService
를 사용해 스레드를 효율적으로 관리할 수 있다.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExecutorExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(3); // 3개의 스레드 풀 생성
for (int i = 0; i < 5; i++) {
executor.execute(() -> {
System.out.println("Executor 실행 중: " + Thread.currentThread().getName());
});
}
executor.shutdown(); // 스레드 종료
}
}
Executor는 스레드 풀(Thread Pool)을 사용해 스레드를 재사용하기 때문에, 성능 최적화에 유리하다.
멀티스레딩 환경에서는 여러 스레드가 동시에 같은 데이터에 접근하면 문제가 발생할 수 있다. 대표적인 동기화 문제와 해결 방법을 살펴보자.
여러 스레드가 동시에 공유 자원을 변경할 때 발생하는 문제로, 실행 순서에 따라 결과가 달라질 수 있다.
class Counter {
private int count = 0;
public void increment() {
count++; // 여러 스레드가 동시에 접근하면 문제 발생
}
public int getCount() {
return count;
}
}
public class RaceConditionExample {
public static void main(String[] args) {
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();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("최종 count 값: " + counter.getCount()); // 예상: 2000, 실제: 랜덤 값
}
}
여러 스레드가 increment()
메서드를 동시에 실행하면 count
값이 올바르게 증가하지 않는다.
synchronized
키워드를 사용하면 메서드가 한 번에 하나의 스레드만 접근 가능하도록 만든다.
class SyncCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
synchronized
를 추가하면 increment()
메서드가 한 번에 하나의 스레드만 실행할 수 있어, Race Condition 문제가 해결된다.
synchronized
대신 ReentrantLock
을 사용할 수도 있다.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class LockCounter {
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;
}
}
ReentrantLock
은 try-finally
블록을 사용하여 lock을 명시적으로 해제할 수 있는 장점이 있다.
데드락은 두 개 이상의 스레드가 서로의 락을 기다리며 영원히 멈추는 상태를 말한다.
class Resource {
public synchronized void methodA(Resource other) {
System.out.println(Thread.currentThread().getName() + " methodA 실행");
other.methodB(this);
}
public synchronized void methodB(Resource other) {
System.out.println(Thread.currentThread().getName() + " methodB 실행");
other.methodA(this);
}
}
public class DeadlockExample {
public static void main(String[] args) {
Resource r1 = new Resource();
Resource r2 = new Resource();
Thread t1 = new Thread(() -> r1.methodA(r2));
Thread t2 = new Thread(() -> r2.methodA(r1));
t1.start();
t2.start();
}
}
위 코드에서는 t1
과 t2
가 서로의 락을 기다리면서 무한 대기 상태가 발생한다.
Lock Ordering
을 적용한다.tryLock()
을 사용해 일정 시간이 지나면 락을 포기하도록 한다.synchronized
, ReentrantLock
, Executor
등을 적절히 활용해야 한다.ExecutorService
를 활용하면 더 효율적인 멀티스레딩 환경을 구축할 수 있다.멀티스레딩은 잘 사용하면 성능을 극대화할 수 있지만, 잘못 사용하면 치명적인 동기화 문제를 초래할 수 있다. 실무에서는 Executor
, synchronized
, ReentrantLock
등을 적절히 활용하여 안전한 멀티스레딩을 구현해야 한다.