개요
채팅 프로그램을 만들 때 유저 세션정보를 저장하는 리스트에 대해 동시성 문제를 고민했습니다. 동시에 두 유저가 접속했을때 동기화가 되지 않으면 한명의 유저가 리스트에 정상적으로 등록되지 않을 가능성이 있기 때문입니다.
이를 스레드 안전성이라 합니다.
ArrayList에 동시에 두 스레드가 접근하게 되면 스레드 안전하지 않은 상태가 됩니다.
동기화는 이를 방지하기 위해 한 자원에 하나의 스레드만 접근할 수 있게 해줍니다.
그래서 동기화를 지원하는 리스트에 대해 알아봤습니다.
CopyOnWriteArrayList
값을 변경할때 내부의 값을 복사해서 복사본을 통해 변경합니다.
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1);
es[len] = e;
setArray(es);
return true;
}
}
보시면 array를 가져와 복사한 뒤에 복사본을 수정하여 세팅하는 모습을 보실 수 있습니다. (트랜잭션 격리성 레벨이 repeatable read일때 중간에 커밋된 데이터가 정합성을 깨트리는 위험을 막기 위해 스냅샷을 찍는 모습과 유사합니다.)
수정 작업이 이뤄질때 이렇게 복사본을 이용하기 때문에 조회 작업은 별도의 동기화 과정을 거치지 않습니다. 그래서 읽기 작업이 빈번한 작업을 수행할때 이 CopyOnWriteArrayList를 효율적으로 사용할 수 있습니다. 반면 쓰기 작업이 빈번한 작업의 경우 일일히 복사본을 만들어 수행하기 때문에 효율이 떨어집니다.
public E get(int index) {
return elementAt(getArray(), index);
}
SynchronizedList
읽기 작업, 쓰기 작업 모두 동기화 과정을 거친 뒤 수행하는 방식입니다.
public E get(int index) {
synchronized (mutex) {return list.get(index);}
}
public E set(int index, E element) {
synchronized (mutex) {return list.set(index, element);}
}
public void add(int index, E element) {
synchronized (mutex) {list.add(index, element);}
}
public E remove(int index) {
synchronized (mutex) {return list.remove(index);}
}
보시면은 모든 작업에 동기화가 걸려 있는 걸 확인할 수 있습니다. 모든 접근에 동기화가 되어 있으니 읽기 작업은 CopyOnWriteArrayList보다 효율이 떨어지겠지만 쓰기 작업은 더 나을 것이라는 걸 확인할 수 있습니다.
성능 비교
어떤게 좋을지 대충 확인해보긴 했는데 정확히 몇 건의 데이터일때 얼마만큼의 차이가 나는지 디테일한 부분이 궁금하여 jmh 라이브러리를 사용한 성능 테스트를 진행해봤습니다.
@State(Scope.Thread)
@BenchmarkMode(Mode.Throughput) // 초당 수행횟수
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 밀리초 단위로 결과출력
@Warmup(iterations = 2) // 워밍업
@Measurement(iterations = 5) // 실제 반복 횟수
public class ListBenchmark {
private List<String> copyOnWriteList;
private List<String> synchronizedList;
@Setup(Level.Invocation)
public void setup() {
}
@Benchmark
public void testCopyOnWriteArrayList() {
//초기화
copyOnWriteList = new CopyOnWriteArrayList<>();
for(int i=0; i<100; i++) {
copyOnWriteList.add("user" + i);
}
for(int i=0; i<copyOnWriteList.size(); i++) {
copyOnWriteList.get(i);
}
}
@Benchmark
public void testSynchronizedList() {
//초기화
synchronizedList = Collections.synchronizedList(new ArrayList<>());
for(int i=0; i<100; i++) {
synchronizedList.add("user" + i);
}
for(int i=0; i<synchronizedList.size(); i++) {
synchronizedList.get(i);
}
}
}
100개의 데이터를 저장한 다음에 크기만큼 조회한 결과입니다.
Benchmark Mode Cnt Score Error Units
sChatTest.pararrelTest.ListBenchmark.testCopyOnWriteArrayList thrpt 5 336.628 ± 110.245 ops/ms
sChatTest.pararrelTest.ListBenchmark.testSynchronizedList thrpt 5 385.350 ± 14.291 ops/ms
오차 범위가 좀 크긴하지만 성능은 그래도 비슷비슷 해보입니다. 그러면 수치를 100에서 10,000으로 조정해보겠습니다.
Benchmark Mode Cnt Score Error Units
sChatTest.pararrelTest.ListBenchmark.testCopyOnWriteArrayList thrpt 5 0.061 ± 0.006 ops/ms
sChatTest.pararrelTest.ListBenchmark.testSynchronizedList thrpt 5 4.138 ± 0.078 ops/ms
비슷했던 성능이 수치를 높이자 유의미한 차이를 나타냈습니다. 두 테스트 간 100건보다 10,000건이 더 짧은 이유는 jvm 내부의 최적화나 가비지 컬렉션 등의 영향 때문에 오히려 표본 수가 적을수록 시간도 더 걸리고 오차범위가 더 큰 것입니다.
결국 같은 개수의 데이터를 쓰기 작업, 읽기 작업을 할 때 CopyOnWriteArrayList의 성능이 SynchronizedList의 성능보다 더 뛰어난 듯 보입니다.
그럼 이제 마지막으로 세션을 조회하고 세팅하는 단계와 흡사하게 데이터를 세팅했습니다.
@Benchmark
public void testCopyOnWriteArrayList() {
copyOnWriteList = new CopyOnWriteArrayList<>();
//접속
for(int i=0; i<10; i++) {
copyOnWriteList.add("user"+i);
//접속자 리스트 생성, 메시지 전달
for(int j=0; j<2; j++) {
for(int k=0; k<copyOnWriteList.size(); k++) {
// 메세지 전송
copyOnWriteList.get(k);
}
}
}
}
Benchmark Mode Cnt Score Error Units
sChatTest.pararrelTest.ListBenchmark.testCopyOnWriteArrayList thrpt 5 6.101 ± 1.736 ops/ms
sChatTest.pararrelTest.ListBenchmark.testSynchronizedList thrpt 5 1.637 ± 0.394 ops/ms
생각했던 것과는 반대의 결과가 나왔습니다. for문이 돌면서 유저 한명이 리스트에 들어오면 채팅 참여자 목록을 각각의 유저들에게 뿌려주기 위해 해당 리스트의 사이즈만큼 돌면서 조회를 합니다. 2번 반복한 이유는 참여자 목록을 만들때 조회하느라 한번, 만든 목록을 참여자들에게 뿌려주는 것 한번입니다.
테스트 코드를 좀 바꿔도 봤습니다.
@Benchmark
@Threads(10)
public void testCopyOnWriteArrayList() {
//접속
copyOnWriteList.add("user");
for(int k=0; k<copyOnWriteList.size(); k++) {
// 유저 리스트 만들기 위해 조회
copyOnWriteList.get(k);
}
for(int k=0; k<copyOnWriteList.size(); k++) {
// 각각 유저에게 뿌리기 위해 조회
copyOnWriteList.get(k);
}
}
@Benchmark
public void testCOWAChat() {
//10명의 유저가 총 100개의 채팅을 침
for(int i=0; i<100; i++) {
for(int k=0; k<copyOnWriteList.size(); k++) {
// 각각 유저에게 뿌리기 위해 조회
copyOnWriteList.get(k);
}
}
}
10명의 유저가 10개의 스레드를 통해 동시에 접속하고 총 100번의 채팅을 쳤을때 10명의 유저에게 뿌려주기 위해 조회를 수행합니다. synchronizedlist도 동일하게 수행해주었습니다.
Benchmark Mode Cnt Score Error Units
sChatTest.pararrelTest.ListBenchmark.testCOWAChat thrpt 5 7119.392 ± 1180.883 ops/ms
sChatTest.pararrelTest.ListBenchmark.testCopyOnWriteArrayList thrpt 5 6.233 ± 4.368 ops/ms
sChatTest.pararrelTest.ListBenchmark.testSLChat thrpt 5 1086.037 ± 109.730 ops/ms
sChatTest.pararrelTest.ListBenchmark.testSynchronizedList thrpt 5 2.988 ± 2.295 ops/ms
역시나 쓰기, 읽기 작업이 동시에 수행되는 경우, 특히 멀티스레딩 환경에서 copyOnWriteArraylist의 성능이 생각보다 많이 떨어집니다. 제가 jmh를 이번에 처음 다뤄보느라 설정 문제가 있을까 체크해봤지만 발견하지 못했습니다. 일단 synchronizedlist를 사용해볼 생각입니다.
회고
@Setup 어노테이션은 벤치마크 테스트를 들어가기 전 사전 세팅해주는 공간입니다. 처음에 의외였던 점은 세팅 시 5000개의 데이터를 추가하고 각각의 읽기 작업을 테스트 했습니다. 벤치마크 테스트 하는 부분에서 읽기 작업만 하다보니 당연히 CopyOnWriteArrayList가 훨씬 성능이 뛰어날 것이라 생각했지만
Benchmark Mode Cnt Score Error Units
sChatTest.pararrelTest.ListBenchmark.testCopyOnWriteArrayList thrpt 5 22.116 ± 69.176 ops/ms
sChatTest.pararrelTest.ListBenchmark.testSynchronizedList thrpt 5 4.531 ± 5.972 ops/ms
반대의 결과가 나왔습니다. 오차범위도 훨씬 컸고 오락가락한 결과가 나온 것을 볼 수 있는데 이는 셋업 단계에서 쓰기 작업을 수행할 때 복사한 데이터가 읽기 작업 과정에 영향을 미치는 것임을 알 수 있습니다. 반면에 모든 작업을 동기화하여 진행하는 synchronizedList는 안정적인 수치를 보여줍니다.
또한 멀티스레딩 환경에서 synchronizedlist는 순회 조회할 때 다른 곳에서 해당 리스트를 수정한다면 동시성 예외인 ConcurrentModificationException이 발생하게 됩니다. 그래서 for문과 같이 순회하는 부분은 synchronized 를 통해 동기화 작업을 추가로 거쳐야 합니다. 반면 copyOnWriteArrayList는 쓰기 작업 시 복사하여 작업하기 때문에 읽기 작업에 이런 동기화에서 자유롭습니다. 대신에 이렇게 동시성 문제가 발생할 경우 수정 전 데이터를 조회하게 됩니다.
'JAVA > 요점정리' 카테고리의 다른 글
헥사고날 아키텍처를 사용한 스프링 부트 로그인 API 구현해보기 (0) | 2024.07.12 |
---|---|
구글 클라우드(GKE), 쿠버네티스, 도커 사용하여 스프링부트 프로젝트 배포하기(맥os M3 기준) (1) | 2024.07.04 |
클린 아키텍쳐(clean architecture)란? 책을 읽고나서 (0) | 2024.07.02 |
자바 스프링부트 테스트코드 작성 JUnit5 그리고 Mock (0) | 2024.06.18 |
스프링 부트 초기세팅(Spring initializer, gradle kotlin, MariaDB, IntelliJ, Java17) (1) | 2024.06.14 |
댓글