동시성 안전한 Set 사용하기: HashSet vs. ConcurrentHashMap.newKeySet
웹 애플리케이션 띄워 특정 키워드를 받아야 하는 상황이라면 메모리에 띄워서 받아오는 것을 택할 수 있다. 이때 단순한 HashSet을 써도 되는지 혹은 특정 시점에서 synchornized 처리를 따로 해야되는지 의문일때가 있다. HashSet을 쓴다면 동시성면에서 안전하지 않다.
프로젝트를 진행하며 특히 Spring WebFlux와 같이 비동기 및 논블로킹 환경에서 동시성 안전한 Set을 사용하는 방법은 매우 중요하다. 이번 글에서는 HashSet 대신 동시성에서 안전한 Collections.synchronizedSet와 ConcurrentHashMap.newKeySet를 비교하여 어떤 상황에서 어떤 방법이 더 효율적인지 알아보자.
1. HashSet에서 동기화 사용하기 (Collections.synchronizedSet)
기존의 HashSet을 동시성 안전하게 만드는 방법 중 하나는 Collections.synchronizedSet을 사용하는 것이다. 이는 내부적으로 모든 메서드 호출을 동기화하여 스레드 안전성을 보장한다.
Set<String> synchronizedSet = Collections.synchronizedSet(new HashSet<>());
동작 방식:
- 모든 메서드 호출을 synchronized 키워드로 동기화한다.
- 단일 잠금을 사용하여 모든 접근을 제어한다.
예시:
public boolean add(E e) {
synchronized (mutex) {
return s.add(e);
}
}
장점:
- 구현이 간단하고 기존의 HashSet을 쉽게 동기화할 수 있다.
단점:
- 성능 오버헤드: 모든 메서드 호출이 동기화되어야 하므로, 높은 경쟁이 발생하는 경우 성능이 저하된다.
- 확장성 부족: 많은 스레드가 동시에 접근하는 환경에서 성능이 좋지 않을 수 있다. 스레드 간의 경쟁이 많아지면, 동기화로 인한 병목 현상이 발생할 수 있다.
2. ConcurrentHashMap에서 newKeySet 사용하기
ConcurrentHashMap을 기반으로 하는 newKeySet은 동시성 제어를 더욱 효율적으로 처리한다. 내부적으로 세그먼트 잠금과 CAS(Compare-And-Swap) 기술을 사용하여 높은 동시성을 제공한다.
Set<String> concurrentSet = ConcurrentHashMap.newKeySet();
동작 방식:
- 내부적으로 세그먼트 잠금과 CAS 연산을 사용하여 동시성을 제어한다.
- 키와 값을 여러 세그먼트로 나누어 부분적인 잠금을 사용한다.
장점:
- 높은 성능: 높은 동시성을 제공하며, 많은 스레드가 동시에 접근해도 성능이 우수하다.
- 확장성: 스레드 수가 증가해도 성능이 상대적으로 안정적으로 유지된다.
단점:
- 메모리 사용량이 상대적으로 많을 수 있다. 그러나 이는 보통 성능상의 장점에 비해 작은 trade-off이다.
효율성 비교: 동작 방식의 차이
Collections.synchronizedSet의 동기화 방식
Collections.synchronizedSet은 단일 잠금을 사용하여 모든 접근을 동기화한다. 이는 경쟁이 많은 상황에서 병목 현상을 초래할 수 있다.
synchronizedSet.add(value);
위 코드는 synchronized 키워드로 인해 단일 스레드만 접근할 수 있다.
ConcurrentHashMap.newKeySet의 동기화 방식
ConcurrentHashMap.newKeySet은 세그먼트 잠금과 CAS를 사용하여 여러 스레드가 동시에 다른 세그먼트에 접근할 수 있게 한다. 이는 병목 현상을 줄여주고 성능을 높여준다.
concurrentSet.add(value);
위 코드는 내부적으로 세그먼트 잠금과 CAS를 사용하여 다수의 스레드가 동시에 접근할 수 있게 한다.
세그먼트 잠금과 CAS 연산 이해하기
ConcurrentHashMap의 내부 동작을 이해하려면, 세그먼트 잠금과 CAS(Compare-And-Swap) 연산이 무엇인지, 그리고 이들이 어떻게 동작하는지 알아야 한다. 이를 쉽게 이해할 수 있도록 예를 들어 설명해보자.
1. 세그먼트 잠금(Segment Locking)
세그먼트 잠금은 ConcurrentHashMap이 내부적으로 데이터를 여러 개의 세그먼트로 나누고, 각 세그먼트에 대해 별도의 잠금을 사용하는 방식이다. 이를 통해 병렬성을 높이고, 전체 성능을 향상시킬 수 있다.
예시:
- 하나의 데이터는 큰 사무실의 여러 파일 캐비닛에 저장되어 있다. 각각의 캐비닛은 특정한 범위의 데이터를 저장하고 있다.
- HashMap에서는 사무실에 들어가는 문에 하나의 잠금 장치가 있어, 누군가 문을 사용하고 있다면 다른 사람은 기다려야 한다. 이는 모든 작업이 일어날 때마다 하나의 잠금을 사용하기 때문에 비효율적이다.
- 반면에, ConcurrentHashMap은 각 파일 캐비닛에 개별적인 잠금 장치를 둔다. 즉, 하나의 캐비닛에서 작업을 하고 있는 동안 다른 캐비닛은 자유롭게 접근할 수 있다. 이는 다수의 작업이 동시에 이루어질 수 있게 하여 전체적인 병목 현상을 줄인다.
동작 방식:
- 데이터가 여러 세그먼트로 분할된다.
- 각 세그먼트는 독립적인 잠금 메커니즘을 가지고 있다.
- 예를 들어, 스레드 A는 세그먼트 1에 접근하고, 스레드 B는 세그먼트 2에 접근할 수 있다. 이 경우 두 스레드는 서로 간섭하지 않고 동시에 작업을 수행할 수 있다.
2. CAS 연산(Compare-And-Swap)
CAS(Compare-And-Swap) 연산은 데이터 업데이트를 원자적으로 수행하기 위해 사용되는 기술이다. 이는 기존의 값을 읽고, 예상한 값과 실제 값이 일치할 때만 새로운 값으로 교체하는 방식이다.
예시:
- 카운터가 있는 티켓 부스에서 일하고 있하고 있다고 상상해보자. 티켓 부스는 현재 팔린 티켓 수를 추적한다.
- CAS는 다음과 같은 시나리오로 이해할 수 있다:
- 현재 티켓 수를 읽는다. 예를 들어, 현재 티켓 수가 10이라고 가정한다.
- 티켓을 한 장 팔아서, 현재 티켓 수를 11로 업데이트하려고 한다.
- CAS 연산은 "현재 티켓 수가 10이라면 11로 업데이트하세요"라고 말한다.
- 만약 누군가 동시에 티켓을 팔아서 이미 11로 업데이트했다면, CAS 연산은 실패하고, 현재 값을 다시 읽어서 같은 절차를 반복한다.
동작 방식:
- 특정 메모리 위치의 값을 읽는다.
- 예상 값과 실제 값을 비교한다.
- 값이 일치하면 새로운 값으로 원자적으로 교체한다. 값이 일치하지 않으면 작업을 다시 시도한다.
synchronized 사용의 필요성?
WebFlux와 같은 비동기 및 논블로킹 환경에서는 일반적으로 synchronized 블록을 사용하는 것이 권장되지 않는다. 그러나 변수의 초기화와 재할당 부분에서 동시성 문제가 발생할 가능성이 있기 때문에, 특정 상황에서는 synchronized 블록이 필요할 수 있다.
변수 초기화와 재할당에서의 동시성 문제
비동기 환경에서는 여러 스레드가 동시에 변수를 읽고 쓰기 때문에, 예상치 못한 동시성 문제가 발생할 수 있다. 특히, lateinit var로 선언된 변수의 경우 초기화되지 않은 상태에서 접근하면 문제가 발생할 수 있다. 예를 들어, lateinit var pcWhiteList: Set 변수를 여러 스레드가 동시에 초기화하거나 재할당할 때 문제가 발생할 수 있다.
lateinit var mySet: Set<String>
private fun example() {
getTestApi().flatMap { result ->
testClient.getAllKeywords(result)
}.subscribe { keywords ->
synchronized(this) {
mySet = keywords.toSet()
}
}
}
위 코드는 synchronized(this) 블록을 사용하여 mySet 변수의 재할당을 동기화한다. 이를 통해 여러 스레드가 동시에 접근할 때 발생할 수 있는 동시성 문제를 방지할 수 있다.
* 꼭 syncrhonized(this) 로 사용해야 하나?
한 클래스 내에 여러 개의 Set이 있다면, 각각의 Set을 개별적으로 동기화하는 것이 더 효율적이다. 각 객체는 모니터를 가지기 때문에 synchronized의 인자로 줄 수 있으며 이는 동기화의 범위가 된다. 따라서 각 set으로 동기화를 한다면 synchronized(this)를 사용하는 경우보다 동시성 면에서 더 나은 성능을 제공할 . 수있다. synchronized(this)는 클래스 전체를 동기화하기 때문에 하나의 스레드가 동기화 블록에 들어가면 다른 스레드가 해당 클래스의 다른 동기화 블록에 접근하는 것도 막기 때문. 반면에, 각 Set을 개별적으로 동기화하면, 다른 스레드가 다른 Set에 접근할 수 있다.
synchronized(setA) {
// ...
}
synchronized(setB) {
// ...
}
왜 synchronized 블록이 필요한가?
synchronized 블록을 사용하면, 한 번에 하나의 스레드만 해당 블록에 접근할 수 있다. 이는 변수의 초기화나 재할당과 같은 중요한 작업에서 동시성 문제를 예방할 수 있는 간단하고 효과적인 방법이기 때문.
비동기 환경에서의 대안
비동기 환경에서 synchronized 블록에 빈번하게 접근한다면 다른 동시성 제어 방법을 사용하는 것도 좋다. 예를 들어, AtomicReference를 사용하면 동기화 없이도 안전하게 변수를 업데이트할 수 있다. AtomicReference는 원자적인 읽기 및 쓰기 작업을 지원하므로, 동시성 문제를 해결할 수 있는 다른 좋은 대안이다.
결론
ConcurrentHashMap.newKeySet은 Collections.synchronizedSet보다 더 효율적일 듯 하다. 최적화된 동시성 제어 메커니즘을 사용하여 병목 현상을 줄이고, 높은 동시성 및 성능을 제공하기 때문이다. WebFlux와 같이 비동기 및 고성능을 요구하는 환경에서는 ConcurrentHashMap.newKeySet이 더 적합한 선택일 것 같다.