운영 중인 기능 하나는 포인트를 사용해 잠겨 있던 정보를 한 번만 열 수 있게 되어 있었다. 정상 요청이라면 상태를 CLOSED에서 OPEN으로 바꾸고 포인트를 한 번 차감하면 끝난다.

문제는 요청 처리가 평소보다 길어졌을 때 나타났다. 첫 요청의 응답을 받지 못한 클라이언트가 같은 요청을 다시 보냈고, 서버는 두 요청을 모두 성공으로 판단했다. 결과적으로 정보는 한 번 열렸지만 포인트는 두 번 빠졌다.

어떤 순서로 중복 차감이 일어났나

관련 로직을 공개용 예시로 줄이면 두 개의 UPDATE가 한 트랜잭션에 있었다.

UPDATE unlock_records
SET state = 'OPEN'
WHERE record_id = :recordId;

UPDATE accounts
SET point = point - :cost
WHERE account_id = :accountId;

첫 번째 UPDATE는 “이미 열었는가”를 기록하고, 두 번째 UPDATE는 비용을 차감한다. 당시 첫 번째 문장에는 현재 상태를 확인하는 조건이 없었다.

실제 식별자와 시각을 걷어내고 순서만 남기면 이렇다.

요청 A                              요청 B
──────                              ──────
unlock_records UPDATE 성공
해당 행에 X-lock 획득
accounts UPDATE가 다른 잠금 때문에 대기
                                    응답 지연 뒤 같은 요청 재전송
                                    unlock_records UPDATE 시도
                                    요청 A의 X-lock을 기다림
accounts UPDATE 완료
COMMIT, X-lock 해제
                                    unlock_records UPDATE 실행
                                    OPEN -> OPEN인데도 성공으로 판단
                                    accounts UPDATE로 포인트 재차감

OPEN인 행을 다시 OPEN으로 바꾸는 UPDATE까지 성공으로 취급한 것이 직접적인 원인이었다.

값이 안 바뀌었는데 affected rows가 1이었다

MySQL 서버의 기본 affected-rows 의미는 실제로 값이 바뀐 행의 수다. 하지만 클라이언트가 CLIENT_FOUND_ROWS capability를 사용하면 “바뀐 행”이 아니라 WHERE에 “찾힌 행”의 수를 돌려준다.

따라서 아래 문장은 연결 설정에 따라 결과가 달라질 수 있다.

UPDATE unlock_records
SET state = 'OPEN'
WHERE record_id = 101;

record_id = 101의 상태가 이미 OPEN이어도 found-rows 방식에서는 1이 반환된다. 애플리케이션이 affectedRows > 0만 보고 후속 차감을 진행한다면 중복 요청을 구별하지 못한다.

이 차이는 드라이버와 연결 옵션에 의존한다. 그래서 “같은 값으로 UPDATE하면 항상 0”이라는 가정도, “항상 1”이라는 가정도 안전하지 않다. 비즈니스 조건을 반환값의 우연한 의미에 맡기지 않는 편이 낫다.

체크를 UPDATE 안으로 옮겼다

수정은 작았다. 상태 변경이 허용되는 조건을 같은 UPDATE의 WHERE 절에 넣었다.

UPDATE unlock_records
SET state = 'OPEN'
WHERE record_id = :recordId
  AND state = 'CLOSED';

서비스는 이제 이 문장의 결과만으로 상태 전이를 판단한다.

int updated = unlockRecordRepository.openIfClosed(recordId);
if (updated == 0) {
    throw new AlreadyOpenedException();
}

pointService.deduct(accountId, cost);

동시에 두 요청이 와도 결과는 달라진다.

1. 요청 A: CLOSED 조건에 맞아 UPDATE 성공, 행 잠금 획득
2. 요청 B: 같은 행 UPDATE를 시도하고 잠금 대기
3. 요청 A: 포인트 차감 뒤 COMMIT
4. 요청 B: 잠금 해제 후 WHERE 조건을 다시 평가
5. 현재 상태가 OPEN이므로 affected rows = 0
6. 요청 B: 후속 포인트 차감 없이 종료

state = 'CLOSED' 확인과 OPEN 변경을 한 UPDATE에서 처리하면, 두 번째 요청은 연결의 affected-rows 설정과 관계없이 0을 받는다.

운영 상황은 잠금을 일부러 만들어 재현했다

단순히 두 스레드를 동시에 시작하는 테스트만으로는 원래 순서가 잘 만들어지지 않았다. 그래서 별도 트랜잭션이 accounts의 테스트 행을 먼저 잠그도록 했다.

잠금 보유 트랜잭션         요청 A                  요청 B
────────────────         ──────                  ──────
accounts FOR UPDATE
                         unlock_records 성공
                         accounts에서 대기
                                                 unlock_records에서 대기
accounts 잠금 해제
                         포인트 차감, COMMIT
                                                 조건 재평가, updated = 0

테스트에서는 소요 시간보다 다음 결과를 확인했다.

  • 상태 변경 성공은 정확히 한 번이어야 한다.
  • 포인트 차감도 정확히 한 번이어야 한다.
  • 뒤늦게 실행된 요청은 updated = 0을 받아야 한다.
  • 두 작업은 같은 트랜잭션에서 함께 커밋되거나 함께 롤백되어야 한다.

이 테스트를 추가하고 나니 “동시 실행”이라는 막연한 조건이 아니라, 문제가 났던 잠금 순서를 계속 검증할 수 있게 됐다.

왜 별도 트랜잭션으로 일찍 커밋하지 않았나

상태 변경만 REQUIRES_NEW로 먼저 커밋하면 잠금 점유 시간은 줄일 수 있다. 하지만 포인트 차감이 실패했을 때 상태를 되돌리는 보상 트랜잭션이 필요해진다.

상태 OPEN 커밋

포인트 차감 실패

상태 CLOSED 복구 시도

복구도 실패하면 상태와 포인트가 불일치

단일 DB 안에서 끝나는 상태 변경에 보상 로직까지 추가하면 실패할 곳이 늘어난다. 이 사례에서는 조건부 UPDATE와 하나의 로컬 트랜잭션으로 상태 변경과 포인트 차감을 함께 처리하는 편이 안전했다.

서버는 중복 요청을 처리할 수 있어야 한다

처음에는 응답이 늦어진 것이 사건의 원인처럼 보였다. 하지만 지연은 중복 요청을 드러낸 계기일 뿐이다. 모바일 네트워크, 프록시, 사용자의 연속 입력, 클라이언트 재시도 중 어느 하나만 있어도 같은 요청은 다시 올 수 있다.

서버가 “클라이언트가 한 번만 보낼 것”을 전제로 하면 지연이 생길 때마다 같은 문제가 반복된다. 중복 요청을 정상 입력으로 보고, DB가 상태를 한 번만 바꾸게 해야 한다. 이 사례에서는 WHERE state = 'CLOSED'가 그 역할을 했다.