회사에서 은행에서 쓰일 hashMap 을 구현 중
ConcurrentHashMap 을 사용할 일이 생겨 정리했습니다.
ConcurrentHashMap 은 Java 에서 멀티스레드 환경에서 안전하게 사용할 수 있는 Map 자료구조입니다.
ConcurrentHashMap
ConcurrentHashMap 은 HashMap 과 비슷한 구조인데, 여러 스레드가 동시에 접근해도 안전하게 동작하도록 만든 동기화된 Map 입니다. 이러한 ConcurrentHashMap 은 java.util.concurrent 패키지에 포함돼어 있습니다.
Map<String, String> map = new ConcurrentHashMap<>();
왜 HashMap 을 사용하지 않는 걸까?
HashMap 은 멀티스레드 환경에서 안전하지가 않습니다.
두 개 이상의 스레드가 동시에 put, get 을 하면 내부 구조가 꼬여서 ConcurrentModificationException 이 발생하거나, 무한루프, 잘못된 값이 나올 수 있습니다.
그러면 Collections.synchronizedMap() 을 사용하면 되는 건 아닌가?라는 의문점이 생길 수 있습니다.
하지만 synchronizedMap 을 사용하면 전체 Map 을 잠가서 작업이 순차적으로 진행되기 때문에 성능이 매우 느립니다.
두 개 이상의 스레드가 동시에 put, get 이라는 말은?
정확히 말하면 "한 프로그램 안에서 동시에 실행 중인 여러 스레드(Thread)" 가 같은 Map 객체를 공유하면서 put, get 을 호출하는 경우를 말합니다.
서버(Spring, Tomcat 등) 은 클라이언트(사용자) 마다 별도의 스레드를 할당해서 요청을 처리합니다.
예를 들어 사용자 A 가 웹사이트에 로그인 요청을 하고 사용자 B도 거의 동시에 로그인 요청을 하면 서버는 스레드 1번, 스레드 2번 각각으로 A 와 B의 요청을 처리합니다.
// 서버 전체에서 공유되는 Map
private static final Map<String, Session> userSessions = new HashMap<>();
// 두 명의 사용자가 거의 동시에 로그인했을 때
public void login(String userId, Session session) {
userSessions.put(userId, session); // 둘 다 여기에 접근함
}
위 코드에서 두 사용자(A, B) 의 요청이 거의 동시에 들어오면 userSessions.put(...) 이 스레드 충돌을 일으킬 수 있습니다.
결과적으로 데이터가 꼬이거나 덮어쓰여서 문제가 발생할 수 있다는 것입니다.
ConcurrentHashMap 의 동작 방식
예전에는 Segment 라는 구조를 썼지만, Java 8 부터는 배열 + 노드 + CAS(Compare-And-Swap) 기반으로 개선됐습니다.
동기화는 필요할 때만, 그리고 최소한의 범위에서 수행되는 방식입니다.
map.put("a", "apple");
map.get("a");
map.remove("a");
위 코드에서 각 연산은 스레드 간 충돌이 거의 없도록 설계됩니다. 예를 들어 put() 시에도 HashMap synchronizedMap 처럼 전체를 잠구는 방법이 아닌 해당 키에 대한 버킷만 잠금(lock) 처리됩니다.
"전체를 잠그지 않고" 란
일반 HashMap 을 synchronized 로 감싸면 모든 작업을 한 번에 하나씩만 처리합니다.
A 스레드가 map.put("a", 1) 하는 동안, B 스레드는 map.get("b") 를 하지 못합니다.
이러한 방식은 마치 은행에 창구 하나만 있는 것과 같이 누군가 창구에서 오래 걸리면 뒤에서 다 대기하는 것과 같습니다.
그래서 "전체를 잠그지 않고" 란 Map 전체를 잠그지 말고, 일부만 잠그자! 라는 뜻입니다.
마치 은행 창구를 16개 만들어서, A 는 3번 창구 이용하고, B 는 12번 창구를 이용하면 둘이 동시에 처리가 가능하다는 뜻입니다.
ConcurrentHashMap 은 내부적으로 데이터를 버킷배열에 나눠서 저장합니다.
이 각각의 버킷(구간) 만 따로 잠금(lock) 처리합니다. 그래서 충돌이 안 나면 여러 스레드가 동시에 읽고 쓰는게 가능해져 속도 측면이나 성능측면에서 우수합니다.
배열 + 노드 + CAS 기반?
ConcurrentHashMap 내부는 큰 칸(배열) 에 여러 개의 작은 바구니(bucket) 이 있는 구조입니다.
각 바구니 안에는 Node(key, value) 들이 줄줄이 연결돼 있습니다.
그리고 여기서 CAS 가 등장합니다.
누군가 map.put("a", 1) 을 하려고 하면 먼저 그 자리에 다른 스레드가 건드렸느지 검사합니다. 이상이 없으면 쓰고 누가 바꿔버렸으면 다시 시도를 합니다. 즉, 잠금을 쓰지 않고도 안전하게 값을 바꿀 수 있는 방법입니다.
[ConcurrentHashMap]
배열 (버킷들)
┌─────────┬─────────┬─────────┬─────────┬─────────┐
│ bucket0│ bucket1│ bucket2│ bucket3│ ... │
└─────────┴─────────┴─────────┴─────────┴─────────┘
↓ ↓ ↓
Node Node Node (key-value들이 연결 리스트로 있음)
↓
CAS 연산으로 안전하게 쓰기
주요 메서드
put(K key, V value) // 값 추가 또는 덮어쓰기
get(Object key) // 값 조회
remove(Object key) // 값 삭제
putIfAbsent(K key, V value) // 키가 없을 때만 추가
computeIfAbsent(K key, Function) // 키가 없을 경우 계산하여 추가
forEach(...) // 병렬 반복 가능(Java 8 이상)
'Java' 카테고리의 다른 글
유용한 람다식 함수들 (2) | 2025.02.04 |
---|---|
자바의 반복문 종류 (1) | 2025.02.04 |
Java 의 Call by Value, Call by Reference (1) | 2025.01.06 |
간단한 에러 출력 방법과 문제점 (0) | 2025.01.02 |
자바의 람다 표현식 (1) | 2024.12.26 |