[Java] 동시성 제어가 가능한 CopyOnWriteArrayList와 일반 ArrayList의 차이점
동시성 제어가 가능한 List인 CopyOnWriteArrayList를 알아보자
📌 서론
동시성 제어가 가능한 HashMap인 ConcurrentHashMap에 대해 공부하고 사용하다 보니 List에도 동시성 제어가 가능한 클래스가 존재할 것이라는 생각이 들었고 열심히 찾아본 결과 java.util.concurrent 패키지 내부에 CopyOnWriteArrayList라는 동시성 제어가 가능한 List가 존재한다는 것을 알아냈다.
이번 포스트를 위해 우리가 개발할 때 일반적으로 사용하는 ArrayList와 동시성 제어를 해준다는 CopyOnWriteArrayList를 스프링에서 사용해 보면서 동시성 제어가 어떻게 되는 것이고 성능은 어떨지 알아보자.
SpringBootTest와 locust를 사용한 동시성 제어 및 성능 검증작업도 진행해 보고 어떤 차이점이 있는지 자세히 알아볼 것이다.
혹시 ConcurrentHashMap이 궁금하다면?
1. CopyOnWriteArrayList의 동작 방식
동시성 제어 방법 이해하기
- CopyOnWriteArrayList는 읽기 작업에서는 잠금(lock)을 사용하지 않고, 쓰기 작업에서는 배열 전체를 복사해서 새로운 배열에 쓰기 작업을 수행하는 방식으로 동시성을 보장한다. 즉, 쓰기 작업이 일어날 때마다 새로운 배열을 생성하고, 기존 배열을 수정하지 않음으로써, 다른 스레드에서 읽기 작업을 안전하게 수행할 수 있다. 이렇게 하면 읽기 작업은 쓰기 작업의 영향을 받지 않게 된다.
쓰기에 대한 성능 비용이 존재
- 쓰기 작업 시마다 배열을 복사하므로, 쓰기 작업이 많으면 성능 저하가 발생할 수 있다. 이는 CopyOnWriteArrayList가 쓰기 작업보다는 읽기 작업이 훨씬 더 많은 환경에서 효과적이라는 것을 의미한다.
안정성을 보장
- 읽기 작업이 매우 많은 환경에서는 일반 List, ArrayList 대신 CopyOnWriteArrayList를 사용하는 것이 적합하다. 예를 들어, 다수의 스레드가(multi thread) 자주 리스트를 읽고 드물게 수정하는 경우에 이상적이다.
결론
- CopyOnWriteArrayList는 thread safe 한 리스트로, 읽기 작업이 많고 쓰기 작업이 적은 환경에서 매우 효과적이다. 동시성 제어를 제공하지만, 쓰기 작업이 발생할 때마다 배열을 복사하는 비용이 있으므로, 쓰기 작업이 빈번한 환경에서는 성능이 저하될 수 있다.
2. 스프링 빈의 특성 (검증 작업을 위한 예시코드 이해를 위한 설명)
스프링 빈(Bean)의 특성
- 스프링 프레임워크는 기본적으로 빈을 싱글톤으로 관리한다. 이는 스프링 서버 내부에서 해당 빈의 인스턴스가 하나만 생성되고, 모든 요청에 대해 동일한 인스턴스를 재사용한다는 의미다. (Bean 관리는 스프링이 자체적으로 한다.)
빈(Bean) 초기화와 필드 초기화
- 스프링 빈이 생성될 때, 클래스 내부에 필드(ArrayList 또는 CopyOnWriteArrayList 등)를 선언해 두면 이 필드는 빈(Bean)이 생성되면서 함께 초기화된다.
- 이때, 필드의 객체를 생성자로 외부에서 주입받는 것이 아니라 필드 자체적으로 new 키워드를 통해 초기화를 하고 있다면, 해당 필드가 빈으로 등록되면서 필드의 메모리 주소값은 고유하게 된다. (빈을 한 번 등록하면 다시 새롭게 생성하는 일이 없기 때문에 처음 빈을 등록할 때 초기화 되는 필드의 값은 빈이 파괴될 때까지 고유하게 유지된다.)
빈(Bean) 인스턴스의 동일한 메모리 참조
- 스프링 빈은 싱글톤 인스턴스(객체)로 메모리에 생성되기 때문에 빈 인스턴스를 사용하는 모든 스레드는 해당 빈의 필드가 참조하는 동일한 메모리 주소를 공유하게 된다. 빈이 처음 등록될 때 초기화된 필드는 그 이후로도 동일한 메모리 주소를 참조하게 되므로, 빈이 재사용될 때마다 내부 필드의 메모리 주소도 항상 동일하게 사용된다.
이러한 특성 때문에, 스프링 빈으로 등록된 객체의 필드가 멀티스레드 환경에서 안전하게 사용되려면, 해당 필드가 thread safe 한 컬렉션(CopyOnWriteArrayList 등) 또는 적절하게 동시성 제어를 해줘야 한다.
일반적으로 스프링 빈 클래스 내부의 필드(멤버 변수)에 상태값을 저장하지 않는다. 동시성 문제가 발생할 여지가 있기 때문이다.
(지금부터 보게 될 코드는 동시성 문제를 발생시키도록 일부로 이렇게 코드를 작성한 것일 뿐이다. 이것을 보고 실제 개발하는 코드에서 따라서 사용하지는 말도록 하자.)
3. 코드 작성: ArrayList와 CopyOnWriteArrayList를 사용하는 클래스 (스프링 빈)
컨트롤러, 서비스 코드 작성 (db는 사용하지 않는다. 대신 List에 저장한다.)
- CopyOnWriteArrayList 사용 (동시성 제어)
@RestController
@RequiredArgsConstructor
@RequestMapping("/safe-chat")
public class SafeChatServiceController {
private final SafeChatService safeChatService;
@PostMapping("/add")
public void addMessage(@RequestBody String message) {
safeChatService.addChatMessage(message);
}
@GetMapping("/messages")
public List<String> getMessages() {
return safeChatService.getChatMessages();
}
}
@RequiredArgsConstructor
@Service
public class SafeChatService {
// 채팅 메시지를 저장하는 리스트 (스레드 safe)
private final List<String> chatMessages = new CopyOnWriteArrayList<>();
// 채팅 메시지를 추가
public void addChatMessage(String message) {
chatMessages.add(message);
}
// 모든 채팅 메시지를 반환
public List<String> getChatMessages() {
return new CopyOnWriteArrayList<>(chatMessages);
}
}
- ArrayList 사용 (동시성 제어 불가)
@RestController
@RequiredArgsConstructor
@RequestMapping("/unsafe-chat")
public class UnSafeChatServiceController {
private final UnSafeChatService unSafeChatService;
@PostMapping("/add")
public void addMessage(@RequestBody String message) {
unSafeChatService.addChatMessage(message);
}
@GetMapping("/messages")
public List<String> getMessages() {
return unSafeChatService.getChatMessages();
}
}
@RequiredArgsConstructor
@Service
public class UnSafeChatService {
// 채팅 메시지를 저장하는 리스트
private final List<String> chatMessages = new ArrayList<>();
// 채팅 메시지를 추가
public void addChatMessage(String message) {
chatMessages.add(message);
}
// 모든 채팅 메시지를 반환
public List<String> getChatMessages() {
return new ArrayList<>(chatMessages);
}
}
4. 동시성 제어 SpringBoot 테스트 진행 (@SpringBootTest)
CopyOnWriteArrayList를 사용했을 때는 동시성 문제가 발생하지 않음
- 1000개의 스레드가 동시에 addChatMessage 메서드를 호출하여 메시지를 추가하도록 하고, 모든 스레드의 작업이 완료된 후 메시지의 총개수를 확인한다.
- 메시지 개수가 스레드 수(threadCount)와 같아야 한다. 만약 메시지 개수가 다르면, 동시성 문제가 발생한 것으로 간주하고 실패로 기록한다.
package com.example.study.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
@SpringBootTest
class SafeChatServiceTest {
@Autowired
private SafeChatService safeChatService;
@DisplayName("1000개의 메시지를 추가하면 동시성 문제가 발생하지 않는다. (CopyOnWriteArrayList 사용)")
@Test
void testSafeChatServiceConcurrency() throws InterruptedException {
int failureCount = 0; // 실패한 횟수를 저장할 변수
int threadCount = 1000; // 1000개의 스레드를 사용
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch startSignal = new CountDownLatch(1); // 시작 신호를 기다리기 위한 Latch
CountDownLatch doneSignal = new CountDownLatch(threadCount); // 모든 스레드가 완료될 때까지 기다리기 위한 Latch
for (int i = 0; i < threadCount; i++) {
int threadNumber = i;
executorService.submit(() -> {
try {
startSignal.await(); // 모든 스레드가 준비될 때까지 대기
safeChatService.addChatMessage("Message: " + threadNumber);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
doneSignal.countDown(); // 작업 완료 신호
}
});
}
startSignal.countDown(); // 모든 스레드가 준비된 후, 동시에 시작
doneSignal.await(); // 모든 스레드의 작업이 완료될 때까지 대기
int messageCount = safeChatService.getChatMessages().size();
if (messageCount != threadCount) {
System.out.println("Test failed with message count: " + messageCount);
failureCount++; // 실패한 경우 카운트를 증가시킴
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.MINUTES); // 스레드 종료까지 대기
System.out.println("Total number of failures (SafeChatService): " + failureCount); // 실패한 횟수 출력
System.out.println("Message count after execution: " + messageCount); // 최종 메시지 개수 출력
}
}
CopyOnWriteArrayList 테스트 결과
- 10번 이상의 테스트를 진행했다. 모든 데이터가 정상적으로 추가되어 1000개의 데이터를 가진다. (동시성 제어 성공)
Total number of failures (SafeChatService): 0
Message count after execution: 1000
Total number of failures (SafeChatService): 0
Message count after execution: 1000
Total number of failures (SafeChatService): 0
Message count after execution: 1000
ArrayList를 사용했을 때는 동시성 문제가 발생함
- 1000개의 스레드가 동시에 addChatMessage 메서드를 호출하여 메시지를 추가하도록 하고, 작업이 완료된 후 메시지의 총개수를 확인한다.
- 실제 실행에서는 스레드 간의 경쟁 조건 때문에 메시지 개수가 기대한 수와 일치하지 않을 수 있다. 이것은 동시성 문제로 인해 메시지 추가 작업이 중복되거나 누락된 경우를 나타낸다.
package com.example.study.service;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
@SpringBootTest
class UnSafeChatServiceTest {
@Autowired
private UnSafeChatService unSafeChatService;
@DisplayName("1000개의 메시지를 추가하면 동시성 문제가 발생할 수 있다. (ArrayList 사용)")
@Test
void testUnSafeChatServiceConcurrency() throws InterruptedException {
int failureCount = 0; // 실패한 횟수를 저장할 변수
int threadCount = 1000; // 1000개의 스레드를 사용
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch startSignal = new CountDownLatch(1); // 시작 신호를 기다리기 위한 Latch
CountDownLatch doneSignal = new CountDownLatch(threadCount); // 모든 스레드가 완료될 때까지 기다리기 위한 Latch
for (int i = 0; i < threadCount; i++) {
int threadNumber = i;
executorService.submit(() -> {
try {
startSignal.await(); // 모든 스레드가 준비될 때까지 대기
unSafeChatService.addChatMessage("Message: " + threadNumber);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
doneSignal.countDown(); // 작업 완료 신호
}
});
}
startSignal.countDown(); // 모든 스레드가 준비된 후, 동시에 시작
doneSignal.await(); // 모든 스레드의 작업이 완료될 때까지 대기
int messageCount = unSafeChatService.getChatMessages().size();
if (messageCount != threadCount) {
System.out.println("Test failed with message count: " + messageCount);
failureCount++; // 실패한 경우 카운트를 증가시킴
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.MINUTES); // 스레드 종료까지 대기
System.out.println("Total number of failures (UnSafeChatService): " + failureCount); // 실패한 횟수 출력
System.out.println("Message count after execution: " + messageCount); // 최종 메시지 개수 출력
}
}
ArrayList 테스트 결과
- 10번 이상의 테스트를 진행했다. 테스트 결과는 뒤죽박죽이다. 실패와 성공이 섞여있다. (동시성 제어 실패)
Test failed with message count: 998
Total number of failures (UnSafeChatService): 1
Message count after execution: 998
Test failed with message count: 996
Total number of failures (UnSafeChatService): 1
Message count after execution: 996
Total number of failures (UnSafeChatService): 0
Message count after execution: 1000
Test failed with message count: 999
Total number of failures (UnSafeChatService): 1
Message count after execution: 999
5. api 호출을 통한 성능 테스트 (locust 세팅)
locust로 테스트를 진행하기 위해 파일을 생성해야 한다. (파일 생성 경로 알아보기)
- 먼저 지금부터 생성할 파일은 프로젝트의 root 경로에 만들어준다.
- 아래와 같이 src 내부가 아니라 외부에 만들어준다고 생각하면 된다. (docker-compose, locustfile)
├── build.gradle
├── docker-compose.yml
├── gradle
├── gradlew
├── gradlew.bat
├── locustfile.py
├── settings.gradle
└── src
├── main
│ ├── java
│ │ └── com
│ │ └── example
│ │ └── study
│ │ ├── StudyApplication.java
│ │ ├── controller
│ │ │ ├── SafeChatServiceController.java
│ │ │ └── UnSafeChatServiceController.java
│ │ └── service
│ │ ├── SafeChatService.java
│ │ └── UnSafeChatService.java
│ └── resources
│ ├── application.yml
│ ├── static
│ └── templates
└── test
locust 테스트를 위한 파이썬 코드 작성하기
- locustfile.py라는 이름으로 파일을 생성하고 아래의 코드를 적어준다. (이름은 자유롭게 바꿔도 문제없지만 docker-compose 조건에 생성한 파일 이름을 적어줘야 하니 주의하도록 하자)
# locustfile.py
from locust import HttpUser, task, between, TaskSet
class SafeChatTasks(TaskSet):
@task(10) # 높은 비율로 POST 요청을 보내기 위해 태스크의 비중을 높임
def add_message(self):
large_message = "A" * 100000 # 100,000자의 큰 메시지 생성
self.client.post("/safe-chat/add", json=large_message)
class UnSafeChatTasks(TaskSet):
@task(10) # 높은 비율로 POST 요청을 보내기 위해 태스크의 비중을 높임
def add_message(self):
large_message = "A" * 100000 # 100,000자의 큰 메시지 생성
self.client.post("/unsafe-chat/add", json=large_message)
# 사용자는 각 요청 후에 평균적으로 0.5초에서 1초 동안 대기합니다. 대기 시간이 더 짧아져 더 많은 요청이 더 짧은 시간 내에 서버로 전송되므로, 서버에 더 높은 부하가 가해집니다. 이 설정은 동시성 문제가 발생할 가능성을 높입니다.
class SafeChatUser(HttpUser):
tasks = [SafeChatTasks]
wait_time = between(0.5, 1) # 각 사용자 사이의 대기 시간 설정 (초)
class UnSafeChatUser(HttpUser):
tasks = [UnSafeChatTasks]
wait_time = between(0.5, 1) # 각 사용자 사이의 대기 시간 설정 (초)
locust를 실행시켜 줄 docker-compose.yml 파일을 작성한다.
- 참고로 "docker-compose up -d --scale worker=3" 명령어를 입력해서 docker-compose를 실행하면 worker를 3개로 늘릴 수 있다. (일반적으로 실행하면 worker는 1개만 생성된다. 생성여부는 docker desktop을 통해 간단하게 확인할 수 있다.)
version: '3.8'
services:
master:
image: locustio/locust
ports:
- "8089:8089"
volumes:
- ./:/mnt/locust
# 커멘드 내부의 .py앞에 파일명은 내가 만든 파이썬 파일명이다. 각 테스트마다 바꿔줘야함
command: -f /mnt/locust/locustfile.py --master -H http://host.docker.internal:8080 # host.docker.internal은 로컬 호스트를 가리킵니다. (8080이면 api 서버)
worker:
image: locustio/locust
volumes:
- ./:/mnt/locust
command: -f /mnt/locust/locustfile.py --worker --master-host master
6. api 호출 성능 테스트 진행 및 결과 (locust)
locust를 실행했다면 접속해 보자
- http://localhost:8089
- 위의 url로 접속하면 아래와 같이 설정 페이지가 나올 것이다. 그럼 여기서 1번 2번 form을 통해 thread를 설정할 수 있다.
- Number of users (peak concurrency): 테스트 중에 동시에 활동할 최대 가상 사용자 수를 나타낸다.
- Ramp up (users started/second): 사용자 수가 설정된 최대 동시성에 도달하기까지의 증가 속도를 나타낸다.
locust 테스트 결과는 다음과 같다. (10분 이상 테스트, 여러 번 테스트해서 비슷한 결과를 얻어 1개를 가져와서 결론 작성)
- 단기간의 테스트를 진행해서는 좋은 결과를 얻어볼 수 없다.
- 적당한 스레드 수를 지정하고 10분 이상 테스트를 진행해 보면 아래와 같은 결과를 얻을 수 있다. (단기간에는 성능차이가 안 나타난다.)
7. api 호출 성능 테스트 결과 분석
테스트 결과
- 테스트 결과를 살펴보면 safe-chat (CopyOnWriteArrayList 사용) 요청의 실패율이 unsafe-chat (ArrayList 사용) 보다 높다. 그 이유는 CopyOnWriteArrayList의 특성과 관련이 있다.
실패(failure)가 발생하는 이유
- 응답 시간 초과: 서버가 요청을 적시에 처리하지 못하는 경우. 이는 부하가 너무 크거나 서버 자원이 부족할 때 발생할 수 있다.
- 서버 오류: 서버 코드에서 발생한 예외로 인해 요청이 실패할 수 있다.
- 네트워크 문제: 네트워크 지연이나 패킷 손실 등으로 인해 요청이 실패할 수 있다.
CopyOnWriteArrayList에서 더 많은 실패가 발생하는 이유
- 쓰기 비용 오버헤드: CopyOnWriteArrayList는 쓰기 작업이 발생할 때마다 내부 배열을 복사한다. 이 과정은 시간이 많이 걸리며, 특히 많은 쓰기 작업이 동시에 발생할 때 서버의 처리 속도가 느려질 수 있다. 이로 인해 응답 시간이 초과되거나 서버가 요청을 적시에 처리하지 못하게 되어 실패가 발생할 수 있다.
- 메모리 사용 증가: 배열을 복사할 때마다 새로운 배열을 생성하기 때문에, 메모리 사용량이 빠르게 증가할 수 있다. 메모리 사용량이 증가하면 가비지 컬렉션(GC)이 자주 발생하게 되고, 이는 서버 성능에 부정적인 영향을 미쳐 실패율이 증가할 수 있다.
테스트 결과 분석
- safe-chat: 평균 응답 시간이 45.62ms로, CopyOnWriteArrayList를 사용하는 경우다. 실패 횟수는 37이다.
- unsafe-chat: 평균 응답 시간이 15.57ms로, ArrayList를 사용하는 경우다. 실패 횟수는 11이다.
이 결과를 보면 CopyOnWriteArrayList가 ArrayList보다 쓰기 작업에 대해 더 많은 자원을 사용한다는 것을 나타낸다. 특히 동시성이 높은 환경에서, CopyOnWriteArrayList의 성능 저하와 함께 실패율이 증가할 가능성이 크다는 것을 알 수 있다.