82. 스레드 안전성 수준을 문서화하라
자바에서 스레드 안전성은 여러 스레드가 동시에 같은 코드를 실행해도 문제가 발생하지 않는 성질이다.
쉽게 말하면, 여러 사람이 동시에 하나의 장난감을 가지고 놀아도 장난감이 고장 나지 않는 것과 비슷하다.
// 스레드 안전하지 않은 예시
public class Counter {
private int value = 0;
public void increment() {
value++; // ❌ 여러 스레드가 동시에 실행하면 문제 발생!
}
public int getValue() {
return value;
}
}
왜 스레드 안전성 수준을 문서화해야 할까?
API를 사용하는 개발자는 그 API가 여러 스레드에서 어떻게 동작할지 알아야 한다.
synchronized
키워드만으로는 스레드 안전성을 판단할 수 없다.
그러므로 API를 만들 때는 스레드 안전성 수준을 명확하게 알려줘야 한다.
// synchronized 키워드가 있어도 안전하지 않을 수 있다
public class UnsafeCache {
private Map<String, Object> cache = new HashMap<>();
public synchronized Object get(String key) {
return cache.get(key); // 안전함
}
public synchronized void put(String key, Object value) {
cache.put(key, value); // 안전함
}
public List<String> getKeys() {
// synchronized 키워드가 없어 스레드 안전하지 않음!
return new ArrayList<>(cache.keySet());
}
}
스레드 안전성 수준 (안전한 순서대로)
1. 불변(immutable)
이 클래스의 객체는 상수처럼 변하지 않아 여러 스레드가 동시에 사용해도 안전하다.
// 불변 클래스 예시
public final class ImmutablePoint {
private final int x; // final로 한 번 초기화되면 변경 불가
private final int y;
public ImmutablePoint(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
// 새로운 객체를 반환하는 메서드(원본은 변경 안 됨)
public ImmutablePoint translate(int dx, int dy) {
return new ImmutablePoint(x + dx, y + dy);
}
}
2. 무조건적 스레드 안전(unconditionally thread-safe)
이 클래스의 객체는 변할 수 있지만, 내부에서 잘 동기화되어 있어 여러 스레드가 동시에 사용해도 안전하다.
// 무조건적 스레드 안전 클래스 예시
public class SafeCounter {
private final AtomicInteger value = new AtomicInteger(0);
public void increment() {
value.incrementAndGet(); // ✅ 원자적 연산으로 내부적으로 동기화됨
}
public int getValue() {
return value.get();
}
}
3. 조건부 스레드 안전(conditionally thread-safe)
일부 메서드는 동시에 사용할 때 주의가 필요하다.
어떤 메서드를 어떻게 사용해야 안전한지 문서로 알려줘야 한다.
// 조건부 스레드 안전 클래스 예시
public class ConditionalSafeList<E> {
private final List<E> list = Collections.synchronizedList(new ArrayList<>());
// ✅ 단일 메서드는 안전함
public void add(E element) {
list.add(element); // 내부적으로 동기화됨
}
// ❌ 반복자는 외부 동기화 필요
public Iterator<E> iterator() {
return list.iterator(); // 반복자 자체는 동기화되지 않음
}
}
올바른 사용법을 아래와 같이 문서화해야 한다
/**
* 이 리스트의 반복자를 사용할 때는 아래와 같이 리스트에 대해 동기화해야 한다
*
* ConditionalSafeList<String> list = new ConditionalSafeList<>();
* ...
* synchronized(list) {
* Iterator<String> i = list.iterator();
* while (i.hasNext()) {
* doSomething(i.next());
* }
* }
*/
4. 스레드 안전하지 않음(not thread-safe)
여러 스레드가 동시에 사용하려면 사용자가 직접 동기화해야 한다.
// 스레드 안전하지 않은 클래스 예시
public class UnsafeCounter {
private int value = 0;
public void increment() {
value++; // ❌ 여러 스레드가 동시에 실행하면 문제 발생
}
public int getValue() {
return value;
}
}
사용자는 이 클래스를 다음과 같이 동기화해서 사용해야 한다
UnsafeCounter counter = new UnsafeCounter();
// 외부 동기화 사용
synchronized (counter) {
counter.increment();
System.out.println(counter.getValue());
}
5. 스레드 적대적(thread-hostile)
동기화를 해도 여러 스레드에서 안전하게 사용할 수 없다. 대부분 실수로 만들어지는 경우다.
// 스레드 적대적 클래스 예시
public class HostileCounter {
private static int counter = 0;
// 정적 변수를 동기화 없이 변경하는 메서드
public static int generateSerialNumber() {
return counter++; // ❌ 심각한 문제 발생 가능
}
}
스레드 안전성 수준 비교
불변
객체가 변하지 않음
String, Long
무조건적 스레드 안전
내부 동기화로 완전히 안전함
AtomicInteger, ConcurrentHashMap
조건부 스레드 안전
일부 기능은 외부 동기화 필요
Collections.synchronizedList
스레드 안전하지 않음
모든 동시 접근에 외부 동기화 필요
ArrayList, HashMap
스레드 적대적
동기화해도 안전하지 않음
정적 변수를 동기화 없이 수정하는 클래스
비공개 락 객체 패턴
무조건적 스레드 안전 클래스를 만들 때는 synchronized
메서드 대신 비공개 락 객체를 사용하는 것이 좋다.
// 비공개 락 객체 패턴
public class BetterSafeCounter {
private int value = 0;
private final Object lock = new Object(); // 비공개 락 객체
public void increment() {
synchronized(lock) { // ✅ 비공개 락 사용
value++;
}
}
public int getValue() {
synchronized(lock) {
return value;
}
}
}
비공개 락 객체의 장점
클래스 자체를 락으로 사용하지 않으므로 클라이언트 코드가 실수로 같은 락을 사용해 교착 상태를 만들 위험이 없다.
하위 클래스가 상위 클래스의 동기화를 방해할 가능성이 없다.
나중에 더 복잡한 동시성 제어 메커니즘으로 전환하기 쉽다.
스레드 안전성 문서화 방법
클래스와 메서드의 주석에 스레드 안전성 수준을 명확히 적어야 한다.
/**
* 이 클래스는 불변이므로 스레드 안전하다.
* 모든 메서드는 여러 스레드에서 동시에 호출해도 안전하다.
*/
public final class SafePoint {
// 구현...
}
/**
* 이 클래스는 스레드 안전하지 않다.
* 여러 스레드에서 사용하려면 외부 동기화가 필요하다.
*/
public class UnsafeList<E> {
// 구현...
}
특히 조건부 스레드 안전 클래스는 어떤 메서드 호출 순서에 동기화가 필요한지, 어떤 락을 획득해야 하는지 자세히 문서화해야 한다.
/**
* 이 클래스는 조건부 스레드 안전하다.
* iterator() 메서드가 반환한 반복자를 사용할 때는
* 아래 예시처럼 이 리스트에 대해 동기화해야 한다:
*
* List<String> list = Collections.synchronizedList(new ArrayList<>());
* synchronized(list) {
* Iterator<String> i = list.iterator();
* while (i.hasNext()) {
* foo(i.next());
* }
* }
*/
정리
모든 클래스가 자신의 스레드 안전성 수준을 명확히 문서화해야 한다.
정확한 언어로 명확히 설명하거나 스레드 안전성 애너테이션(@Immutable, @ThreadSafe, @NotThreadSafe)을 사용하는 것이 좋다.
synchronized
한정자는 문서화와 관련이 없다.조건부 스레드 안전 클래스는 어떤 메서드 호출 순서에 외부 동기화가 필요한지, 어떤 락을 얻어야 하는지도 문서화해야 한다.
무조건적 스레드 안전 클래스를 작성할 때는
synchronized
메서드가 아닌 비공개 락 객체를 사용하자.
🧩 어려웠던 점
비공개 락 객체를 사용하는 이유도 처음에는 이해하기 어려웠다.
그냥 synchronized
메서드를 사용하는 것이 더 간단해 보였지만, 실제로는 비공개 락 객체가 제공하는 보안과 유연성이 중요하다는 것을 알게 되었다.
스레드 안전성을 문서화할 때 어떤 내용을 포함해야 하는지 결정하는 것도 쉽지 않았다.
💡 느낀 점
코드만 보고는 어떻게 동작할지 예측하기 어렵기 때문에, 사용자에게 명확한 가이드라인을 제공하는 것이 필수적인것을 다시한번 느꼈다.
또한 스레드 안전성에 대한 고려가 API 설계 초기 단계부터 이루어져야 한다는 점도 깨달았다.
나중에 스레드 안전성을 추가하려고 하면 API 전체를 변경해야 할 수도 있다.
Last updated