배치 작업에서 중복 데이터가 생겼다: Redis Lock으로 멱등성 잡은 과정
가끔 회원권이 2개씩 생기는 버그, 왜 생겼고 왜 Redis Lock이었나. UNIQUE를 못 거는 공유 DB 환경에서 레이스 컨디션을 잡은 이야기.
배치 작업에서 중복 데이터가 생겼다: Redis Lock으로 멱등성 잡은 과정
상황
LMS에는 회원권 배치가 있었다. ERP 시스템에서 신규 회원권 데이터를 가져와 자체 DB에 반영하는 작업이었다.
그런데 어느 날부터 가끔 회원권이 2개씩 생기는 문제가 보고됐다. 처음엔 단순 데이터 오입력이겠거니 했는데 재현 패턴을 보니 아니었다. 특정 시간대에만 발생했고, 데이터를 보면 완전히 동일한 회원권이 두 번 삽입돼 있었다. 데이터 문제가 아니라 코드 문제였다.
왜 중복이 생겼나: 레이스 컨디션
배치 구조는 이랬다.
// 기존 배치 구조 (단순화)
@Scheduled(fixedDelay = 60000)
public void syncMembership() {
List<MembershipDto> erpData = erpClient.fetchNewMemberships();
for (MembershipDto dto : erpData) {
boolean exists = membershipRepository.existsByErpId(dto.getErpId());
if (!exists) {
membershipRepository.save(dto.toEntity()); // 신규 삽입
}
}
}코드만 보면 문제가 없어 보인다. existsByErpId로 중복을 체크하고 없을 때만 저장한다. 문제는 이 배치가 여러 스레드에서 동시에 실행될 수 있다는 거였다.
스레드 A: existsByErpId("ERP-001") → false (없음)
스레드 B: existsByErpId("ERP-001") → false (없음) ← 아직 A가 저장 전
스레드 A: save("ERP-001")
스레드 B: save("ERP-001") ← 중복 삽입
exists 확인과 save 사이에는 아주 짧지만 분명한 간격이 있다. 그 찰나에 다른 스레드가 비집고 들어와 똑같이 "없음"을 확인하면, 둘 다 삽입으로 직행한다. 확인한 시점과 실제로 쓰는 시점이 어긋나서 생기는 TOCTOU(Time-of-Check to Time-of-Use) 문제다.
왜 이 시점에 불거졌나
배치가 처음 만들어질 때는 단일 스레드로 돌았다. 그러다 ERP에 신규 상품 카테고리가 추가되면서 처리할 데이터가 늘었고, 속도를 올리려고 멀티스레드를 적용했다.
멀티스레드 도입 자체는 맞는 방향이었다. 문제는 동시성 비용은 계산하지 않고 "데이터 많아졌으니 스레드 늘려 속도만 올리자"며 가볍게 손댄 거였다.
먼저, 터진 데이터부터 복구해야 했다
원인을 찾기 전에, 이미 들어간 중복부터 걷어내야 했다. 이게 진짜 고생이었다. 회원권은 결제·이용권과 엮여 있어서 한쪽을 그냥 지운다고 끝나는 게 아니었다. 둘 중 어느 게 진짜고 어느 게 중복인지 결제 내역까지 건건이 대조해야 했고, 잘못 지우면 멀쩡한 회원권이, 그러니까 누군가 실제로 돈을 낸 이용권이 날아간다. 운영 데이터라 마음 편히 막 지울 수도 없었다. 결국 밤을 새서 한 건 한 건 눈으로 맞춰가며 되돌렸다. 코드 버그는 한 줄 고치면 끝나는데, 그 한 줄이 만들어 놓은 데이터를 되돌리는 데는 하룻밤이 통째로 들었다. 망가진 데이터를 복구하는 게 코드를 고치는 것보다 몇 배는 힘들다는 걸 이때 몸으로 알았다.
가장 좋은 방법을 쓸 수 없었다
동시성 문제에서 가장 깔끔한 해법은 DB UNIQUE 제약이다. erp_id에 UNIQUE를 걸면 중복 삽입은 DB가 알아서 막는다.
그런데 이 배치 DB에서는 그걸 못 걸었다. 오라클이었고, 다른 부서도 같이 쓰는 공유 DB였다. 우리가 마음대로 제약을 추가할 수 있는 테이블이 아니었고, 구조상 PK나 UNIQUE를 거는 것도 자유롭지 않았다. 제일 정석적인 카드가 환경 때문에 막혀 있었다.
그래서 애플리케이션 레벨에서 막을 방법을 찾아야 했다.
DB 레벨에서 막기: 비관적 락
SELECT FOR UPDATE로 행을 잠그고 처리하는 방식.
@Transactional
public void syncMembership(String erpId) {
Membership membership = membershipRepository
.findByErpIdWithLock(erpId) // SELECT FOR UPDATE
.orElse(null);
if (membership == null) {
membershipRepository.save(new Membership(erpId));
}
}같은 erpId에 두 스레드가 동시에 들어오면 하나가 락을 잡고 다른 하나는 대기한다. 확실하지만 DB 커넥션을 락이 풀릴 때까지 붙잡는다. 배치처럼 대량 데이터를 빠르게 처리할 때는 병목이 된다. 게다가 공유 DB라 락 경합을 우리 배치가 다 일으키는 것도 부담이었다.
앞단에서 쳐내기: Redis 분산 락
DB 락 대신 Redis의 원자적 연산으로 "이미 처리 중인지"를 앞단에서 체크하는 방식.
public void syncMembership(String erpId) {
String lockKey = "membership:lock:" + erpId;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "locked", 30, TimeUnit.SECONDS); // NX + EX
if (!acquired) {
return; // 이미 다른 스레드가 처리 중
}
try {
boolean exists = membershipRepository.existsByErpId(erpId);
if (!exists) {
membershipRepository.save(new Membership(erpId));
}
} finally {
redisTemplate.delete(lockKey); // 처리 완료 후 락 해제
}
}SETNX(Set if Not Exists)는 원자적 연산이다. 두 스레드가 동시에 실행해도 하나만 성공한다. 락을 못 잡은 스레드는 DB에 접근조차 안 한다.
왜 Redis 락을 골랐나
Redis 락을 고른 이유는 중복 요청을 DB에 닿기 전에 걸러내기 때문이다.
비관적 락은 결국 중복 요청이 모두 DB 커넥션을 잡는다. 배치가 처리하는 데이터가 수천 건이면 수천 개의 락 경합이 DB에서 발생한다. 우리 DB도 아니고 공유 DB인데 거기에 락 부하를 다 떠넘기는 건 좋지 않았다.
Redis 락은 중복 요청을 DB 밖에서 걸러낸다. 첫 번째 스레드만 DB에 접근하고 나머지는 Redis에서 즉시 반환된다. 그리고 30초 TTL을 걸어서, 처리 중에 서버가 죽어도 30초 뒤에는 락이 자동 해제되어 재처리가 가능하다.
결국 멱등성 이야기였다
이 문제를 풀면서 가장 크게 와닿은 게 멱등성이었다. 정의를 길게 늘어놓을 것도 없이, 배치에선 이게 선택이 아니라 생존의 문제다. 네트워크가 끊겨서 다시 돌든, 재배포로 두 번 실행되든, 스케줄러가 버그를 뿜어 겹쳐 돌든, 배치는 몇 번을 실행해도 결과가 같아야 한다. 그 안전장치가 없으면 그 비용은 고스란히, 앞에서 겪은 것 같은 새벽 데이터 복구로 돌아온다.
이 배치에선 "없으면 삽입, 있으면 무시"가 그 보장이다. Redis 락은 그걸 지키는 수단 중 하나고, 락과 별개로 existsByErpId 체크도 남겨뒀다. 락이 만료되는 드문 케이스를 위한 두 번째 방어선이다.
락 해제 안전성
처음 구현에 한 가지 구멍이 있었다. 처리 도중 예외가 나면 finally에서 락을 해제하는데, 자신이 잡은 락인지 확인하지 않고 삭제했다.
드문 케이스지만, 락 TTL이 만료되고 다른 스레드가 같은 키로 새 락을 잡은 상태에서 원래 스레드가 finally에 도달하면 남의 락을 해제하게 된다.
락 값에 고유 식별자를 넣고, 해제 시 자신이 잡은 락인지 확인하도록 고쳤다.
String lockValue = UUID.randomUUID().toString();
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS);
// 해제 시: 값이 같을 때만 삭제 (Lua Script로 원자적 처리)
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
redisTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
Collections.singletonList(lockKey),
lockValue
);get과 del을 분리하면 그 사이에 다른 스레드가 끼어들 수 있다. Lua Script는 Redis에서 원자적으로 실행되므로 이 문제를 막는다.
결과
고치고 나서 중복 회원권은 0건이 됐다. 중복 요청이 몰려도 첫 스레드만 DB에 닿고 나머지는 Redis에서 즉시 튕겨 나가니, 공유 DB에 락 부하를 떠넘기던 부담도 사라졌다. 처리 도중 서버가 죽어도 30초 TTL이 지나면 락이 알아서 풀려 다음 배치가 이어받는다.
하지만 숫자보다 크게 남은 건 따로 있었다. "배치가 몇 번 실행되든 안전하다"는 신뢰다. 그 전까지는 배포할 때마다, 재시작할 때마다 배치가 두 번 돌아 또 중복이 생기진 않을지 마음 한구석이 불편했다. 그게 없어지니 배포가 한결 가벼워졌다.
마무리
한 가지 분명히 해둘 게 있다. Redis 락은 만능이 아니다. 이 케이스에서 Redis 락을 쓴 건 공유 오라클 DB라 UNIQUE 제약을 못 걸었기 때문이지, Redis 락이 UNIQUE보다 나아서가 아니다. DB에 UNIQUE를 걸 수 있는 환경이라면 그게 훨씬 낫다. 가장 단순하고 가장 확실하다. Redis 락은 그걸 못 쓸 때 애플리케이션 레벨에서 막는 우회로다.
이번 버그의 진짜 원인은 멀티스레드로 바꿀 때 동시성을 보지 않고 스레드만 늘린 거였다. 레이스 컨디션은 "가끔 발생"이라 발견하기 어렵다. 재현이 안 되면 코드를 봐도 멀쩡해 보인다. 특히 단일 스레드로 짠 코드를 멀티스레드로 돌릴 때 이 함정이 숨어 있다. 스레드를 늘리는 건 그냥 빨라지는 게 아니라 동시성 비용을 같이 떠안는 일이다.
그래서 내가 일할 때 기준으로 삼는 게 하나 있다. 무엇을 하든 내가 지금 하는 게 정확히 무엇이고 어떤 영향을 주는지 알아야 한다는 거다. "스레드 늘리면 빨라지겠지"처럼 영향을 다 모르고 손대면, 빨라지는 대신 가끔 회원권이 두 개씩 생기고 누군가 밤을 새서 데이터를 복구하게 된다.