본문 바로가기
Java

CAS 알고리즘으로 이해하는 ConcurrentHashMap의 내부 동작 원리

by 정권이 내 2026. 2. 24.

오늘은 자바 백엔드 개발자라면 반드시 마주하게 되는 '동시성(Concurrency)' 문제,

그리고 이를 해결해 주는 ConcurrentHashMap과 그 이면에 숨겨진 CAS 알고리즘에 대해 깊이 있게 파헤쳐 보겠습니다.

🏗️ 도입: 왜 '동시성'을 고민해야 할까?

우리가 만드는 Spring Boot 애플리케이션은 기본적으로 멀티 스레드(Multi-thread) 방식으로 동작합니다. 우리가 작성한 @Service@RestController는 싱글톤으로 관리되는데, 수많은 사용자가 동시에 접속해 이 빈(Bean)의 상태를 수정하려고 하면 데이터가 꼬이는 현상이 발생합니다.

예를 들어, 실시간 이벤트 선착순 응모인기 게시글의 조회수 카운팅 기능을 일반적인 HashMap으로 구현하면 어떻게 될까요? 운이 좋으면(?) 값이 조금 틀리는 정도로 끝나지만, 최악의 경우 데이터 유실이나 무한 루프에 빠져 CPU 점유율이 100%를 찍으며 서버가 멈춰버릴 수도 있습니다.

이런 멀티 스레드 환경에서 안전하게 데이터를 관리하기 위해 ConcurrentHashMap은 선택이 아닌 필수입니다.

⚠️ 시행착오: 초보 개발자가 흔히 하는 실수

"Thread-safe한 자료구조를 썼으니 이제 안전하겠지?"라고 생각하는 순간 버그는 시작됩니다.

1. "조회 후 수정(Check-then-Act)"의 함정

// 위험한 코드: ConcurrentHashMap을 써도 Thread-safe 하지 않음!
if (!map.containsKey(key)) { // 1. 있는지 확인
    map.put(key, value);     // 2. 없으면 넣기
}

ConcurrentHashMap의 개별 메서드(put, get)는 원자적이지만, 위 코드처럼 '확인하고 넣는' 전체 과정은 원자적이지 않습니다. 1번과 2번 사이의 찰나의 순간에 다른 스레드가 데이터를 새치기해서 넣어버릴 수 있기 때문이죠.

2. Collections.synchronizedMap의 성능 저하

HashMap 전체에 통째로 락(Lock)을 걸어버리는 방식은 사용자가 많아질수록 병목 현상을 일으킵니다.

한 명이 지도를 보는 동안 나머지 수천 명은 줄을 서서 기다려야 하는 상황과 같습니다.

💡 핵심 설명 1: CAS(Compare-And-Swap) 알고리즘

ConcurrentHashMap이 성능과 안전성을 모두 잡은 비결 중 하나는 CAS 알고리즘입니다. 전통적인 방식은 데이터에 접근할 때 락을 걸어 다른 스레드를 막지만, CAS는 일단 수정을 시도하고 "내가 알던 값이 맞나?"를 확인한 뒤에만 최종 반영을 하는 낙관적(Optimistic) 방식입니다.

CAS의 3가지 핵심 요소

CAS 연산이 성공하려면 다음 세 가지 정보가 필요합니다.

  • V (Value): 현재 메모리에 저장된 실제 값
  • E (Expected Value): 내가 알고 있는, 업데이트 전의 예상 값
  • N (New Value): 업데이트하려는 새로운 값

작동 메커니즘과 스핀 락(Spin-lock)

  1. 비교(Compare): 메모리의 실제 값($V$)이 내가 예상한 값($E$)과 일치하는지 확인합니다.
  2. 교체(Swap): 일치한다면 안전하게 $V$를 $N$으로 교체합니다. 만약 다르다면 교체하지 않고 실패를 반환합니다.
  3. 재시도: 실패하면 포기하는 게 아니라 성공할 때까지 무한 반복을 시도합니다.
// AtomicInteger의 내부 동작 원리 (의사 코드)
public int incrementAndGet() {
    while (true) {
        int current = get();           // 1. 현재 값 확인 (E)
        int next = current + 1;        // 2. 새 값 계산 (N)
        if (compareAndSwap(current, next)) { // 3. CAS 시도!
            return next;               // 성공하면 반환
        }
        // 실패하면 다시 루프를 돌며 시도
    }
}

CAS의 장단점

  • 장점: 스레드를 멈추고 깨우는 컨텍스트 스위칭 비용이 없고, 자원을 점유하지 않아 데드락 위험이 없습니다.
  • 단점: 다른 스레드가 값을 $A \to B \to A$로 바꿨을 때 알아채지 못하는 ABA 문제가 발생할 수 있으며, 경쟁이 심할 경우 CPU 부하(Busy-wait)가 증가할 수 있습니다.

💡 핵심 설명 2: 상황별 메서드 활용 가이드

실무에서는 단순히 put, get 대신 아래 메서드들을 적재적소에 사용해야 합니다.

① 값이 없을 때만 초기화할 때 (putIfAbsent)

중복 로그인을 방지하거나 특정 키에 대한 초기 세션을 생성할 때 유용합니다.

// 세션이 없으면 새로 생성해서 저장 (있으면 기존 값 반환)
String existingSession = userSessions.putIfAbsent("user_123", "SESSION_A");

if (existingSession != null) {
    System.out.println("이미 로그인된 사용자입니다.");
}

② DB 조회 등 무거운 로직이 동반될 때 (computeIfAbsent)

캐싱 로직의 정석입니다. 키가 없을 때만 람다식이 실행되어 불필요한 DB 조회를 차단합니다.

// 'user_123'이 맵에 없을 때만 딱 한 번 실행됨 (Thread-safe)
User user = userCache.computeIfAbsent("user_123", key -> userRepository.findById(key).orElseThrow());

③ 실시간 통계 및 합산이 필요할 때 (merge)

여러 스레드가 동시에 숫자를 올릴 때 Race Condition을 방어하는 가장 우아한 방법입니다.

// 기존 값이 없으면 1L 저장, 있으면 기존 값에 1L을 더함
likeCounts.merge(postId, 1L, Long::sum);

④ 기존 값을 바탕으로 복잡한 객체를 수정할 때 (compute)

단순 합산이 아니라 객체의 상태를 조건부로 변경할 때 사용합니다.

cartMap.compute("product_A", (key, item) -> {
    if (item == null) return new CartItem(1); 
    item.setCount(Math.min(item.getCount() + 1, 10)); // 최대 10개 제한
    return item;
});

⚠️ 주의점: computeIfAbsent 같은 메서드의 람다식 내부에서 맵을 다시 수정하거나 무거운 연산을 수행하지 마세요. 잘못하면 데드락(Deadlock)이 발생할 수 있습니다.

✅ 마무리: 오늘의 3줄 요약

  1. Spring Boot는 멀티 스레드 환경이므로 공유 데이터 관리 시 반드시 동시성을 고려해야 한다.
  2. ConcurrentHashMap은 CAS 알고리즘을 통해 무거운 락 없이도 안전하고 빠른 성능을 제공하는 '사기 캐릭터'다.
  3. 단순 put 보다는 computeIfAbsent, merge 등 원자적 연산 메서드를 사용해야 데이터 정합성을 완벽히 지킬 수 있다.
반응형

댓글