Posted on

Stack 대신 ConcurrentHashMap을 권장하는 이유 (Java 동기화 방식 비교)

들어가며

Stack은 쓰지 말라고 하고 ConcurrentHashMap(이하 CHM)은 권장하는 듯한 뉘앙스를 볼 수 있다. 둘 다 동기화 처리가 되어 있는 JCF인데 어째서 다르게 받아들이는가?

Stack은 메소드 단위로 synchronized를 걸고, CHM은 특정 구간에만 synchronized 블록을 만들어 처리한다. 그렇다면 이 차이가 실제로 무엇을 바꾸는가? 스택과 맵은 애초에 다른 자료구조라 일대일로 비교할 대상은 아니지만, 동기화를 어떤 단위로 거느냐는 설계 축에서는 좋은 대조군이 된다. 이 글은 그 축만 놓고 둘을 살펴본다.

이 글에서 설명하는 CHM의 구현은 Java 8 이후 기준이다. Java 7 이하는 Segment extends ReentrantLock을 사용하는 다른 구조였다.

Stack의 synchronized 방식

Stack의 공개 연산은 모두 인스턴스 단위의 락으로 보호된다. pop, peek, search에는 synchronized가 직접 붙어 있고, pushempty는 키워드가 없지만 내부에서 Vector.addElement, Vector.size 같은 synchronized 메소드에 위임한다. 어느 쪽이든 락이 걸리는 대상은 스택 인스턴스 자체다.

메소드 단위로 락을 건다는 것은 메소드가 반환되는 순간 락이 풀린다는 뜻이다. 따라서 스택이 비었는지 확인하고 거기서 pop을 하는 것은 하나의 락 안에서 처리되는 것이 아니라 각각 별개의 락으로 처리된다. 개별 연산은 안전하지만, 두 연산을 이어 붙인 코드는 그 사이가 비어 있다. 그래서 synchronized가 스택에 선언되어 있음에도 불구하고 아래와 같은 코드는 멀티스레드 환경에서 충분히 오류가 발생할 수 있다.

// 원소가 하나 들어있는 공유 스택
Stack<Item> stk = getSharedStack();

if (!stk.isEmpty()) {
    stk.pop();
}

위와 같은 코드에서 stk의 크기가 스레드 A가 최초 진입한 시점에 1이라고 가정하자. 두 스레드는 각 연산의 시작과 끝에서만 락을 잡았다 놓으므로, 연산과 연산 사이에는 락이 유지되지 않는다.

시각스레드 A스레드 Bstk.size()
t1isEmpty() -> false, 락을 잡았다 바로 놓음락 대기1
t2다음 연산 진입 전A가 락을 놓았으므로 락을 획득해 stk.pop() 수행0
t3stk.pop() -> EmptyStackException0

CHM의 synchronized 방식

CHM은 synchronized 블록을 쓴다고 했다. CHM은 내부적으로 (n - 1) & hash로 계산된 인덱스마다 버킷을 두고, 버킷의 배열로 원소를 저장한다. 버킷이 비어 있으면 synchronized 블록에 들어가지 않고 CAS로 노드를 꽂는다. synchronized 블록에 가는 케이스는 버킷에 이미 노드가 존재하는 경우인데, 이때 락을 거는 대상은 버킷의 첫번째 노드이다. 아래는 remove(삭제 메소드)의 실제 구현을 단순화한 코드이다.

// CHM.remove를 단순화 한 의사 코드
public V remove(Object key) {
    int i = getIndex(key);

    // 1. [스핀 루프] 작업이 완료될 때까지 계속 재시도
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f = tabAt(tab, i); // 버킷의 현재 Head 읽기 (Volatile Read)

        if (f == null) {
            return null; // 삭제할 대상 없음 (락 없이 즉시 종료)
        }

        // 2. [세분화된 락] 전체 테이블이 아닌, 해당 버킷의 Head(f)에만 락을 검
        synchronized (f) {

            // 3. [이중 검증] 락을 얻는 대기 시간 동안 Head가 바뀌지 않았는가?
            if (tabAt(tab, i) == f) {
                
                // Head 노드 자체가 삭제 대상인 경우
                if (isTarget(f, key)) {
                    setTabAt(tab, i, f.next); // 배열의 Head를 다음 노드로 교체
                    return f.val;              // 성공! 루프 탈출
                }

                // 연결 리스트 내부 노드가 삭제 대상인 경우
                for (Node<K,V> prev = f, e = f.next; e != null; prev = e, e = e.next) {
                    if (isTarget(e, key)) {
                        prev.next = e.next;   // 리스트에서 e 노드 제거
                        return e.val;          // 성공! 루프 탈출
                    }
                }

                return null; // 리스트 끝까지 찾았으나 대상 없음
            }
        }
        // 4. [검증 실패 시] 락을 얻었더니 Head가 바뀌었다면(tabAt != f)
        // 아무것도 하지 않고 for 루프의 처음으로 돌아가 변경된 최신 Head로 다시 시도!
    }
}

락을 거는 대상이 버킷의 첫 노드라는 것은 다른 버킷에는 락을 걸지 않는다는 의미이다. 따라서 락의 범위가 버킷 단위로 잘게 쪼개져 있다. 버킷이 비어 있는 경우는 아예 락을 걸지도 않고, 조회(get)는 volatile 읽기만 하므로 락 자체가 등장하지 않는다.

Stack과 CHM의 동기화 방식 차이

만약 CHM이 Stack처럼 메소드 단위 락을 걸었다면, 서로 다른 버킷을 건드리는 삽입끼리도 줄을 서야 하고 조회 연산마저 락을 기다려야 했을 것이다. 락의 범위를 버킷 단위로 좁힌 덕분에 CHM은 그 비용(성능 하락 등)을 내지 않는다.

다만 이것만으로 “그래서 CHM은 마음 놓고 써도 된다”가 되지는 않는다. CHM도 앞의 스택과 같은 형태로 연산을 조합하면 똑같이 깨지기 때문이다.

// CHM이어도 이 코드는 안전하지 않다
if (!map.containsKey(key)) {
    map.put(key, value);   // 그 사이에 다른 스레드가 put 할 수 있다
}

진짜 차이는 여기서 갈린다. CHM은 putIfAbsent, computeIfAbsent, merge처럼 확인과 실행을 하나의 원자 연산으로 묶어주는 메소드를 제공한다. 반면 Stack에는 “비어 있지 않으면 꺼낸다”를 한 번에 처리해주는 메소드가 없다. 사용자가 직접 if(check) { act }를 조합할 수밖에 없고, 그 조합은 락으로 보호되지 않는다. 멀티스레드 간 스레드 세이프가 필요한 로직에 CHM은 권장되나 스택의 사용은 권장하지 않는 것은 이 API 설계의 차이 때문이다.

핵심은 락의 문법이 아니라 원자 단위의 크기

중요한 것은 synchronized를 메소드에 썼느냐, 블록으로 선언했느냐가 아니다. 개별 연산만 놓고 보면 Stack.pop()CHM.remove()도 똑같이 원자적이다. 갈리는 지점은 여러 연산을 묶었을 때 그 묶음까지 원자적이냐이다.

데이터베이스 트랜잭션에 빗대면 이해가 쉽다. 트랜잭션은 다 되든지 안 되든지가 보장되어야 하고(원자성, Atomicity), 처리 중인 트랜잭션끼리 서로의 중간 상태를 보지 않아야 한다(격리성, Isolation). isEmpty() 다음에 pop()을 부르는 코드는 이 두 가지를 모두 잃는다. 확인과 실행 사이가 열려 있고, 그 틈으로 다른 스레드가 만든 중간 상태가 새어 들어온다. CHM의 computeIfAbsent는 같은 일을 하나의 단위로 묶어 그 틈을 없앤다. 결국 스레드 세이프한 자료구조를 고르는 일은 “락이 걸려 있는가”가 아니라 “내가 필요로 하는 작업의 크기만큼 원자성이 보장되는가”를 묻는 일이다.

마치며

JCF를 들여다볼 때마다 느끼지만 쉬운 접근성 때문에 사용하는 것에 비해 그 내부 설계에는 배울 점이 많은 소재인 것 같다. CAS는 낙관적 락과 유사해 보이고, synchronized는 비관적 락과도 유사해 보인다. 동기화를 어떤 단위로, 얼마나 넓게 걸 것인가라는 설계 판단을 여기서 배울 수 있다.