4장: 객체 구성

스레드 안전한 클래스

조건

  • 객체의 상태를 보관하는 변수가 어떤 것인지, 객체의 상태를 보관하는 변수가 어떤 값을 가질 수 있는지, 동시에 객체 내부의 값을 사용하고자 할 때 어떻게 관리하는지를 고려해야 한다.

  • 객체 내부에 또다른 객체를 변수로 갖고 있는 경우, 객체의 상태 범위가 다양해지므로 주의해야 한다.

    • 예를 들어 LinkedList 객체의 상태 범위는 객체 내에 있는 모든 원소의 상태 범위를 포함한다.

  • 객체의 상태 범위가 좁을수록 논리적인 상태를 파악하기 쉽다.

  • 클래스가 특정 상태가 될 수 없도록 구현해야 한다면 그와 관련된 변수들을 클래스 내부에 숨겨두어야 한다.

  • 특정 연산을 실행했을 때 올바르지 않은 상태 값을 가질 수 있다면 해당 연산을 단일 연산으로 구현해야 한다.

  • 서로 연관된 변수들에 대해서 단일 연산으로 한 번에 읽거나 변경해야 한다.

  • 현재 상태에 따라 동작 여부가 달라지는 상태 의존 연산을 수행할 때 특정 조건이 될 때 까지 기다려야 한다면 세마포어나 블로킹 큐를 사용하는 것이 좋다. wait, notify를 사용할 수도 있긴 하지만 올바르게 사용하기 어렵다.

소유권

  • 자바 언어 특성 상 소유권을 명확하게 표현하기 어렵지만, 객체의 내부에 상태 정보를 숨기면 객체의 상태에 대한 소유권을 가질 수 있다.

  • 특정 변수를 객체 외부에 공개한다면 통제권을 어느 정도 잃게 된다.

  • List, Set 등과 같은 컬렉션 클래스에서는 컬렉션 내부 구조에 대한 소유권은 컬렉션 클래스가 갖고, 컬렉션에 추가된 객체에 대한 소유권은 컬렉션을 사용하는 쪽에서 갖게 된다.

  • 예를 들어 ServletContext 클래스는 Map과 유사한 구조로 만들어져 있으며 setAttribute/getAttribute 메서드를 통해 외부에서 생성된 객체를 추가하고 조회할 수 있다. 이 때 추가된 객체는 소유권이 ServletContext 객체에 없기 때문에 스레드 안전성을 충분히 확보해야 한다.

인스턴스 한정

개념

  • 데이터를 객체 내부에 캡슐화하여 숨겨두어 인스턴스에 한정되도록 하면, 해당 데이터는 당연히 객체 내부에서만 사용되므로 스레드 안전성을 확보하기 쉽다.

  • 데이터를 객체 내부에만 숨겨둘 수도 있고, 특정 블록 내부에 한정시킬 수도 있고, 특정 스레드만 사용하도록 한정시킬 수도 있다. 이렇게 한정된 데이터는 다른 범위에 노출되면 안된다.

  • Collections.synchronizedList 같은 팩토리 메서드에서 제공하는 wrapper 클래스는 데코레이터 패턴을 이용해 모든 메서드를 동기화시킨다.

  • 특정 코드 범위 등에 한정되어야 할 객체가 외부에 공개된다면 버그가 발생할 수 있다.

자바 모니터 패턴과 함께 인스턴스 한정 활용하기

  • 변경 가능한 데이터를 모두 객체 내부적으로만 사용할 수 있도록 하고 객체의 암묵적 락을 통해 데이터에 대한 동시 접근을 막아 간결하게 스레드 안정성을 확보할 수 있다.

  • 다음 예제 코드와 같이 내부 락을 private final로 선언하고 자바 모니터를 통해 제공되는 synchronized 블록으로 동시성 제어를 할 수 있다.

public class PrivateLock {
    private final Object myLock = new Object();
    @GuardedBy("myLock") Widget widget;
    
    void doWithWidget() {
        synchronized(myLock) {
            // widget 변수 값을 읽거나 변경
        }
    }
}

스레드 안전성 위임

위임 기법 활용

  • 대부분의 자바 객체는 여러 객체를 조합해 만든 합성(composite) 객체이다.

  • 합성 객체에서 가지는 여러 객체들이 스레드 안전성을 이미 확보하고 있고, 합성 객체가 가지는 상태가 해당 객체들 외에 따로 없다면 스레드 안전성을 해당 객체들에 위임(delegate)할 수 있다.

  • 다음과 같이 불변 클래스인 Point를 포함하는 ConcurrentHashMap에 상태를 저장하는 경우, 별다른 동기화 작업을 하지 않고 ConcurrentHashMap에 그대로 스레드 안전성을 위임하게 된다.

  • 외부에 Point 객체가 노출되더라도 외부에서 변경할 수 없으며, UnmodifiableMap으로 반환되므로 Map 자체도 외부에서 변경할 수 없다.

  • 다만 UnmodifiableMap 내부의 데이터는 locations에 의해 변경 가능하다.

public class DelegatingVehicleTracker {
    private final ConcurrentMap<String, Point> locations;
    private final Map<String, Point> unmodifiableMap;

    public DelegatingVehicleTracker(Map<String, Point> points) {
        locations = new ConcurrentHashMap<String, Point>(points);
        unmodifiableMap = Collections.unmodifiableMap(locations);
    }

    public Map<String, Point> getLocations() {
        return unmodifiableMap;
    }
        
    public Point getLocation(String id) {
        return locations.get(id);
    }
    
    public void setLocation(String id, int x, int y) {
        if (locations.replace(id, new Point(x, y)) == null) {
            throw new IllegalArgumentException();
        }
    }
}

독립 상태 변수

  • 클래스에 속한 변수가 여러 개이고 모두 스레드 안전하며 서로 상태 값에 대한 연관성이 없는 경우 스레드 안전성을 위임할 수 있다.

  • 따라서 클래스가 별도로 동기화 작업을 추가하지 않아도 된다.

  • 아래 예시 코드는 CopyOnWriteArrayList를 사용하여 스레드 안전하게 상태를 객체에 저장하고 있으므로, 별도로 클래스 단에서 동기화해주지 않고 있다.

public class VisualComponent {
    private final List<KeyListener> keyListeners = new CopyOnWriteArrayList<KeyListener>();
    private final List<MouseListener> mouseListeners = new CopyOnWriteArrayList<MouseListener>();
    
    public void addKeyListener(KeyListener listener) {
        keyListeners.add(listener);
    }
    
    public void removeKeyListener(KeyListener listener) {
        keyListeners.remove(listener);
    }
    
    // ...
}

위임이 불가능한 경우

  • 클래스에 두 개 이상의 변수를 사용하는 복합 연산 메서드를 갖고 있다면, 각 변수가 스레드 안전하더라도 위임 기법으로 스레드 안전성을 확보할 수 없다.

  • 따라서 변수 주변에 락을 적용하는 등 동기화 작업이 필요하다.

  • 다음과 같이 setLower, setUpper 메서드에서 여러 변수를 참조하여 상태를 변경하는 경우 위임이 불가능하다. 두 메서드가 동시에 여러 스레드로부터 호출되는 경우 동기화가 되지 않고 있기 때문이다.

public class NumberRange {
    private final AtomicInteger lower = new AtomicInteger(0);
    private final AtomicInteger upper = new AtomicInteger(0);
    
    public void setLower(int i) {
        if (i < upper.get())
            throw new IllegalArgumentException();
        lower.set(i);
    }
    
    public void setUpper(int i) {
        if (i < lower.get())
            throw new IllegalArgumentException();
        upper.set(i);
    }
    
    // ...
}

내부 변수를 외부에 공개

  • 상태를 저장하는 변수가 스레드 안전하고 클래스 내부에서 상태 변수 값에 대해 의존성을 갖지 않는다면, 외부에서 어떤 연산을 수행하더라도 잘못된 상태가 될 일이 없으므로 공개해도 된다.

스레드 안전 클래스에 기능 추가

  • 스레드 안전하게 구현된 클래스에 더 필요한 기능이 있는 상황이라면, 기존 클래스에 메서드를 추가하거나 클래스를 상속받아 하위 클래스에서 구현하면 된다.

  • 하위 클래스에서 구현하는 경우 상위 클래스의 동기화 방식이 달라지는 경우 적절히 수정해주어야 한다.

도우미 클래스

  • 앞서 다룬 방식들을 사용할 수 없다면 도우미 클래스를 통해 추가 기능을 구현할 수 있다. 이 때 클라이언트 측 락 혹은 외부 락을 통해 내부 클래스가 도우미 클래스와 동일한 락을 사용하도록 하여 스레드 안전성을 확보해야 한다.

  • 아래 코드는 클라이언트 측 락을 사용해 새로운 기능을 스레드 안전하게 추가한 예시이다.

public class ListHelper<E> {
    public List<E> list = Collections.synchronizedList(new ArrayList<E>());
    
    // ...
    
    public boolean putIfAbsent(E x) {
        synchronized (list) {
            boolean absent = !list.contains(x);
            if (absent)
                list.add(x);
            return absent;
        }
    }
}

클래스 재구성 (composition)

  • 원본 클래스를 감싸는 클래스를 생성한 후, 원본 클래스가 갖고 있던 기능들을 새로운 메서드에서 호출하도록 하는 방식이다.

  • 원래 클래스와 다른 수준에서 락을 활용하므로 원래 클래스가 스레드 안전한지 아닌지, 동기화 정책이 바뀌는지 등에 영향을 받지 않는다.

  • 동기화 단계가 하나 더 추가되므로 전체적인 성능이 떨어질 수는 있다.

public class ImprovedList<T> implements List<T> {
    private final List<T> list;
    
    public synchronized boolean putIfAbsent(E x) {
        boolean absent = !list.contains(x);
        if (absent)
            list.add(x);
        return absent;
    }
    
    public synchronized void clear() {
        list.clear(); // 원래 클래스의 메서드를 그대로 호출하면서 동기화 정책만 적용함
    }
    
    // ...
}

동기화 정책 문서화

  • 클래스가 스레드에 안전한지, 락이 걸린 상태에서 콜백 함수를 호출하면 어떻게 되는지, 클래스의 동작 내용이 달라지는 락이 있는지 등 어디까지 스레드 안전성을 보장하는지 문서화해두어야 한다.

  • 객체를 사용하는 입장에서 코드를 충분히 알고 있다는 가정을 하면 안된다.

  • 외부 클래스에 저장하는 객체의 경우, 스레드 안전하거나 결론적으로 불변인 객체를 저장하는 것이 좋다.

Last updated