개발 slecs

판매자 잔액 조회 기능 추가

목차

판매자 잔액 조회는 플랫폼 운영에서 생각보다 자주 요청이 들어오는 기능이다. 정산 주기마다 판매자 측에서 "내 잔액이 얼마냐"를 확인하고 싶어하는 건 당연한 흐름인데, 그게 화면이나 API로 깔끔하게 빠져있지 않으면 어드민에서 직접 DB 조회해주거나, 기존 다른 화면을 억지로 우회해서 쓰는 상황이 생긴다. 이번 작업은 그 우회 경로를 없애고 제대로 된 흐름을 만드는 것이었음. 실제 사용자 흐름에서 필요한 기능이거나 운영 중 발견된 개선 포인트인 경우, 작업 자체는 작아도 해결하지 않으면 계속 사람이 끼어야 한다는 점에서 우선순위가 높아진다.

Spring MVC + MyBatis 구조에서 잔액 조회를 붙이는 법

구조 자체는 단순하다. Controller에서 요청을 받고, 내부 클래스(혹은 Service)에서 로직을 처리하고, MyBatis Mapper를 통해 SQL XML의 쿼리를 실행한 뒤 결과를 JSP로 내려주거나 API 응답으로 직렬화한다. 근데 "단순하다"와 "깔끔하게 짜는 것"은 다른 얘기다.

잔액 관련 쿼리는 조건 분기가 많아지기 쉽다. 특정 날짜 기준으로 볼 건지, 전체 누적인지, 특정 채널만 볼 건지 등. MyBatis XML에서 <choose>, <when>, <if> 조합으로 동적 쿼리를 구성하다 보면 이게 지저분해지는 순간부터 유지보수 지옥이 시작된다. 이번엔 파라미터를 최대한 명확하게 정의하고, 쿼리 분기를 최소화하는 방향으로 짰음.

<select id="selectMerchantBalance" parameterType="MerchantBalanceParam" resultType="MerchantBalanceResult">
    SELECT
        merchant_id,
        SUM(amount) AS total_balance,
        MAX(updated_at) AS last_updated
    FROM merchant_balance
    WHERE merchant_id = #{merchantId}
    <if test="baseDate != null">
        AND created_at &lt;= #{baseDate}
    </if>
    GROUP BY merchant_id
</select>

파라미터 VO는 내부 클래스로 잡았다. 외부에서 재사용될 가능성이 낮은 요청 객체는 굳이 별도 파일로 빼지 않아도 된다. 응집도 측면에서 Controller 또는 Service 안에 static 내부 클래스로 두는 게 오히려 코드 읽기 편하다. 특히 요청-응답 VO가 그 클래스 하나에서만 쓰인다면 파일이 늘어날수록 오히려 파악이 더 어려워진다.

요청 바인딩은 @RequestParam 또는 @ModelAttribute로 받고, 들어오자마자 null/빈 값 체크를 했다. 잔액 조회처럼 금융 성격의 데이터는 입력 파라미터 오류를 그냥 넘기면 안 된다. 잘못된 merchantId가 들어왔을 때 빈 결과를 내려줄지 에러로 응답할지는 케이스마다 다른데, 이번엔 조회 기능이라 빈 결과 응답 + 로그 남기는 쪽으로 처리했음.

트랜잭션과 예외 처리, 어디서 어떻게 끊을지

조회 전용 기능이라 트랜잭션이 필요 없을 것 같지만, @Transactional(readOnly = true)는 붙이는 게 낫다. 이유는 두 가지다. 첫째, readOnly 힌트를 통해 커넥션 풀에서 read replica로 라우팅하는 구성이 있을 경우 자동으로 분기된다. 지금 당장 그 인프라가 없더라도 나중에 생겼을 때 코드 변경 없이 동작하게 된다. 둘째, 실수로 내부에서 write 작업이 끼어들었을 때 즉시 예외가 터져서 잡기 쉽다. 조회 메서드 안에서 INSERT가 실행되는 건 버그인데, readOnly가 없으면 그냥 흘러간다.

상황 처리 방식
merchantId null / 빈 값 400 응답 또는 빈 결과 + 경고 로그
DB 조회 결과 없음 빈 VO 반환, 화면에서 "잔액 없음" 표시
DB 연결 오류 등 Runtime 상위로 전파, 공통 예외 핸들러 처리
예상 외 NPE 로그 + 500 응답, 운영 알림

RuntimeException을 잡아서 응답 변환할지, 그냥 위로 올릴지는 이번에도 고민했다. 결론은 "비즈니스 예외는 잡고, 시스템 예외는 올린다"는 기존 패턴을 그대로 따랐음. 공통 ExceptionHandler가 있는 구조에서 각 서비스마다 try-catch를 도배하면 오히려 예외 흐름이 불명확해진다. 어디서 잡혔는지 추적하려면 결국 로그를 전부 다 뒤져야 하는 상황이 온다.

로그 전략과 코드 리뷰에서 챙긴 것

운영 로그는 중요 입출력값을 INFO로 남겼다.

log.info("[MerchantBalance] merchantId={}, balance={}", param.getMerchantId(), result.getTotalBalance());

이 한 줄이 운영에서 얼마나 유용한지는 장애 상황을 한 번이라도 겪어봤으면 안다. 디버거를 못 붙이는 운영 환경에서 로그가 없으면 재현 시도를 처음부터 해야 한다. 금액 데이터가 민감 정보에 해당하는지는 검토 후 판단했음. 어드민 내부 기능이라 전체 금액은 그대로 남기되, 개인 식별 정보가 섞이는 케이스엔 마스킹을 넣었다. DEBUG가 아닌 INFO에 핵심값을 찍는 이유는, 운영 환경에서 DEBUG를 항상 켜두는 건 부담이라 조회 수준의 중요 흐름은 INFO에 남겨야 끄지 않아도 남기 때문이다.

코드 리뷰에서 주로 확인한 건 기존 잔액 관련 로직과의 중복 여부였다. 비슷한 SQL이 다른 Mapper XML에 이미 있으면 재사용할지, 목적이 달라 분리할지를 판단해야 한다. 이번엔 조회 조건이 달라서 별도로 뒀음. 억지로 합치면 나중에 한쪽 요구사항이 바뀔 때 같이 깨진다. 재사용성은 좋은 것이지만, 목적이 다른 걸 하나로 묶는 건 결합도를 높이는 거라 오히려 독이 된다.

작업 규모 자체는 크지 않았다. 근데 이런 기능 하나 추가할 때 파라미터 검증, 트랜잭션 범위, 예외 처리, 로그 전부 빠짐없이 챙기는 게 습관이 되면 나중에 큰 기능 붙일 때도 같은 패턴이 자연스럽게 따라온다. 작은 커밋이 쌓여서 전체 시스템 신뢰도가 올라가는 건 이런 식이다. 로컬 기동 확인 후 개발 DB 반영, 배포 순서는 이번도 동일하게 밟았음.

댓글 0

첫 댓글 달아줘.