개발 slecs

수익 대시보드 실시간화와 결제 데이터 정합성 개선

목차

system-revenue 영역에 새 기능을 붙이는 작업이었다. 수익 대시보드를 실시간으로 갱신하고, 그 과정에서 결제 데이터 정합성까지 손봤음. 변경된 파일은 내부 클래스 하나, SQL 매퍼 하나, 뷰/스타일 하나. 숫자로 보면 소박하지만 실제 작업 무게는 달랐다.

기존 화면과 API가 제공하지 않던 데이터가 필요해졌다. 단순히 화면에 값 하나 띄우는 게 아니라, 그 값이 다른 화면의 수치와 일치해야 하고, 권한에 따라 다르게 보여야 하고, 빈 데이터나 NULL이 들어왔을 때도 화면이 깨지지 않아야 한다. 그러니까 UI 레이어만 건드리는 게 아니라 쿼리 레벨부터 설계를 다시 잡아야 했음.

실시간 갱신 전략 선택 - 폴링이냐 이벤트냐

대시보드를 실시간화한다고 하면 가장 먼저 결정해야 하는 게 "어떤 방식으로 갱신할 것인가"다. 크게 세 가지 선택지가 있다.

방식 구현 복잡도 서버 부하 갱신 지연 적합 상황
폴링 (interval) 낮음 중간 (주기적 쿼리) 폴링 주기만큼 데이터 변경 빈도가 낮고 지연이 허용될 때
SSE (Server-Sent Events) 중간 낮음 (연결 유지) 거의 없음 단방향 알림, 서버 push
WebSocket 높음 낮음 거의 없음 양방향 통신이 필요할 때

이번 경우는 결제 집계 수치를 보여주는 대시보드라 단방향 갱신으로 충분했고, WebSocket 도입까지 갈 필요는 없었다. 결국 일정 주기 폴링으로 결론냈는데, 이유는 단순하다. 데이터 변경이 초단위로 일어나는 게 아니고, 연결 유지 비용보다 쿼리 최적화를 제대로 해두는 게 더 실익이 컸기 때문. 잘 튜닝된 SQL 하나가 구조적으로 복잡한 push 채널보다 운영하기 쉽다.

폴링 간격은 도메인 특성을 고려해서 결정해야 한다. "가능한 짧게"가 좋아 보이지만, 짧을수록 DB 쿼리 발생 빈도도 올라간다. 집계 쿼리가 무거우면 짧은 폴링이 오히려 부담이 됨. 이번엔 쿼리 실행 계획을 먼저 확인하고 폴링 간격을 정했다.

SQL 설계와 정합성 유지

금융/결제 도메인에서 집계 쿼리를 짤 때 가장 신경 써야 하는 건 어떤 시점의 데이터를 보여주느냐다. 트랜잭션이 진행 중인 건을 집계에 포함시키느냐, 완료 건만 보느냐에 따라 숫자가 달라진다. 이게 화면마다 기준이 다르면 사용자 입장에서 "왜 저 화면이랑 이 화면 숫자가 다르지?"가 된다.

이번 작업에서 SQL 매퍼에 쿼리를 추가할 때 기존 집계 쿼리와 기준을 맞추는 데 시간을 꽤 썼다. 기존 화면이 어떤 상태값을 기준으로 집계하고 있는지 먼저 파악하고, 새 쿼리도 동일한 필터 조건을 가져갔다.

-- 기존 집계 쿼리의 상태 필터 패턴 (예시)
SELECT
    DATE(created_at) AS date,
    SUM(amount)      AS total_revenue,
    COUNT(*)         AS transaction_count
FROM payment
WHERE status IN ('COMPLETED', 'SETTLED')  -- 진행중 제외
  AND created_at >= :startDate
  AND created_at <  :endDate
GROUP BY DATE(created_at)
ORDER BY date DESC

여기서 IN ('COMPLETED', 'SETTLED') 같은 상태 필터가 화면마다 달리 적용되어 있으면 숫자가 틀린다. 실제로 이전에 이런 케이스로 "왜 합계가 안 맞냐"는 이슈가 나온 적이 있다고 들었는데, 이번에 새 쿼리 작성하면서 기존 코드를 뒤져 기준을 명확히 통일했음.

내부 클래스 메서드도 같은 맥락이다. 집계 결과를 받아서 가공하는 로직이 있으면, 그 가공 방식도 기존 화면과 동일해야 한다. NULL을 0으로 처리하는지, 반올림 기준은 어떻게 잡는지 같은 사소해 보이는 부분들이 합산하면 꽤 큰 차이를 만든다.

예외 케이스 방어는 코드 레벨에서도 챙겼다.

// 빈 결과나 NULL 값에 대한 방어 패턴
public BigDecimal getTotalRevenue(List<RevenueDto> results) {
    if (results == null || results.isEmpty()) {
        return BigDecimal.ZERO;
    }
    return results.stream()
        .map(r -> r.getAmount() != null ? r.getAmount() : BigDecimal.ZERO)
        .reduce(BigDecimal.ZERO, BigDecimal::add);
}

NULL을 그냥 흘려보내면 화면에서 NaN이나 비어있는 칸이 나온다. 금융 대시보드에서 숫자가 비어있으면 데이터가 없는 건지 오류가 난 건지 사용자가 구분 못함.

검증 방식과 작업 습관

구현 후 직접 화면에서 동작을 확인했다. 이때 확인 순서가 있다.

  • 새 기능이 의도대로 동작하는지 기본 케이스 확인
  • 기존 화면의 숫자와 새 화면의 숫자가 같은 기준으로 보이는지 cross-check
  • 빈 데이터 케이스, 권한 없는 접근 케이스에서 화면이 정상 처리되는지 확인
  • 쿼리 실행 계획으로 풀스캔이나 예상 외 부하가 없는지 검토

cross-check할 때는 같은 기간으로 기존 화면과 새 화면을 나란히 띄워두고 수치를 비교했다. 이게 좀 번거롭긴 한데, 이 단계를 건너뛰면 나중에 사용자가 먼저 발견하게 된다.

작업 전에 현재 화면 수치를 메모하거나 스크린샷을 찍어두는 것도 습관이 됐다. 변경 전후 비교가 가능해야 "이번 배포로 뭔가 바뀌었다"는 제보가 왔을 때 대응이 빠르다.

커밋은 논리적으로 독립된 단위로 쪼개는 것도 계속 유지 중. SQL 매퍼 변경, 클래스 메서드 추가, 뷰 연동을 하나의 커밋에 밀어넣으면 나중에 어디서 문제가 생겼는지 bisect하기 어렵다. 작게 쪼개두면 롤백 범위도 명확하고, 코드 리뷰할 때 리뷰어도 맥락을 잡기 쉽다.

사내 서비스다 보니 빠르게 치고 나가야 하는 압박이 있는데, 금융/결제 도메인은 그 압박에 굴복했을 때 대가를 치르는 영역이다. "대충 맞는 것 같다"로 나간 숫자는 반드시 다시 돌아온다. 그게 빠르면 다음 날, 늦으면 월말 정산 시점에.

댓글 0

첫 댓글 달아줘.