운세봇 예약 중복 삽입 버그 수정
목차
grade-temp-bot 예약 기능에서 같은 레코드가 중복 INSERT되는 버그가 발견됐다. 상태 값이 SCHEDULED인 예약 행이 동일한 요청으로 여러 번 쌓이는 상황이었고, 수정 파일은 SQL 3개. 변경 규모는 작지만 짚어야 할 지점이 꽤 많았음.
왜 중복이 생기는가
예약처럼 "미래에 처리할 것"을 기록하는 구조는 특성상 멱등성을 보장하기 어렵다. 동일한 사용자 요청이 네트워크 재시도나 프론트 중복 호출로 두 번 들어오는 경우, INSERT 앞단에 방어 로직이 없으면 그냥 두 행이 생긴다. 이번 케이스가 정확히 그랬음.
SCHEDULED 상태로 같은 대상 레코드가 여러 개 존재하면 처리 시점에 어떤 행 기준으로 실행해야 하는지 모호해진다. 봇이 중복으로 실행되거나, 집계 쿼리에서 숫자가 두 배로 잡히거나, 아니면 상태 전이가 꼬여서 특정 행은 영원히 처리 안 된 채 남기도 함. 운세봇처럼 예약 발송 타이밍이 핵심인 도메인에서는 이 꼬임이 사용자 경험에 직접 닿는다.
해결 방향은 크게 두 가지다. 애플리케이션 레이어에서 INSERT 전에 존재 여부를 SELECT해서 막거나, DB 레이어에서 unique constraint를 걸어 물리적으로 막거나. 둘 다 쓰는 것도 방법이지만 둘의 역할이 다름.
| 방어 위치 | 장점 | 단점 |
|---|---|---|
| 앱 레이어 (SELECT 후 판단) | 에러 없이 조용히 처리 가능 | race condition 존재, 트랜잭션 범위 밖이면 틈새 발생 |
| DB 레이어 (unique key) | race condition 원천 차단 | constraint violation을 앱에서 잡아줘야 함 |
이번 수정에서는 DB에 unique key를 추가하는 방향으로 처리했다. 동시 요청이 들어와도 DB가 한 건만 허용하게 하는 게 근본적으로 안전하고, constraint violation은 어차피 앱단에서 ON DUPLICATE KEY 또는 예외 처리로 흡수하면 됨.
실제 SQL에서 자주 쓰는 패턴은 이런 식이다.
-- 중복 방어 unique key 예시
ALTER TABLE scheduled_readings
ADD UNIQUE KEY uq_user_target_status (user_id, target_date, status);
-- INSERT 시 중복이면 무시
INSERT IGNORE INTO scheduled_readings (user_id, target_date, status, created_at)
VALUES (?, ?, 'SCHEDULED', NOW());
-- 혹은 중복이면 업데이트
INSERT INTO scheduled_readings (user_id, target_date, status, created_at)
VALUES (?, ?, 'SCHEDULED', NOW())
ON DUPLICATE KEY UPDATE updated_at = NOW();
INSERT IGNORE와 ON DUPLICATE KEY UPDATE 중 어느 것을 고를지는 중복 발생 시 로그를 남길 필요가 있는지에 달려 있음. 조용히 넘어가도 괜찮으면 IGNORE, 나중에 중복 시도 자체를 추적하고 싶다면 ON DUPLICATE KEY로 타임스탬프라도 찍는 게 낫다.
수정 범위 판단
단일 증상만 픽스하고 끝내기 전에 같은 패턴이 다른 SQL에도 있는지 확인했다. 예약 관련 INSERT가 이 파일 하나만이 아니었고, 구조가 비슷한 곳을 같이 훑었음. 위험하다고 판단된 케이스는 같이 묶어서 수정했다.
이 과정에서 체크하는 것들:
- 같은 테이블에 INSERT하는 경로가 몇 개인지. 하나를 막아도 다른 경로가 뚫려 있으면 소용없음
- unique key 컬럼 조합이 도메인 의미와 맞는지. "이 조합이 중복이면 실제로 중복인가"를 사람이 한 번 더 생각해야 함
- 기존 데이터에 이미 중복 행이 있는지. unique key를
ALTER TABLE로 추가하면 기존 중복 데이터가 있을 경우 마이그레이션이 실패하므로 선 정리가 필요함 - 수정이 정상 케이스에 영향을 주는지. 예약 취소 후 재예약 같은 흐름에서 같은 키 조합이 유효하게 재사용될 수 있는지 확인
기존 중복 행 정리는 별도 스크립트로 처리했고, 그 SQL도 이번 3개 파일 중 하나임.
검증과 숫자 정합성
수정 후에 버그를 직접 재현했다. 중복 요청을 의도적으로 보내서 INSERT가 1회만 들어오는지, 두 번째 요청이 에러 없이 무시되는지, 집계 쿼리에서 숫자가 맞게 나오는지 확인했음.
운세봇처럼 예약 발송 카운트가 UI에 노출되는 구조에서는 집계 쿼리 결과가 UI 수치와 맞아야 한다. 그래서 수정 전/후 수치를 관련 화면과 cross-check하는 과정을 꼭 거쳤음. 눈으로 "맞겠지"가 아니라 실제 숫자를 비교하는 것. 이게 귀찮아 보여도 나중에 "왜 숫자가 다르냐"는 질문을 다시 받는 시간 비용이 훨씬 크다.
사내 서비스를 만들다 보면 기능 하나가 단순히 버튼 하나 추가로 끝나지 않는다는 걸 계속 체감한다. SQL 집계, 상태 머신, 예외 처리, 화면 렌더링이 다 엮여 있어서 어느 하나를 빠뜨리면 숫자가 안 맞거나 특정 케이스에서 이상한 결과가 나옴. 이번 버그도 결국 상태 관리(SCHEDULED) 와 데이터 무결성(unique constraint)과 집계 정합성이 동시에 연관된 문제였고, 하나만 보고 닫으면 안 됐음.
커밋은 "unique key 추가", "기존 중복 데이터 정리", "앱단 예외 처리" 세 단위로 쪼갰다. 나중에 어떤 커밋에서 어떤 문제가 생겼는지 추적할 때 덩어리로 묶인 커밋보다 훨씬 낫고, 메시지도 "무엇"보다 "왜"를 담으려고 의식했음. "unique key 추가"보다는 "SCHEDULED 중복 INSERT 방어 - 동시 요청 race condition 원천 차단" 쪽이 6개월 뒤의 나한테 훨씬 친절하다.
댓글 0
첫 댓글 달아줘.