동시성 제어를 위해 다양한 구현방법이 있습니다. 각 방법은 장단점이 있고 환경에 따라 결과도 달라지게 됩니다.
이커머스에서 주문로직을 통해 낙관적락, 비관적락, Redis락의 구현방법, 성능, 부하 등을 비교 분석하였고, 특정 시나리오에서 어떤 방식을 사용하는 것이 가장 효과적인지 지표를 통해 판단해보겠습니다.
테스트 환경
API 서버 : Spring Boot(3.3.4), JDK 17
부하 테스트 도구 : Jemeter
DB : Amazon RDS
모니터링 : Amazon CloudWatch
API 서버와 Jemeter는 Local PC에서 실행하였고, DB 만 따로 분리를 하였습니다.
프리티어에서 제공하는 EC2에 API 서버를 실행시키니 서버가 죽어서... 어쩔 수 없이 Local PC에서 API 서버를 실행시겼습니다.
AWS CloudWatch를 통해 DB의 CPU 사용률을 확인하였습니다.
시나리오
1000명의 사용자가 1개의 제품에 대해서 1초안에 요청을 동시에 보낸다.
// 생략...
@Entity
public class ProductInventory {
// 생략...
@Version
@Builder.Default
private Long version = 0L; // 낙관적 락을 위한 버전 필드
}
JPA의 @Version 어노테이션을 이용하여 낙관적 락을 구현하였습니다. 이 필드는 데이터베이스의 레코드가 수정될 때마다 버전이 증가하며, 동시성 문제가 발생할 경우 ObjectOptimisticLockingFailureException 예외를 발생시킵니다.
동시성 문제가 발생했을 때 재시도를 하지 않은체로 Jemeter를 통해 테스트를 진행하면 아래와 같은 결과가 나옵니다.
Jemeter Summery Report

에러 발생률이 86.90%가 나왔는데 1000번을 요청했을 때 869번의 낙관적락예외가 발생한 것 입니다.
AWS CloudWatch지표

재시도 없는 낙관적 락을 사용했을 경우 CPU 사용률이
6.81%로 측정된 것을 확인할 수 있습니다.
재시도 로직을 직접구현할 수도 있지만 우아하게? 구현하기 위해 라이브러리를 사용해서 구현하였습니다.
// 비관적락 재시도를 도와주는 라이브러리
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springframework:spring-aspects'
@Retryable(
value = OptimisticLockingFailureException.class,
maxAttempts = 5,
backoff = @Backoff(delay = 500)
)
@Transactional
public void order(OrderCommand.Order command) {
//로직 생략...
}
낙관적예외가 발생했을 때 5번까지는 재시도를 한다는 설정입니다.
Jemeter Summery Report

에러발생률은
86.90%에서 재시도로직을 통해15.50%까지 감소한 것을 확인할 수 있습니다. 하지만 평균 응답시간이4.577ms에서3.7106ms까지 8배가량 증가하였습니다.
AWS CloudWatch지표

재시도가 있는 낙관적락을 사용했을 때는 CPU 사용률이
9.79%가 발생한 것을 확인할 수 있습니다.
장점: 데이터 충돌 가능성이 낮은 경우 성능 유지에 유리하며, 락을 사용하지 않으므로 데이터베이스 자원 사용이 적습니다.
단점: 충돌 발생 시 재시도로 인해 응답 시간이 길어질 수 있으며, 동시성 충돌이 빈번하면 오히려 데이터 베이스 자원을 많이 사용하여 성능 저하가 심각해질 수 있습니다.
비관적 락은 데이터베이스에서 동일한 레코드에 대해 동시 수정이 일어나지 않도록 보호하는 방식입니다. JPA에서는 @Lock 어노테이션과 LockModeType.PESSIMISTIC_WRITE를 사용하여 구현할 수 있습니다.
@Transactional
@Override
public List<ProductInventory> getInventoryList(Long... productIds) {
var productInventory = QProductInventory.productInventory;
return queryFactory.selectFrom(productInventory)
.where(productInventory.productId.in(productIds))
.setLockMode(LockModeType.PESSIMISTIC_WRITE) // 비관적 읽기 락 설정
.fetch();
}
QueryDsl을 사용해서 재고 데이터를 조회하는데 이때 setLockMode를 통해 비관적 락을 걸었습니다.
Jemeter Summery Report

낙관적락을 사용했을 때는 에러률이
0%인 것을 확인할 수 있습니다. 또한 평균 응답시간은36.035ms로 낙관적락 + 재시도를 했을 때와 비슷한 수치가 나왔습니다.
AWS CloudWatch지표

비관적락을 사용했을 때 CPU 사용률은
8.59%가 나왔습니다.
장점: 데이터 일관성을 높게 보장합니다. 동시성 문제가 발생할 가능성이 높은 환경에서 안전하게 사용할 수 있습니다.
단점: 트랜잭션 대기 시간이 증가하여 성능이 저하될 수 있으며, 많은 요청이 들어올 때 경합이 발생할 가능성이 큽니다.
Redis 락은 분산 환경에서 락을 걸어 동시성 문제를 해결하는 데 사용됩니다. Redis의 빠른 응답 속도를 이용해 락을 관리하며, 특히 분산 시스템에서 효과적으로 사용할 수 있습니다.
implementation 'org.redisson:redisson:3.20.0'
@Configuration
public class RedissionConfig {
@Value("${spring.redission.address}")
private String address;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress(address)
;
return Redisson.create(config);
}
}
spring:
redission:
address: redis://localhost:6379
라이브러리 추가 및 Config 설정을 해줍니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final RedissonClient redissonClient;
public void order(OrderCommand.Order command) {
var productIds = command.products().stream().map(OrderCommand.Order.Product::productId).toArray(Long[]::new);
// 락 획득
List<RLock> locks = Arrays.stream(productIds).map(v -> redissonClient.getLock("productId:" + v)).toList();
try {
// 모든 락을 획득
for (RLock lock : locks) {
lock.lock(10, TimeUnit.SECONDS);
}
// 락을 획득한 이후 트랜잭션 시작
processOrderWithTransaction(command, productIds);
} finally {
// 락 해제
for (RLock lock : locks) {
lock.unlock();
}
}
}
@Transactional
public void processOrderWithTransaction(OrderCommand.Order command, Long[] productIds) {
// 로직 생략...
}
}
redissonClient 의 getLock 메서드를 통해 락을 획득하고, 비즈니스 로직을 수행한 뒤 락을 해제하여 동시성 제어를 할 수 있습니다.
Jemeter Summery Report

Redis 분산락을 사용했을 때 에러율이
0%이지면 평균 응답시간이92.690ms로 높게 나온 것을 확인할 수 있습니다.
AWS CloudWatch지표

Redis 분산락을 사용했을 때 CPU 사용률은 6.76% 가 나왔습니다.
장점: 분산 환경에서 성능이 뛰어나며, 트래픽이 많은 경우에도 안정적으로 처리할 수 있습니다.
단점: Redis 서버의 가용성에 따라 전체 시스템의 신뢰성이 좌우되며, Redis와의 네트워크 지연이 발생하면 성능 저하 가능성이 있습니다.
| 낙관적락(재시도X) | 낙관적락(재시도 5회) | 비관적락 | Redis락 | |
|---|---|---|---|---|
| 평균응답시간(ms) | 4.577 | 37.106 | 36.035 | 92.690 |
| 에러발생률(%) | 86.90 | 15.50 | 0 | 0 |
| CPU사용률(%) | 6.81 | 9.79 | 8.59 | 6.76 |
| 구현용이성 | 쉬움 | 쉬움 | 쉬움 | 높음 |
| 비고 | version 컬럼 추가 필요 | 재시도 횟수에 따라 성능이 달라짐 | LockMode설정 필요 | Redis DB + redisson 라이브러리 필요 |
- 낙관적락은
에러발생률이 15% 이상이면 서비스를 하기에 적합하지 않다고 판단되어 낙관적락은 동시성 제어하기에는 적합하지 않다고 생각이 듭니다.CPU 사용률은 Redis락이 비관적락에 비해1.83%적게사용하였습니다.평균 응답시간은 비관적락이 Redis락에 비해2.57배 가량 빠르게 응답을 하였습니다.
CPU 사용률이 Redis락이 가장 성능이 좋게 나왔지만 평균 응답시간에서 비관적락이 Redis락에 비해 압도적으로 좋은 성능이 나왔기 때문에 비관적락을 사용하는 것이 이번 시나리오에서 더 적합한 동시성 제어 방법으로 판단이 됩니다.