자동화 slecs

시스템 포인트 캐시 드리프트를 배치로 자동 보정

목차

포인트 시스템은 숫자가 작아 보여도 건드리기 까다로운 영역이다. 적립·사용·취소·만료가 동시다발로 발생하고, 각 이벤트가 DB에 반영되는 타이밍이 조금씩 다르다 보면 어느 순간 캐시 값과 실제 합계가 조용히 틀어진다. 그걸 화면에서 "뭔가 이상한데?" 하고 발견하면 이미 늦은 거다. 이번 배치 작업은 그 drift를 사람이 인지하기 전에 주기적으로 탐지하고 자동으로 되돌리는 루틴을 추가한 것이다.

변경된 파일은 두 개였다. 배치 실행 흐름을 담당하는 내부 클래스 하나, 그리고 보정 쿼리를 담은 SQL 매퍼 하나. 파일 수로만 보면 작지만, 설계 결정은 생각보다 많았다.

왜 drift가 생기나

포인트 캐시 drift는 대부분 세 가지 경로에서 온다.

첫째, 집계 쿼리와 캐시 갱신 사이의 타이밍 gap. 트랜잭션이 커밋되는 순간과 캐시를 업데이트하는 로직이 같은 트랜잭션 안에 묶여 있지 않으면 그 사이에 서버가 죽거나 예외가 터질 수 있다. 두 번째는 배치 재처리나 어드민 수동 조정처럼 일반 흐름을 우회하는 케이스. 이런 작업은 캐시 갱신 코드를 타지 않고 직접 DB를 건드리는 경우가 많다. 세 번째는 스키마 변경이나 버그 픽스 이후에 과거 데이터가 새 계산식과 맞지 않는 경우다.

세 케이스 모두 공통점이 있다. 발생 당시에는 조용하다. 그리고 시간이 지날수록 누적된다.

구현 핵심 - 멱등성과 범위 제어

가장 먼저 고민한 건 "얼마나 넓은 범위를 보정 대상으로 잡느냐"였다. 전체 사용자를 매번 돌리면 쿼리 부하가 크고, 너무 좁히면 drift를 놓친다. 이번엔 최근 N일 이내에 포인트 변동 이력이 있는 계정만 추려서 집계와 캐시 값을 비교하는 방식으로 범위를 한정했다. 이 구간은 SQL 매퍼에서 파라미터로 빼뒀다.

멱등성도 필수였다. 배치가 두 번 돌아도 보정 결과가 같아야 하고, 정합성이 맞는 행은 건드리지 않아야 한다. 구현은 단순하게, 집계값과 캐시값을 비교해서 다를 때만 UPDATE를 치도록 했다.

UPDATE user_point_cache c
JOIN (
    SELECT user_id, SUM(point_delta) AS real_total
    FROM point_history
    WHERE updated_at >= DATE_SUB(NOW(), INTERVAL #{rangeDays} DAY)
    GROUP BY user_id
) agg ON c.user_id = agg.user_id
SET c.total_point = agg.real_total,
    c.synced_at   = NOW()
WHERE c.total_point != agg.real_total

실제 매퍼는 더 복잡했지만 핵심 구조는 이거다. WHERE 조건으로 drift가 있는 행만 쳐서 불필요한 I/O를 줄였고, synced_at을 찍어두면 나중에 "언제 보정됐는지"를 추적할 수 있다.

배치 클래스에는 세 가지를 추가했다.

  • cron 표현식으로 주기 설정 (새벽 저트래픽 시간대 고정)
  • 실행 결과(보정 건수, 소요 시간)를 로그 테이블에 적재
  • 수동 트리거 API - 개발 중 직접 실행하거나 운영에서 즉시 돌려야 할 때 쓴다

수동 API가 있으면 배포 직후나 데이터 마이그레이션 뒤에 검증 사이클을 훨씬 빠르게 돌릴 수 있다. 배치 주기를 기다릴 필요 없이 호출 한 번으로 결과를 바로 확인하니까.

설계 시 갈렸던 결정들

폴링이냐 이벤트냐. drift를 이벤트 발생 시점마다 즉시 보정하면 더 실시간에 가깝다. 하지만 포인트 변동이 빈번하면 보정 쿼리 자체가 핫 패스에 올라가고, 장애 시 전파 범위가 넓어진다. 배치로 주기 처리하면 보정 지연이 생기는 대신 비즈니스 로직과 완전히 분리된다. 이번 도메인에서는 수분 단위 지연이 허용 가능했고, 안정성 우선이라 배치를 선택했다.

집계 기준 시점. 포인트 이력 집계를 어떤 시각 기준으로 잡느냐에 따라 결과가 달라진다. 트랜잭션 커밋 시각, 이벤트 발생 시각, 서버 수신 시각 - 어느 걸 쓰냐에 따라 경계 케이스가 다르게 튄다. 일관된 기준 하나를 잡고, 그 기준을 SQL 매퍼와 캐시 갱신 로직 양쪽에 동일하게 적용했다.

보정 실패 처리. 한 유저의 보정이 실패했을 때 전체 배치를 롤백할지, 해당 행만 skip하고 계속 갈지. 개별 실패를 격리해서 실패 목록만 로그에 남기고 나머지는 정상 처리하는 쪽을 택했다. 운영 입장에서 "몇 명이 안 됐는지"를 알 수 있는 게 전체 실패보다 낫다.


구현 후 검증은 직접 했다. 임의로 캐시 값을 틀리게 세팅하고 배치를 수동 트리거해서 보정되는지 확인, 이미 맞는 행은 건드리지 않는지 확인, 경계 케이스(포인트 이력 없음, 신규 유저, 음수 합계)도 케이스별로 돌렸다.

금융/결제 도메인에서 "대충 맞는 것 같다"는 실제로 틀린 거랑 같다. 숫자 하나가 맞지 않는 화면을 본 사용자는 그 순간 시스템 전체를 의심하기 시작한다. 이 배치 하나가 전체 포인트 신뢰도를 받쳐주는 구조가 되는 거다.

커밋도 두 개로 쪼갰다. SQL 매퍼 추가, 배치 클래스 변경. 나중에 문제가 생겼을 때 어느 쪽에서 나왔는지 git log 한 줄로 좁혀지는 게 훨씬 낫기 때문이다. "왜 이 배치를 추가했는가"를 커밋 메시지에 한 줄이라도 남겨두면, 몇 달 뒤에 이 코드를 보는 사람(본인 포함)이 맥락을 이해하는 데 드는 시간이 확 줄어든다.

댓글 0

첫 댓글 달아줘.