2024-07-01 00:07
실무에서 서버 성능 이슈를 다루다 보면, 한 번쯤은 꼭 마주치는 게 바로 멀티스레드와 동시성(Concurrency) 문제다.
자바는 기본적으로 멀티스레드를 지원하지만, 막상 직접 구현해보면 생각보다 복잡하고, 예상치 못한 버그가 숨어있는 경우가 많다.
간단히 말해, 여러 작업(스레드)이 동시에 실행되는 것이다.
예를 들어, 웹 서버에서 여러 사용자의 요청을 동시에 처리할 때, 각 요청을 별도의 스레드로 처리하면 효율이 올라간다.
public class MyThread extends Thread {
private String threadName;
public MyThread(String name) {
this.threadName = name;
}
public void run() {
System.out.println(threadName + " 시작!");
try {
// 실제 작업 시뮬레이션
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(threadName + " 완료!");
}
}
public class Main {
public static void main(String[] args) {
MyThread t1 = new MyThread("작업1");
MyThread t2 = new MyThread("작업2");
t1.start(); // run()이 아닌 start()로 실행!
t2.start(); // 동시에 실행된다
}
}여기서 주의할 점은 run() 메서드를 직접 호출하면 새로운 스레드가 생성되지 않고, 현재 스레드에서 그냥 메서드가 실행될 뿐이다. 반드시 start()를 호출해야 한다!
실제로는 Thread를 상속받기보다는, Runnable 인터페이스를 구현하는 방식이 더 많이 쓰인다.
이유는? 자바는 다중 상속이 안 되기 때문!
public class MyRunnable implements Runnable {
private final String taskName;
public MyRunnable(String taskName) {
this.taskName = taskName;
}
public void run() {
System.out.println(taskName + " 시작 - " + Thread.currentThread().getName());
// 작업 수행
System.out.println(taskName + " 완료!");
}
}
// 사용법 1: Thread 생성자에 전달
Thread t = new Thread(new MyRunnable("DB 조회"));
t.start();
// 사용법 2: 람다 표현식 활용 (Java 8+)
Thread t2 = new Thread(() -> {
System.out.println("람다로 간단하게!");
});
t2.start();Runnable의 장점은 다른 클래스를 상속받을 수 있고, 작업(태스크)과 스레드 관리를 분리할 수 있다는 점이다. 실무에서는 거의 대부분 Runnable을 사용한다고 보면 된다.
여러 스레드가 공유 자원에 동시에 접근하면, 데이터가 꼬이는 문제가 발생한다.
이럴 때 사용하는 게 바로 synchronized 키워드다.
public class Counter {
private int count = 0;
// 메서드 전체 동기화
public synchronized void increment() {
count++;
}
// 동기화 블록 사용 (더 세밀한 제어)
public void incrementWithBlock() {
// 다른 작업들...
synchronized(this) {
count++;
}
// 다른 작업들...
}
public int getCount() {
return count;
}
}
// 동시성 문제 예시
public class ConcurrencyProblem {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
// 1000개의 스레드가 각각 1000번씩 증가
Thread[] threads = new Thread[1000];
for (int i = 0; i < 1000; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
counter.increment();
}
});
threads[i].start();
}
// 모든 스레드가 끝날 때까지 대기
for (Thread t : threads) {
t.join();
}
System.out.println("최종 카운트: " + counter.getCount());
// synchronized가 없다면 1,000,000이 아닌 값이 나올 수 있다!
}
}하지만 무조건 synchronized만 쓰면 성능이 떨어질 수 있으니, 꼭 필요한 부분에만 적용하는 게 중요하다. 특히 동기화 블록을 사용하면 임계 영역을 최소화할 수 있다.
JDK 1.5부터는 직접 스레드를 만들기보다는, ExecutorService를 사용하는 게 표준이다.
// 고정 크기 스레드풀
ExecutorService executor = Executors.newFixedThreadPool(3);
// 여러 작업 제출
for (int i = 0; i < 10; i++) {
final int taskId = i;
executor.submit(() -> {
System.out.println("작업 " + taskId + " 시작 - " + Thread.currentThread().getName());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("작업 " + taskId + " 완료!");
});
}
// 새로운 작업 제출 중단
executor.shutdown();
// 모든 작업이 완료될 때까지 대기 (최대 1분)
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow(); // 강제 종료
}
} catch (InterruptedException e) {
executor.shutdownNow();
}// 1. 고정 크기 스레드풀 (가장 많이 사용)
ExecutorService fixedPool = Executors.newFixedThreadPool(5);
// 2. 캐시 스레드풀 (필요에 따라 스레드 생성/제거)
ExecutorService cachedPool = Executors.newCachedThreadPool();
// 3. 단일 스레드 (작업 순서 보장)
ExecutorService singleThread = Executors.newSingleThreadExecutor();
// 4. 스케줄 실행 (주기적 작업)
ScheduledExecutorService scheduledPool = Executors.newScheduledThreadPool(2);
scheduledPool.scheduleAtFixedRate(() -> {
System.out.println("5초마다 실행!");
}, 0, 5, TimeUnit.SECONDS);이렇게 하면 스레드 생성/소멸 비용을 줄이고, 안정적으로 스레드를 관리할 수 있다.
여러 스레드가 동시에 접근해도 안전한 컬렉션이 필요할 때는?
ConcurrentHashMap, CopyOnWriteArrayList 등 동시성 컬렉션을 사용하자.
// 1. ConcurrentHashMap - 읽기/쓰기 동시 처리 최적화
Map<String, String> map = new ConcurrentHashMap<>();
map.put("key", "value");
// 동시에 여러 스레드가 접근해도 안전
map.compute("counter", (k, v) -> v == null ? "1" : String.valueOf(Integer.parseInt(v) + 1));
// 2. CopyOnWriteArrayList - 읽기가 많고 쓰기가 적을 때
List<String> list = new CopyOnWriteArrayList<>();
list.add("안전한 리스트");
// 3. BlockingQueue - 생산자-소비자 패턴
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
// 생산자
new Thread(() -> {
try {
queue.put("작업1");
queue.put("작업2");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
// 소비자
new Thread(() -> {
try {
while (true) {
String task = queue.take(); // 큐가 비어있으면 대기
System.out.println("처리: " + task);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();두 개 이상의 스레드가 서로를 기다리며 무한 대기하는 상황이다.
public class DeadlockExample {
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public void method1() {
synchronized(lock1) {
System.out.println("Lock1 획득");
synchronized(lock2) {
System.out.println("Lock2 획득");
}
}
}
public void method2() {
synchronized(lock2) { // 순서가 반대!
System.out.println("Lock2 획득");
synchronized(lock1) {
System.out.println("Lock1 획득");
}
}
}
}해결법: 항상 같은 순서로 락을 획득하도록 한다!
스레드 간 변수 값의 가시성을 보장한다.
public class VolatileExample {
private volatile boolean running = true;
public void stop() {
running = false; // 다른 스레드에서도 즉시 반영
}
public void doWork() {
while (running) {
// 작업 수행
}
}
}각 스레드마다 독립적인 변수를 가질 수 있다.
public class UserContext {
private static ThreadLocal<String> userHolder = new ThreadLocal<>();
public static void setUser(String user) {
userHolder.set(user);
}
public static String getUser() {
return userHolder.get();
}
public static void clear() {
userHolder.remove(); // 메모리 누수 방지!
}
}적절한 스레드 수 설정
Lock-free 알고리즘 활용
// AtomicInteger 사용
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // synchronized 없이도 안전!CompletableFuture로 비동기 처리
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> "결과")
.thenApply(s -> s + " 처리")
.thenAccept(System.out::println);멀티스레드와 동시성은 자바에서 절대 빼놓을 수 없는 핵심 주제다.
실무에서 성능 이슈, 데이터 꼬임, 서버 다운 등 다양한 문제의 원인이 되기도 한다.
꼭 직접 예제를 돌려보고, synchronized와 ExecutorService, 동시성 컬렉션을 적재적소에 활용하는 습관을 들이는 것이 좋다
특히 요즘처럼 멀티코어 CPU가 기본인 시대에는, 동시성을 제대로 활용하는 것과 그렇지 않은 것의 성능 차이가 엄청나다. 하지만 무작정 스레드를 늘린다고 빨라지는 게 아니니, 항상 측정하고 개선하는 자세가 필요하다.
더 나아가, 아예 전통적인 스레드 모델에서 벗어나 리액티브 프로그래밍(Spring WebFlux, Project Reactor)이나 이벤트 루프 기반의 논블로킹 I/O 방식을 채택하는 것도 고려해볼 만하다. 특히 대용량 트래픽을 처리해야 하는 환경에서는 스레드 자체를 최소화하면서도 높은 동시성을 달성할 수 있는 이런 대안들이 오히려 더 효과적일 수 있다. 결국 중요한 건 무조건적인 멀티스레드 적용이 아니라, 시스템의 특성과 요구사항에 맞는 최적의 동시성 모델을 선택하는 것이다.
이렇게 정리해두면, 나중에 실무에서 멀티스레드 이슈가 터졌을 때 당황하지 않고 대처할 수 있을 것이다.