- PATCH
/point/{id}/charge
: 포인트를 충전한다. - PATCH
/point/{id}/use
: 포인트를 사용한다. - GET
/point/{id}
: 포인트를 조회한다. - GET
/point/{id}/histories
: 포인트 내역을 조회한다. - 잔고가 부족할 경우, 포인트 사용은 실패하여야 합니다.
- 동시에 여러 건의 포인트 충전, 이용 요청이 들어올 경우 순차적으로 처리되어야 합니다.
- 포인트 충전, 사용에 대한 정책 추가 (잔고 부족, 최대 잔고 등)
- 동시에 여러 요청이 들어오더라도 순서대로 (혹은 한번에 하나의 요청씩만) 제어될 수 있도록 리팩토링
- 동시성 제어에 대한 통합 테스트 작성
- 동시성 제어 방식에 대한 분석 및 보고서 작성
여러 스레드가 동시에 동일한 자원에 접근하면 첫 번째 요청이 반영되기 전에 두 번째 요청이 먼저 반영되면서 데이터 불일치가 발생할 수 있습니다.
이러한 문제는 특히 동일한 사용자에 대해 동시다발적으로 여러 요청이 들어올 때, 잔여 포인트의 부정확한 계산 등으로 이어질 수 있습니다.
동시성 문제를 해결하는 방법에는 여러 가지가 있습니다.
- 낙관적 락은 데이터에 대한 변경을 시도할 때 충돌이 없을 것이라고 가정하고, 충돌이 발생하면 그때 가서 다시 시도하는 방식입니다.
- 보통 데이터베이스의 버전 번호를 이용하여 변경 전후의 일관성을 검증합니다.
충돌 가능성이 낮은 경우 유리
하며, 스레드 간의 경합이 적을 때 성능상의 이점이 큽니다.- 주로 버전 관리자 CAS(Compare-And-Swap) 같은 알고리즘을 통해 일관성을 유지합니다.
- 예) 특정 데이터에 대한 변경이 많지 않고 읽기 요청이 주로 발생하는 경우.
- 비관적 락은 데이터에 접근하는 동안
항상 충돌이 발생할 것으로 가정
하여, 데이터를 읽거나 쓰기 전에 미리 락을 거는 방식입니다. - 데이터베이스나 객체 수준에서 락을 걸어 다른 스레드가 해당 자원에 접근하지 못하게 합니다.
- 보통 데이터베이스 락이나 자바의
synchronized
또는ReentrantLock
으로 구현합니다. - 예) 경합이 자주 발생하거나 데이터 무결성이 매우 중요한 경우로 은행 계좌 이체와 같은 중요한 데이터 수정 시.
- ReentrantLock은 자바에서 제공하는 락 중 하나로, 한 스레드가 락을 여러 번 획득할 수 있는 재진입 가능한 락입니다.
- 명시적으로 락을 획득하고 해체할 수 있는 장점이 있으며, 락을 걸어야 할 범위를 유연하게 조정할 수 있습니다.
- 이번 프로젝트에서 사용자별 ReentrantLock을 ConcurrentHashMap과 함께 사용하여 특정 사용자에 대해 여러 요청이 발생할 때 안전하게 처리될 수 있도록 했습니다.
- 자바의 synchronized 키워드는 특정 코드 블록이나 메서드에 대한 락을 거는 방법입니다.
- 간단하게 사용할 수 있지만, 락 범위를 세밀하게 조정하기 어렵고, 모든 접근을 직렬화하여 성능 저하의 문제가 발생할 수 있습니다.
- 예) 간단한 동기화가 필요한 경우, 락의 범위를 최소화할 필요가 없는 경우.
- ConcurrentHashMap은 자바에서 제공하는 안전한 해시맵으로, 내부적으로 효율적인 동시성 제어를 위해 부분적인 락을 사용합니다.
- 맵 전체가 아닌 맵의 일부(버킷)에 대해 락을 걸어 여러 스레드가 병렬로 다른 키에 접근할 수 있도록 설계 되었으며, 이를 통해 높은 동시성을 보장합니다.
- Java 8 이후에는 CAS(Compare-And-Swap)와 스트라이핑 기법을 사용하여 성능을 더 최적화했습니다. 즉, 일부 낙관적 락의 특성과 비관적 락의 특성을 혼합하여 사용합니다.
- ConcurrentHashMap을 사용함으로써 여러 스레드가 동시에 데이터를 읽고 쓸 수 있지만, 내부적으로 충돌이 일어나지 않도록 부분적으로만 락을 걸어 성능과 안전성을 동시에 유지할 수 있습니다.
- 예) 특정 키에 대한 쓰기 작업이 ㅁ낳지 않으며 동시에 여러 읽기 작업이 이루어지는 경우.
이번 포인트 관리 시스템에서는 ConcurrentHashMap과 ReentrantLock을 조합하여 사용자별로 동시성 제어를 수행했습니다.
ConcurrentHashMap
을 사용하여 각 사용자 ID에 대해 개별적인 락(ReentrantLock
)을 생성하고 관리했습니다.computeIfAbsent()
메서드를 사용하여 특정 사용자에 대해 처음 락이 필요할 때만 ReentrantLock을 생성함으로써 락의 수를 최적화했습니다.- 이를 통해 각 사용자의 포인트 충전 및 사용 요청이 순차적으로 처리되도록 보장하여 데이터 무결성을 유지했습니다.
import java.util.concurrent.locks.ReentrantLock;
public class PointService {
private final ConcurrentHashMap<Long, ReentrantLock> userLocks = new ConcurrentHashMap<>();
private ReentrantLock getUserLock(long userId) {
return userLocks.computeIfAbsent(userId, k -> new ReentrantLock());
}
public UserPoint charge(long id, long amount) {
ReentrantLock lock = getUserLock(id);
lock.lock();
try {
// 포인트 충전 로직
} finally {
lock.unlock();
}
}
public UserPoint use(long id, long amount) {
ReentrantLock lock = getUserLock(id);
lock.lock();
try {
// 포인트 사용 로직
} finally {
lock.unlock();
}
}
}
다음과 같은 동시성 시나리오를 주어 테스트를 진행했습니다.
- 포인트 충전 및 사용 요청이 동시에 들어오는 경우
- 여러 사용자에 대해 포인트 충전 및 사용 요청이 동시에 들어오는 경우
- 잔여 포인트 보다 큰 금액 사용 요청이 들어오는 경우
ConcurrentHashMap
과 ReentrantLock
을 조합한 사용자별 동시성 제어 방식은 포인트 관리 시스템의 데이터 일관성을 보장하고, 사용자별로 독립적인 요청 처리를 가능하게 합니다. 이를 통해
특정 사용자에 대한 다중 요청이 순차적으로 처리될 수 있도록 하여 동시성 문제를 효과적으로 해결했습니다.
이번 개선된 구조는 낙관적 락과 비관적 락의 특성을 고려하여 사용자별 락을 세밀하게 적용함으로써 성능과 데이터 일관성을 모두 유지하는 것을 목표로 했습니다.