카테고리 없음

동시성 안전한 Set 사용하기: HashSet vs. ConcurrentHashMap.newKeySet

100win10 2021. 11. 7. 13:32

웹 애플리케이션 띄워 특정 키워드를 받아야 하는 상황이라면 메모리에 띄워서 받아오는 것을 택할 수 있다. 이때 단순한 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는 다음과 같은 시나리오로 이해할 수 있다:
    1. 현재 티켓 수를 읽는다. 예를 들어, 현재 티켓 수가 10이라고 가정한다.
    2. 티켓을 한 장 팔아서, 현재 티켓 수를 11로 업데이트하려고 한다.
    3. CAS 연산은 "현재 티켓 수가 10이라면 11로 업데이트하세요"라고 말한다.
    4. 만약 누군가 동시에 티켓을 팔아서 이미 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이 더 적합한 선택일 것 같다.