개발 slecs

파트너 포털 회원 실잔액 집계 오류 수정

목차

파트너 포털 회원상세 화면의 실잔액이 실제 잔액과 다르게 표시되는 버그를 수정했다. 변경 파일은 SQL 매퍼 하나. diff 자체는 작았지만 원인을 좁히는 데 시간이 꽤 걸렸다.

뭐가 문제였나

회원상세에 표시되는 "실잔액"이 다른 화면 값과 미묘하게 달랐다. 딱 떨어지는 재현 조건이 없어서 처음엔 렌더링 이슈인가 싶었는데, 쿼리 결과를 직접 실행해 보니 DB 레벨에서 이미 값이 달랐다.

원인은 balance_after 컬럼이었다. 이 컬럼은 거래가 발생했을 때 "해당 시점의 잔액"을 스냅샷으로 저장하는 필드다. 거래 로그 테이블에 박혀 있는 과거 시점 값이지, 지금 이 순간의 잔액이 아니다. 실잔액 집계 쿼리가 이 값을 그대로 가져다 쓰고 있었으니, 마지막 거래 이후 잔액 변동이 생기면 화면에 표시되는 값이 틀릴 수밖에 없었다.

-- before: 거래 로그의 balance_after 스냅샷을 실잔액으로 사용
SELECT
    m.id,
    m.name,
    tl.balance_after AS actual_balance   -- 과거 시점 스냅샷
FROM members m
LEFT JOIN transaction_log tl
    ON tl.member_id = m.id
    AND tl.id = (
        SELECT MAX(id) FROM transaction_log
        WHERE member_id = m.id
    )

-- after: balance_after 스냅샷 제거, 실잔액 직접 조회
SELECT
    m.id,
    m.name,
    m.balance AS actual_balance          -- 최신 실잔액
FROM members m

balance_after 같은 스냅샷 필드는 "그 거래가 일어난 시점에 잔액이 얼마였나"를 추적하는 감사 목적으로는 정확하다. 근데 이걸 "현재 잔액"으로 오해하는 순간 버그가 생긴다. 컬럼 이름만 보면 혼동하기 쉬운 패턴이라, 비슷한 쿼리가 다른 매퍼에도 있는지 훑어봤다. 다행히 이번 건 하나였다.

원인 좁히는 방법

재현 조건이 불명확할 때 쓰는 방법이 있다. 화면 단에서 값을 비교하는 게 아니라, 쿼리 레이어를 한 단씩 내려가면서 어디서 값이 갈리는지를 찾는 것이다.

  1. 화면 표시값 vs API 응답 비교
  2. API 응답값 vs 서비스 레이어 반환값 비교
  3. 서비스 레이어 vs SQL 실행 결과 비교

대부분 3번에서 걸린다. 집계 버그는 거의 다 SQL이다. 이번에도 쿼리를 직접 실행해 보는 순간 바로 잡혔다.

수정 후 체크 순서는 이렇게 했다.

체크 항목 방법
회귀 방지 정상 케이스(거래 없음, 단건, 복수) 각각 확인
숫자 정합성 회원상세 vs 다른 잔액 표시 화면 cross-check
엣지 케이스 잔액 0, 마이너스 허용 여부, NULL 처리
배포 전 스테이징에서 동일 케이스 재확인

엣지 케이스 따지는 게 귀찮아 보여도, 결제/포인트 도메인에서 숫자 하나 틀리면 신뢰 자체가 흔들린다. 나중에 같은 버그로 다시 오는 시간 비용이 훨씬 크다.

이 도메인에서 반복되는 실수들

금융/잔액 관련 집계 쿼리를 다루다 보면 자주 보이는 패턴이 있다.

  • 스냅샷 컬럼을 실시간 값으로 쓰기: balance_after, price_at_order 같은 컬럼은 과거 시점 값이다. 현재 기준 집계에 섞으면 틀린다. 이번 버그가 여기에 해당.
  • LEFT JOIN 후 NULL 미처리: 거래 이력이 없는 회원을 JOIN으로 끌어오면 잔액이 NULL로 내려온다. COALESCE 없이 그대로 노출하면 화면이 이상해진다.
  • 최신 1건 서브쿼리의 정렬 기준 모호: MAX(id) 대신 MAX(created_at)이 맞는 경우도 있고, 동일 시점에 여러 건이 들어올 수 있는 구조면 둘 다 신뢰하기 어렵다. 그런 경우는 ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) 방식이 더 안전하다.
  • 집계 함수와 일반 컬럼 혼용: GROUP BY 없이 집계 함수와 일반 컬럼을 섞으면 DBMS에 따라 에러가 나거나 예측 불가능한 값이 나온다. MySQL 특유의 관대한 동작을 믿고 넘어갔다가 이전 버전이나 다른 환경에서 터지는 케이스가 있다.

사내 서비스를 만들다 보면 기능 하나가 화면에 버튼 하나 추가하는 것으로 끝나지 않는다는 걸 계속 체감한다. SQL 집계, 상태 머신, 예외 처리, 화면 렌더링, 권한 체크가 모두 엮여 있어서, 어느 하나만 빠뜨려도 숫자가 안 맞거나 특정 사용자에게 이상한 화면이 나타난다.

작업 전에 현재 동작을 수치로 메모해 두고, 수정 후 같은 케이스로 다시 확인하는 게 습관이 됐다. 커밋 메시지도 "무엇을 바꿨다"보다 "왜 바꿨는지"를 담으려 한다. 이번 커밋은 대략 이렇게 썼다.

fix: 회원상세 실잔액 쿼리에서 balance_after 스냅샷 제거

balance_after는 거래 시점의 스냅샷이라 현재 잔액과 다를  있음.
실잔액은 members.balance 직접 조회로 수정.

작은 단위로 커밋을 쪼개는 이유도 비슷한 맥락이다. 나중에 "어느 변경에서 숫자가 깨졌지?"를 추적할 때, 논리적으로 독립된 커밋들이 있으면 git bisect이 효과적으로 먹힌다. 여러 수정을 뭉쳐서 올린 커밋은 그 안에서 원인을 찾는 게 처음부터 다시 디버깅하는 것과 다르지 않다. 귀찮더라도 논리 단위로 쪼개는 습관은 유지하는 게 맞다.

댓글 0

첫 댓글 달아줘.