1. 배경
Spring Bean의 기본 컨셉은 'Singleton' 이고, 이는 많은 사용자가 트래픽을 유발시키는 엔터프라이즈 환경에서 무분별한 객체 생성을 막아 메모리 관련 성능 이슈에 효율적이기 위함이다. 이러한 특징으로 인해 Spring Bean을 설계할 때는 반드시 '무상태'를 고려해야 한다.
하지만 최근 업무에서 특이사항이 생겼다. 아래와 같은 코드를 작성하게 된 것이다.
@Component public class MetricSubmiter { private GaugeService gaugeService; public MetricSubmiter(GaugeService gaugeService) { this.gaugeService = gaugeService; } private List<SomeMetric> accumulatedMetricList = ??; public void accumulateMetric(SomeMetric someMetric) { accumulatedMetricList.add(someMetric); } @Scheduled(fixedDelay = 5_000) public void submitMetrics() { SomeMetric someMetric = getWorstMetric(); // from accumulatedMetricList if (Objects.isNull(someMetric)) { return; } gaugeService.submit("metric1", someMetric.getMetric1()); gaugeService.submit("metric2", someMetric.getMetric2()); gaugeService.submit("metric3", someMetric.getMetric3()); } . . . . }
간단히 설명하면 이렇다. 특정 API에서 발생하는 metric 정보(예를 들어 응답시간과 같은)를 누적하고, 5초마다 스케줄러가 동작하여 누적된 리스트로부터 99 percentile의 metric객체를 가져와 GaugeService를 이용해 spring actuator의 'metrics'로 노출시킨다.
'MetricSubmiter'는 Singleton이지만 'accumulatedMetricList'라는 상태를 가지고 있다. 따라서 API 요청을 처리하는 스레드들, 그리고 5초마다 동작하는 스케줄러가 모두 이 'accumulatedMetricList'에 접근하기 때문에, 해당 리스트를 동기화된 리스트로 생성해야 한다. 이런 상황에서는 어떤 Collection을 선택해야 할까?
2. 두가지 방법
동기화된 ArrayList를 생성하는 방법은 아래 두가지가 있다.
- Collections.synchronizedList() // since 1.2
- new CopyOnWriteArrayList (이하 COWAL) // since 1.5
주석으로 언급했지만, 'synchronizedList'는 자바 1.2 버전 부터 존재한 것으로 보인다. 하지만 또다시 비슷한 컨셉의 COWAL이 등장한 것은 무엇 때문일까? 초창기 멀티 스레드 환경에서, SynchronizedList는 몇가지 한계를 가지고 있었다. 그 중 하나를 예를 들면, ’SyncronizedList’의 모든 읽기와 쓰기 동작은 이름에 걸맞게 ’synchronized’ 키워드로 동기화 되어 있는데, 이는 하나의 스레드만이 해당 리스트에 대해 읽기나 쓰기를 할 수 있다는 것이다. 어찌보면 매우 융통성이 없는 설계 라고 볼 수 있다.
그러므로, Java5 출시와 더불어 좀 더 유연하게 동기화된 리스트인 COWL가 등장하게 되었다.
3. 차이점
SynchronizedList와 뒤늦게 등장한 CopyOnWriteList의 주요 차이점을 정리해보자.
1) Locking of threads
SynchronizedList는 읽기와 쓰기 동작시 'synchronized' 키워드를 사용하기 때문에 리스트 자체에 lock이 걸린다. 반면 COWL는 그 이름에서 알 수 있듯이 모든 쓰기 동작(add, set, remove, etc)시, 원본배열에 있는 요소를 복사(Copy)하여 새로운 임시배열을 만들고 이 임시배열에 쓰기 동작을 수행 후 원본배열을 갱신한다. 이 덕분에 읽기(get, etc) 동작은 lock에서 자유로울 수 있게 되고, 이는 읽기 Performance에서 COWL가 SynchronizedList보다 우월한 강력한 이유다.
2) Wirte Operations
COWL은 쓰기 동작 수행시 명시적 락(ReentrantLock)을 사용한다. 결국 두 종류의 Collection 모두 이 동작에서 lock이 걸리는 것이다. 하지만 COWL는 SynchronizedList보다 쓰기 동작이 느리다. 이미 이야기 했지만 COWL은 비용이 상대적으로 높은 배열 복사 작업을 하기 때문이다. 아래를 보자.
SynchronizedList는 읽기와 쓰기 동작시 'synchronized' 키워드를 사용하기 때문에 리스트 자체에 lock이 걸린다. 반면 COWL는 그 이름에서 알 수 있듯이 모든 쓰기 동작(add, set, remove, etc)시, 원본배열에 있는 요소를 복사(Copy)하여 새로운 임시배열을 만들고 이 임시배열에 쓰기 동작을 수행 후 원본배열을 갱신한다. 이 덕분에 읽기(get, etc) 동작은 lock에서 자유로울 수 있게 되고, 이는 읽기 Performance에서 COWL가 SynchronizedList보다 우월한 강력한 이유다.
2) Wirte Operations
COWL은 쓰기 동작 수행시 명시적 락(ReentrantLock)을 사용한다. 결국 두 종류의 Collection 모두 이 동작에서 lock이 걸리는 것이다. 하지만 COWL는 SynchronizedList보다 쓰기 동작이 느리다. 이미 이야기 했지만 COWL은 비용이 상대적으로 높은 배열 복사 작업을 하기 때문이다. 아래를 보자.
더 얘기 할 것도 없다. 단순 동기화만 적용된 SynchronizedList보다 훨씬 더 많은 Overhead가 발생하는 건 당연한 이야기...
3) Behavior during Modification
일반적인 ArrayList는 'fail-fast iterator'이다. 아래의 코드를 보자.
위 코드와 같이, 반복자 동작(next메서드 호출) 도중 상태가 변경 되는 경우 'ConcurrentModificationException'을 발생 시킨다.
하지만 COWL은 아래 처럼 'fail-safe iterator'이다.
COWL가 가지고 있는 'COWIterator'의 next메서드는 상대적으로 심플한 모습이다. 딱 보면 알겠지만, 이것이 상태 변경 상황에서 Exception을 발생 시키지 않고 다시 말해 'fail-safe'한 이유는 복사한 임시배열의 참조를 가지고 있는 'snapshot'을 대상으로 반복을 수행하기 때문이다.
4) Iterating within block
3번에서 이야기한 맥락을 고려해 보면, SynchronizedList는 동기화 블록 안에서 반복이 이루어진다. 하지만 COWL는 snapshot을 대상으로 명시적 락 '밖'에서 안전하게 반복을 수행한다. 이것은 당연히 반복 성능에도 영향을 미칠 것이다.
4. Wrap up
정리해 보자. SynchronizedList는 단순히 'synchronized' 키워드를 사용한 동기화된 List이다. 따라서 모든 읽기, 쓰기 동작에 lock이 작용된다. 반복 작업 또한 동기화 블록 안에서 수행 된다. 때문에 읽기보다 쓰기 동작이 많고, 크기가 큰 리스트의 경우에 적합할 것이다.
반면에 COWL는 특유의 동작 방식으로 인해 반복작업(iterating)과 같은 읽기 동작의 Performance가 좋다. 다만 또 그 특유의 동작 방식으로 인해 쓰기 동작에서 상당한 Overhead가 발생한다. 따라서 쓰기보다 읽기 동작이 많고, 크기가 작은 리스트에 적용하는 것이 바람직하다.
. . .
이제 처음의 상황으로 돌아가 보자. 'accumulatedMetricList'의 쓰기 동작은 해당 API로 몰리는 '모든' Request에서 수행된다. 하지만 읽기 동작은 스케줄러에 의해 5초마다 수행된다. 읽기보다 쓰기가 훨씬 많으며, 모든 요청 처리시 SomeMetric 객체가 리스트에 추가 되므로 크기 또한 급속도로 커진다. 이렇게 명백한 이유로 인해 결국 'SynchronizedList'를 선택하게 되었다.
'Tomcat'과 같은 서블릿 컨테이너 및 각종 프레임워크 덕분에, 직접 작성하지 않고도 알게 모르게 멀티 스레드의 혜택을 보게 된다. 때문에 아무 생각 없이 멍때리고 있다가는 내가 작성하고 있는 코드가 이러한 멀티 스레드 환경에서 동작한다는 사실을 망각할 수 있다. 이것은 정합성이 어긋난 어플리케이션 상태를 유발시키는 실수로 이어질 수 있기에, 그 위험성을 내포하고 있다.
이번 글을 포스팅 하면서, '멀티 스레드 환경'에서의 동기화된 Collection 사용 뿐 아니라, Spring Singleton Bean 설계 또한 고찰 할 수 있었다. 우리는 여러 스레드들과 동거동락한다는 사실을 잊지 말아야 한다.