개발 slecs

수수료 구조 감사 기능 개선

목차

수수료 구조 감사 기능 쪽 작업이었다. 규모 자체는 작았는데, 손댄 지점들이 꽤 여러 레이어에 걸쳐 있어서 생각보다 신경 쓸 게 많았음.

Spring MVC + MyBatis 기반 구조에서 내부 클래스를 수정하는 작업이라 겉으로 보이는 변경량보다 영향 범위를 추적하는 데 더 시간이 걸렸다. 요청 파라미터 검증부터 SQL 쿼리 결과 매핑까지 한 흐름 안에서 처리되는 구조인데, 한 쪽 바꾸면 다른 쪽에서 기대하는 타입이나 값 형태가 달라지는 경우가 종종 있음.

요청 흐름과 내부 클래스 구조

Spring MVC에서 내부 클래스(또는 커맨드 객체)를 요청 바인딩에 쓰면 컨트롤러가 깔끔해지는 장점이 있다. 단, 검증 로직을 어디에 두느냐가 늘 애매해진다. @Valid + BindingResult 조합을 쓰면 컨트롤러에서 바로 잡을 수 있지만, 복잡한 비즈니스 규칙(예: 수수료율 범위 제한, 조합 유효성)은 서비스 레이어까지 내려가야 제대로 검증이 가능함.

이번 작업에서 택한 방향:

  • 파라미터 null/빈 값 체크는 컨트롤러 바인딩 단에서 처리
  • 수수료 구조 관련 비즈니스 규칙 검증은 서비스 내부 클래스로 위임
  • 예외는 RuntimeException 계열로 올려서 공통 핸들러가 잡도록 처리

직접 try-catch로 잡아서 응답을 만드는 방식도 고민했는데, 공통 예외 핸들러가 이미 있는 구조라면 개별 메서드마다 중복 처리하는 게 유지보수 측면에서 오히려 불리하다. 핸들러가 없는 프로젝트라면 얘기가 달라지지만.

// 컨트롤러 레이어 - 바인딩 + 기본 검증만
@RequestMapping("/audit")
public String auditCommission(@ModelAttribute AuditRequest req,
                               BindingResult result, Model model) {
    if (result.hasErrors()) {
        return "error/badRequest";
    }
    auditService.process(req);
    return "redirect:/commission/list";
}

// 서비스 내부 - 비즈니스 규칙 검증
private void validateAuditRequest(AuditRequest req) {
    if (req.getRate() == null || req.getRate().compareTo(BigDecimal.ZERO) < 0) {
        throw new InvalidCommissionRateException("rate must be non-negative");
    }
}

트랜잭션 범위 설정

DB를 건드리는 로직에서 트랜잭션 범위를 어디까지 잡느냐는 늘 고민이다. 이번엔 감사(audit) 특성상 원본 데이터 변경과 이력 저장이 같은 트랜잭션 안에 있어야 했음. 둘 중 하나가 실패했을 때 롤백이 온전히 일어나야 하기 때문.

@Transactional 어노테이션을 메서드 레벨에 걸고, propagation은 기본값(REQUIRED) 그대로 뒀다. 별도 트랜잭션이 필요한 케이스가 아니었으니까. 주의할 점은 내부 클래스 호출이 self-invocation이 되는 경우인데, 그러면 프록시를 타지 않아 트랜잭션이 적용 안 됨. 이건 Spring AOP의 고전적인 함정이라 항상 체크함.

상황 선택 이유
단순 조회 @Transactional(readOnly = true) 락 오버헤드 최소화
변경 + 이력 저장 @Transactional (REQUIRED) 원자성 보장
배치성 대량 처리 REQUIRES_NEW 고려 부분 실패 허용 시

SQL 쿼리와 매핑

MyBatis XML에 쿼리를 관리하는 구조라 쿼리 수정할 때 타입 불일치가 가장 흔한 문제다. 결과 매핑(resultMap)이 정의된 VO 필드와 실제 컬럼 alias가 안 맞으면 값이 null로 떨어지는데, 런타임 되기 전까지 잡기 어려움. 이번엔 수수료 구조 테이블에서 추가로 끌어와야 하는 컬럼이 있었고, 해당 컬럼을 기존 resultMap에 추가 매핑하는 작업을 같이 진행했다.

MyBatis에서 resultMap 상속(extends)을 활용하면 기존 매핑을 건드리지 않고 확장 가능하다. 기존 쿼리에 영향 없이 새 컬럼만 붙이는 케이스라면 이 패턴이 안전함.

<resultMap id="CommissionAuditResult" type="CommissionAuditVO"
           extends="BaseCommissionResult">
    <result column="audit_memo" property="auditMemo" />
    <result column="audited_by" property="auditedBy" />
</resultMap>

로그와 CI/CD

운영 환경에서 디버깅 가능하도록 중요 입출력값은 INFO 레벨로 남겨뒀다. 수수료 감사 특성상 "어떤 값이 들어와서 어떤 결과가 나갔는지" 추적 가능해야 나중에 문의 왔을 때 대응이 된다.

배포 과정에서는 CI/CD 파이프라인 쪽에서 SSH 타임아웃이 간간이 터졌다. 빌드가 길어지면 연결이 끊기는 건데, command_timeout 명시해두면 어느 정도 잡힌다. Gradle 캐시 충돌도 주의할 것으로, CI 환경에서 캐시가 꼬이면 --no-daemon 옵션이나 캐시 클리어로 해결됨.

배포 순서는 로컬 기동 확인 → 개발 DB 반영 → 배포 흐름을 지켰다. 작은 변경이라도 이 순서를 건너뛰면 나중에 원인 추적이 어려워진다. 특히 수수료처럼 금전 관련 로직은 더 그렇고.

작업 규모 대비 레이어를 여러 개 건드렸던 게 이번 작업의 특징이었음. 작은 수정들이 쌓여서 시스템 전체 신뢰도를 유지하는 거라고 생각하면, 이런 작업도 꼼꼼히 챙길 이유가 충분하다.

댓글 0

첫 댓글 달아줘.