개발 slecs

파트너 정산 잔액 변동 리스트 시간축 오류 수정

목차

파트너 정산 쪽 잔액 변동 리스트에서 시간축이 꼬이는 버그가 있었다. 증상은 단순했는데, 원인을 따라 내려가다 보니 생각보다 조금 더 깊은 곳에 있었음.

뭐가 문제였나

잔액 변동 리스트는 "이 시점에 잔액이 얼마였는가"를 순서대로 나열하는 화면이다. 각 트랜잭션 발생 시점마다 누적 잔액을 보여주는 건데, 이게 SQL 레벨에서 생각보다 틀리기 쉬운 형태다.

전형적인 구현 패턴이 이렇다:

SELECT
  tx_id,
  tx_type,
  amount,
  SUM(amount) OVER (
    PARTITION BY partner_id
    ORDER BY created_at
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS running_balance
FROM partner_transactions
WHERE partner_id = :partnerId
ORDER BY created_at;

윈도우 함수로 누적합을 구할 때 ORDER BY가 단일 컬럼이면 동일 타임스탬프 내 행 순서가 DB 엔진 구현에 따라 달라진다. 단순화하면 이런 상황이다:

tx_id created_at amount running_balance (올바른 순서 기준)
1001 10:00:00 +5,000 5,000
1002 10:00:00 -2,000 3,000
1003 10:00:01 +1,000 4,000

1001과 1002가 같은 초에 들어왔을 때 DB가 1002를 먼저 처리하면 -2,000부터 시작해서 누적이 뒤집힌다. 최종 잔액 자체는 같더라도 중간 row들의 running_balance 값이 달라지고 그게 화면에 그대로 찍힌다. 개발 환경에서는 항상 같은 순서로 나와서 발견 못 하다가, 프로덕션에서 I/O 스케줄링이나 쿼리 플랜이 달라지면 그때 증상이 나오는 패턴이다.

픽스는 단조 증가하는 보조 키를 ORDER BY에 추가해서 순서를 결정론적으로 강제하는 것:

ORDER BY created_at, tx_id  -- 타임스탬프 동률이면 id로 tie-break

SQL 매퍼에서 이 정렬 기준을 명시하지 않은 게 버그 진원지였고, 내부 클래스는 매퍼에서 받은 순서를 신뢰하면서 이전 잔액 + 변동액으로 재가공하는 구조였다. 정렬이 이미 틀린 채 들어오면 클래스 내부 계산이 아무리 정확해도 화면에 나오는 숫자가 맞지 않는다. 뷰 쪽도 렌더링 순서 의존이 있어서 함께 수정했음.

금융/정산 도메인에서 숫자 정합성이 더 무거운 이유

정산 화면에서 잔액이 시간 순서대로 오르락내리락하는 게 눈에 보이는데, 특정 구간에서 숫자가 이상하게 찍히면 파트너 입장에서는 "내 돈이 이상하다"는 신호로 즉시 받아들여진다. 내부에서 먼저 발견하면 수정하고 넘어가지만, 파트너가 먼저 인지하면 그때부터 신뢰 이슈가 된다. 신뢰는 배포 한 번으로 회복되지 않음.

그래서 시간 순서에 의존하는 집계는 경계 조건을 아예 체계적으로 따지는 편이 낫다. 이번 버그를 기점으로 정리해보면:

  • 같은 초 내 트랜잭션 여러 건 - 이번 케이스. 순서 비결정론으로 running_balance가 꼬임.
  • 취소/환불 처리 - 잔액이 음수 방향으로 움직이거나, 취소 시점 기록 방식에 따라 집계 결과가 달라질 수 있음.
  • 배치 정산 + 실시간 트랜잭션 혼재 - 같은 날짜 안에서도 생성 경로가 다른 레코드가 섞이면 타임스탬프 기준 정렬이 의도대로 안 될 수 있음.
  • 파트너 ID 범위 - PARTITION BY 대상이 화면에서 보여주는 단위와 일치하는지.

이번 수정 과정에서 비슷한 패턴이 다른 화면에도 있는지 확인했고, 위험한 케이스는 같이 정리했음. 버그를 증상만 픽스하면 며칠 뒤 같은 구조에서 다른 형태로 다시 돌아온다.

검증과 작업 방식

버그를 재현하는 케이스를 먼저 특정한 다음 수정하는 순서를 지키는 게 중요하다. 순서가 뒤집히면 "픽스한 것 같은데 재현이 안 된다"로 끝날 수 있고, 실제로 버그가 고쳐진 건지 재현 방법 자체가 달랐던 건지 구분이 안 된다.

이번 검증 체크리스트:

  • 동일 타임스탬프 트랜잭션 2건 이상 - running_balance 순서 정상 여부
  • 타임스탬프가 다른 일반 케이스 - 기존 동작이 깨지지 않는지 회귀 확인
  • 정산 합계 화면과 숫자 cross-check - 같은 데이터를 보는 다른 뷰와 총액이 일치하는지
  • SQL 매퍼에 캐싱 레이어가 있으면 캐시 무효화 후 재확인

cross-check를 다른 화면과 하는 이유는, 한 화면만 수정했을 때 화면별 집계 결과가 달라지는 케이스가 생기기 때문이다. 정산 변동 내역에서는 맞는데 합산 리포트에서 다르게 나오면 오히려 더 혼란스러워짐. 이번에는 정산 합계 화면과 숫자가 맞는 걸 확인하고 닫았음.

수정 파일이 세 개(SQL 매퍼, 내부 클래스, 뷰/스타일)지만 커밋은 논리 단위로 쪼갰다. SQL 정렬 수정, 클래스 로직 수정, 뷰 수정을 하나로 묶으면 나중에 git bisect나 blame으로 들어갔을 때 어느 변경에서 문제가 시작됐는지 추적하기 불편해진다.

커밋 메시지도 "잔액 컬럼 정렬 수정"보다는 "동일 타임스탬프 트랜잭션에서 running_balance 순서 비결정 문제 수정"이 낫다. 6개월 뒤 blame 들어갔을 때 "왜 이걸 바꿨나"가 바로 읽혀야 하니까. "무엇을" 바꿨는지는 diff가 보여주는데, diff가 못 보여주는 건 "왜"다.

변경 전 쿼리 결과나 화면 스크린샷을 메모해두는 것도 도움이 됐음. "원래 어떻게 나왔더라"를 기억에 의존하면 검증이 흐릿해진다. 수치를 기준으로 before/after를 비교해야 "맞아 보인다"가 아니라 "맞다"로 끝낼 수 있다.

사내 서비스는 기능 하나가 SQL 집계, 상태 처리, 예외 케이스, 화면 렌더링이 다 엮여 있어서 어느 하나만 빠뜨려도 특정 케이스에서 숫자가 맞지 않거나 이상한 결과가 나온다. 금융/정산 도메인은 이 의존성이 더 촘촘하기 때문에 수정 범위를 좁게 잡는 것보다 관련 레이어를 같이 훑는 편이 결국 더 빠름. 엣지 케이스를 꼼꼼히 따지는 게 귀찮아 보여도, 같은 버그로 다시 오는 시간 비용이 훨씬 크다.

댓글 0

첫 댓글 달아줘.