Study/이펙티브 자바 / / 2024. 5. 13. 21:51

[Effective Java] 과도한 동기화는 피하라

💥 개요

과도한 동기화는 성능을 떨어뜨리고, 교착상태에 빠뜨리고 심지어 예측할 수 없는 동작을 낳기도 한다.

응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대 클라이언트에 양도하면 안 된다.

아래는 동기화 영역 안에서 해서 안되는 예시이다.

  • 재정의할 수 있는 메서드 호출
  • 클라이언트가 넘겨준 함수 객체 호출

 

⚠️ 구체적인 예시

// Broken - invokes alien method from synchronized block!
public class ObservableSet<E> extends ForwardingSet<E> {
    public ObservableSet(Set<E> set) { super(set); }

    private final List<SetObserver<E>> observers
            = new ArrayList<>();

    public void addObserver(SetObserver<E> observer) {
        synchronized(observers) {
            observers.add(observer);
        }
    }

    public boolean removeObserver(SetObserver<E> observer) {
        synchronized(observers) {
            return observers.remove(observer);
        }
    }

    private void notifyElementAdded(E element) {
        synchronized(observers) {
            for (SetObserver<E> observer : observers)
                observer.added(this, element);
        }
    }

    @Override public boolean add(E element) {
        boolean added = super.add(element);
        if (added)
            notifyElementAdded(element);
        return added;
    }

    @Override public boolean addAll(Collection<? extends E> c) {
        boolean result = false;
        for (E element : c)
            result |= add(element);  // Calls notifyElementAdded
        return result;
    }
}

위 코드는 옵저버 패턴으로 관찰자들은 addObserver와 removeObserver 메서드를 호출해 구독을 신청하거나 해지한다. 두 경우 모두 다음 콜백 인터페이스의 인스턴스를 메서드에 건넨다. 멀쩡해 보이지만 아래 예시를 더 보자

public static void main(String[] args) {
        ObservableSet<Integer> set =
                new ObservableSet<>(new HashSet<>());

        set.addObserver((s, e) -> System.out.println(e));

        for (int i = 0; i < 100; i++)
            set.add(i);
    }

간단하게 0~99까지를 출력한다.

하지만 아래처럼 집합에 추가된 정수를 출력하다 값이 23이면 자기 자신을 제거하는 경우 문제가 발생한다.

public static void main(String[] args) {
    ObservableSet<Integer> set =
            new ObservableSet<>(new HashSet<>());

    set.addObserver(new SetObserver<>() {
        public void added(ObservableSet<Integer> s, Integer e) {
            System.out.println(e);
            if (e == 23)
                s.removeObserver(this);
        }
    });

    for (int i = 0; i < 100; i++)
        set.add(i);
}

exception 발생

이 프로그램은 23까지 출력하고 오류가 발생한다. 옵저버의 added 메서드 호출이 일어난 시점이 notifyElementAdded가 옵저버들의 리스트를 순회하는 도중이기 때문이다. added 메서드는 ObservableSet의 removeObserver 메서드를 호출하고, 이 메서드는 다시 observers.remove를 호출하는데 여기서 문제가 발생한다.

리스트에서 원소를 제거하려 하는데, 그 시점에 리스트를 순회하고 있기 때문에 즉 허용되지 않는 동작이 발생한다.

notifyElementAdded 메서드에서 수행하는 순회는 동기화 블록 안에 있으므로 동시수정이 일어나지 않도록 보장하지만, 정작 자신이 콜백을 거쳐 되돌아와 수정하는 것까지 막지는 못한다.

실제 시스템(GUI)에서도 동기화된 영역 안에 외계인 메서드를 호출하여 교착 상태에 빠지기도 하고, 심지어 특정 상황에서는 응답 불가(교착상태), 안전 실패(데이터 훼손)까지 일어날 가능성이 있다.

 

💡해결 방법

아주 간단하게 이런 문제들을 해결 할 수 있다.

바로 외게인 메서드를 동기화 블록 바깥으로 옮기면 간단하게 해결이 된다.

// Alien method moved outside of synchronized block - open calls
private void notifyElementAdded(E element) {
    List<SetObserver<E>> snapshot = null;
    synchronized(observers) {
        snapshot = new ArrayList<>(observers);
    }
    for (SetObserver<E> observer : snapshot)
        observer.added(this, element);
}

이 방식으로 리스트를 복사해 쓰면 락 없이도 안전하게 순회할 수 있다.

기본 규칙은 동기화 영역에서는 가능한 한 일을 적게 하는 것이다. 오래 걸리지 않는 작업이면 동기화 영역 밖으로 옮기는 방법을 찾아보자.

 

💡 동기화의 성능 측면

자바의 동기화 비용은 빠르게 낮아져 왔지만, 과도한 동기화를 피하는 일은 오히려 가장 중요하다. 경쟁하느라 낭비되는 시간, 즉 병렬 프로그래밍으로 실행되지 못하고 모든 코어가 메모리를 일관되게 보기 위한 지연시간이 진짜 비용이다. JVM의 코드 최적화를 제한한다는 점도 동기화의 숨은 비용이다.

가변 클래스를 작성하려면 아래 선택지 중 하나를 따르자

  1. 동기화를 사용하지 않고 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화(ex.java.util)
  2. 동기화를 내부에 수행하고 thread safe한 클래스로 생성(ex.java.util.concurrent)

클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선할 수 있을 때만 두 번째 방법을 선택해야 한다.

클래스 내부 동기화로 결정했다면 아래와 같은 기법으로 동시성 향상이 가능하다.

  • 락 분할(lock splitting)
  • 락 스트라이핑(lock striping)
  • 비차단 동시성 제어(nonblocking concurrency controll 등

 

❤️ 내 생각

실제 서버 개발을 진행할 때 동기화는 자주 사용되지 않는다. 그 이유는 대부분의 시스템이 하나의 서버로 돌아가지 않기 때문이다.(Multiprocessing)동시성 처리가 중요한 시점에 java를 통해 동기화를 한다면 서버마다 동기화가 진행되는 문제가 발생하고, 이는 volatile, synchronized, atomic 등 순수 java를 활용하여 해결하기는 어렵다.

third-party 시스템이나 데이터베이스(db를 사용한다면 db-lock, redis 등 인메모리 db를 활용한 distributed lock)를 활용하는게 보편적으로 사용된다. 이 방법으로 동기화를 진행하는 편이 해결법이 될 수도 있을 것 같다. 

하지만 단일 서버 환경에서는 해당 방식도 나쁘지 않은 선택일 수 있을 것 같다.

 

교착상태와 데이터 훼손을 피하려면 동기화 영역 안에 외계인 메서드를 절대 호출하지 말자. 일반화해 이야기하면, 동기화영역 안에서의 작업은 최소한으로 줄이자. 가변 클래스를 설계할 때는 스스로 동기화해야 할지 고민하자. 멀티코어 세상인 지금 과도한 동기화를 피하는게 과거 어느 때보다 중요하다. 합당한 이유가 있을 때만 내부에서 동기화하고, 동기화했는지 여부를 문서에 명확히 밝히자.