개발 slecs

대시보드 월별 지출 집계 필터 정합성 버그 수정

목차

dashboard-pg의 월별 지출 집계 필터, pgExpenseMonth에서 GLOBAL 필터 정합성 문제가 있었다. 수정 파일은 SQL 매퍼 하나였지만, 그 한 파일이 대시보드의 핵심 집계 경로 위에 걸쳐 있어서 영향 범위가 생각보다 넓었다.

월별 집계 필터를 구현할 때 단순하게 생각하면 WHERE YEAR(created_at) = :year AND MONTH(created_at) = :month 조건 하나로 끝날 것 같지만, 실제로는 여러 엣지 케이스가 따라붙는다. 가장 자주 무너지는 지점은 이 세 가지다:

  • 경계 날짜 처리: 월말 23:59:59 이후 데이터나, 타임존이 혼재된 환경에서 UTC 기준 자정을 넘어가는 레코드
  • GLOBAL 필터 우선순위: 개별 조건보다 상위의 GLOBAL 집계 필터가 있을 때, 두 조건이 AND로 결합되는 것처럼 보이지만 실제 SQL 실행 계획에서는 다르게 평가되는 경우
  • 집계 함수의 NULL 처리: SUM이나 COUNT에서 NULL 행이 어떻게 집계되는지에 따라 숫자가 달라짐

이번 케이스는 GLOBAL 필터 정합성 쪽이었다. 특정 조건에서 월별 필터가 의도대로 맞물리지 않아 집계값이 틀리게 나오거나, 아예 오류를 반환하는 상황이었다.

SQL 매퍼에서 집계 필터가 깨지는 패턴

MyBatis 계열 매퍼에서 월별 집계를 쓸 때 이런 구조가 자주 나온다:

<select id="selectExpenseByMonth" resultType="ExpenseDto">
  SELECT
    YEAR(expense_date)  AS expYear,
    MONTH(expense_date) AS expMonth,
    SUM(amount)         AS totalAmount
  FROM expense
  <where>
    <if test="year != null">
      AND YEAR(expense_date) = #{year}
    </if>
    <if test="month != null">
      AND MONTH(expense_date) = #{month}
    </if>
  </where>
  GROUP BY YEAR(expense_date), MONTH(expense_date)
</select>

문제는 GLOBAL 필터, 즉 상위 쿼리나 다른 집계 조건이 이 <where> 블록 밖에서 주입될 때 발생한다. 파라미터가 null인 케이스에서 조건 자체가 빠져버리면, GLOBAL 필터 범위와 월별 필터 범위가 교차하지 않아 집계 대상 집합이 달라진다. 숫자가 틀리는 건 당연한 결과다.

수정 방향은 <where> 블록 내부의 조건 순서와 null 처리를 정리하고, GLOBAL 조건이 항상 먼저 평가된 뒤 월 조건이 그 위에서 좁혀지도록 구조를 맞추는 것이었다. 추가로 GROUP BYORDER BY 절이 서로 다른 기준을 바라보고 있던 부분도 함께 정리했다.

동적 조건이 조합되는 매퍼에서 가장 중요한 원칙 하나는, 조건 누락 시 "전체 데이터"가 되는지 아니면 "빈 데이터"가 되는지를 명확히 설계해두는 것이다. 이 둘이 혼재되면 null 파라미터 조합에 따라 집계가 폭발적으로 커지거나 0이 되는 현상이 나타난다. 이번 버그도 그 경계에서 발생했다.

수정하면서 체크한 것들

단순히 틀린 쿼리를 고치는 것으로 끝내지 않았다. 같은 패턴이 다른 매퍼에도 있는지 먼저 확인했고, 결과적으로 이번에 건드린 파일 하나로 수렴됐지만 비슷한 집계 쿼리가 있는 파일들은 눈으로 훑었다.

수정 전후 확인 순서는 이렇게 했다:

단계 내용
버그 재현 특정 연월 파라미터로 직접 호출, 틀린 숫자 확인
수정 적용 SQL 매퍼 조건 정리, GLOBAL 필터 정합성 보정
정상 케이스 회귀 기존에 잘 나오던 월 데이터가 그대로인지 확인
cross-check 관련 화면과 숫자 비교, 집계 합계 교차 검증

회귀 확인이 특히 중요하다. 엣지 케이스 하나를 잡겠다고 조건을 추가했다가 정상 케이스가 깨지는 건 수정이 아니라 버그를 하나 더 만드는 것이다. SQL 매퍼처럼 조건이 동적으로 조합되는 구조에서는 특히 그렇다. 조건이 몇 가지 안 되더라도 (null, null), (year, null), (null, month), (year, month) 네 가지 조합은 모두 손으로 한 번씩 찔러봐야 한다. 이걸 귀찮다고 넘기면 나중에 특정 사용자 케이스에서만 재현되는 버그로 돌아온다. 그게 훨씬 더 잡기 어렵다.

비슷한 로직이 다른 경로에도 있는지 확인하는 것도 습관으로 만들어두는 게 좋다. 같은 쿼리를 복붙한 코드가 있다면 지금 고친 버그가 거기도 잠복해 있을 가능성이 높다. 이번엔 다행히 해당 파일 하나로 정리됐다.

이 작업을 통해 다시 확인한 것

금융/지출 데이터 집계에서 숫자 하나가 틀리면 사용자 신뢰에 직접 영향이 간다. 숫자가 맞지 않는다는 걸 사용자가 먼저 발견하는 순간, 그 이후부터는 맞는 숫자도 의심받는다. 그래서 이 도메인에서는 "대충 비슷해 보인다"는 판단을 기준으로 넘기면 안 된다. 재현 조건이 명확하지 않더라도 가능한 케이스를 다 밟아보고, 관련 화면과 숫자를 직접 비교해서 닫아야 한다.

사내 서비스는 SQL 집계, 상태 머신, 예외 처리, 렌더링, 권한 체크가 모두 엮인 구조 위에 서 있다. 변경 파일이 매퍼 하나라도 그 하나가 어디에 어떻게 연결되어 있는지는 따로 짚어봐야 한다. 팀장 위치에서는 이 연결 구조를 직접 보는 습관이 중요하다. 작은 수정이 넓은 범위로 파급되는 걸 먼저 인식하고 있어야 코드 리뷰에서도 같은 눈으로 볼 수 있다.

커밋 메시지는 짧게 끝냈지만 커밋 본문에는 어떤 케이스에서 어떤 이유로 틀렸는지를 적었다. 나중에 git log로 이 커밋을 다시 열었을 때 파일 diff만 보고 "왜 이걸 바꿨지?"가 생기지 않도록. 작은 습관이지만 반복하면 팀 전체의 히스토리 가독성이 달라지고, 같은 류의 버그가 다시 들어왔을 때 참고할 수 있는 맥락이 된다.

논리적으로 독립된 단위로 커밋을 쪼개는 것도 마찬가지다. 이번처럼 GLOBAL 필터 수정과 GROUP BY 정리가 함께 들어갔다면 두 커밋으로 나누는 게 원칙이었는데, 두 변경이 같은 쿼리 블록 내에서 맞물려 있어 분리하는 게 오히려 어색한 상황이었다. 이럴 때는 하나로 묶되 메시지에 두 변경을 모두 명시하는 게 현실적인 타협이다. 분리 원칙보다 커밋 메시지의 정확성이 우선인 케이스다.

댓글 0

첫 댓글 달아줘.