[업무일지] 더티체킹 절대값 저장이 지운 회수 차감 7건
가상계좌 입금 반영과 1분 주기 회수 배치가 같은 셀러의 잔액을 동시에 고치면서, 회수 차감 7건이 잔액에서 사라졌습니다. 부풀려진 잔액은 그대로 셀러에게 환급으로 나갔습니다. 입금은 읽은 잔액에 입금액을 더한 값을 더티체킹으로 저장했고, 회수는
amount = amount - X로 DB에서 뺐습니다. 입금이 읽고 저장하는 사이에 커밋된 차감은 입금의 저장에 덮였습니다. 입금도amount = amount + X로 바꿨습니다. 사용자 락은 일부러 걸지 않았고, 벌크 UPDATE가 영속성 컨텍스트를 비우기 때문에 이력 저장을 그 뒤로 옮겼습니다.
Java 21, Spring Boot 3.3.5, Spring Data JPA(Hibernate 6), MariaDB 환경입니다. 스케줄러 서버에서 있었던 일입니다. 설명을 위해 테이블명과 클래스명은 가명으로 씁니다. 실제 구조와 이름만 다릅니다.
balances: 셀러별 회수용 잔액. 가상계좌로 들어온 돈이 여기 쌓이고, 회수할 때 여기서 빠집니다.balance_histories: 잔액이 들어오고 나간 이력.
입금 13건과 회수 배치가 같은 1초 안에 돌았다
셀러별 가상계좌로 들어온 정산금에는 배치가 둘 붙어 있습니다. 입금 반영 배치는 입금 건을 하나씩 잔액에 더하고, 회수 배치는 1분마다 그 잔액에서 회수할 금액을 뺀 뒤 남은 돈을 셀러에게 환급합니다.
10월 7일, 경영지원팀에서 문의가 왔습니다. 한 셀러의 그날 입금보다 어드민의 회수 반영액이 더 크다는 내용이었어요. 화면 집계 오류일 거라고 생각하고 봤습니다. 아니었습니다. 실제로 돈이 더 나가 있었습니다.
경위는 이랬습니다. 국내 대형 오픈마켓이 이 셀러의 가상계좌로 1초 사이에 13건을 이체했습니다. 10분 뒤 입금 반영 배치가 그 13건을 0.6초쯤에 걸쳐 차례로 처리했는데, 처리가 시작되고 0.3초 뒤에 1분 주기로 돌던 회수 배치가 하필 같은 셀러를 집었습니다.
그 0.6초 동안 입금 7번째부터 13번째까지가 각각 직전에 커밋된 회수 차감을 하나씩 지웠습니다. 모두 7건입니다. 회수 대상 12건은 전부 정상 완료로 처리됐는데, 잔액에서는 그중 7건의 차감이 사라진 상태였죠. 5분 뒤 환급은 잔액만 보고 송금했으니 부풀려진 금액이 그대로 나갔습니다.
숫자도 맞았습니다. 덮인 차감 7건의 합이 차액과 원 단위까지 같았고, 사건 직후 잔액은 0인데 이력 합계는 차액만큼 마이너스였습니다.
초과로 나간 금액은 리스크관리팀이 셀러와 협의해 운영 쪽에서 정리했습니다. 이 글은 재발 방지 코드만 다룹니다.
입금은 엔티티에 더했고 회수는 SQL로 뺐다
입금 경로에는 사용자 락도 없었습니다.
1
2
3
4
5
6
7
8
9
// 입금 반영. 사용자 락 없음
@Transactional
public void processDeposit(Deposit deposit) {
Balance balance = balanceRepository.findByUserId(deposit.userId())
.orElseGet(() -> balanceRepository.save(Balance.ofNew(deposit.userId())));
historyRepository.save(BalanceHistory.in(balance.getId(), deposit.amount()));
balance.add(deposit.amount()); // 커밋 때 UPDATE balances SET amount = <읽은 값 + 입금액>
}
1
2
3
4
// 회수. 사용자 단위 분산락 안에서 실행된다
@Modifying(clearAutomatically = true)
@Query("UPDATE Balance b SET b.amount = b.amount - :amount WHERE b.id = :id")
int subtract(@Param("id") Long id, @Param("amount") BigDecimal amount);
회수는 DB가 현재 값에서 빼니까 순서가 어떻게 섞여도 차감이 사라지지 않습니다. 입금은 다릅니다. 트랜잭션 안에서 읽어 둔 잔액에 입금액을 더한 값을 커밋 때 통째로 씁니다. 읽은 뒤 커밋 전에 회수 차감이 들어오면, 입금의 UPDATE가 차감이 없던 시절의 값을 덮어씁니다. 전형적인 갱신 손실(lost update)이죠.
운영 DB의 REPEATABLE READ는 이걸 막아 주지 않았습니다. 입금 트랜잭션은 잔액을 잠금 없이 읽었고, 더티체킹이 내보내는 UPDATE는 WHERE id = ? 만 보고 씁니다. 그 사이 값이 바뀌었는지는 아무도 확인하지 않습니다.
찾아 보니 같은 잔액 테이블에 금액을 더하는 다른 경로는 이미 amount = amount + X 를 쓰고 있었습니다. 가상계좌 입금 경로 하나만 엔티티 필드에 더하고 있었던 거예요.
열 달 동안 터지지 않은 이유
이 코드는 열 달째 운영 중이었습니다. 같은 일이 다시 날 조건이 얼마나 흔한지부터 확인했습니다.
2월 이후 한 셀러에게 같은 분에 입금이 7건 이상 몰린 경우는 7번이었고, 그중 6번은 그 분에 회수할 대상이 0건이었습니다. 덮어쓸 차감이 애초에 없었던 거죠. 나머지 한 번은 타이밍이 어긋나 피해가 없었습니다. 가상계좌 잔액 전체를 이력 합계와 맞춰 봐도 어긋난 곳은 이 한 건뿐이었습니다.
버그는 처음부터 있었고, 입금이 몰리는 순간과 회수 대상이 있는 순간이 겹쳐야만 드러났습니다.
사용자 락은 걸지 않았다
고치는 방향은 입금도 상대 갱신으로 바꾸는 것이었습니다.
작업지시에는 하나를 열어 두었습니다. 입금 처리에도 회수와 같은 사용자 단위 분산락을 걸지는 구현하면서 판단하자고요. 둘이 같은 락을 쓰면 애초에 겹치지 않을 테니 안전해 보였습니다.
걸지 않기로 했습니다.
상대 갱신만으로 덮어쓰기는 사라집니다. 입금과 회수가 둘 다 DB에 더하고 빼니까 실행 순서가 섞여도 최종 잔액은 “입금 합계 − 차감 합계” 가 됩니다. 락을 더하면 잔액 쪽에서 얻는 건 없는데 실패 경로가 하나 새로 생겨요. 회수 쪽 락은 대기 시간이 10초라서, 회수 배치가 그 셀러를 붙잡고 있는 동안 입금이 들어오면 입금이 락을 기다리다 실패하고 입금 이벤트가 다시 발행되는 경로가 열립니다. 지금 코드에는 없는 경로입니다.
이 판단은 작업 기록에 따로 적어 두었습니다. 나중에 누가 두 코드를 나란히 놓고 “회수에는 락이 있는데 입금에는 빠졌다” 고 생각해서 락을 넣어 주는 일이 생기지 않게 하려고요.
이력 저장을 벌크 UPDATE 뒤로 옮겼다
설계에는 없던 변경입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Transactional
public void processDeposit(Deposit deposit) {
Balance balance = balanceRepository.findByUserId(deposit.userId())
.orElseGet(() -> balanceRepository.save(Balance.ofNew(deposit.userId())));
// 상대 갱신이라 회수 차감과 엇갈려도 덮어쓰지 않는다.
// clearAutomatically 로 영속성 컨텍스트가 비워지므로 이력 저장은 그 뒤에 둔다.
int updated = balanceRepository.add(balance.getId(), deposit.amount());
if (updated == 0) {
throw new IllegalStateException("잔액 갱신 실패 - balanceId: " + balance.getId());
}
historyRepository.save(BalanceHistory.in(balance.getId(), deposit.amount()));
}
1
2
3
@Modifying(clearAutomatically = true)
@Query("UPDATE Balance b SET b.amount = b.amount + :amount WHERE b.id = :id")
int add(@Param("id") Long id, @Param("amount") BigDecimal amount);
원래 코드는 이력을 먼저 저장했습니다. clearAutomatically = true 는 벌크 UPDATE 뒤에 영속성 컨텍스트를 비우는데, Hibernate는 그 전에 쿼리가 건드리는 테이블의 변경만 flush합니다. 다른 테이블에 아직 내려가지 않은 변경이 있으면 같이 버려질 수 있어요.
솔직히 지금 코드에서 당장 터지는 경로는 아닙니다. 이력 엔티티가 IDENTITY 전략이라 save() 순간 INSERT가 나가거든요. 그래도 식별자 전략이 바뀌면 조용히 사라지는 쪽이라 순서를 옮겼고, 호출처가 없어진 Balance.add 도 지웠습니다.
경합을 테스트로 재현했다
회귀 테스트는 실제 DB로 짰습니다. 입금 처리가 잔액을 읽은 직후에 다른 스레드가 별도 트랜잭션으로 회수 차감을 커밋하게 만들었습니다. 리포지토리를 @SpyBean 으로 감싸 조회 메서드가 값을 돌려주기 직전에 차감 커밋을 끼워 넣는 방식입니다.
1
2
3
4
5
6
7
doAnswer(invocation -> {
Optional<Balance> balance = balanceRepository.findById(balanceId);
commitSubtractInOtherThread(balanceId, withdrawAmount); // 읽은 직후 차감 커밋
return balance;
})
.when(balanceRepository)
.findByUserId(userId);
차감이 별도 트랜잭션으로 실제 커밋돼야 하니 테스트 메서드에는 @Transactional 을 걸지 않았습니다. 수정 전 코드로 돌리면 expected 570000 but was 600000 으로 실패합니다. 테스트에 넣은 차감 3만이 사라진 겁니다.
남겨 둔 것
잔액 컬럼이 nullable입니다. 어떤 행이 NULL이면 amount + X 는 NULL이 되고 UPDATE는 1행을 갱신했다고 조용히 성공합니다. 코드 리뷰 봇이 짚은 부분인데, 이번에는 고치지 않았습니다. 컬럼 기본값과 엔티티 생성 메서드가 모두 0을 넣고, 같은 쿼리를 쓰는 회수 경로도 조건이 같거든요.
이번 일은 화면 숫자를 이상하게 여긴 사람이 문의해서 알았습니다. 잔액과 이력 합계를 맞춰 보는 점검이 있었다면 먼저 잡았을 텐데, 그 점검은 아직 없습니다. 배포 뒤 입금이 몰리는 날에 둘이 맞는지도 아직 확인 전입니다.