배치 작업에서 중복 데이터가 생겼다 — Redis Lock으로 멱등성 잡은 과정
가끔 회원권이 2개씩 생기는 버그, 왜 생겼고 왜 Redis Lock이었나. 레이스 컨디션부터 멱등성 설계까지.
배치 작업에서 중복 데이터가 생겼다 — 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에 신규 상품 카테고리가 추가되면서 배치가 처리해야 하는 데이터가 늘어났고, 처리 속도를 올리기 위해 멀티스레드를 적용했다.
멀티스레드 도입 자체는 맞는 방향이었다. 다만 동시성 문제를 고려하지 않은 채 스레드만 늘렸던 게 문제였다.
해결 전략 비교
방법 1: DB UNIQUE 제약 + 예외 처리
erp_id 컬럼에 UNIQUE 인덱스를 걸고, 중복 삽입 시 발생하는 예외를 무시하는 방식.
ALTER TABLE membership ADD UNIQUE INDEX uk_erp_id (erp_id);try {
membershipRepository.save(entity);
} catch (DataIntegrityViolationException e) {
// 중복이면 무시
}간단하고 DB 레벨에서 확실히 막아준다. 하지만 예외를 흐름 제어에 쓰는 건 좋지 않다. 정상 케이스에서 예외가 발생하고, 트랜잭션 롤백이 섞이면 의도치 않은 동작이 생길 수 있다. 또한 중복 삽입 시도 자체는 계속 발생해서 불필요한 DB 쓰기가 반복된다.
방법 2: 비관적 락 (Pessimistic Lock)
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 커넥션을 락이 풀릴 때까지 붙잡는다. 배치처럼 대량 데이터를 빠르게 처리해야 할 때는 병목이 된다.
방법 3: 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에서 발생한다.
Redis 락은 중복 요청을 DB에 닿기 전에 걸러낸다. 첫 번째 스레드만 DB에 접근하고, 나머지는 Redis에서 즉시 반환된다. DB 부하가 현저히 줄어든다.
또한 ERP 연동 배치 특성상 30초 TTL을 걸었다. 처리 중에 서버가 죽어도 30초 후에는 락이 자동 해제되어 재처리가 가능하다. 비관적 락이라면 서버 재시작 시 락이 해제되지만, 이미 부분 처리된 데이터 정합성 문제가 남는다.
멱등성이 왜 중요한가
이 문제를 해결하면서 가장 크게 느낀 개념이 **멱등성(Idempotency)**이었다.
멱등성이란 같은 연산을 여러 번 실행해도 결과가 달라지지 않는 성질이다. f(f(x)) = f(x)라고 생각하면 된다.
배치 작업에서 멱등성이 중요한 이유가 있다.
- 네트워크 오류로 배치가 중단됐다가 재시작될 수 있다
- 서버 재배포로 배치가 두 번 실행될 수 있다
- 스케줄러 버그로 동일 배치가 겹쳐 실행될 수 있다
이 모든 상황에서 몇 번 실행하든 결과가 같아야 한다는 게 멱등성이다. "없으면 삽입, 있으면 무시"가 그 보장이다.
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건 |
| 동시 처리 방식 | 멀티스레드, 락 없음 | Redis 분산 락 |
| DB 부하 (중복 요청 시) | 모든 스레드가 DB 접근 | 첫 스레드만 DB 접근 |
| 락 만료 시 재처리 | 수동 처리 필요 | TTL 후 자동 재처리 가능 |
배치 관련 장애가 사라진 것도 있지만, 더 중요한 건 "배치가 몇 번 실행되든 안전하다"는 신뢰가 생긴 것이었다. 이후 배포나 재시작 상황에서 배치 중복 실행을 과도하게 신경 쓰지 않아도 됐다.
마무리
레이스 컨디션은 "가끔 발생"하기 때문에 발견하기 어렵다. 재현이 안 되면 코드를 봐도 문제가 없어 보인다. 특히 단일 스레드 환경에서 작성한 코드를 멀티스레드로 전환할 때 이런 함정이 숨어 있는 경우가 많다.
문제를 해결하면서 배운 것들을 정리하면:
- Check-Then-Act 패턴은 항상 동시성 위험이 있다 — 확인과 실행 사이에 다른 스레드가 끼어들 수 있다
- 락의 선택은 DB 부하를 어디서 막을지의 선택이다 — 비관적 락은 DB 안, Redis 락은 DB 밖
- 배치 작업은 멱등성을 기본으로 설계해야 한다 — 몇 번 실행해도 결과가 같아야 운영이 편하다
- Lua Script로 원자성을 보장하라 — get + del 분리는 또 다른 레이스 컨디션이다