항해 1주차에서 TDD 개발을 진행하고,
심화 과제로 포인트 충전 / 사용을 동시에 요청했을때 발생할 수 있는 동시성 이슈를 제어하는게 목표이다.
목차
- 요구 사항 분석
- 동시성 제어할 레벨 선택
- 애플리케이션(자바)에서 동시성 제어하는 방식 후보
- 동시성 제어 통합테스트 TDD 과정
- 최적화된 동시성 고민
- 결론
요구 사항 분석
- 동시에 여러 요청이 들어오더라도 순서대로 (혹은 한번에 하나의 요청씩만) 제어될 수 있도록 리팩토링한다.
- 동시성 제어에 대한 통합 테스트를 작성한다.
- 분산 환경은 고려하지 않는다.
요구 사항을 분석해봤을때
우선, 동시성을 어느 레벨에서 제어할지 결정해야한다.
이어서 동시성 제어를 하면서 순서 보장 할 수 있게 작업을 수행해야한다.
동시성 제어할 레벨 선택
동시성을 제어하는 방법으로는 크게 3가지 애플리케이션, 데이터베이스, 분산 데이터베이스 레벨로 나뉜다.
이번 과제에서는 분산 환경을 고려하지 않고, 데이터베이스(인메모리 DB 사용)의 구현체는 수정하지 않아야 돼서
애플리케이션 레벨에서 동시성을 제어하도록 한다.
애플리케이션(자바)에서 동시성 제어하는 방식 후보
- 동시성 제어
- synchronized
- 한 번에 하나의 스레드만 임계영역에 접근하도록 한다.
- 락을 획득하지 못한 다른 스레드들은 대기 상태가 되어 전반적인 실행 시간이 길어져 성능이 저하되는 단점이 있다.
- ReentrantLock
- ReentrantLock은 synchronized와 유사하지만 더 세밀한 제어가 가능하다.
- lock()과 unlock() 메서드를 통해 잠금을 수동으로 관리할 수 있다.
- new ReetrantLock(true)로 생성자에 true를 전당하면 공정성(fairness)을 부여할 수 있다.
- 주 특징으로는 원자성, 가시성 보장이 있다.
- synchronized vs ReentrantLock
- synchronized
- synchronized 블럭으로 동기화 하면 자동적으로 lock이 잠기고 풀린다.
(synchronized 블럭 내에서 예외가 발생해도 lock은 자동적으로 해제) - 그러나 같은 메소드 내에서만 lock을 걸 수 있다를 제약이 존재
- 암묵적인 lock 방식
- WAITING 상태인 스레드는 interrupt 불가
synchronized(lock) {
// 임계영역
}
- synchronized 블럭으로 동기화 하면 자동적으로 lock이 잠기고 풀린다.
- ReentrantLock
- synchronized와 달리 수동으로 lock을 잠그고 해제한다.
- 명시적인 lock 방식
- 암묵적인 락 만으로는 해결할 수 없는 복잡한 상황에서 사용
- lockInterruptibly() 함수를 통해 WAITING 상태의 스레드를 interrupt 할 수 있다.
lock.lock();
// 임계영역
lock.unlock();
- synchronized
- synchronized
- 순서 보장
- ExecutorService
- 스레드 풀을 관리하고 작업을 큐에 넣어서 동시성 문제를 해결한다.
- 여러 작업을 병렬로 실행할 수 있고, 작업이 완료되면 결과를 받을 수 있다.
- Concurrent Collections
- 자바에서 동시성 문제를 처리할 수 있는 컬렉션 클래스를 제공한다.
- 종류에는 ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue 등이 있다.
- ExecutorService
최종 선택
- 세밀한 잠금 범위를 설정할 수 있는 ReentrantLock을 사용하여 교착 상태를 방지하고 , 큐를 사용하여 순서 보장하면서 병렬로 실행 가능한 ExecutorService를 사용해 보려고 한다.
동시성 제어 통합테스트 TDD 과정
// 10개의 스레드
private int threads = 10;
// 충전할 포인트
private long addAmount = 10L;
// 초기 포인트 값
private long initPoint;
@BeforeEach
public void setUp(){
// 기본 포인트 100L 충전
...
// 초기 포인트 값
...
}
@Test
@DisplayName("동시 포인트 충전 테스트 - 10L 포인트 10회 동시 충전")
public void concurrentPointChargeTest() throws InterruptedException {
//given
ExecutorService executorService = Executors.newFixedThreadPool(threads);
//when
for(int i=0; i < threads; i++){
executorService.submit(() ->{
pointService.patchPointCharge(userId, addAmount, System.currentTimeMillis());
});
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.MINUTES);
//then
long finalPoint = pointService.getUserPoint(userId).point();
// 예상 포인트와 동시에 포인트 충전을 10회 요청한 최종 포인트가 일치하는지 검증
assertEquals(initPoint + (threads * addAmount), finalPoint);
}
스레트 풀을 10개 생성하고 스레드 수 만큼 동시에 충전을 실행하면
동시성 제어가 안 되어 예상 포인트와 최종 포인트 값이 차이 나는 것을 확인할 수 있다.
이를 해결하기 위해서는 임계영역에 접근하기 전에 lock을 걸어주어 한 번에 한 스레드만 접근할 수 있도록 해야한다.
private ReentrantLock lock = new ReentrantLock(true);
public UserPoint patchPointCharge(long userId, long addAmount,long fixTime){
lock.lock();
try{
// 포인트 충전 로직 ...
} finally {
lock.unlock();
}
}
ReentrantLock을 사용하여 동시성을 제어한 결과
예상값과 최종값이 일치하여 테스트를 성공하는 것을 확인할 수 있다.
그런데 스레드 10개로 돌렸을 때 6초 정도 소요되어 효율성이 떨어지는 것을 확인할 수 있다.
이에 대한 성능을 개선하기 위한 리팩토링을 진행 해야한다.
최적화된 동시성 고민
유저별로 동시에 실행되어도 되는지, 동기화를 보장해야 하는지 고민해보았다.
- 서로 다른 유저인 경우
- 대기가 발생하지 않아야 한다.
- 효율성을 위해서 각각 동시에 실행되어야 한다. 이 경우 데이터 정합성이 깨지진 않는다.
- 같은 유저인 경우
- 데이터를 읽는 시점, 쓰는 시점에 따라 데이터 정합성의 문제가 발생 가능하다.
- 이 경우 동시성 제어가 필요하다.
결론 : 사용자ID에 따라 동시성 제어를 하느냐, 하지 않느냐 구별해주기 위해서 ConcurrentHashMap을 사용하여 처리한다.
사용자 ID에 따라 lock을 적절하게 걸어주기 위해서는 ConcurrentHashMap을 사용하여 사용자별로 락 개체를 관리하게 하였다.
public UserPoint patchPointCharge(long userId, long addAmount,long fixTime){
ReentrantLock lock = lockMap.computeIfAbsent(userId, id -> new ReentrantLock(true));
try{
// 동일 사용자에 대해 동기화
lock.lock();
// 충전 로직 ...
} finally {
lock.unlock(); // 락 해제
lockMap.remove(userId, lock); // 락 객체 정리
}
}
ReentrantLock 객체 생성 시 생성자에 매개변수를 true로 전달하여 가장 오래 기다린 스레드가 lock을 획득할 수 있게 공정(fair)하게 처리한다.
- ConcurrentHashMap
- 사용자별로 고유한 키(userId)를 맵에 저장하여 ReentrantLock 객체를 관리
- computeIfAbsent 메서드를 사용해 필요할 때만 락을 생성
- ReentrantLock
- 동일한 사용자 키에 대해 동기화 처리를 보장
- 비동기 처리
- 다른 사용자는 각자의 락을 사용하기 때문에 서로 영향을 받지 않고 병렬 처리 가능
최적화 후 스레드 처리 속도 비교
- 동일한 사용자가 충전 요청을 10번 했을때 - 6 sec 343ms 발생
- 다른 사용자가 충전 요청을 10번 했을때 - 5 sec 429ms 발생
결론
동시성 제어가 이뤄지고 스레드 순서를 보장하도록 테스트를 진행했다.
또한 유저 별로 동일한 유저, 다른 유저가 요청했을 때 각각의 케이스로 나누어 비동기 처리할 것인지, 동기화를 할 것인지 고민했다.
그런데 테스트 코드를 추가할 부분이 있다.
동일한 유저일 때 충전과 사용이 동시에 발생하면 데이터 정합성이 깨지는지 확인하는 작업이 필요하다.
예를 들면,
현재 포인트가 1000포인트고 +1000(충전) / -2000(사용) 요청이 어떤 순서로 들어올지 모를때,
1. 충전이 먼저 들어오는 경우
1000(현재 잔액)
+1000(충전) = 2000(성공)
-2000(사용) = 0(성공)
첫 번째 요청(충전) 성공, 두 번째 요청(사용) 성공
2. 사용이 먼저 들어오는 경우
1000(현재 잔액)
-2000(사용) = -1000(실패)
+1000(충전) = 2000(성공)
첫 번째 요청(사용) 실패, 두 번째 요청(충전) 성공
위와 같은 테스트 케이스가 추가로 더 필요하다.
이번 과제를 진행하면서 느낀점은 단순히 성공을 하는 테스트 케이스가 아니라 실패하는 테스트 케이스를 생각해내야 한다는 점이었다.
대부분의 트러블 슈팅은 예상치 못한 상황에서 일어나기 때문에...
이 부분이 이번 주차의 주요 골자였을거란 생각이 든다.
'항해 플러스 백엔드 > TDD & 클린 아키텍처' 카테고리의 다른 글
[항해] 2주차, 동시성 제어하기 (with DB 레벨) (0) | 2025.01.05 |
---|---|
[항해] 2주차, 클린 아키텍처 (2) | 2025.01.05 |
[항해] 1주차, TDD 방법론을 사용하여 개발하기 (2) | 2024.12.21 |