파트너 트리 전체로 회원 등급 옵션 조회 버그 수정
목차
grade-temp-bot에서 등급 옵션 조회가 틀린 결과를 반환하는 버그를 수정했다. 변경 파일은 SQL 매퍼 하나지만, 그 한 파일에 꽤 많은 고민이 담겼다.
문제: 파트너 트리를 얕게 보고 있었다
등급 옵션은 회원이 속한 파트너 트리를 기반으로 계산된다. 기존 쿼리는 직속 파트너 - 1단계 - 까지만 조회하고 있었다. 트리가 얕은 케이스에서는 멀쩡히 동작하니 오랫동안 발견되지 않았다.
문제는 파트너 관계가 중첩될수록 드러났다. 파트너의 파트너, 그 밑의 파트너까지 이어지는 케이스에서 옵션 집계가 잘렸다. 특정 회원 입장에서는 자신에게 적용 가능한 등급 선택지가 일부 빠진 채 화면에 나왔다.
이런 계층 구조 조회는 recursive CTE(Common Table Expression)로 해결해야 한다. 애플리케이션 레이어에서 루프 돌면서 N+1로 가져오는 방식은 파트너 뎁스가 늘수록 쿼리 수가 선형으로 증가하고, 중간에 누락이 생겨도 감지하기 어렵다. 계층 순회는 DB에 한 번에 위임하는 게 훨씬 낫다.
수정한 쿼리 구조는 대략 이렇다:
WITH RECURSIVE partner_tree AS (
-- 앵커: 회원의 직속 파트너
SELECT partner_id, parent_partner_id, 0 AS depth
FROM partner
WHERE partner_id = :rootPartnerId
UNION ALL
-- 재귀: 상위 파트너를 타고 올라감
SELECT p.partner_id, p.parent_partner_id, pt.depth + 1
FROM partner p
JOIN partner_tree pt ON p.partner_id = pt.parent_partner_id
WHERE pt.depth < 10 -- 무한 루프 방지
)
SELECT DISTINCT go.grade_option_id, go.grade_name, go.criteria
FROM partner_tree pt
JOIN grade_option go ON go.partner_id = pt.partner_id
WHERE go.is_active = 1
ORDER BY go.grade_name;
depth 제한을 빠뜨리면 데이터에 순환 참조가 있을 때 무한 루프에 빠진다. 데이터 무결성이 완벽히 보장된다고 믿을 수 없는 이상 방어 조건을 다는 게 안전하다. 상한을 얼마로 잡느냐는 실제 파트너 구조 뎁스에 따라 다르지만, 10 정도면 대부분의 상황을 커버하고 이상 데이터도 걸러낸다.
이 유형의 버그가 오래 살아남는 이유
파트너 구조가 단순한 환경 - 직속 1단계만 쓰는 경우 - 에서 개발하면 얕은 조회가 "작동하는 것처럼" 보인다. 테스트 데이터도 보통 뎁스를 깊게 구성하지 않으니 QA 단계에서 잘 안 걸린다. 서비스가 성장하면서 실제 파트너 구조가 깊어질 때 비로소 이상 동작이 나타난다.
"지금 안 깨진다"는 신호는 "앞으로도 안 깨진다"는 신호가 아니다. 트리 구조처럼 뎁스가 가변적인 데이터를 다룰 때는 처음부터 전체 순회를 전제로 쿼리를 짜야 한다.
비슷한 패턴이 다른 곳에도 있는지 매퍼 파일을 훑었다. 파트너 관련 집계를 직접 JOIN으로 처리하는 쿼리가 몇 개 더 있었는데, 해당 쿼리는 조회 범위가 항상 단일 파트너로 고정돼 있어서 같은 문제가 없었다. 맥락이 달라 건드리지 않았다. 이 판단이 맞는지 확인하는 데 시간이 좀 걸렸다. 코드를 볼 때 "이것도 고쳐야 하나?"가 아니라 "이게 같은 문제를 가지고 있나?"를 물어야 한다. 두 질문은 비슷해 보이지만 실제로 다른 방향으로 작업을 이끈다.
수정 후 검증과 개발 습관
버그 수정이면 수정 전에 문제 케이스를 실제로 재현해두는 게 원칙이다. 재현 안 된 채로 코드만 바꾸면 '무언가 고쳤다'는 느낌은 있지만 '제대로 고쳐졌다'는 확인이 없다. 이번엔 2단계 이상 파트너 구조를 가진 회원으로 등급 옵션 화면을 열어서 옵션이 부족하게 나오는 걸 먼저 눈으로 확인했고, 수정 후 같은 케이스에서 전체 옵션이 정상적으로 나오는 것을 확인했다.
숫자 cross-check도 했다. 등급 옵션이 파트너 관리 화면 쪽에서도 보이는 구간이 있어서 두 화면 사이의 선택지 수를 비교했다. 이 방법이 생각보다 유효하다. 같은 데이터를 다른 경로로 보여주는 화면이 있으면 양쪽이 일치하지 않을 때 어딘가 버그가 있다는 신호다.
버그 수정 시 내가 거치는 체크 순서를 정리하면:
- 같은 로직이 다른 경로에도 있는지 - 중복 코드 체크
- 수정이 기존 정상 케이스를 망가뜨리지 않는지 - 회귀 방지
- 해당 화면 또는 API에서 문제 케이스 직접 재현 후 확인
- 관련 숫자를 다른 화면과 비교
엣지 케이스를 꼼꼼히 따지는 게 귀찮아 보여도, 나중에 같은 버그로 다시 오는 시간 비용이 훨씬 크다. 처음에 5분 더 쓰면 나중에 몇 시간을 아끼는 경우가 반복된다.
커밋 메시지는 "recursive CTE로 파트너 트리 전체 순회하도록 수정"보다 "1단계 파트너만 조회해 등급 옵션 누락되던 문제 수정"을 앞에 뒀다. 나중에 git log를 볼 때 "무슨 문제를 고쳤는지"가 "어떻게 고쳤는지"보다 먼저 보여야 히스토리가 읽힌다. 어떻게 고쳤는지는 diff를 보면 된다.
작은 커밋을 자주 하는 이유도 같다. 문제가 생겼을 때 어느 변경에서 깨졌는지 찾으려면 커밋 단위가 논리적으로 독립돼 있어야 한다. 한 커밋에 여러 변경이 섞이면 bisect가 어렵고, 롤백도 어렵다.
금융/결제 도메인에서 등급 계산은 민감하다. 등급에 따라 수수료율, 한도, 혜택이 달라지는 구조라면 보여줘야 할 등급 옵션이 누락된 것 자체가 운영 오류로 이어질 수 있다. 기능 명세에는 "등급 선택 화면 구현"으로만 적혀 있어도 그 안에 파트너 구조 이해, 계층 쿼리, 집계 정합성이 다 묻어 있다. 겉보기에 단순한 드롭다운 하나가 이렇게까지 물고 늘어진다.
사내 서비스를 만들면서 계속 체감하는 건, 기능 단위보다 도메인 단위로 이해가 쌓여야 버그를 빨리 잡는다는 점이다. "이 회원이 왜 이 등급 옵션을 봐야 하는가"를 모르면 버그의 증상만 보고 수정하게 된다. 증상만 픽스하면 같은 버그가 다른 모양으로 다시 온다. SQL 집계, 상태 머신, 예외 처리, 화면 렌더링, 권한 체크가 모두 엮여 있어서 어느 하나만 빠뜨려도 숫자가 맞지 않거나 특정 사용자에게 이상한 화면이 나타난다. "대충 맞는 것 같다"로 넘어가면 반드시 나중에 다시 돌아온다.
댓글 0
첫 댓글 달아줘.