사이드프로젝트 slecs

가맹점 결제 DB 조회 로직 안정성 개선

목차

가맹점 결제 관련 DB 조회는 플랫폼 전체에서 꽤 중심적인 흐름이다. 가맹점이 자기 결제 내역을 조회할 때, 파라미터 바인딩에서 SQL 실행까지 이어지는 체인 중 한 곳이라도 구멍이 있으면 운영에서 이상한 결과가 튀어나오거나, 조용히 빈 결과를 리턴하는 상황이 생긴다.

이번 작업은 새 기능을 통째로 만든 건 아니고, 이미 돌아가던 조회 흐름에서 운영 중 발견된 개선 포인트를 반영한 거다. 수정 대상이 내부 클래스였기 때문에, 기존 흐름에 영향이 가지 않도록 추적하는 데 신경을 꽤 썼음.

Spring MVC + MyBatis 구조는 자바 웹 레거시 스택에서 전형적인 조합이다. 컨트롤러가 HTTP 요청을 받아서 서비스로 넘기고, 서비스는 MyBatis 매퍼를 통해 SQL XML 쪽 쿼리를 실행하고, 결과를 VO에 담아 돌려준다. JSP가 뷰 레이어에서 HTML로 렌더링하거나, REST 응답이면 JSON으로 내려보내는 구조다.

이 구조에서 내부 클래스가 담당하는 영역은 생각보다 넓다. 요청 파라미터를 받아서 검증하고, 서비스 레이어를 호출하고, 결과를 응답에 맞게 변환하는 것까지 내부 클래스에 걸쳐있는 경우가 많다. 클래스 하나를 건드리면 그게 얽혀있는 흐름 전체를 추적해야 하는 이유가 여기 있다.

파라미터 검증과 SQL 동적 쿼리 처리

파라미터 바인딩은 표면적으로 단순해 보이지만, 결제 조회처럼 조건이 여러 개인 경우 null 처리를 제대로 안 하면 의도치 않은 결과가 나온다.

MyBatis의 #{} 문법은 PreparedStatement를 써서 SQL 인젝션을 막아주는데, 문제는 null이 그대로 SQL로 내려갔을 때다. WHERE merchant_id = #{id} 상태에서 id가 null이면 WHERE merchant_id = NULL이 되어 결과가 0건으로 나온다. 반대로 동적 조건 없이 구성하면 파라미터가 비었을 때 해당 조건이 생략되면서 의도치 않게 전체 데이터를 긁어오는 경우도 있었음.

MyBatis XML에서 동적 쿼리를 구성할 때 <if> 조건을 꼼꼼하게 쓰는 게 기본이다.

<select id="selectMerchantPayments" parameterType="map" resultType="PaymentVO">
  SELECT
    MERCHANT_ID,
    PAY_AMT,
    PAY_DATE,
    STATUS
  FROM PAYMENT
  WHERE 1=1
  <if test="merchantId != null and merchantId != ''">
    AND MERCHANT_ID = #{merchantId}
  </if>
  <if test="startDate != null">
    AND PAY_DATE >= #{startDate}
  </if>
  <if test="endDate != null">
    AND PAY_DATE &lt;= #{endDate}
  </if>
  ORDER BY PAY_DATE DESC
</select>

WHERE 1=1 패턴은 동적 AND 조건을 붙일 때 구문 오류 없이 쌓을 수 있어서 MyBatis에서 자주 쓰인다. 단, <if> 조건이 하나도 안 붙으면 전체 테이블 스캔이 일어나므로, 필수 파라미터는 XML 조건과 별개로 서비스 레이어 입구에서 미리 체크하는 게 낫다.

public List<PaymentVO> getMerchantPayments(Map<String, Object> param) {
    String merchantId = (String) param.get("merchantId");
    if (merchantId == null || merchantId.isEmpty()) {
        throw new IllegalArgumentException("merchantId는 필수 파라미터입니다.");
    }
    return paymentMapper.selectMerchantPayments(param);
}

서비스 입구에서 미리 튕겨내면 SQL이 실행되기 전에 빠르게 실패하고, 로그에도 어떤 파라미터가 문제였는지 바로 찍힌다. XML에 검증을 다 맡기면 "왜 결과가 없지?"를 추적할 때 XML까지 파고 들어가야 한다.

트랜잭션 범위와 예외 처리 선택

조회 로직이라도 트랜잭션 어노테이션 설정은 명확히 해두는 편이 좋다. 조회 전용 메서드에 @Transactional(readOnly = true)를 붙이면 DB 레벨에서 불필요한 락을 피할 수 있고, 커넥션 반환도 빠르다. 커넥션 풀 여유가 빡빡한 환경에서는 readOnly 설정 여부가 체감에 영향을 주기도 한다. 실수로 해당 메서드 안에서 쓰기 연산이 들어왔을 때 Spring이 경고를 내주는 것도 부수 효과로 좋다.

상황 권장 트랜잭션 설정
단순 조회 @Transactional(readOnly = true)
조회 후 상태 변경 포함 @Transactional (REQUIRED, 기본값)
외부 API 연동이 섞임 트랜잭션 범위 밖으로 분리 고려
배치성 대용량 조회 readOnly = true + 페이징

예외 처리는 이번에 선택지가 명확히 둘로 나뉘었다. RuntimeException을 그대로 위로 전파하거나, 서비스 레이어에서 잡아서 커스텀 응답 객체로 변환하거나.

전파 방식은 @ControllerAdvice에서 일괄 처리할 수 있어서 코드가 얇아진다. 이미 어드바이스가 운영 중이면 서비스마다 try-catch를 쓸 이유가 없다. 변환 방식은 서비스마다 보일러플레이트가 생기는 대신, "가맹점 정보 없음"과 "DB 오류"를 구분해서 응답 메시지를 세분화하기 쉽다.

이번엔 기존 코드가 전파 방식으로 통일되어 있었기 때문에 그 패턴을 따랐다. 기술적으로 어느 쪽이 낫냐보다, 팀에서 이미 선택한 방향을 따르는 게 현실적인 선택이었음. 신규 코드에서만 다른 패턴을 쓰면 코드 리뷰에서 "왜 여기만 다르게 처리했냐"는 질문이 나오고, 나중에 온보딩하는 사람이 두 패턴을 다 파악해야 하는 부담이 생긴다. 일관성이 깨지는 순간 유지보수 비용이 은근히 올라간다.

로그 설계와 배포 흐름

운영 로그를 어떻게 찍어두느냐는 나중에 장애 대응 시간에 직결된다. 결제 조회에서 "왜 이 가맹점 건은 결과가 안 나와요?"라는 문의가 들어왔을 때, 로그에 파라미터가 남아있지 않으면 추적이 막막해진다.

이번엔 서비스 입구와 출구에 주요 입출력값을 INFO 레벨로 남겼다.

log.info("[MerchantPayment] 조회 시작 - merchantId={}, startDate={}, endDate={}",
    param.getMerchantId(), param.getStartDate(), param.getEndDate());

List<PaymentVO> result = paymentMapper.selectMerchantPayments(param);

log.info("[MerchantPayment] 조회 완료 - resultSize={}", result.size());
return result;

DEBUG 대신 INFO로 남기는 이유는 운영 환경에서 보통 DEBUG 로그를 끄기 때문이다. 파라미터 로그가 DEBUG로 들어가면 정작 장애 상황에서 아무것도 안 보인다. 반대로 INFO가 너무 많아지면 로그 노이즈가 쌓이니까, 입구/출구 두 지점만 찍고 중간 단계 로그는 최소화했음.

배포는 로컬 기동 확인 → 개발 DB 반영 → 배포 순서를 지켰다. 조회 로직이라서 데이터 변경은 없지만, SQL 조건 하나 차이로 조회 결과가 완전히 달라질 수 있다. "어차피 조회 로직이니까"라는 생각으로 개발 DB 검증을 흐지부지 넘기고 싶은 유혹이 있는데, 그 유혹을 한 번 허용하면 다음번 더 큰 작업에서도 같은 논리로 검증을 건너뛰게 된다.

기존 로직과의 호환성 추적도 빼먹지 않았다. 내부 클래스를 수정할 때 그 클래스를 참조하는 다른 흐름이 없는지 먼저 확인하고, 수정 후에는 변경된 메서드 시그니처가 기존 호출부와 맞는지 점검했다. 코드 리뷰에서도 이 부분을 위주로 봤음.

작업 규모는 크지 않았지만, 이런 작은 수정이 쌓이는 게 시스템 신뢰도를 만드는 방식이다. 당장 눈에 띄는 기능 추가보다, 기존 흐름이 예외 상황에서 흔들리지 않도록 다지는 작업이 운영 관점에서 더 가치 있을 때가 많다고 생각한다.

댓글 0

첫 댓글 달아줘.