문제 상황멀티 인스턴스 환경에서 공유 자원에 대한 동시 접근 시 발생하는 문제들
💡 왜 로컬 락으로는 안 되는가?
// ❌ 이런 코드는 멀티 인스턴스에서 무용지물 public synchronized void decreaseStock(Long productId) { Product product = repository.findById(productId); product.decrease(); }
문제점: synchronized는 JVM 레벨 락이므로 서버 A와 서버 B가 각각 다른 락을 잡음 → 동시성 문제 발생
해결책: 중앙 집중식 락 스토어 필요 → Redis 선택
# NX: Not Exists (키가 없을 때만 세팅)
# PX: 밀리초 단위 TTL (자동 만료)
SET resource:lock <random-uuid> NX PX 30000
핵심 포인트
-- KEYS[1] = 락 키
-- ARGV[1] = 클라이언트의 UUID
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
왜 Lua를 써야 하는가?
// ❌ 이렇게 하면 안 됨 (race condition)
String value = redis.get("resource:lock");
if (myUuid.equals(value)) {
redis.del("resource:lock"); // 이 사이에 다른 클라이언트가 락 획득 가능!
}
Lua 스크립트는 원자적(atomic)으로 실행되어 위 문제 해결
단일 Redis 노드 사용 시:
시간 순서:
t0: Client A가 Master에서 락 획득
t1: Master → Slave 복제 전에 Master 장애 발생
t2: Slave가 Master로 승격 (Failover)
t3: Client B가 새 Master에서 동일한 락 획득
t4: Client A와 B 모두 락을 보유하고 있다고 믿음 💥
근본 원인: Redis의 비동기 복제(Asynchronous Replication)
N개의 독립적인 Redis 단일 노드를 사용하여, 과반(N/2+1) 이상에서 락을 획득해야 성공으로 간주
1. 현재 시간(ms) 기록: start_time
2. N개 노드에 순차적으로 SET NX PX 시도
- 각 노드마다 짧은 타임아웃 사용 (ex: 5~50ms)
- 다운된 노드에서 오래 블로킹되는 것 방지
3. 경과 시간 계산: elapsed_time = now() - start_time
4. 성공 조건 체크
- 획득한 락 개수 >= N/2 + 1
- elapsed_time < 락 유효 시간(TTL)
5. 성공 시
- 유효 시간 = TTL - elapsed_time
6. 실패 시
- 모든 노드에서 락 해제 시도
- 랜덤 지연 후 재시도
public boolean acquireRedLock(String resource, String token, long ttlMs) {
long startTime = System.currentTimeMillis();
int successCount = 0;
// N개 노드에 동시 요청 (이상적으로는 멀티플렉싱)
for (RedisNode node : redisNodes) {
boolean acquired = node.setNX(resource, token, ttlMs, shortTimeout);
if (acquired) successCount++;
}
long elapsedTime = System.currentTimeMillis() - startTime;
int quorum = redisNodes.size() / 2 + 1;
if (successCount >= quorum && elapsedTime < ttlMs) {
long validityTime = ttlMs - elapsedTime;
return true; // 락 획득 성공
} else {
// 실패 시 모든 노드 해제
releaseAll(resource, token);
return false;
}
}
Redis Nodes: A, B, C, D, E
Quorum: 3 (5/2 + 1)
Case 1: 성공
- A ✅, B ✅, C ✅, D ❌, E ❌
- 3개 >= Quorum → 락 획득 성공
Case 2: 실패
- A ✅, B ❌, C ✅, D ❌, E ❌
- 2개 < Quorum → 락 획득 실패 → 모든 노드 해제
상황: 노드 C의 시계가 빨라지는 경우
시간 순서:
t0: Client 1이 A, B, C에서 락 획득 (D, E는 네트워크 이슈)
t1: 노드 C의 시계가 점프 → TTL 조기 만료
t2: Client 2가 C, D, E에서 락 획득 (A, B는 네트워크 이슈)
t3: Client 1과 2 모두 락 보유하고 있다고 믿음 💥
RedLock은 "로컬 시간이 거의 동일한 속도로 흐른다"는 가정에 의존
현실에서는 NTP 동기화 실패, 가상화 환경의 클록 drift 등 발생
시나리오: 파일 업데이트 로직
// ❌ 이 코드는 안전하지 않음
function writeData(filename, data) {
var lock = lockService.acquireLock(filename);
if (!lock) throw 'Failed to acquire lock';
try {
var file = storage.readFile(filename); // ⬅️ 여기서 GC 발생 가능!
var updated = updateContents(file, data);
storage.writeFile(filename, updated);
} finally {
lock.release();
}
}
문제 발생 과정:
시간 순서:
t0: Client 1이 RedLock 획득 (TTL = 10초)
t1: Client 1이 파일 읽기
t2: Client 1에서 GC Stop-The-World 발생 (15초 지속)
t10: 락 TTL 만료
t11: Client 2가 RedLock 획득 및 파일 업데이트
t17: Client 1의 GC 종료 후 파일 업데이트 💥
해결책: 단조 증가하는 토큰으로 요청 순서 보장
// ✅ 펜싱 토큰 사용
function writeDataSafe(filename, data) {
var lockResult = lockService.acquireLockWithToken(filename);
var lock = lockResult.lock;
var token = lockResult.token; // 33, 34, 35... (증가하는 숫자)
try {
var file = storage.readFile(filename);
var updated = updateContents(file, data);
// 토큰과 함께 쓰기 요청
storage.writeFile(filename, updated, token);
// 스토리지가 작은 토큰의 쓰기를 거부
} finally {
lock.release();
}
}
| 구분 | Lettuce | Redisson |
|---|---|---|
| 구현 방식 | setnx/setex 직접 구현 | Lock 인터페이스 제공 |
| 락 획득 방식 | Spin Lock(지속 폴링) | Pub/Sub(이벤트 기반) |
| Redis 부하 | 높음 | 낮음 |
| Retry/Timeout | 직접 구현 | 내장 지원 |
| 사용 편의성 | 낮음 | 높음 |
dependencies {
implementation 'org.redisson:redisson-spring-boot-starter:3.18.0'
}
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + redisHost + ":" + redisPort);
return Redisson.create(config);
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
/**
* 락 이름 (SpEL 지원)
* 예: "#couponName", "#model.getName().concat('-').concat(#model.getShipmentNumber())"
*/
String key();
/**
* 시간 단위
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 락 대기 시간 (기본 5초)
* 이 시간 동안 락 획득 시도
*/
long waitTime() default 5L;
/**
* 락 임대 시간 (기본 3초)
* 이 시간이 지나면 자동 해제
*/
long leaseTime() default 3L;
}
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(DistributedLock)")
public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock annotation = method.getAnnotation(DistributedLock.class);
// 1️⃣ SpEL로 동적 키 생성
String key = REDISSON_LOCK_PREFIX +
CustomSpringELParser.getDynamicValue(
signature.getParameterNames(),
joinPoint.getArgs(),
annotation.key()
);
// 2️⃣ RLock 인스턴스 획득
RLock rLock = redissonClient.getLock(key);
try {
// 3️⃣ 락 획득 시도 (waitTime 동안 대기, leaseTime 후 자동 해제)
boolean available = rLock.tryLock(
annotation.waitTime(),
annotation.leaseTime(),
annotation.timeUnit()
);
if (!available) {
log.warn("Failed to acquire lock: {}", key);
return false; // 또는 커스텀 예외
}
// 4️⃣ 별도 트랜잭션으로 비즈니스 로직 실행
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw e;
} finally {
// 5️⃣ 락 해제 (반드시 실행)
try {
rLock.unlock();
} catch (IllegalMonitorStateException e) {
log.info("Already unlocked or not owner: {}", key);
}
}
}
}
@Component
public class AopForTransaction {
/**
* REQUIRES_NEW: 부모 트랜잭션과 무관하게 새 트랜잭션 시작
* 이렇게 해야 커밋 → unlock 순서 보장
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
public class CustomSpringELParser {
public static Object getDynamicValue(
String[] parameterNames,
Object[] args,
String expression
) {
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
return parser.parseExpression(expression)
.getValue(context, Object.class);
}
}
@Service
@RequiredArgsConstructor
public class CouponService {
private final CouponRepository couponRepository;
// 예시 1: 단순 파라미터
@DistributedLock(key = "#couponName", waitTime = 5, leaseTime = 3)
public void issueCoupon(String couponName, Long userId) {
Coupon coupon = couponRepository.findByName(couponName)
.orElseThrow(() -> new IllegalArgumentException("쿠폰 없음"));
coupon.decrease();
coupon.addUser(userId);
}
// 예시 2: 복합 키 (SpEL)
@DistributedLock(
key = "#model.getName() + '-' + #model.getShipmentNumber()",
waitTime = 10,
leaseTime = 5
)
public void processShipment(ShipmentModel model) {
// 출고 처리 로직
}
// 예시 3: 재고 차감
@DistributedLock(key = "'product:' + #productId", leaseTime = 2)
public void decreaseStock(Long productId, int quantity) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("상품 없음"));
product.decreaseStock(quantity);
}
}
시간 순서 (재고 10개):
t0: Client1과 Client2가 동시에 요청
t1: Client1이 락 획득 → 재고 조회(10개)
t2: Client1이 재고 차감(10-1=9) → 락 해제 ❌
t3: Client2가 락 획득 → 재고 조회(10개) ⚠️ 아직 커밋 안 됨!
t4: Client1의 트랜잭션 커밋(DB에 9 반영)
t5: Client2가 재고 차감(10-1=9) → 락 해제 → 커밋(DB에 9 반영)
결과: 2명이 차감했는데 재고는 1개만 줄어듦 💥
시간 순서 (재고 10개):
t0: Client1과 Client2가 동시에 요청
t1: Client1이 락 획득 → 재고 조회(10개)
t2: Client1이 재고 차감(10-1=9) → 트랜잭션 커밋 ✅
t3: Client1이 락 해제
t4: Client2가 락 획득 → 재고 조회(9개) ✅ 최신 데이터!
t5: Client2가 재고 차감(9-1=8) → 커밋 → 락 해제
결과: 재고가 정확히 2개 줄어듦 ✨
잘못된 순서 (데이터 정합성 깨짐):
Client1: [락획득] → [조회:10] → [차감:9] → [락해제] → [커밋]
Client2: [락획득] → [조회:10] → [차감:9] → [커밋]
↑ 여기서 조회하면 10개!
올바른 순서 (데이터 정합성 보장):
Client1: [락획득] → [조회:10] → [차감:9] → [커밋] → [락해제]
Client2: [락획득] → [조회:9] → [차감:8] → [커밋]
↑ 여기서 조회하면 9개!
// ❌ 잘못된 구현
@Transactional
public void decreaseStock(Long productId) {
RLock lock = redissonClient.getLock("product:" + productId);
lock.lock();
try {
// 비즈니스 로직
} finally {
lock.unlock(); // 트랜잭션 커밋 전에 unlock!
}
}
// ✅ 올바른 구현 (AOP 사용)
@DistributedLock(key = "'product:' + #productId")
public void decreaseStock(Long productId) {
// AOP가 알아서 처리:
// 1. 락 획득
// 2. 별도 트랜잭션으로 이 메서드 실행
// 3. 트랜잭션 커밋
// 4. 락 해제
}
시나리오: KURLY_001 쿠폰 100장을 100명이 동시에 발급 요청
엔티티
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Coupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private Long availableStock;
public Coupon(String name, Long availableStock) {
this.name = name;
this.availableStock = availableStock;
}
public void decrease() {
validateStockCount();
this.availableStock -= 1;
}
private void validateStockCount() {
if (availableStock < 1) {
throw new IllegalArgumentException("재고 부족");
}
}
}
서비스
@Service
@RequiredArgsConstructor
public class CouponService {
private final CouponRepository couponRepository;
// 분산락 O
@DistributedLock(key = "#couponName")
public void decreaseWithLock(String couponName, Long couponId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow();
coupon.decrease();
}
// 분산락 X
@Transactional
public void decreaseWithoutLock(Long couponId) {
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow();
coupon.decrease();
}
}
테스트 코드
@SpringBootTest
class CouponConcurrencyTest {
@Autowired CouponService couponService;
@Autowired CouponRepository couponRepository;
private Coupon coupon;
@BeforeEach
void setUp() {
coupon = new Coupon("KURLY_001", 100L);
couponRepository.save(coupon);
}
@Test
@DisplayName("분산락 적용 - 100명 동시 요청 시 정확히 100개 차감")
void withLock() throws InterruptedException {
int threadCount = 100;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
couponService.decreaseWithLock(
coupon.getName(),
coupon.getId()
);
} finally {
latch.countDown();
}
});
}
latch.await();
Coupon result = couponRepository.findById(coupon.getId()).orElseThrow();
// ✅ 정확히 0개
assertThat(result.getAvailableStock()).isZero();
System.out.println("잔여 쿠폰: " + result.getAvailableStock());
}
@Test
@DisplayName("분산락 미적용 - 100명 동시 요청 시 정합성 깨짐")
void withoutLock() throws InterruptedException {
int threadCount = 100;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
couponService.decreaseWithoutLock(coupon.getId());
} finally {
latch.countDown();
}
});
}
latch.await();
Coupon result = couponRepository.findById(coupon.getId()).orElseThrow();
// ❌ 79개 남음 (21개만 차감됨)
System.out.println("잔여 쿠폰: " + result.getAvailableStock());
assertThat(result.getAvailableStock()).isNotZero();
}
}
✅ 분산락 적용:
잔여 쿠폰: 0 (정확히 100개 차감)
❌ 분산락 미적용:
잔여 쿠폰: 79 (21개만 차감, 동시성 문제 발생)
시나리오: KURLY_001 발주 10건이 동시에 중복 수신
엔티티
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Purchase {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true) // DB 레벨 방어선
private String code;
public Purchase(String code) {
this.code = code;
}
}
서비스
@Service
@RequiredArgsConstructor
public class PurchaseService {
private final PurchaseRepository purchaseRepository;
// 분산락 O
@DistributedLock(key = "#code")
public void registerWithLock(String code) {
if (purchaseRepository.existsByCode(code)) {
throw new IllegalArgumentException("중복 발주");
}
purchaseRepository.save(new Purchase(code));
}
// 분산락 X
@Transactional
public void registerWithoutLock(String code) {
if (purchaseRepository.existsByCode(code)) {
throw new IllegalArgumentException("중복 발주");
}
purchaseRepository.save(new Purchase(code));
}
}
테스트 코드
@SpringBootTest
class PurchaseConcurrencyTest {
@Autowired PurchaseService purchaseService;
@Autowired PurchaseRepository purchaseRepository;
@Test
@DisplayName("분산락 적용 - 10건 중복 요청 시 1건만 등록")
void withLock() throws InterruptedException {
String code = "KURLY_001";
int threadCount = 10;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
purchaseService.registerWithLock(code);
} catch (Exception e) {
// 락 획득 실패 또는 중복 예외
} finally {
latch.countDown();
}
});
}
latch.await();
long count = purchaseRepository.countByCode(code);
// ✅ 정확히 1건
assertThat(count).isOne();
System.out.println("등록된 발주: " + count);
}
@Test
@DisplayName("분산락 미적용 - 10건 중복 요청 시 여러 건 등록")
void withoutLock() throws InterruptedException {
String code = "KURLY_001";
int threadCount = 10;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
purchaseService.registerWithoutLock(code);
} catch (Exception e) {
// 일부는 DB unique 제약으로 실패
} finally {
latch.countDown();
}
});
}
latch.await();
long count = purchaseRepository.countByCode(code);
// ❌ 4~7건 등록됨 (환경에 따라 다름)
System.out.println("등록된 발주: " + count);
assertThat(count).isGreaterThan(1);
}
}
✅ 분산락 적용:
등록된 발주: 1
❌ 분산락 미적용:
등록된 발주: 5 (connection pool 크기에 따라 다름)
// ✅ Good: 자원 단위로 세분화
LOCK:coupon:{couponId}
LOCK:product:{productId}:stock
LOCK:purchase:{code}
LOCK:user:{userId}:payment
// ❌ Bad: 너무 넓은 범위
LOCK:all-products // 모든 상품 차감이 직렬화됨
// 공식: TTL = 최악의 처리 시간 + 여유 버퍼
@DistributedLock(
key = "#productId",
leaseTime = 5, // 평균 1초 + 버퍼 4초
waitTime = 10 // 대기 허용 시간
)
TTL 너무 짧으면: 작업 중간에 락 만료 → 중복 처리
TTL 너무 길면: 장애 시 복구 지연
// leaseTime을 -1로 설정하면 자동 갱신
RLock lock = redissonClient.getLock("myLock");
lock.lock(); // Watchdog가 30초마다 자동 갱신
try {
// 긴 작업도 안전
} finally {
lock.unlock();
}
주의: 무한 갱신 방지를 위해 최대 시간 설정 필요
다층 방어 (Defense in Depth)
@Service
public class PaymentService {
// 1단계: 분산락
@DistributedLock(key = "'payment:' + #orderId")
public void processPayment(Long orderId) {
// 2단계: 낙관적 잠금 (DB)
Order order = orderRepository.findByIdWithLock(orderId);
if (order.getVersion() != expectedVersion) {
throw new OptimisticLockException();
}
// 3단계: 멱등성 키
String idempotencyKey = generateKey(orderId);
if (paymentRepository.existsByIdempotencyKey(idempotencyKey)) {
return; // 이미 처리됨
}
// 4단계: DB 유니크 제약
// CREATE UNIQUE INDEX ON payments(order_id);
Payment payment = new Payment(orderId, idempotencyKey);
paymentRepository.save(payment); // unique 제약 위반 시 예외
}
}
-- 유니크 제약으로 2차 방어선
CREATE UNIQUE INDEX idx_purchase_code ON purchase(code);
CREATE UNIQUE INDEX idx_payment_order ON payments(order_id);
-- 체크 제약으로 음수 방지
ALTER TABLE product ADD CONSTRAINT chk_stock CHECK (stock >= 0);
@Aspect
@Component
@Slf4j
public class LockMonitoringAspect {
private final MeterRegistry meterRegistry;
@Around("@annotation(DistributedLock)")
public Object monitor(ProceedingJoinPoint pjp) throws Throwable {
String lockName = getLockName(pjp);
Timer.Sample sample = Timer.start(meterRegistry);
try {
Object result = pjp.proceed();
// 성공 메트릭
meterRegistry.counter("distributed.lock.acquired",
"lock", lockName,
"result", "success"
).increment();
return result;
} catch (Exception e) {
// 실패 메트릭
meterRegistry.counter("distributed.lock.acquired",
"lock", lockName,
"result", "failed"
).increment();
throw e;
} finally {
// 대기 시간 기록
sample.stop(meterRegistry.timer("distributed.lock.wait.time",
"lock", lockName
));
}
}
}
모니터링 지표
✅ 락 획득 실패 시 재시도 전략
✅ 락 해제 실패 대응
✅ 데드락 방지
✅ Redis 장애 대응
✅ 부하 테스트
| 요구사항 | 단일 Redis | RedLock | ZooKeeper |
|---|---|---|---|
| 구현 복잡도 | ⭐ 낮음 | ⭐⭐ 중간 | ⭐⭐⭐ 높음 |
| 운영 복잡도 | ⭐ 낮음 | ⭐⭐ 높음 (N개 노드) | ⭐⭐⭐ 높음 |
| 성능 | ⭐⭐⭐⭐ 빠름 | ⭐⭐ 중간 | ⭐ 느림 (합의 오버헤드) |
| 안전성 | ⭐⭐ 중간 | ⭐⭐⭐ 높음 | ⭐⭐⭐⭐ 매우 높음 |
| SPOF 위험 | ❌ 있음 | ✅ 낮음 | ✅ 없음 (Quorum) |
| 클럭 의존성 | ✅ 낮음 | ❌ 높음 | ✅ 낮음 (논리 시계) |
| 펜싱 토큰 | ❌ 없음 | ❌ 없음 | ✅ 있음 |
// 이 조합이면 99% 케이스 해결
@DistributedLock(key = "#orderId")
public void processOrder(Long orderId) {
// + DB 유니크 제약
// + 낙관적 잠금
// + 멱등성 키
}
주의사항
https://github.com/redisson/redisson
https://helloworld.kurly.com/blog/distributed-redisson-lock/