시작은 단순했다.
단위 테스트는 모두 통과했지만
통합 테스트 환경에서 동시에 요청이 들어오면 간헐적인 오류가 발생하곤 했다.
우선 낙관적 락을 고려했다.
일부 도메인에서는 @Version을 활용해 재시도 기반 흐름을 구성해봤지만
충돌 가능성, 재시도 설계/구현의 복잡도, UX 처리의 불명확성 등을 고려했을 때
적합하지 않은 시나리오가 많았다.
그런 경우 비관적 락과 트랜잭션을 조합해 문제를 해결했고
동시성 문제를 일정 수준 안정적으로 제어할 수 있었다.
하지만 여전히 아쉬움이 남아 Redis 기반 분산락을 시도하게 됐다.
- 응답 시간 개선
- DB 부하 감소
- 멀티 인스턴스 대응 가능
하지만 실제로 비관적 락을 제거하고 Redis 락을 대체하려 하자 또 다른 문제가 발생했다.
- 동시성 테스트 실패
- 테스트 시나리오 재작성
- TTL 설정, 락 해제 검증, race condition 회피 등 설계 복잡성 증가
그래서 질문은 바뀌었다.
✅ “어떤 락이 더 좋은가?”가 아니라
🔍 “어떤 자원에, 어떤 조건에서, 어떤 방식으로 락을 써야 할까?”가 진짜 고민이었다.
🟦 낙관적 락, 정말 낙관해도 괜찮을까?
1. 낙관적 락의 개념
낙관적 락은 동시성 충돌이 ‘드물 것’이라는 가정 하에, 잠금을 걸지 않고 작업을 먼저 수행한 뒤
최종 저장 시점에 데이터가 바뀌었는지를 확인하여 충돌을 감지하는 방식이다.
- 실제로는 "버전 번호"나 "타임스탬프" 를 기준으로 충돌을 감지한다.
- 충돌이 없으면 정상 커밋되고, 충돌이 감지되면 예외를 던지고 트랜잭션은 롤백된다.
2. JPA에서의 적용 방식
JPA에서는 @Version 어노테이션을 통해 낙관적 락을 쉽게 구현할 수 있다.
@Entity
public class Product {
@Id
private Long id;
private int stock;
@Version
private Long version; // 이 필드로 낙관적 락을 적용함
}
이렇게 정의하면 JPA는 아래와 같은 쿼리를 생성한다:
UPDATE product
SET stock = ?, version = version + 1
WHERE id = ? AND version = ?
- 이 쿼리는 version 값이 일치해야만 업데이트가 수행된다.
- 만약 누군가 먼저 업데이트를 완료해 version이 바뀌었다면 해당 쿼리는 영향을 주지 못하고 0 row가 업데이트된다.
- 이 경우 OptimisticLockingFailureException 예외가 발생한다.
3. 낙관적 락이 적합한 상황
- 충돌 가능성이 낮고
- 재시도에 따른 UX 손실이 크지 않으며
- 성능을 중요하게 생각하는 경우
케이스 | 낙관적 락 적합 이유 |
---|---|
인기 상품 조회수 증가 | 경합이 거의 없음, 실패 시 재시도 간단 |
게시글 좋아요 수 증가 | 변경 충돌 발생 확률 낮음 |
마이페이지 정보 갱신 | 사용자별 독립 작업, 충돌 가능성 낮음 |
4. 낙관적 락의 한계와 복잡성
🔴 재시도 정책의 부담
낙관적 락의 본질은 실패를 감수하고 빠르게 시도하는 것이다.
실패 시 어떻게 재시도할지를 명시적으로 설계해야 한다.
int maxRetry = 3;
int retryCount = 0;
while (retryCount < maxRetry) {
try {
service.update();
break;
} catch (ObjectOptimisticLockingFailureException e) {
retryCount++;
Thread.sleep(100); // backoff
}
}
이렇게 되면 비즈니스 로직과 충돌 처리 로직이 섞이게 되며,
테스트 코드도 그 복잡도를 따라가야 하는 구조가 된다.
🔴 실패 시 UX가 모호해짐
낙관적 락 충돌로 인한 실패는 사용자 입장에서 "왜 실패했는지" 알기 어렵다.
- 주문 결제 실패?
- 정보 갱신 실패?
- 업데이트 타이밍이 겹쳤기 때문에?
🔴 테스트와 디버깅 어려움
- 단위 테스트로는 충돌 조건을 재현하기 어렵다.
- 통합 테스트나 부하 테스트 환경에서만 발견되는 충돌이 많음.
- “충돌 발생 가능성은 낮지만 발생했을 때 영향은 큰” 시나리오에서 설계 검증이 어려움.
5. 낙관적 락을 적용할지 판단하는 기준
판단 기준 | 낙관적 락 적합 | 비적합 |
---|---|---|
충돌 가능성 | 낮다 | 높다 |
재시도 복잡도 | 낮다 | 높다 |
처리 대상 수 | 적다 | 많다 |
사용자 경험 영향 | 미미 | 크다 |
시스템 구조 | 분산 환경에서 간단한 흐름 | 중앙 집중 or 다수 요청 경합 |
🔒 비관적 락, 확실하지만 조심스러운 선택
1. 개념
비관적 락은 말 그대로
“언젠가는 충돌이 날 것”이라는 전제를 깔고
미리 해당 자원을 점유하는 방식이다.
- 락을 걸고 작업을 시작하기 때문에 다른 트랜잭션이 해당 자원에 접근하면 대기하거나 예외 발생
- 동시성 문제가 발생할 여지를 원천 차단하는 전략
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Product findByIdWithLock(@Param("id") Long id);
이렇게 하면 해당 Row에 대해 SELECT ... FOR UPDATE 쿼리가 실행된다.
2. 어떤 락이 걸리는가?
SELECT * FROM product WHERE id = ? FOR UPDATE;
- 이 쿼리는 트랜잭션이 끝날 때까지 해당 Row에 쓰기 락이 걸린다.
- 다른 트랜잭션이 해당 Row를 수정하거나 삭제하려고 하면 대기하거나 Deadlock이 발생할 수 있다.
- MySQL InnoDB의 경우 레코드 락(Row Lock) 이며 Index가 없거나 조건이 애매하면 테이블 락으로 확장될 수 있음.
3. 비관적 락이 적합한 상황
상황 | 비관적 락이 적합한 이유 |
---|---|
재고 감소, 선착순 쿠폰 | 경쟁 조건이 심각하고 충돌 발생 시 금전적 손실이 큼 |
동일 자원에 대한 높은 쓰기 빈도 | 충돌 가능성이 높고 반드시 순서 보장이 필요 |
중복 결제 방지 | 동시 요청에 대한 강력한 제어가 필요 |
4. 락은 걸었지만.. 문제가 시작됐다
⚠️ 1. Deadlock
락 순서가 꼬이면 순식간에 데드락이 발생한다.
Tx1: 상품1 → 상품2
Tx2: 상품2 → 상품1
- 같은 트랜잭션에서 다중 row를 lock할 경우 획득 순서 보장이 없으면 교착상태 발생
- 해결책: 항상 동일한 정렬 순서로 조회하거나 하나의 쿼리로 묶기
⚠️ 2. Lock Timeout
- 동시 요청이 많으면 대기하다가 타임아웃 발생
- 사용자 요청에서 재시도 처리가 없으면 "알 수 없는 에러"로 UX 최악
- 비즈니스 로직과 예외 핸들링을 같이 고민해야 함
try {
// 비관적 락으로 상품 조회 후 수정
} catch (CannotAcquireLockException e) {
// 락 획득 실패 - 사용자에게 안내
}
⚠️ 3. 트랜잭션 누수
- 락은 트랜잭션 범위 안에서만 유지되기 때문에,
- 락을 걸고 트랜잭션이 길어지면 시스템 전체의 성능 저하를 야기한다.
- 특히 락 걸린 상태에서 외부 API 호출 등 blocking 작업이 있으면 락 홀드 시간이 비정상적으로 늘어남
5. 테스트 환경에서의 혼란
실제로는 락을 걸었지만 단위 테스트 환경에서는 락이 제대로 동작하지 않는 착각을 겪기 쉽다.
- H2 DB 등에서는 FOR UPDATE가 무시되거나 다중 커넥션이 아닌 환경에서는 의미 없는 테스트가 된다.
- 통합 테스트에서만 확인 가능한 문제들이 존재함
6. 실무 적용 예시
@Transactional
public void order(Long productId) {
Product product = productRepository.findByIdWithLock(productId);
if (product.getStock() <= 0) {
throw new SoldOutException();
}
product.decreaseStock();
// 이후 저장 로직
}
🧭 그럼 비관적 락을 쓰면 되는 걸까?
비관적 락은 분명히 안정적이다.
하지만 락이 가진 성격은 곧바로 설계의 제약으로 이어진다.
- 어떤 시점에 락을 거는가?
- 하나의 자원에만 락을 걸면 충분한가?
- 여러 자원을 동시에 갱신해야 할 땐 어떻게 할까?
비관적 락은 기능 그 자체보다도
“락의 단위와 범위, 트랜잭션 구조” 를 어떻게 설계하느냐에 따라
성공과 실패가 갈린다.
🔐 Redis 분산락, 언제 어떻게 쓸까?
1. Redis 분산락의 핵심 개념
Redis 분산락은 중앙 집중형 데이터베이스가 아닌 외부 공유 자원(Redis)을 활용하여 멀티 인스턴스 환경에서도 락을 걸 수 있게 해주는 기술이다.
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, clientId, Duration.ofSeconds(3));
- SET NX EX 명령으로 락을 설정한다.
- 실패하면 재시도하거나 실패 처리한다.
- TTL을 설정하여 락 해제 실패 시 자동 만료된다.
→ 핵심은 "락을 점유했다는 신뢰성"을 어떻게 확보할 것인가다.
2. 왜 Redis 락을 고민하게 되었나?
앞서 비관적 락은 DB Row에 직접 락을 걸어 강력한 제어를 제공하지만
- 트랜잭션 홀드 시간 동안 DB 성능 저하
- 데드락 발생 위험
- 인스턴스 수평 확장에 제약
이라는 단점이 있었고
낙관적 락은 재시도 흐름이 복잡하고 불확실했다.
이런 배경에서 Redis 락은 다음과 같은 이유로 적합한 대안이 될 수 있었다:
상황 | 비관적 락 한계 | Redis 락이 유리한 이유 |
---|---|---|
재고 감소 | DB 락 충돌 시 전체 대기 | 빠르게 실패하고 로직 유연화 가능 |
선착순 쿠폰 | 높은 경합, 빠른 응답 필요 | 락 점유 여부만 빠르게 판단 |
인기 랭킹 집계 | 트래픽 높고, 실시간성 중요 | Redis 접근은 훨씬 빠르고 유연함 |
3. Redis 락 사용 시 고려할 사항
- 락 점유자 구분 (clientId 필수) → 동일 자원이 여러 노드에서 접근될 수 있으므로 락 점유자를 명확히 식별해야 함.
- TTL 설정 및 연장 정책 → 락이 무한정 유지되는 상황은 시스템 위험 요소다.
- 락 해제 시 검증 → 락을 해제할 때는 clientId가 동일한지 반드시 검증해야 함.
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
- 락 선점 실패 시 로직 설계 → 재시도 vs 바로 실패 처리? 비즈니스 요구에 따라 결정.
4. 시퀀스 흐름 예시
Client -> Redis: SET lockKey clientId NX PX 3000
alt 락 획득 성공
Client -> DB: 트랜잭션 처리 (재고 감소 등)
Client -> Redis: Lua Script 통해 락 해제
else 실패
Client: 실패 처리 or 재시도
end
DB 락은 트랜잭션 범위 내에서 자동으로 관리되지만
Redis 락은 별도의 리소스이므로 락 해제 시점을 개발자가 직접 제어해야 한다.
5. Redis 락의 장단점
✅ 장점
- DB 부하 없음
- 인스턴스 간 공유 가능 (멀티 서버 구조에 적합)
- TTL 기반으로 데드락 방지
- 빠른 응답이 중요한 환경에 적합
⚠️ 주의점
- 락 유실 가능성 (ex. Redis 다운, TTL 만료)
- 락 해제 로직 미흡 시 데이터 불일치 가능
- 분산환경에서는 Redlock 알고리즘 등의 보강 필요
6. 언제 Redis 락을 선택할까?
조건 | Redis 락 적합도 |
---|---|
단일 자원 잠금, 빠른 처리 | ✅ 매우 적합 |
높은 트래픽, 읽기-쓰기 간섭 적음 | ✅ 적합 |
긴 트랜잭션 / 외부 API 포함 | ❌ 부적합 (DB 락 필요) |
확실한 순차성 보장 필요 | ❌ 부적합 (DB 락이 더 안정적) |
🔍 어디에 락을 걸어야 할까?
락은 성능을 희생해서 정합성을 지키는 도구다.
따라서 락이 필요한 자원은 다음의 특성을 가져야 한다:
✅ 1. 공유 자원인가?
락은 공유되는 데이터가 동시에 수정될 위험이 있을 때만 필요하다.
- X: 회원 로그인 기록 → 사용자별 독립 저장, 락 불필요
- O: 상품 재고 → 여러 사용자가 동시에 구매 가능, 락 필요
✅ 2. 정합성 손실이 치명적인가?
수정 충돌로 인해 비즈니스 손실이나 UX 오류가 발생한다면 락을 걸어야 한다.
- 주문 중복 생성 → 결제 이중 처리, 환불 문제
- 쿠폰 초과 발급 → 마케팅 비용 초과, 사용자 불신
- 재고 음수 발생 → 고객에게 없는 상품 판매
✅ 3. 락이 필요한 최소 단위는 무엇인가?
- 상품 단위로 락을 걸면? → 한 상품의 재고만 제어 가능
- 카테고리 단위로 락을 걸면? → 동시성은 줄지만 지나치게 범위가 넓어져 성능 저하
최소한의 단위로, 꼭 필요한 자원만 보호하라
✅ 4. 락을 거는 ‘시점’은 언제가 적절한가?
- 너무 이른 시점에 락을 걸면 → 불필요한 대기와 병목
- 너무 늦게 락을 걸면 → 이미 정합성이 깨졌을 수 있음
예시) 쿠폰 발급
- ❌ 잘못된 시점: DB에서 남은 수량 조회 후, 로직 수행 중 후속 요청이 수량을 차감함
- ✅ 올바른 시점: 수량 차감 시점에 트랜잭션 락 or 분산 락 적용
🧭 락이 필요한 자원과 그렇지 않은 자원을 구분하자
자원 | 락 필요 여부 | 이유 |
---|---|---|
유저 프로필 | ❌ | 개별 사용자 독립 자원 |
상품 재고 | ✅ | 경합, 수량 오류 발생 가능 |
게시글 좋아요 수 | 경우에 따라 | 통계성 데이터는 무락 처리도 가능 |
쿠폰 발급 수량 | ✅ | 마케팅/비용과 직결됨 |
인기 상품 조회수 | ❌ or 낙관적 락 | 충돌 가능 낮고, 실패해도 재시도 용이 |
🔐 어디에, 어떤 락을 사용할까?
🧩 1. 정합성이 가장 중요한 자원에는 비관적 락
대표 자원 | 이유 | 적용 방식 |
---|---|---|
상품 재고 | 재고가 음수가 되면 판매 불가 → 실질 피해 | SELECT ... FOR UPDATE 사용 |
쿠폰 수량 | 초과 발급 시 마케팅 손실 | JPA PESSIMISTIC_WRITE |
주문 중복 방지 | 이중 결제 및 재고 소진 가능성 | 자원에 대한 강력한 락 필요 |
➡ 즉시성, 강한 정합성이 중요하고, 충돌 가능성이 높은 자원에 적합
➡ 단점은 성능 저하, 데드락 가능성, 트랜잭션 길이에 민감
🪶 2. 충돌 가능성은 낮지만 보호는 필요한 자원에는 낙관적 락
대표 자원 | 이유 | 적용 방식 |
---|---|---|
마이페이지 정보 수정 | 사용자 단위 → 대부분 충돌 없음 | @Version 필드 이용 |
좋아요 수 증가 | 경합은 적고, 실패 시 UX 영향 적음 | 실패 시 재시도 로직 포함 |
조회수 증가 | 통계용, 재시도해도 무방 | 간단한 실패 처리로 충분 |
➡ 데이터 정합성은 필요하지만 트래픽이 많거나 빠른 처리가 필요한 곳에 적합
➡ 단점은 재시도 설계 필요, 복잡한 UX 대응, 충돌 예외 처리
🌐 3. 분산 환경 + 동시 접근 제어에는 Redis 분산 락
대표 자원 이유 적용 방식
대표 자원 | 이유 | 적용 방식 |
---|---|---|
선착순 쿠폰 발급 | 동시에 여러 서버에서 발급 요청 → 분산 락 필요 | Redisson, Lettuce 등 사용 |
재고 감소 (분산 구조) | DB 락은 인스턴스 로컬 스코프이기에 분산 서버 간 제어엔 적합하지 않음 | Redis SETNX + TTL 조합 |
결제 중복 방지 (외부 API) | 외부 호출 중 중복 요청 → 락으로 제어 | 분산 환경에서 글로벌 락 필요 |
➡ 멀티 인스턴스, API Gateway, 서버 간 경쟁 환경에 강력
➡ 단점은 락 해제 실패 대응, TTL 설정, 신뢰성 확보 추가 설계 필요
🔎 적용 전략 요약
판단 기준 | 비관적 락 | 낙관적 락 | Redis 분산 락 |
---|---|---|---|
충돌 가능성 | 높음 | 낮음 | 중간 ~ 높음 |
트래픽 대응력 | 낮음 | 중간 | 높음 (분산 처리에 적합) |
재시도 복잡도 | 없음 | 있음 (명시적 필요) | 없음 (락 실패 시 우회 가능) |
정합성 요구 수준 | 매우 높음 | 보통 | 매우 높음 |
설계 난이도 | 낮음 (직관적) | 중간 (재시도/예외처리) | 높음 (TTL, 해제 검증 등) |
분산 환경 적합성 | ❌ 낮음 | ❌ 낮음 | ✅ 매우 높음 |
🧠 실무 판단 팁
- 시작은 비관적 락 + 트랜잭션 → 가장 안전하며 설계가 단순함.
- 낙관적 락은 트래픽/재시도 적고 UX가 중요하지 않을 때 고려
- Redis 락은 분산 환경에서 락이 ‘서버 범위’를 넘어야 할 때 → 단일 인스턴스라면 DB 락으로도 충분할 수 있음.
🎯 비교 대상 시나리오 : 선착순 쿠폰 발급
1. 비즈니스 요구사항
- 선착순 100명에게만 발급
- 중복 발급은 안 됨
- 트래픽은 많음 (1만 동시 요청)
🧩 DB 기반 락 (비관적 락 or 낙관적 락)
🔗 흐름 시퀀스 (DB 락 기반)
[사용자 요청]
↓
[DB 트랜잭션 시작]
↓
[쿠폰 조회 + FOR UPDATE]
↓
[중복 확인 → 발급]
↓
[트랜잭션 커밋]
✅ 장점
- 데이터 정합성이 강력하게 보장됨 (ACID)
- 이미 도입된 트랜잭션 시스템에 자연스럽게 녹아듦
⚠️ 단점
- 락 획득 경쟁 발생 → DB 부하 증가
- DB 스케일업 없이는 수평 확장 어려움
- 부하 테스트 시 Deadlock or Lock Timeout 가능성
- 사용자 경험: 대기 or 실패가 느리게 전달됨
🔐 Redis 기반 락 (분산락)
🔗 흐름 시퀀스 (Redis 락 기반)
[사용자 요청]
↓
[Redis SET NX 시도]
↓
락 획득 성공 → [DB 트랜잭션 시작]
↓
[발급 처리]
↓
[Redis 락 해제 (Lua)]
✅ 장점
- 락 획득 자체가 빠르고 독립적 (네트워크 분산 구조)
- DB에 불필요한 부하 안 줌 → Scale-out 대응 유리
- 트래픽 제어 역할까지 가능 (ex. 1 user 1 request 제한)
⚠️ 단점
- 락 TTL 만료 시점, 해제 실패 등 설계 고려 사항 많음
- 락 획득 후 DB 실패 → 중복 처리 여부 직접 관리 필요
- Redis 다운 or 네트워크 지연 시 예외 처리 설계 필요
🧠 그래서 어떤 전략이 맞을까?
조건 | Redis 락 | DB 락 |
---|---|---|
트래픽 많음 (1만+) | 👍 (Scale-out 유리) | ❌ (DB 병목 위험) |
정합성 최우선 | ⚠️ (직접 관리 필요) | 👍 (ACID 기반 보장) |
단일 노드 환경 | ❌ | 👍 |
멀티 인스턴스 | 👍 (분산 환경에 적합) | ❌ |
코드 간결성 | ❌ | 👍 |
락 제어 세밀함 필요 | 👍 | ❌ |
🔄 결론: 같은 흐름, 다른 전략
- Redis 락은 "락을 빨리 잡고, 쿼리는 나중에 최소한으로 간다"
- DB 락은 "쿼리 자체가 락이다. 순서 보장과 정합성이 핵심이다"
🧠 결국 락은 ‘정합성, 트래픽, 구조’ 이 세 가지 축을 바탕으로 선택되어야 한다.
🔎 하나의 정답은 없다.
🛠️ 중요한 건 “왜 이 자원에 이 락을 썼는지”를 말할 수 있는 자신감이다.