백엔드 시스템을 개발하다 보면 여러 Thread에서 동시에 접근해야 하는 인메모리 캐시세션 저장소를 구현해야 할 때가 있다. 이 때 가장 만만하게 쓰는 자료구조가 바로 Map이다. 필자도 그랬듯이 아무 생각 없이 HashMap을 사용했다가, 데이터 유실이나 무한 루프(Java 7 이하)로 인해 서버가 뻗어버리는 대참사를 겪게 된다.

 

1. 멀티스레드와 HashMap의 충돌

여러 스레드가 동시에 접속하는 트래픽 환경에서, 전역 변수로 선언된 HashMap에 데이터를 읽고 쓰는 상황을 가정해보자

 

Bad Code 1: 스레드 안정성(Thread-Safe)이 없는 HashMap

public class CacheService {
    // 여러 스레드가 동시에 접근하는 공유 자원
    private Map<String, Integer> cache = new HashMap<>();

    public void addCache(String key, int value) {
        // 멀티스레드 환경에서 동시에 put()이 호출되면 
        // 데이터가 덮어씌워지거나, 내부 노드 링크가 꼬여버린다.
        cache.put(key, value); 
    }
}

 

HashMap은 동기화(Synchronization) 처리가 전혀 되어 있지 않다. 두 스레드가 동시에 같은 해시 노드에 데이터를 삽입하려고 하면 경쟁 상태가 발생하여 데이터가 유실된다.

 

Bad Code 2: 무식한 동기화, HashTable과 SynchronizedMap

동시성 문제를 해결하기 위해 자바 레거시의 잔재인 HashTable이나 Collections.synchronizedMap()을 사용하는 경우

 

public class CacheService {
    // 스레드 안전성을 보장하지만 성능이 최악인 방식
    private Map<String, Integer> cache = Collections.synchronizedMap(new HashMap<>());
    // 혹은 private Map<String, Integer> cache = new Hashtable<>();

    public void addCache(String key, int value) {
        cache.put(key, value);
    }
}

 

이들은 내부적으로 모든 get(), put() 메서드에 전체 맵(Map) 단위의 잠금을 걸어버린다. 한 스레드가 데이터를 읽기만 하고 있어도, 다른 모든 스레드는 대기 상태에 빠지게 되어 심각한 병목현상을 유발한다.

 

2. 해결책 : ConcurrentHashMap

데이터의 안전성도 지키면서, 성능까지 끌어올리기 위해 ConcurrentHashMap를 사용한다.

 

public class CacheService {
    // 동시성 보장 + 뛰어난 성능
    private Map<String, Integer> cache = new ConcurrentHashMap<>();

    public void addCache(String key, int value) {
        cache.put(key, value); 
    }
    
    public int getCache(String key) {
        return cache.get(key); // 읽기 작업에는 Lock이 걸리지 않아 매우 빠르다.
    }
}

 

3. ConcurrentHashMap의 특징

전체 맵에 락을 거는 HashTable과 달리, ConcurrentHashMap은 영리한 동기화 전략을 사용한다.

 

Node 단위 부분 잠금 (Lock Striping / Node Locking)

ConcurrentHashMap은 맵 전체가 아니라, 데이터가 삽입되는 해당 노드에만 잠금을 건다. 만약 스레드 A가 1번 노드에 데이터를 쓰고 있더라도, 스레드 B가 5번 노드에 데이터를 쓰는 작업은 전혀 방해받지 않고 동시에 실행된다.

 

CAS(Compare And Swap) 알고리즘 적용

새로운 노드를 삽입할 때, 해당 노드가 비어있다면 synchronized 락을 거는 대신 가벼운 CAS 알고리즘을 사용하여 원자적으로 값을 삽입한다. CAS는 현재 메모리의 값에 내가 기대하는 값과 같을 때만 새로운 값으로 교체하는 하드웨어 지원 원자적연산으로, 락을 획득하고 해제하는 오버헤드가 없어 매우 빠르다.

 

읽기(Read) 작업은 Lock-Free

가장 중요한 장점으로 get()으로 데이터를 읽어올 때는 Lock을 전혀 사용하지 않는다. 내부 데이터 구조에 volatile 키워드를 활용하여, 항상 최신 상태의 데이터를 읽어올 수 있도록 가시성을 보장한다.

 

결론

멀티스레드 환경에서 Map 자료구조가 필요하다면, 고민하지 말고 ConcurrentHashMap를 쓰자.

 - 단일 스레드 환경 : HashMap (동기화 비용 없이 제일 빠름)

 - 멀티 스레드 환경 : ConcurrentHashMap (부분 잠금 및 CAS 알고리즘으로 안전성과 성능 동시 확보)

+ Recent posts