1. 놓친 unlock/release: 부주의함이 부르는 함정
ReentrantLock이나 Semaphore 같은 현대적인 동기화 도구를 사용할 때 가장 교묘한 실수 중 하나는 unlock() 또는 release() 호출을 잊는 것입니다. 락을 해제하지 않으면 다른 스레드는 해제가 될 때까지… 영원히 기다립니다. 프로그램은 멈추고, 왜 아무 일도 일어나지 않는지 화면만 한참 바라보게 됩니다.
ReentrantLock 예제를 보겠습니다:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
// 어이쿠! unlock()을 잊었습니다 — 이제 모두 멈춰 버립니다!
count++;
}
}
겉보기에는 아무 문제없어 보이지만, 여러 스레드에서 increment()를 여러 번 호출하면 첫 호출 이후 나머지 스레드는 락이 해제되기를 무기한 기다리게 됩니다.
이 상황을 피하려면 try-finally 구조를 사용하세요:
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
이제 메서드 중간에 예외가 발생하더라도 락은 반드시 해제됩니다.
마치 누군가 화장실에 들어가서(안에서 문을 잠그고) 나갈 때 문을 열지 않고 창문으로 빠져나간 상황과 같습니다. 다른 사람들은 그 사람이 나오기만을 기다리죠… 이렇게 하지 마세요!
2. 잘못된 객체에 동기화: “어, 자물쇠를 엉뚱한 데 걸었네!”
Java에서 키워드 synchronized는 어떤 객체에 대해 접근을 차단합니다. 하지만 잠글 객체를 잘못 선택하면, 동기화는 기대한 대로 동작하지 않습니다.
오류 1: 지역 변수에 대한 동기화
public void doSomething() {
Object lock = new Object();
synchronized (lock) {
// 매번 새 객체 — 동기화가 전혀 되지 않습니다!
// 스레드들은 서로를 기다리지 않습니다.
// 임계 구역이 보호되지 않습니다!
}
}
여기서는 각 스레드가 자신만의 lock 객체를 생성합니다. 그 결과 실질적인 락이 걸리지 않아 스레드들이 동시에 임계 구역에 진입하게 됩니다.
올바른 방법:
private final Object lock = new Object();
public void doSomething() {
synchronized (lock) {
// 이제 모든 스레드가 동일한 lock 객체를 사용하므로
// 서로 실제로 기다리게 됩니다.
}
}
오류 2: 문자열 리터럴에 대한 동기화
public void doSomething() {
synchronized ("lock") {
// 문자열 리터럴은 인터닝됩니다: 프로그램의 다른 부분이
// 우연히 같은 문자열에 동기화할 수 있습니다!
}
}
결론:
동기화는 이 목적을 위해 특별히 만든 private 객체에만 하세요. 그리고 그 객체가 다른 어디에서도 사용되지 않도록 하세요.
3. 교착 상태(deadlock): “너 먼저 — 아니, 네가 먼저”, 결국 둘 다 멈춤
데드락(상호 교착)은 고전적인 문제입니다. 두 개(또는 그 이상)의 스레드가 서로 다른 락을 차례로 획득하고 서로를 기다리다가 프로그램이 완전히 멈춰 버립니다.
예시:
public class DeadlockExample {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void method1() {
synchronized (lockA) {
// 실험의 명확성을 위해 잠시 기다립니다
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockB) {
// ...
}
}
}
public void method2() {
synchronized (lockB) {
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockA) {
// ...
}
}
}
}
한 스레드가 method1()을, 다른 스레드가 method2()를 호출하면, 첫 번째 스레드는 lockA를 잡고 lockB를 기다리고, 두 번째 스레드는 그 반대가 됩니다. 그 결과 둘 다 영원히 서로를 기다리게 됩니다.
피하는 법:
- 모든 스레드에서 항상 동일한 순서로 락을 획득하세요.
- 동시에 보유하는 락의 개수를 최소화하세요.
- 프로그램이 멈췄다면 진단 도구(예: jstack)를 사용하세요.
비유:
좁은 복도에서 두 사람이 마주쳤는데, 서로 먼저 비켜 주기를 상대에게 요구하는 상황과 같습니다. 결국 둘 다 서서 누가 먼저 포기할지 기다리게 됩니다.
4. 과도한 동기화: “과유불급이 더 낫다?” — 항상 그런 건 아닙니다!
때로는 실수를 피하려고 모든 것을 마구 동기화하는 경우가 있습니다. 그 결과 성능은 떨어지지만, 실질적인 이득은 없습니다.
예시:
public synchronized void add(int value) {
// 여기에는 동기화가 필요 없는 한 줄만 있습니다!
System.out.println("추가됨: " + value);
}
이 경우에는 동기화가 필요 없습니다. System.out.println 자체가 이미 스레드 안전하며, 이 메서드는 공유 자원을 다루지 않습니다.
어디서 치명적일까요?
보호가 필요 없는 메서드를 자주 호출하면서 동기화해 버리면, 프로그램의 성능이 급격히 떨어집니다. 실제로는 병렬로 처리할 수 있는데도 스레드들이 줄을 서야 합니다.
모범 사례:
정말 필요한 부분만 동기화하세요. 임계 구역은 가능한 한 작게 유지해야 합니다.
5. volatile의 오사용: “가시성은 있지만, 원자성은 없다!”
Java의 volatile 한정자는 변수의 변경 사항이 모든 스레드에 보이도록 보장합니다. 하지만 이는 원자성은 보장하지 않습니다.
오류:
private volatile int counter = 0;
public void increment() {
counter++; // 원자적이지 않음!
}
counter++ 연산은 값을 읽고, 증가시키고, 다시 쓰는 단계로 이루어집니다. 두 스레드가 동시에 이 코드를 실행하면 최종 값이 기대보다 작아질 수 있습니다.
올바른 방법:
원자적 연산이 필요하다면 synchronized, AtomicInteger 또는 다른 스레드 안전한 클래스를 사용하세요.
import java.util.concurrent.atomic.AtomicInteger;
private final AtomicInteger counter = new AtomicInteger();
public void increment() {
counter.incrementAndGet();
}
volatile은 언제 사용할까요?
원자성이 필요 없는 단순한 플래그(예: “작업 종료”)에 적합합니다.
GO TO FULL VERSION