개발 slecs

관리자 대시보드 카드 레이아웃과 정산 검증 체계 구축

목차

관리자 대시보드를 손본 작업이었다. 기능 자체는 간단해 보여도, 운영 도구라는 특성상 "잘못 보여주는 것"이 "안 보여주는 것"보다 더 위험하다는 걸 이번에 다시 실감했다.

카드 레이아웃과 데이터 분리 전략

대시보드의 기본 목적은 현황을 빠르게 파악하는 것. 그래서 카드 단위로 쪼개는 게 맞다. 이번에 구성한 카드는 네 가지였다.

  • 실시간 거래 현황
  • 잔액 및 수수료 집계
  • 최근 가입 사용자
  • 미처리 건 알림

카드마다 쿼리를 분리한 건 이유가 있다. 단일 쿼리로 한 번에 때려서 가져오면 코드가 깔끔해 보이지만, 한 카드의 슬로우 쿼리가 전체 대시보드를 멈추게 만든다. 분리하면 각 카드가 독립적으로 로드되고, 문제가 생겼을 때 어느 쿼리가 범인인지 바로 보인다. 나중에 특정 카드만 새로고침 주기를 다르게 가져가야 할 때도 분리된 구조가 훨씬 유연하게 대응된다.

캐시 TTL은 짧게 잡았다. 대시보드는 최신 데이터가 의미 있는 공간이라, 너무 오래 캐시하면 관리자가 이미 처리된 이슈를 다시 보게 되는 상황이 생긴다. 그렇다고 캐시를 아예 안 쓰면 페이지 접근할 때마다 DB 부하가 쌓인다. 결국 각 카드의 데이터 성격에 따라 TTL을 다르게 가져가는 게 균형점이다.

카드 캐시 전략 이유
실시간 거래 현황 짧은 TTL / 폴링 시시각각 변함
잔액·수수료 집계 중간 TTL 배치 주기에 맞춤
최근 가입 사용자 중간 TTL 신규 가입 빈도 낮음
미처리 건 알림 TTL 없음 또는 최소화 즉시 인지 필요

레이아웃 편집 기능은 카드 위치와 표시 여부를 관리자가 직접 바꿀 수 있게 하는 것이다. 설정을 DB에 저장하느냐 로컬스토리지에 저장하느냐는 팀마다 다르게 가는데, 다중 기기에서 같은 설정을 유지해야 하는 운영 도구라면 서버 사이드 저장이 맞다. 개인 취향 수준의 뷰 설정이라면 로컬스토리지로도 충분하지만, 이번에는 전자로 결정했다.

정산 쿼리 설계와 멱등성 처리

정산 계산은 기간별 집계가 핵심이다. WHERE settled_at BETWEEN start AND end 같은 조건이 매번 들어가는데, 이 컬럼에 인덱스가 없으면 풀 스캔으로 직행한다. 테이블이 클수록 치명적이다.

-- 기간별 정산 집계 예시 구조
SELECT
    user_id,
    SUM(amount)         AS total_amount,
    SUM(fee)            AS total_fee,
    SUM(amount - fee)   AS net_amount
FROM transactions
WHERE
    status = 'settled'
    AND settled_at BETWEEN :start AND :end
GROUP BY user_id;

서브쿼리 구조는 계층별 수수료 요율을 적용할 때 복잡해진다. 요율이 사용자 등급이나 거래 유형에 따라 다르면 조인이 늘어나는데, 이때 서브쿼리를 CTE(WITH 절)로 빼는 게 가독성과 실행 계획 양쪽에서 낫다. 옵티마이저가 CTE를 materialize하느냐 인라인으로 처리하느냐는 DB 종류마다 다르니까 실행 계획은 반드시 직접 확인해야 한다.

인덱스는 (status, settled_at) 복합 인덱스가 효과적이다. status 카디널리티가 낮아도 settled_at 범위 조건이 뒤에 붙으면 복합 인덱스가 의미 있다. 반대로 (settled_at, status) 순서로 바꾸면 범위 스캔 후 status 필터가 되는데, 데이터 분포를 보고 어느 쪽이 나은지 따져봐야 한다. 인덱스 순서 하나로 응답 속도가 확연히 갈리는 경우가 생각보다 많다.

수수료 계산은 금전이 오가는 영역이라 오류가 바로 신뢰 손상으로 이어진다. 계층별 요율을 다르게 적용하면서 올림/버림 처리까지 엮이면 최종 금액이 미묘하게 달라질 수 있다. 원 단위 처리는 수수료를 내는 쪽에 유리하게, 내림으로 처리하는 게 분쟁을 줄인다. 1원 차이라도 누적되면 민감해지는 도메인이라서, 반올림 방향을 코드 주석에만 남기지 말고 테스트 케이스로 명시해두는 게 안전하다.

import math

def calculate_fee(amount: int, rate: float) -> int:
    # 수수료는 내림 처리 - 수수료 부담자에게 유리한 방향
    return math.floor(amount * rate)

def calculate_net(amount: int, fee: int) -> int:
    return amount - fee

정산 배치의 멱등성은 설계 단계에서 잡아야 한다. 같은 기간으로 배치를 두 번 돌렸을 때 결과가 달라지면 안 된다는 게 원칙이고, 일반적인 방법은 정산 레코드에 period_key 같은 유니크 키를 두고 이미 처리된 기간이면 INSERT를 막는 것이다. INSERT OR IGNOREON CONFLICT DO NOTHING 패턴이 여기서 자연스럽게 쓰인다. 멱등성이 보장되지 않으면 재처리나 재배치 자체를 두려워하게 되고, 그 두려움이 장애 대응 속도를 늦춘다.

정산 UI에서 관리자가 결국 보는 건 세 숫자다. 총 거래액, 수수료, 실수령액. 이 셋을 명확하게 보여주는 게 전부다. 천 단위 구분자와 '원' 단위는 기본이고, 마이너스 금액은 빨간색으로 처리해서 시각적으로 바로 튀게 만들었다. 별거 아닌 것 같아도 이런 디테일이 관리자가 숫자를 잘못 읽는 실수를 줄여준다.

작업 규모가 크지 않아도, 정산처럼 금전이 오가는 영역은 반올림 방향이나 멱등성 처리 같은 의도를 코드 바깥에 명시적으로 남겨두는 것이 나중 디버깅 시간을 크게 아낀다. 코드만 봐서는 "왜 내림이냐"를 파악하기 어렵고, 그 컨텍스트가 사라지는 순간 누군가 반올림으로 바꿔버리는 일이 생긴다.

댓글 0

첫 댓글 달아줘.