사이드프로젝트 slecs

수수료 검증 로직 안정성 개선

목차

수수료처럼 금전 계산이 얽힌 로직은 잘못되면 파급이 조용히 크다. 버그 한 줄이 여러 케이스에 걸쳐 누적될 수 있고, 운영 중에 발견되면 롤백 비용도 따라온다. 이번 작업은 규모 자체는 크지 않았지만, 바로 그 이유 때문에 더 꼼꼼하게 챙겼다.

변경 대상은 Spring MVC + MyBatis 구조 안의 내부 클래스. 요청 파라미터 검증과 바인딩, 비즈니스 로직 처리, SQL 쿼리 결과 매핑, JSP 렌더링까지 한 흐름 안에서 돌아가는 구조다. 수수료 검증이 제대로 걸리지 않으면 잘못된 값이 그냥 통과될 수 있음.

검증 로직 쪽을 건드릴 때 제일 먼저 확인하는 건 "어떤 입력이 현재 방어되지 않고 있나"다. null은 처리하는데 빈 문자열은 그냥 통과한다거나, 음수 입력을 막지 않는다거나. 이런 틈이 실제 사용자 흐름에서 생각보다 자주 나온다. 폼 입력 단계에서 프론트가 막아줄 거라 믿었다가 서버 사이드 검증이 없어서 이상한 값이 DB에 들어간 케이스는 레거시 프로젝트에서 심심찮게 본다.

내부 클래스 구조에서 검증 로직 분리하기

Spring MVC에서 컨트롤러 내부에 커맨드 객체를 내부 클래스로 두는 패턴은 레거시 프로젝트에서 흔하다. 외부로 빼기엔 재사용성이 낮고, 해당 컨트롤러에서만 쓰이는 구조라면 오히려 응집도가 높아서 나쁘지 않다. 문제는 이 내부 클래스가 요청 바인딩, 검증, 로직 호출까지 한 번에 담당하다 보면 책임이 흐릿해진다는 거다.

이번에 개선한 핵심도 그 지점이었음. 검증 로직이 바인딩 처리와 뒤섞여 있어서 예외 케이스를 제대로 잡지 못하는 상황이 있었고, 그 부분을 명확히 분리해서 안정성을 올렸다. 분리한다고 파일을 새로 만든 건 아니고, 흐름상 검증 단계가 바인딩 이후에 명확하게 위치하도록 순서를 정리하는 식으로.

파라미터 검증 패턴을 대략 정리하면 이렇다:

// 수수료 파라미터 검증 예시
if (feeVo.getAmount() == null || feeVo.getAmount() <= 0) {
    model.addAttribute("errorMsg", "수수료 금액이 유효하지 않습니다.");
    return "fee/errorView";
}

if (StringUtils.isBlank(feeVo.getFeeType())) {
    throw new IllegalArgumentException("수수료 유형은 필수값입니다.");
}

// 비정상 입력 - 최대값 방어
if (feeVo.getAmount() > MAX_FEE_LIMIT) {
    log.warn("[FeeVerification] 비정상 금액 입력 감지: amount={}", feeVo.getAmount());
    model.addAttribute("errorMsg", "입력값이 허용 범위를 초과했습니다.");
    return "fee/errorView";
}

null 체크, 빈 값 체크, 음수 방어를 각각 따로 처리하는 게 포인트다. 한 조건에 묶어버리면 어느 케이스에서 실패했는지 로그로 잡기가 어려워진다. 로그를 나중에 봤을 때 "왜 에러가 났는지"가 바로 보여야 운영에서 써먹을 수 있음.

기존 로직과의 호환성 확인은 수정 전후로 같은 입력에 대해 같은 결과가 나오는지 직접 손으로 케이스를 돌려보는 게 제일 확실하다. 자동화 테스트가 있으면 더 낫지만, 레거시 환경에서는 없는 경우도 많으니까. 없으면 핵심 케이스 몇 가지는 직접 돌린다. 특히 수수료 같은 계산 로직은 경계값, 0, 최대값, 소수점 케이스까지.

트랜잭션 범위와 예외 전략 - 어디서 자를지

DB를 건드리는 로직에 트랜잭션 범위를 명확히 잡는 건 기본인데, 실무에서 놓치기 쉬운 부분이 있다. 수수료 검증 이후 실제 저장 처리가 이어지는 흐름에서, 검증 단계의 예외를 어떻게 처리하느냐에 따라 트랜잭션이 예상치 못하게 롤백되거나 커밋되는 경우가 생긴다.

이번에 고민한 지점들:

  • RuntimeException을 그냥 전파하면 스프링 트랜잭션이 자동 롤백을 탄다. 의도한 흐름이면 OK, 아니면 직접 잡아야 함
  • 검증 실패는 "데이터 이상"이 아니라 "잘못된 요청"이므로, 예외를 잡아서 에러 응답으로 변환하는 쪽이 더 맞다고 판단했음
  • 실제 INSERT/UPDATE가 들어가는 메서드에만 @Transactional을 걸고, 검증 단계는 트랜잭션 바깥에서 처리했다
처리 단계 트랜잭션 범위 예외 전략
파라미터 검증 바깥 잡아서 에러 응답 변환
비즈니스 로직 (수수료 계산/저장) 포함 RuntimeException 전파, 자동 롤백
결과 매핑, JSP 렌더링 바깥 체크 예외로 핸들링

이 구분이 명확해야 운영 중 예외 로그만 봐도 어느 단계에서 문제가 난 건지 바로 파악할 수 있다. 로그가 뒤섞여 있으면 트랜잭션 롤백 원인인지 검증 실패인지 헷갈리고, 디버깅 시간이 늘어난다.

로그는 INFO 레벨로 입력값 요약과 처리 결과를 남겨뒀다. 운영 환경에서 DEBUG는 성능 부담이 있어서, 핵심 입출력 값만 INFO로 박아두는 게 현실적으로 맞다.

log.info("[FeeVerification] feeType={}, amount={}, userId={}, result={}",
         feeVo.getFeeType(), feeVo.getAmount(), feeVo.getUserId(), result);

이 한 줄이 나중에 운영 이슈를 추적할 때 엄청난 차이를 만든다. 특히 수수료 관련 이슈는 "언제, 어떤 값으로, 어떻게 처리됐는지"가 재현의 시작점이 되는 경우가 많음.

배포 흐름과 CI/CD에서 자주 걸리는 것들

로컬 기동 확인 → 개발 DB 반영 → 배포 순서는 항상 지킨다. 단순해 보이지만 이 순서를 건너뛰면 꼭 개발 DB에서만 재현되는 이슈가 뒤늦게 발견된다. 특히 SQL XML에서 쿼리를 수정했을 때는 MyBatis 매퍼 파일 로딩을 한 번 더 눈으로 확인하는 게 습관이 됐음. 쿼리 문법 오류는 런타임에 터지기 때문에, 기동 직후 해당 기능 한 번씩 찔러보는 걸 빠뜨리지 않는다.

이번 배포 과정에서도 CI/CD 파이프라인에서 몇 가지 걸렸다.

  • SSH 타임아웃: 빌드가 길어지면 연결이 끊김. command_timeout을 명시하지 않으면 기본값에서 잘리는데, 이게 중간에 잘리면 배포 스크립트가 어디서 멈췄는지 추적이 번거롭다
  • Gradle 캐시 충돌: CI 환경에서 캐시가 꼬이면 --no-daemon으로 돌리거나 캐시 디렉토리를 직접 날려야 한다. 로컬에서 잘 되는데 CI만 실패하는 케이스의 절반은 여기서 난다고 봐도 됨
  • 환경변수 누락: Secrets 설정이 빠지면 배포 자체는 성공으로 뜨는데 앱이 제대로 안 뜨거나 특정 기능만 동작하지 않는다. 이게 제일 찾기 까다롭다. 배포 후 기동 확인 단계에서 반드시 해당 기능을 직접 찔러봐야 하는 이유가 여기 있음
  • SSH 키 인증 오류: 키 파일에 개행 문자가 끼어들어가 있으면 인증이 안 됨. cat -A로 확인하면 바로 보인다

작업 규모 자체는 크지 않았다. 하지만 수수료 검증이 누락되거나 잘못 처리되는 케이스는 조용히 쌓이다가 나중에 크게 터지는 유형이라, 작을 때 제대로 잡아두는 게 맞다고 판단했음. 코드 리뷰 단계에서도 수정한 로직이 기존 흐름에 영향을 주는 경로가 있는지 직접 추적해봤다. 이런 작은 수정들이 쌓이면서 전체 시스템의 신뢰도가 올라간다.

댓글 0

첫 댓글 달아줘.