성능 최적화 6편 - 분산 락(Distributed Lock)으로 동시성 문제 해결하기
지금까지는 조회 성능을 개선하는 방법들을 다뤘습니다. 이번 글에서는 방향을 조금 바꿔서, 여러 서버 인스턴스가 동시에 같은 자원에 접근할 때 발생하는 동시성 문제와, 이를 해결하는 분산 락 전략을 정리합니다.
1. 문제 상황 - 여러 서버가 동시에 같은 작업을 실행한다면
서비스를 여러 대의 서버(인스턴스)로 운영하는 환경에서는, 하나의 스케줄러 작업이나 재고 차감 로직이 동시에 여러 서버에서 중복 실행될 수 있습니다.
@Scheduled(cron = "0 0 * * * *")
public void expireCoupons() {
couponService.expireOldCoupons(); // 서버 A, B, C에서 동시에 실행될 수 있음
}
서버가 3대라면, 같은 스케줄이 3번 중복 실행되면서 쿠폰이 중복 처리되거나, 재고가 이중으로 차감되는 등의 데이터 정합성 문제가 발생할 수 있습니다.
2. 단일 서버라면 문제없는 이유
단일 서버 환경에서는 synchronized 키워드나 애플리케이션 내부 락으로 동시성을 제어할 수 있습니다.
public synchronized void decreaseStock(Long productId, int quantity) {
// ...
}
하지만 이 방식은 같은 JVM 안에서만 유효합니다. 서버가 여러 대로 늘어나면 각 서버는 서로 다른 JVM에서 동작하기 때문에, synchronized는 다른 서버의 스레드를 전혀 막지 못합니다. 여러 서버에 걸친 락, 즉 분산 락이 필요한 이유입니다.
3. 데이터베이스 락으로 해결하기 - SELECT FOR UPDATE
가장 기본적인 방법은 데이터베이스 자체의 락 기능을 활용하는 것입니다.
@Query(value = "select * from product where id = :id for update",
nativeQuery = true)
Product findByIdForUpdate(@Param("id") Long id);
-- SQL Server 기준
select * from product with (updlock, rowlock)
where id = @productId;
FOR UPDATE(또는 SQL Server의 UPDLOCK)는 해당 행을 조회하는 시점에 락을 걸어, 다른 트랜잭션이 같은 행을 수정하지 못하도록 막습니다. 먼저 락을 획득한 트랜잭션이 끝날 때까지 나머지는 대기합니다.
@Transactional
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findByIdForUpdate(productId);
product.decrease(quantity);
}
4. 대기 중인 트랜잭션이 많다면 - READPAST
경우에 따라, 락이 걸려 있는 행은 대기하지 않고 아예 건너뛰고 싶을 때가 있습니다. 예를 들어 여러 워커가 작업 큐에서 처리할 항목을 꺼내갈 때, 이미 다른 워커가 집어간 항목은 기다릴 필요 없이 건너뛰는 것이 효율적입니다.
select top 1 * from job_queue with (updlock, readpast)
where status = 'PENDING'
order by created_at;
READPAST는 락이 걸린 행을 만나면 대기하지 않고 건너뛰어, 다음 대기 없는 행을 반환합니다. 대기 시간 없이 여러 워커가 각자 다른 작업을 가져갈 수 있어 처리량을 높이는 데 유용합니다.
5. 애플리케이션 레벨 분산 락 - ShedLock
스케줄러 중복 실행처럼, 특정 로직 자체를 여러 서버 중 딱 한 서버에서만 실행하고 싶은 경우에는 ShedLock 같은 라이브러리를 사용하는 것이 더 직관적입니다.
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.10.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.10.0'
@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class ShedLockConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime()
.build());
}
}
@Scheduled(cron = "0 0 * * * *")
@SchedulerLock(name = "expireCoupons", lockAtMostFor = "5m", lockAtLeastFor = "1m")
public void expireCoupons() {
couponService.expireOldCoupons();
}
@SchedulerLock을 붙이면, 여러 서버 중 락을 먼저 획득한 서버 하나만 로직을 실행하고 나머지 서버는 실행을 건너뜁니다. 락 정보는 데이터베이스 테이블(또는 Redis 등)에 저장되어 서버 간에 공유됩니다.
lockAtMostFor: 락을 최대 이 시간까지만 유지 (작업이 비정상 종료돼도 이 시간 후 자동 해제)lockAtLeastFor: 락을 최소 이 시간만큼은 유지 (너무 빨리 끝나서 다른 서버가 곧바로 재실행하는 것을 방지)
6. DB 락 vs ShedLock, 언제 무엇을 쓸까
| 상황 | 적합한 방식 |
|---|---|
| 특정 행(재고, 잔액 등)에 대한 동시 수정 방지 | DB 락 (UPDLOCK, FOR UPDATE) |
| 여러 워커가 큐에서 작업을 나눠 가져가야 함 | READPAST |
| 스케줄러/배치 작업을 서버 중 하나만 실행 | ShedLock |
| 특정 비즈니스 로직 전체에 대한 분산 락 | Redis 기반 분산 락 (Redisson 등) |
7. 락 사용 시 주의할 점
- 락을 오래 들고 있을수록 다른 트랜잭션의 대기 시간이 길어집니다. 락을 거는 범위와 시간을 최소화해야 합니다.
- 여러 자원에 락을 걸어야 하는 경우, 항상 같은 순서로 락을 획득하도록 설계해야 데드락(Deadlock)을 방지할 수 있습니다.
lockAtMostFor처럼 락의 최대 유지 시간을 반드시 설정해, 서버 장애 시 락이 영원히 풀리지 않는 상황을 방지해야 합니다.
8. 정리
- 여러 서버 환경에서는
synchronized같은 애플리케이션 내부 락이 통하지 않으므로, 분산 락이 필요하다 - 특정 행에 대한 동시 수정은 데이터베이스 락(UPDLOCK, FOR UPDATE)으로 제어한다
- 작업 큐처럼 대기 없이 다음 항목을 가져가야 한다면 READPAST를 활용한다
- 스케줄러나 배치 작업을 단일 서버에서만 실행하고 싶다면 ShedLock 같은 라이브러리가 더 직관적이다
동시성 문제는 트래픽이 적을 때는 잘 드러나지 않다가, 서버가 스케일 아웃되는 시점에 갑자기 나타나는 경우가 많습니다. 서비스가 여러 대의 서버로 운영되기 시작하는 순간부터는, 처음 설계 단계에서부터 동시성 제어 전략을 함께 고려하는 것이 중요합니다.
'성능 최적화' 카테고리의 다른 글
| 성능 최적화 STEP 8 - APM으로 병목 구간 추적하기 (0) | 2026.07.07 |
|---|---|
| 성능 최적화 STEP 7 - HikariCP 커넥션 풀 튜닝으로 DB 병목 줄이기 (0) | 2026.07.07 |
| 성능 최적화 STEP 5 - 인덱스 설계와 튜닝으로 쿼리 속도 개선하기 (0) | 2026.07.06 |
| 성능 최적화 STEP 4 - Spring Batch 청크 지향 처리로 대량 데이터 안전하게 다루기 (0) | 2026.07.03 |
| 성능 최적화 STEP 3 - Redis를 활용한 캐싱 전략 (0) | 2026.07.03 |