- Published on
MySQL InnoDB 데드락 폭증, Redis 분산락으로 잠재우기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 트래픽이 늘거나 특정 이벤트(정산, 쿠폰, 좌석 선점, 재고 차감)가 겹치면 MySQL InnoDB 데드락이 갑자기 폭증하는 경우가 있습니다. 애플리케이션은 Deadlock found when trying to get lock; try restarting transaction 예외를 뿜고, 재시도 로직이 더 많은 트랜잭션을 밀어 넣어 상황이 악화되기도 합니다.
이 글은 “왜 갑자기 데드락이 늘었는지”를 InnoDB 관점에서 짚고, DB 내부 락 경합을 애플리케이션 앞단에서 줄이는 방법으로 Redis 분산락을 적용하는 설계를 다룹니다. 또한 Redlock을 둘러싼 오해/주의점, 그리고 더 안전한 대안(단일 Redis + fencing token, 혹은 DB 기반 직렬화)까지 현실적인 선택지를 정리합니다.
운영 진단 관점에서의 접근법은 장애를 빠르게 좁히는 습관이 중요합니다. 인프라·플랫폼 이슈를 10분 내로 좁히는 루틴이 필요하다면 EKS Pod간 통신만 실패? MTU·PMTUD 10분 진단 같은 글의 방식(가설-검증-로그/지표 체크리스트)을 응용해보면 좋습니다.
InnoDB 데드락이 “폭증”하는 전형적 패턴
데드락은 “서로가 서로의 락을 기다리는 순환 대기”입니다. InnoDB는 데드락을 감지하면 한 트랜잭션을 희생(victim)시켜 롤백합니다. 문제는 데드락 자체보다, 데드락이 폭증하는 조건이 따로 있다는 점입니다.
1) 동일 자원에 대한 동시 업데이트가 급증
- 재고 차감:
UPDATE stock SET qty = qty - 1 WHERE product_id = ? AND qty > 0 - 유니크 키 생성 경쟁: 주문번호, 쿠폰 사용 기록, 멱등키 테이블
- “하나만 만들기” 패턴: 특정 사용자에 대한 row 생성 경쟁
이때 애플리케이션이 재시도를 걸면, 실패한 요청이 다시 DB로 들어가며 경합이 더 커집니다.
2) 인덱스 설계/쿼리 패턴 때문에 잠금 범위가 커짐
InnoDB는 레코드 락뿐 아니라 넥스트키 락(next-key lock)으로 갭(gap)을 잠글 수 있습니다(특히 REPEATABLE READ).
- 범위 조건이 인덱스를 제대로 타지 못하면, 더 넓은 범위를 잠그며 데드락 가능성이 증가
SELECT ... FOR UPDATE가 의도보다 많은 row를 잠그는 경우
3) 락 획득 순서가 요청마다 달라짐
예를 들어 주문 생성 시
- 트랜잭션 A:
user업데이트 후coupon업데이트 - 트랜잭션 B:
coupon업데이트 후user업데이트
서로 다른 순서로 락을 잡으면 데드락 확률이 급격히 올라갑니다. “락 순서를 통일”하는 것만으로도 데드락이 크게 줄어드는 케이스가 많습니다.
먼저 해야 할: 데드락 로그로 원인 확정
데드락은 추측으로 고치면 재발합니다. 최소한 아래 2가지는 확보하세요.
- InnoDB 데드락 상세
SHOW ENGINE INNODB STATUS의LATEST DETECTED DEADLOCK
- 실행 중 쿼리/락 대기
performance_schema의data_locks,data_lock_waits
예시(간단화):
SHOW ENGINE INNODB STATUS;
여기서 어떤 테이블/인덱스/레코드에서 경합이 났는지, 두 트랜잭션이 어떤 쿼리를 수행했는지 확인합니다. 이후 대응은 보통 아래 우선순위로 갑니다.
- 쿼리/인덱스 수정으로 잠금 범위를 줄인다
- 트랜잭션 길이를 줄인다(불필요한 로직을 트랜잭션 밖으로)
- 락 획득 순서를 통일한다
- 그래도 경합이 높은 “핫 키”는 애플리케이션 레벨에서 직렬화한다(여기서 Redis 분산락)
Redis 분산락을 왜 쓰나: DB 경합을 앞단에서 흡수
MySQL 락은 강력하지만, “핫 키”가 생기면 DB가 전쟁터가 됩니다. Redis 분산락은 다음 목적에 적합합니다.
- 동일 자원에 대한 동시 요청을 DB에 들어가기 전에 줄인다
- 데드락/락 대기 시간을 애플리케이션에서 제어한다
- 요청을 빠르게 실패시키거나(429/409), 큐잉/재시도로 사용자 경험을 조절한다
다만 분산락은 만능이 아닙니다. 락이 풀리거나(만료), 네트워크 분할이 생기거나, 프로세스가 멈추는 상황에서도 “데이터 무결성”이 깨지지 않게 설계해야 합니다. 핵심은 Redis 락은 DB 무결성의 최종 보루가 아니라, 경합 완화 장치로 보는 것입니다.
가장 현실적인 선택: 단일 Redis SET key value NX PX ttl
운영에서 가장 흔하고 실용적인 패턴은 단일 Redis(또는 Redis Cluster지만 단일 키는 같은 슬롯에 고정)에서 다음 명령으로 락을 잡는 것입니다.
NX: 키가 없을 때만 설정PX: TTL 밀리초
락 키 예시
lock:stock:{productId}lock:coupon:{couponId}lock:order:{idempotencyKey}
락 획득/해제의 핵심 규칙
- 락 값은 랜덤 토큰(요청 고유값)으로 저장
- 해제는 “내가 잡은 락인지” 확인 후 삭제(원자적으로)
- TTL은 “최악의 트랜잭션 시간”보다 약간 길게
- 락을 잡고 나서도 DB에서는 여전히 제약조건/원자 업데이트로 무결성 보장
Lua로 안전하게 해제하기
-- unlock.lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
이 Lua 스크립트는 GET 과 DEL 을 원자적으로 수행해 “다른 요청이 잡은 락을 실수로 해제”하는 문제를 막습니다.
Spring Boot 예제: 재고 차감에 Redis 락 적용
아래는 개념을 보여주는 예시입니다. 핵심은
- 락 획득 성공 시에만 DB 트랜잭션 진입
- DB에서는 조건부 업데이트로 최종 무결성 보장
- 실패 시 빠르게 리턴하거나 제한된 재시도
1) Redis 락 유틸(간단 버전)
import org.springframework.data.redis.core.StringRedisTemplate;
import java.time.Duration;
import java.util.UUID;
public class RedisLock {
private final StringRedisTemplate redis;
public RedisLock(StringRedisTemplate redis) {
this.redis = redis;
}
public String tryLock(String key, Duration ttl) {
String token = UUID.randomUUID().toString();
Boolean ok = redis.opsForValue().setIfAbsent(key, token, ttl);
return Boolean.TRUE.equals(ok) ? token : null;
}
}
2) 안전한 unlock (Lua)
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.List;
public class RedisUnlocker {
private final StringRedisTemplate redis;
private final DefaultRedisScript<Long> script;
public RedisUnlocker(StringRedisTemplate redis) {
this.redis = redis;
this.script = new DefaultRedisScript<>();
this.script.setResultType(Long.class);
this.script.setScriptText(
"if redis.call('GET', KEYS[1]) == ARGV[1] then " +
" return redis.call('DEL', KEYS[1]) " +
"else return 0 end"
);
}
public boolean unlock(String key, String token) {
Long r = redis.execute(script, List.of(key), token);
return r != null && r == 1L;
}
}
3) 서비스 로직(재고 차감)
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
@Service
public class StockService {
private final RedisLock redisLock;
private final RedisUnlocker unlocker;
private final StockRepository stockRepository;
public StockService(RedisLock redisLock, RedisUnlocker unlocker, StockRepository stockRepository) {
this.redisLock = redisLock;
this.unlocker = unlocker;
this.stockRepository = stockRepository;
}
public boolean decreaseStock(long productId, int amount) {
String lockKey = "lock:stock:" + productId;
String token = redisLock.tryLock(lockKey, Duration.ofSeconds(3));
if (token == null) {
// 여기서 무한 재시도는 금물. 빠른 실패(409/429) 또는 짧은 백오프 재시도.
return false;
}
try {
return decreaseStockTx(productId, amount);
} finally {
unlocker.unlock(lockKey, token);
}
}
@Transactional
protected boolean decreaseStockTx(long productId, int amount) {
// 조건부 업데이트로 DB 무결성 유지
int updated = stockRepository.decreaseIfEnough(productId, amount);
return updated == 1;
}
}
Repository 예시:
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
public interface StockRepository extends Repository<StockEntity, Long> {
@Modifying
@Query("update StockEntity s set s.qty = s.qty - :amount where s.productId = :pid and s.qty >= :amount")
int decreaseIfEnough(@Param("pid") long productId, @Param("amount") int amount);
}
이 구조의 장점은 Redis 락이 깨지거나 TTL이 만료되어도, DB 업데이트가 조건부로 안전하게 막아준다는 점입니다(재고가 음수가 되지 않음).
TTL, 재시도, 백오프: 데드락보다 더 중요한 운영 파라미터
분산락은 설정을 잘못하면 오히려 장애를 키웁니다.
TTL은 어떻게 잡나
- TTL이 너무 짧으면: 트랜잭션이 끝나기 전에 락이 풀려 동시 진입 발생
- TTL이 너무 길면: 락을 잡은 프로세스가 죽었을 때 회복이 느림
권장 접근
p99트랜잭션 시간 측정 후p99 * 2정도로 시작- 락을 오래 잡는 로직을 제거(외부 API 호출, 파일 I/O 등을 트랜잭션/락 밖으로)
재시도는 제한적으로
- 즉시 재시도는 락 폭풍을 만든다
- 지수 백오프 + 지터(jitter) 적용
의사코드:
for attempt in 1..3:
if tryLock():
doWork()
break
sleep(base * 2^attempt + random(0..jitter))
실패 응답을 설계하라
락을 못 잡으면
- 409(Conflict)로 “동일 자원 처리 중”을 명확히
- 또는 429(Too Many Requests)로 레이트 리밋 의미를 부여
- 프론트엔드/클라이언트는 재시도 정책을 가져야 함
Redlock을 꼭 써야 하나? 논쟁 포인트 정리
Redlock은 여러 Redis 노드에 락을 분산해 “단일 Redis 장애”를 줄이려는 알고리즘으로 알려져 있습니다. 하지만 네트워크 분할, 클럭, GC stop-the-world, 지연 등 현실 변수에서 “안전한 분산 상호배제”를 보장한다고 보기 어렵다는 비판이 꾸준합니다.
실무적으로는 다음처럼 결론 내리는 경우가 많습니다.
- 목표가 “DB 데드락/경합 완화”라면: 단일 Redis 락으로도 효과가 크다
- 목표가 “절대적인 무결성 보장”이라면: Redis 락만으로는 부족하다. DB 제약/원자 업데이트/멱등성/펜싱 토큰이 필요
즉, Redis 락은 성능 최적화/경합 완화 레이어로 두고, 무결성은 DB가 최종 보증하도록 설계하는 게 안전합니다.
더 안전한 패턴: fencing token(펜싱 토큰)
TTL 만료로 락이 풀린 뒤, 이전 작업자가 늦게 DB에 쓰기를 시도하면 “오래된 작업이 최신 작업을 덮어쓰는” 문제가 생길 수 있습니다. 이를 막는 방법이 fencing token입니다.
아이디어
- 락 획득 시 증가하는 토큰을 발급(예: Redis
INCR) - DB row에
lock_version같은 컬럼을 두고 - 업데이트 시 토큰이 더 큰 요청만 성공하도록 조건을 건다
예시 흐름
- Redis:
INCR fence:stock:{productId}결과를token으로 사용 - DB:
UPDATE ... SET qty = qty - 1, lock_version = :token WHERE product_id = :pid AND lock_version < :token
이러면 “늦게 도착한 오래된 작업”은 DB에서 거부됩니다.
MySQL 측에서도 같이 해야 하는 것들(중요)
Redis 락을 붙였는데도 데드락이 남는다면, 대개 아래가 원인입니다.
1) 트랜잭션이 길다
- 트랜잭션 안에서 외부 API 호출
- 불필요한 조회/검증 로직
해결: 트랜잭션 범위를 최소화하고, 필요한 row만 조건부 업데이트로 처리합니다.
2) 인덱스가 부족해 잠금 범위가 커진다
WHERE조건 컬럼에 적절한 인덱스- 복합 인덱스 순서 점검
3) JPA 사용 시 N+1이나 불필요한 flush로 지연이 커진다
트랜잭션 시간이 늘면 락 보유 시간이 늘고, 데드락 가능성이 상승합니다. JPA 성능 문제를 함께 정리하려면 Spring Boot 3+ JPA N+1 즉시 잡는 7가지도 같이 보는 것을 권합니다.
운영 체크리스트: “데드락 폭증”을 재발 방지로 연결
- 데드락 로그 수집
- MySQL 에러 로그,
SHOW ENGINE INNODB STATUS자동 수집 - 데드락 발생 쿼리/테이블/인덱스 상위
top N정리
- 지표
innodb_row_lock_time,innodb_row_lock_waits- 애플리케이션 레벨: 락 획득 실패율, 평균 대기 시간, 재시도 횟수
- 정책
- 락 키 설계: 너무 넓게 잡지 말고(전체 테이블 락 같은 효과), 자원 단위로
- TTL/재시도 제한
- 실패 응답 표준화
- 최종 무결성은 DB로
- 유니크 인덱스
- 조건부 업데이트
- 멱등키 테이블
결론
InnoDB 데드락 폭증은 대부분 “핫 키 경합 + 긴 트랜잭션 + 넓은 잠금 범위”가 겹치며 발생합니다. 먼저 데드락 로그로 원인을 확정하고, 쿼리/인덱스/트랜잭션 범위를 줄이는 것이 1순위입니다.
그럼에도 특정 자원에 요청이 몰리는 구조라면 Redis 분산락을 앞단에 두어 DB에 들어가는 동시성을 줄이는 것이 효과적입니다. 단, Redis 락은 무결성의 최종 보루가 아니므로, DB의 조건부 업데이트·제약조건·멱등성으로 안전장치를 유지하세요. 더 강한 안전성이 필요하면 fencing token까지 고려하는 것이 운영에서 가장 후회가 적습니다.