개발 slecs

판매자 잔액 조회 기능 완성

목차

판매자 잔액 조회는 플랫폼에서 생각보다 민감한 기능이다. 잔액이 조금이라도 다르게 보이면 판매자 문의가 바로 들어오고, 조회 자체가 느리거나 화면이 이상하게 렌더링되면 신뢰 문제로 번진다. 이번에 그 흐름을 완성했다.

작업 범위 자체는 크지 않았다. 내부 클래스 수정, SQL 조정, 화면 바인딩 정도. 근데 이런 작은 변경이 오히려 더 조심스럽다. 건드리는 범위가 좁을수록 "이 부분만 바꿨으니 괜찮겠지"라는 방심이 생기는데, 잔액처럼 숫자가 직접 보이는 기능은 그 방심이 장애로 이어지기 쉬움.

Spring MVC + MyBatis 구조에서 잔액 조회 흐름

이 프로젝트 스택은 Spring MVC + MyBatis + JSP다. 내부 클래스가 컨트롤러 역할을 담당하고 SQL은 XML에서 관리한다. 잔액 조회 흐름을 크게 보면 이렇다:

HTTP 요청
  → 컨트롤러 내부 클래스 (파라미터 바인딩 · 검증)
  → 서비스 레이어 (비즈니스 로직)
  → MyBatis Mapper (SQL XML 조회)
  → DB
  → 결과 매핑 (ResultMap)
  → JSP 렌더링 or API 응답 구성

파라미터 바인딩 단계에서 먼저 걸러야 할 게 있다. merchantId가 null로 들어오거나, 조회 기간 파라미터가 빈 문자열로 넘어오는 경우. 이걸 컨트롤러 레벨에서 안 잡으면 SQL까지 내려가서 의미 없는 쿼리를 날리거나, 예외를 뒤늦게 터뜨린다. 이번에 그 부분을 명시적으로 처리했음.

컨트롤러에서 검증하는 게 충분한지 서비스 레이어에서도 해야 하는지는 팀마다 의견이 갈리는데, 내 입장은 양쪽 다 하는 게 맞다는 쪽이다. 컨트롤러 단 검증은 HTTP 요청에 특화된 거고, 서비스 단 검증은 그 메서드 자체의 계약이다. 나중에 컨트롤러를 우회해서 서비스를 직접 호출하는 상황이 생기면 서비스 단 검증이 없으면 뚫린다.

이번 작업에서 고민했던 것들

트랜잭션 범위

잔액 조회는 SELECT만 있으면 트랜잭션이 필요 없어 보인다. 근데 잔액 계산이 여러 테이블의 합산이고 읽기 일관성을 보장해야 한다면 얘기가 달라진다. 중간에 다른 트랜잭션이 끼어들어 값이 달라지는 케이스가 이론상 있을 수 있어서, 이번엔 @Transactional(readOnly = true)로 명시했다. 쓰기 트랜잭션과 분리되고 커넥션 풀 최적화 힌트도 됨.

@Transactional(readOnly = true)
public MerchantBalanceDto getBalance(String merchantId) {
    if (merchantId == null || merchantId.isBlank()) {
        throw new IllegalArgumentException("merchantId is required");
    }
    return balanceMapper.selectBalance(merchantId);
}

readOnly 트랜잭션이 무조건 성능을 올려주는 건 아니지만, 쓰기 작업이 섞이는 걸 코드 레벨에서 방지하는 의도로도 유용하다. 실수로 INSERT가 들어가 있으면 컴파일이 아닌 런타임에야 알게 되는데, readOnly 힌트가 DB 드라이버나 JDBC 구현에 따라 그 시도를 막아주기도 한다.

예외 처리 전략

RuntimeException을 그냥 전파할지, 잡아서 응답으로 변환할지. 이번엔 잡아서 변환하는 방향으로 갔다. JSP 화면으로 에러 페이지를 보여줘야 하는 상황이라, 예외가 터지면 ModelAndView에 에러 메시지를 담아 렌더링하는 방식. API 응답 구조였다면 @ExceptionHandler로 JSON으로 내리는 게 자연스럽지만, JSP 구조에서는 뷰 제어를 컨트롤러 가까이에서 하는 게 더 명확했음.

상황 예외 처리 방향 이유
JSP 뷰 반환 잡아서 에러 ModelAndView 반환 화면 흐름을 컨트롤러가 직접 제어
REST API RuntimeException 전파 + @ExceptionHandler 전역 핸들러로 일관된 에러 응답
배치/백그라운드 로깅 후 커스텀 예외로 래핑 호출자가 복구 여부 결정

로그 전략

운영에서 잔액 이상 신고가 들어왔을 때 원인 파악이 가능하려면 로그에 요청 파라미터와 응답 결과가 최소한 남아 있어야 한다. 잔액은 개인정보 레벨은 아니어도 사업적으로 민감한 숫자다. INFO에는 merchantId와 조회 결과 건수만 남기고, 상세 금액은 DEBUG로 뒀다.

INFO  [BalanceService] getBalance - merchantId=M1234, resultCount=1
DEBUG [BalanceService] getBalance - balance=320000, pendingAmount=15000

운영 환경에서 DEBUG를 끄고 INFO만 켜면, 이슈 발생 시 최소한 요청이 왔는지 응답이 나갔는지는 파악 가능하다. 나중에 디버깅이 필요할 때 DEBUG만 잠깐 켜서 들여다보는 것도 된다.

MyBatis ResultMap 분리

배포 전 코드 리뷰에서 하나 더 확인한 게 있다. 수정한 쿼리가 기존에 같은 Mapper를 쓰는 다른 기능에 영향을 주지 않는지. MyBatis에서 SQL XML을 공유 Mapper로 쓰면 이게 생각보다 자주 문제가 된다. resultMap을 수정했다가 다른 쿼리 결과 매핑이 깨지는 사례가 있어서, 이번엔 잔액 조회용 resultMap을 별도로 분리했음.

<!-- 기존 공유 resultMap을 건드리지 않고 전용 resultMap 추가 -->
<resultMap id="merchantBalanceResultMap" type="MerchantBalanceDto">
    <result property="merchantId"    column="merchant_id"/>
    <result property="balance"       column="balance"/>
    <result property="pendingAmount" column="pending_amount"/>
    <result property="updatedAt"     column="updated_at"/>
</resultMap>

<select id="selectBalance" resultMap="merchantBalanceResultMap">
    SELECT merchant_id, balance, pending_amount, updated_at
    FROM   merchant_balance
    WHERE  merchant_id = #{merchantId}
</select>

기존 resultMap을 extends로 상속할 수도 있는데, 잔액 전용 필드가 다르면 분리하는 게 더 명확하다. 나중에 컬럼이 추가되거나 바뀔 때 다른 기능에 영향 없이 수정 가능하고.

작게 바꾼 게 더 무섭다

이번 작업은 로컬 기동 확인, 개발 DB 반영, 배포 순서로 진행했다. 작업 규모가 작을수록 "이 정도야" 하고 검증을 얕게 하고 싶은 유혹이 생기는데, 판매자 잔액처럼 화면에 숫자가 직접 보이는 기능은 경계값을 빠뜨리면 안 됨.

챙겨볼 경계값들:

  • null 또는 빈 merchantId
  • 잔액이 정확히 0인 판매자
  • 환불 등으로 잔액이 마이너스가 나올 수 있는 케이스
  • 조회 기간 파라미터가 미래 날짜인 경우
  • 동일 merchantId로 동시 조회 요청이 몰리는 경우

전부 시나리오로 만들 필요는 없지만, null과 0원 케이스는 반드시 확인했다. 운영에서 이 두 케이스가 가장 자주 문제가 된다. 호환성 쪽도 마찬가지인데, 기존 로직에서 같은 서비스 메서드를 다른 경로로 호출하는 곳이 있으면 거기까지 추적하고 닫아야 한다. IDE의 Find Usages를 습관적으로 쓰는 게 이럴 때 제일 확실함.

이런 작은 수정들이 쌓여서 시스템 신뢰도가 올라간다고 믿고 있다. 큰 피처보다 이런 작업이 실제 운영에서 더 오래 남는 경우가 많음.

댓글 0

첫 댓글 달아줘.