개발 slecs

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

목차

수수료 재검증 로직은 건드리기 부담스러운 영역 중 하나다. 금액이 얽혀 있고, 틀리면 바로 운영 이슈가 된다. 이번 작업은 크기가 작아도 손이 많이 갔다.

Spring MVC + MyBatis 구조에서 수수료 관련 흐름은 대략 이렇게 생겼다: 컨트롤러 내부 클래스가 요청을 받아 파라미터를 바인딩하고, 서비스 레이어에서 재검증 로직을 태우고, MyBatis XML 쿼리로 DB를 조회한 결과를 다시 화면이나 API 응답에 매핑한다. 구조 자체는 단순한데, 수수료 재검증이라는 도메인이 끼어들면서 신경 써야 할 경우의 수가 늘었다.

내부 클래스에서 비즈니스 로직을 처리할 때 생기는 문제

컨트롤러 내부 클래스 구조는 오래된 Spring MVC 코드베이스에서 자주 보인다. 요청 단위로 폼 바인딩 객체나 커맨드 객체를 중첩 클래스로 두는 패턴인데, 이게 어느 시점이 지나면 비즈니스 로직이 슬금슬금 섞여 들어온다. 이번 수정 대상도 그랬다.

수수료 재검증은 입력값이 유효한지 먼저 따지고, 유효하면 DB에서 기준값을 가져와 비교한 뒤, 결과에 따라 승인/반려 상태를 업데이트하는 흐름이다. 이 과정에서 문제가 되는 케이스는 크게 세 가지였다:

  • 요청 파라미터가 null이거나 빈 문자열로 넘어오는 경우
  • DB 조회 결과가 없는 경우 (기준값 자체가 없음)
  • 비정상적인 수수료 값 - 음수, 소수점 자리 불일치, 허용 범위 초과

각각 별도로 처리하지 않으면 뒤에서 NPE가 터지거나, 잘못된 값이 그냥 통과하는 상황이 생긴다. 재검증 로직이라는 이름답게 여기서 막아야 한다.

파라미터 바인딩 단에서 기본 검증을 끝내고, 서비스 레이어로 넘어가기 전에 빈 값 체크를 한 번 더 했다. 이중으로 거르는 게 과하다는 의견도 있지만, 수수료처럼 금액이 걸린 로직은 레이어마다 한 번씩 확인하는 게 낫다고 본다.

// 파라미터 진입 시점 검증 예시 패턴
if (param == null || param.getCommissionRate() == null) {
    log.info("[commission-verify] invalid param: {}", param);
    return ResponseEntity.badRequest().body("유효하지 않은 요청입니다.");
}

BigDecimal rate = param.getCommissionRate();
if (rate.compareTo(BigDecimal.ZERO) < 0 || rate.scale() > 2) {
    log.info("[commission-verify] out-of-range rate: {}", rate);
    return ResponseEntity.badRequest().body("수수료 범위 오류");
}

코드 패턴은 이런 식이다. 실제 구현 세부는 달라도 흐름은 같다 - 조기에 튕겨내고, 로그 남기고, 응답 변환.

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

이번에 가장 고민한 부분이 여기였다. 재검증 로직이 DB 조회만 하는 경우와, 상태 업데이트까지 같이 하는 경우가 섞여 있었다. 트랜잭션 범위를 어디에 두느냐에 따라 롤백 단위가 달라진다.

@Transactional을 서비스 전체에 걸면 조회까지 트랜잭션에 포함된다. 읽기 전용이라면 readOnly = true를 명시하는 게 낫고, 쓰기가 섞이면 따로 분리하는 쪽이 깔끔하다.

상황 선택 이유
조회 전용 재검증 @Transactional(readOnly = true) 불필요한 락 방지
상태 업데이트 포함 @Transactional 조회-업데이트 원자성 보장
외부 API 호출 포함 트랜잭션 분리 롤백 범위에 외부 호출 포함 시 문제

예외 처리는 RuntimeException 전파 vs 잡아서 응답 변환 중에서 선택해야 했다. 내부 로직의 오류는 RuntimeException으로 올려서 @ControllerAdvice에서 통일 처리하게 두는 게 일반적이다. 다만 재검증 결과가 "실패"인 것 - 즉 수수료가 맞지 않는 상태 - 은 예외가 아니라 정상 응답 범주다. 이걸 섞으면 나중에 로그 분석할 때 헷갈린다.

이번에는 검증 실패는 응답 객체로 반환하고, 예상 외 오류(DB 접속 실패, 매핑 오류 등)만 예외로 전파하는 방식을 택했다. 운영에서 로그를 볼 때 WARN/ERROR 레벨이 진짜 문제 상황을 가리켜야 하니까.

로그는 INFO 레벨에 입력값과 검증 결과를 남겼다. 수수료 관련 이슈가 들어오면 로그에서 바로 흐름을 추적할 수 있게 하려는 의도다. 운영에서 "왜 이 건은 반려됐냐"는 질문이 들어왔을 때, 로그 한 줄로 답할 수 있으면 디버깅 시간이 확 줄어든다.

기존 흐름에 영향 없는지 추적하는 방법

수정 범위가 내부 클래스에 한정되더라도, 그 클래스를 호출하는 상위 흐름에 사이드 이펙트가 없는지 봐야 한다. 이번엔 특히 기존 로직과의 호환성이 중요했다 - 재검증을 추가한다고 기존 검증 흐름이 변하면 안 된다.

확인한 순서는 단순하다. 해당 클래스를 참조하는 컨트롤러와 서비스 메서드를 IDE에서 찾아서, 각 호출부에서 넘기는 파라미터와 기대하는 반환값이 그대로인지 본다. SQL XML 쪽도 resultMap이나 컬럼 매핑이 기존과 맞는지 확인한다.

로컬에서 기동 후 해당 화면을 직접 찍어보고, 개발 DB 반영 후 한 번 더 확인했다. 배포 전에 이 단계를 건너뛰면 십중팔구 나중에 다시 들어오는 작업이 된다.

작업 규모로 보면 크지 않았다. 하지만 수수료 도메인이라는 특성상 꼼꼼하게 할 수밖에 없었고, 파라미터 검증-트랜잭션-예외 처리-로그라는 네 가지를 챙기는 게 이 종류의 작업에서 반복되는 패턴이라는 걸 다시 확인했다. 다음에 비슷한 영역을 건드릴 때 이 순서를 그대로 가져가면 된다.

댓글 0

첫 댓글 달아줘.