- 서버가 여러 대일 때 생기는 문제
- 분산 락이란?
- Redis 기반 분산 락 동작 원리
- Redisson으로 구현하기
- 실전: 커머스 재고 차감에 적용
- 핵심 파라미터 이해하기
- 분산 락 사용 시 주의사항
- 정리
1. 서버가 여러 대일 때 생기는 문제
서버 1대일 때는 간단하다
Java에서는 synchronized나 ReentrantLock으로 동시 접근을 막을 수 있다. 같은 JVM 안에서 동작하기 때문에 스레드 간 락 공유가 가능하다.
// 서버가 1대일 때 — 이것만으로 충분
private final ReentrantLock lock = new ReentrantLock();
public void reduceStock() {
lock.lock();
try {
// 재고 차감 로직
stock -= 1;
} finally {
lock.unlock();
}
}
서버가 여러 대가 되면?
실제 운영 환경에서는 트래픽 분산을 위해 서버를 여러 대 띄운다. 이때 각 서버는 독립된 JVM에서 동작하므로, Java 레벨 락은 서로 공유되지 않는다.
시즌그리팅 잔여재고: 1개
[서버 A] 사용자1 → 구매 요청 → Java 락 획득 → 재고 확인(1개) → 차감!
[서버 B] 사용자2 → 구매 요청 → Java 락 획득 → 재고 확인(1개) → 차감!
→ 두 서버 모두 성공 → 재고가 -1이 됨 (과판매 발생!)
각 서버의 Java 락은 자기 JVM 안에서만 유효하다. 서버 A의 락을 잡았다고 서버 B가 기다리지 않는다. 이것이 분산 환경에서 Java 기본 락의 한계다.
2. 분산 락이란?
분산 락은 여러 서버(프로세스)가 공통으로 바라보는 외부 저장소에 락을 거는 것이다. 모든 서버가 하나의 "중앙 열쇠 보관소"를 통해 순서를 정한다.
┌─────────────────┐
[서버 A] ──락 요청──→ │ │
│ Redis (중앙) │ ← 모든 서버가 여기를 봄
[서버 B] ──락 요청──→ │ │
└─────────────────┘
1. 서버 A가 먼저 도착 → Redis에 키 생성 성공 → 락 획득!
2. 서버 B가 도착 → 키가 이미 있음 → 대기...
3. 서버 A 작업 완료 → 키 삭제 → 락 해제
4. 서버 B가 감지 → 키 생성 성공 → 락 획득!
비유로 이해하기
| 개념 | 비유 |
|---|---|
| 분산 락 | 여러 지점의 직원이 공유하는 중앙 금고 열쇠 |
| Redis | 열쇠를 보관하는 중앙 보관소 |
| 락 키 | 금고마다 붙어있는 번호 (상품별로 다른 금고) |
| waitTime | 열쇠가 없으면 얼마나 기다릴지 |
| leaseTime | 열쇠를 빌린 후 자동 반납 시간 (잃어버려도 안전) |
3. Redis 기반 분산 락 동작 원리
Redis의 SETNX(SET if Not eXists) 명령이 핵심이다. "키가 없을 때만 생성" — 이 원자적(atomic) 연산이 분산 락의 기반이 된다.
Step by Step
── Step 1: 서버 A가 락을 요청 ──
Redis 명령: SET lock:product:SG2025 "serverA" NX EX 120
│ │ │ │
키 이름 값 │ 만료시간(초)
└─ 키가 없을 때만 생성
결과: OK (키가 없었으므로 생성 성공 → 락 획득!)
── Step 2: 서버 B가 락을 요청 ──
Redis 명령: SET lock:product:SG2025 "serverB" NX EX 120
결과: (nil) (키가 이미 있으므로 생성 실패 → 대기 또는 재시도)
── Step 3: 서버 A 작업 완료, 락 해제 ──
Redis 명령: DEL lock:product:SG2025
결과: 1 (키 삭제 성공 → 락 해제)
── Step 4: 서버 B 재시도 ──
Redis 명령: SET lock:product:SG2025 "serverB" NX EX 120
결과: OK (키가 없으므로 생성 성공 → 락 획득!)
왜 Redis인가?
| 특성 | 설명 |
|---|---|
| 싱글 스레드 | Redis는 명령을 순차 처리하므로 SETNX 연산의 원자성이 보장된다 |
| 고성능 | 인메모리 기반이라 락 획득/해제가 1ms 이내 |
| TTL 지원 | 키에 만료 시간을 설정하여 데드락 방지 가능 |
| 이미 사용 중 | 대부분의 서비스가 이미 캐시용으로 Redis를 운영 → 추가 인프라 비용 없음 |
4. Redisson으로 구현하기
Redis에 직접 SETNX 명령을 보내도 되지만, 재시도 로직, 자동 만료, 재진입 락 등을 직접 구현해야 한다. Redisson은 이 모든 것을 lock.tryLock() / lock.unlock() 두 줄로 해결해주는 Java 라이브러리다.
4-1. Redisson 설정
@Configuration
public class RedissonConfig {
private @Value("${spring.redis.host}") String redisHost;
private @Value("${spring.redis.port}") int redisPort;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + redisHost + ":" + redisPort)
.setConnectionPoolSize(60);
return Redisson.create(config);
}
}
Redis 연결 정보만 넣어주면 RedissonClient Bean이 생성된다. 이후 이 Bean을 주입받아 락을 사용하면 된다.
4-2. 기본 사용법
// Redisson의 분산 락 사용 기본 패턴
RLock lock = redissonClient.getLock("lock:product:SG2025");
if (lock.tryLock(120, 120, TimeUnit.SECONDS)) {
// │ │
// │ └─ leaseTime: 락 점유 최대 시간
// └─ waitTime: 락 획득 대기 최대 시간
try {
// 여기서 안전하게 재고 차감!
reduceStock();
} finally {
lock.unlock(); // 반드시 해제
}
} else {
// 120초 동안 락을 못 잡음 → 에러 처리
throw new RuntimeException("락 획득 실패");
}
tryLock vs locklock()은 락을 잡을 때까지 무한 대기한다.tryLock()은 지정한 시간만 대기하고, 실패하면 false를 반환한다. 운영 환경에서는 무한 대기를 피하기 위해 tryLock이 권장된다.
5. 실전: 커머스 재고 차감에 적용
매번 getLock() → tryLock() → unlock()을 반복 작성하면 코드가 지저분해진다. 커스텀 어노테이션 + AOP로 깔끔하게 만들 수 있다.
5-1. 커스텀 어노테이션 정의
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key(); // 락 키 (SpEL 표현식 지원)
long waitTime() default 10; // 락 대기 시간 (초)
long leaseTime() default 10; // 락 점유 시간 (초)
boolean fair() default false; // 공정 락 여부
}
5-2. AOP Aspect — 락 로직을 자동화
@DistributedLock이 붙은 메서드가 호출되면, AOP가 자동으로 메서드를 감싸서 락 획득 → 실행 → 해제를 처리한다.
@Aspect
@Component
public class DistributedLockAspect {
private final RedissonClient redissonClient;
@Around("@annotation(lockAnnotation)")
public Object around(ProceedingJoinPoint joinPoint,
DistributedLock lockAnnotation) throws Throwable {
// 1. SpEL로 동적 키 생성
String lockKey = parseKey(lockAnnotation.key(), joinPoint);
// 2. Redisson 락 객체 생성
RLock lock = redissonClient.getLock(lockKey);
// 3. 락 획득 시도
if (lock.tryLock(lockAnnotation.waitTime(),
lockAnnotation.leaseTime(),
TimeUnit.SECONDS)) {
try {
// 4. 원래 메서드 실행
return joinPoint.proceed();
} finally {
// 5. 반드시 락 해제
lock.unlock();
}
} else {
throw new RuntimeException("락 획득 실패: " + lockKey);
}
}
// SpEL 표현식을 파싱하여 실제 키 문자열로 변환
private String parseKey(String spEL, ProceedingJoinPoint joinPoint) {
StandardEvaluationContext context = new StandardEvaluationContext();
Object[] args = joinPoint.getArgs();
String[] paramNames = ((MethodSignature) joinPoint.getSignature())
.getParameterNames();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
return new SpelExpressionParser()
.parseExpression(spEL)
.getValue(context, String.class);
}
}
SpEL(Spring Expression Language)이 핵심이다.
락 키를 하드코딩하면 모든 상품이 같은 락을 공유하게 된다. SpEL을 사용하면 메서드 파라미터 값을 런타임에 키로 만들 수 있어, 상품 그룹별로 독립된 락을 생성할 수 있다.
5-3. 실제 적용 — 결제 완료 검증
@DistributedLock(
key = "'lock:itemGroupKey:' + #tOrder.displayGroupKey",
// └─ 예: tOrder.displayGroupKey가 "SG2025"이면
// 최종 키 = "lock:itemGroupKey:SG2025"
waitTime = 120, // 최대 120초 대기
leaseTime = 120, // 최대 120초 점유
fair = false
)
public void validateComplete(TOrder tOrder, Long userId,
Map<Long, TDisplayItemGroup> tDisplayItemGroupMap,
Map<Long, TDisplayItem> tDisplayItemMap) {
validateOrderUser(tOrder, userId);
validateOrder(tOrder, tDisplayItemGroupMap, tDisplayItemMap);
}
이 메서드가 호출되면 실제로는 다음과 같이 동작한다:
validateComplete(order, userId, ...) 호출
│
├─ [AOP 진입]
│ ├─ SpEL 파싱: "lock:itemGroupKey:SG2025"
│ ├─ Redis에 락 요청: tryLock(120s, 120s)
│ │
│ ├─ 락 획득 성공:
│ │ ├─ validateOrderUser() 실행
│ │ ├─ validateOrder() 실행
│ │ └─ finally: lock.unlock() ← 락 해제
│ │
│ └─ 락 획득 실패 (120초 초과):
│ └─ RuntimeException 발생
│
└─ [AOP 종료]
메서드 코드에는 락 로직이 전혀 없고, 어노테이션 한 줄만으로 분산 락이 적용된다. 이것이 AOP의 장점이다.
5-4. 상품별 독립 락이 중요한 이유
/* 만약 락 키가 하나라면? */
key = "lock:global"
사용자A → 시즌그리팅 구매 → 락 획득 → 처리중...
사용자B → 포토카드 구매 → 대기 (전혀 다른 상품인데?!)
/* 상품 그룹별 키라면? */
key = "lock:itemGroupKey:" + displayGroupKey
사용자A → 시즌그리팅(SG2025) 구매 → lock:itemGroupKey:SG2025 획득
사용자B → 포토카드(PC2025) 구매 → lock:itemGroupKey:PC2025 획득
→ 서로 다른 락이므로 동시에 진행 가능! (성능 OK)
사용자C → 시즌그리팅(SG2025) 구매 → lock:itemGroupKey:SG2025 대기
→ 같은 상품이므로 사용자A 완료 후 진행 (재고 안전!)
6. 핵심 파라미터 이해하기
waitTime — 얼마나 기다릴 것인가
다른 서버가 락을 잡고 있을 때, 최대 얼마나 기다릴지를 정하는 값이다.
| waitTime | 상황 | 적합한 경우 |
|---|---|---|
| 짧게 (5~10초) | 못 잡으면 빠르게 실패 | 빠른 응답이 중요한 API |
| 길게 (120초) | 꽤 오래 기다려서라도 처리 | 결제처럼 반드시 처리해야 하는 경우 |
이 프로젝트에서 120초로 설정한 이유는, 한정판 오픈 직후 수천 건의 동시 요청이 몰릴 때 락 대기 큐가 길어질 수 있고, 결제 요청은 가능한 한 실패시키지 않아야 하기 때문이다.
leaseTime — 데드락 방지 안전장치
락을 잡은 서버가 비정상 종료되면 unlock()이 실행되지 않는다. leaseTime이 없다면 다른 모든 서버가 영원히 대기하게 된다.
── leaseTime이 없는 경우 (위험!) ──
[서버 A] 락 획득 → 처리 중 → 서버 다운! → unlock() 미실행
[서버 B] 락 대기... 영원히... 데드락!
── leaseTime = 120초인 경우 (안전) ──
[서버 A] 락 획득 → 처리 중 → 서버 다운! → unlock() 미실행
... 120초 경과 ...
Redis가 TTL 만료로 키 자동 삭제 → 락 자동 해제
[서버 B] 락 획득 → 정상 처리!
주의: leaseTime은 실제 작업 시간보다 충분히 길어야 한다. 작업이 끝나기 전에 leaseTime이 만료되면, 다른 서버가 락을 잡아서 동시 처리가 발생할 수 있다.
fair — 순서를 보장할 것인가
| 옵션 | 동작 | 특징 |
|---|---|---|
fair = false |
락이 해제되면 대기 중인 아무나 획득 | 성능 우수, 순서 미보장 |
fair = true |
먼저 요청한 순서대로 획득 (FIFO) | 공정하지만 성능 오버헤드 |
이 프로젝트에서는 fair = false를 사용한다. 결제에서 중요한 건 "누가 먼저 처리되느냐"가 아니라 "재고가 정확히 차감되느냐"이기 때문이다.
7. 분산 락 사용 시 주의사항
7-1. 반드시 finally에서 해제
비즈니스 로직에서 예외가 발생해도 락은 해제되어야 한다. finally 블록에서 unlock()을 호출하지 않으면, leaseTime 만료까지 다른 요청이 모두 대기하게 된다.
// ❌ 잘못된 예 — 예외 시 unlock 미실행
lock.tryLock(120, 120, TimeUnit.SECONDS);
doSomething(); // 여기서 예외 발생하면?
lock.unlock(); // 이 줄 실행 안 됨!
// ✅ 올바른 예 — finally 보장
if (lock.tryLock(120, 120, TimeUnit.SECONDS)) {
try {
doSomething(); // 예외가 발생해도
} finally {
lock.unlock(); // 반드시 실행됨!
}
}
7-2. 락 범위(키)를 좁게 설계
락 키가 너무 넓으면 불필요한 대기가 발생한다.
| 키 설계 | 예시 | 영향 |
|---|---|---|
| 너무 넓음 | lock:shop |
모든 상품 구매가 순차 처리 → 병목 |
| 적절함 | lock:itemGroupKey:{groupKey} |
같은 상품 그룹만 순차 처리 → 균형 |
| 너무 좁음 | lock:order:{orderId} |
주문별 독립이라 재고 보호 불가 |
7-3. Redis 장애 대비
분산 락은 Redis에 의존한다. Redis가 다운되면 모든 락 요청이 실패한다. 이를 위한 대비책이 필요하다.
| 대비책 | 설명 |
|---|---|
| Redis Sentinel | 마스터 장애 시 자동 페일오버 |
| Redis Cluster | 데이터 분산 + 고가용성 |
| Fallback 전략 | 락 획득 실패 시 DB 비관적 락으로 대체 |
7-4. 락 안에서 외부 API 호출 주의
락을 잡은 상태에서 외부 API(PG 결제 승인 등)를 호출하면, 외부 API 응답이 느릴 경우 leaseTime이 초과될 수 있다. 가능하면 락 범위를 최소화하고, 외부 호출은 락 밖에서 처리하는 게 좋다.
// ❌ 락 안에서 외부 API 호출 — leaseTime 초과 위험
@DistributedLock(key = "...", leaseTime = 30)
public void process() {
validateStock(); // 1초
callPgApi(); // 외부 API — 30초 넘으면?!
reduceStock(); // 락이 이미 풀렸을 수 있음
}
// ✅ 락은 재고 검증에만 사용 — 외부 호출은 밖에서
@DistributedLock(key = "...", leaseTime = 30)
public void validateComplete() {
validateStock(); // 재고 검증만 (빠름)
}
public void processComplete() {
validateComplete(); // 락 안 (검증)
callPgApi(); // 락 밖 (외부 호출)
reduceStock(); // 원자적 연산으로 처리 (INCRBY)
}
8. 정리
| 구분 | Java 기본 락 | 분산 락 (Redis) |
|---|---|---|
| 적용 범위 | 단일 JVM (서버 1대) | 모든 서버 (다중 인스턴스) |
| 저장 위치 | JVM 메모리 | Redis (외부 저장소) |
| 데드락 방지 | 직접 구현 필요 | TTL(leaseTime) 자동 해제 |
| 성능 | 매우 빠름 (나노초) | 빠름 (밀리초, 네트워크 비용 포함) |
| 장애 시 | 서버 재시작으로 자동 해제 | TTL 만료로 자동 해제 |
| 구현 난이도 | 낮음 | Redisson 사용 시 낮음 |
핵심 요약
1. 서버가 여러 대면 Java 기본 락으로는 동시성 제어가 안 된다.
2. Redis같은 외부 저장소에 락을 걸어 모든 서버가 순서를 지키게 하는 것이 분산 락이다.
3. Redisson + @DistributedLock 어노테이션으로 깔끔하게 구현할 수 있다.
4. leaseTime은 데드락 방지의 안전장치, 락 키는 좁게 설계하는 것이 성능의 핵심이다.
'DEV Heart' 카테고리의 다른 글
| 메세징! Kafka vs SQS (0) | 2026.03.17 |
|---|---|
| Object랑 T로 받았을떄 무슨 차이야? (2) | 2025.06.30 |
| Elastic search (0) | 2023.06.23 |
| javax.validation @어노테이션 (0) | 2021.12.27 |
| 초간단 Spring 프로젝트 생성 + dependencies 추가 (0) | 2021.12.20 |