커미션 정산 오류 수정
목차
정산 로직을 건드릴 때는 항상 긴장이 한 단계 올라간다. 기능 추가나 UI 개선과 달리, 금액 계산이나 정산 상태를 다루는 코드는 오류가 나는 순간 바로 체감된다. 틀린 숫자가 한 건이라도 나오면 디버깅 전에 신뢰 문제부터 생긴다. 이번 커미션 정산 오류 수정은 규모는 크지 않았지만 그래서 더 꼼꼼하게 들어갔다.
Spring MVC + MyBatis 구조에서 내부 클래스가 하는 일
프레임워크 스택이 Spring MVC + MyBatis고, 뷰는 JSP다. 이 조합 자체는 오래된 구조지만 지금도 많은 레거시 서비스가 이 위에서 돌아간다. Controller 안에 내부 클래스를 두는 패턴도 흔하다. 보통 요청 파라미터를 바인딩하는 VO/DTO 역할로 쓰거나, 특정 화면에 국한된 처리 단위를 같은 파일 안에 묶어두는 형태로 쓰인다.
문제는 내부 클래스가 비즈니스 로직까지 같이 들고 있을 때다. 테스트가 어려워지고, 클래스가 커질수록 무엇이 어디서 실행되는지 추적이 힘들어진다. 이상적으로는 Service 레이어로 분리해야 하지만, 기존 코드가 그런 구조라면 갑자기 리팩터링을 끼워 넣는 것도 위험하다. 수정 범위가 뭉개지면 어디서 회귀가 생겼는지 찾기가 배로 힘들어진다.
그래서 이번 작업의 방향은 명확했다. 현 구조 안에서 오류 지점만 정확히 잡고, 외부 의존을 새로 끌어들이지 않는다. 리팩터링은 별도 이슈로 끊어내서 영향 범위를 명확히 한다.
구현하면서 실제로 챙긴 것들
파라미터 검증과 바인딩
요청 파라미터는 null이거나 빈 문자열로 오는 케이스, 숫자 필드에 비정상 값이 오는 케이스를 먼저 정리했다. 커미션 정산 특성상 금액 관련 값은 BigDecimal로 처리하는 게 기본인데, 파싱 전에 방어 코드가 없으면 NumberFormatException이 그냥 올라온다. 운영에서 이 예외가 터지면 로그 보고 파악하는 데 시간이 걸린다.
// 파라미터 방어 처리 - 패턴 예시
String amountStr = request.getParameter("commissionAmount");
if (amountStr == null || amountStr.isBlank()) {
throw new IllegalArgumentException("커미션 금액 파라미터 누락");
}
BigDecimal amount = new BigDecimal(amountStr.trim());
이런 검증이 여러 파라미터에 인라인으로 쌓이면 금방 중복이 생긴다. 유틸 메서드로 뽑거나 @InitBinder에서 일괄 처리하는 방법도 있지만, 이번에는 기존 패턴에 맞춰 최소 변경으로 처리했다. 규모가 커지면 그때 정리하는 게 낫다.
트랜잭션 범위
DB를 건드리는 로직이 들어가면 트랜잭션 범위는 반드시 명시적으로 잡아야 한다. @Transactional을 Service 메서드에 두고 Controller에서 호출하는 게 일반적인데, 내부 클래스가 Service 역할을 겸하는 구조라면 스프링이 프록시를 적용하지 못해서 트랜잭션이 실제로 걸리지 않는 경우가 있다. 이건 코드만 봐서는 잘 안 보이는 함정이다.
정산 관련 INSERT/UPDATE가 일부만 반영되고 롤백이 안 되는 상황은 데이터 정합성 문제로 바로 이어진다. 커미션 건은 처리됐는데 상태가 갱신 안 된 채로 남는다거나, 역으로 상태만 변경되고 실제 반영이 빠지는 케이스 모두 마찬가지다.
이번 작업에서는 기존 트랜잭션 처리 방식을 먼저 확인하고, 새로 추가한 DB 처리가 동일 범위 안에 묶이는지 명시적으로 추적했다.
| 상황 | 위험도 | 대응 방향 |
|---|---|---|
| 정산 INSERT 성공 후 상태 UPDATE 실패, 롤백 안 됨 | 높음 | 동일 트랜잭션으로 묶기 |
| null 파라미터 미검증 통과 | 중간 | 입력 단계 방어 처리 |
| RuntimeException 미처리 전파 | 중간 | 의미 있는 응답으로 변환 |
| SQL resultMap 컬럼 오타 | 낮음-중간 | 런타임 전 로컬 검증 필수 |
예외 처리 방향
RuntimeException을 그냥 올려버리면 상위 레이어에서 500 응답이 나가거나 JSP 에러 페이지로 튄다. 커미션 처리 화면에서 그러면 사용자 입장에서는 뭔가 잘못됐다는 것만 알고 무엇이 문제인지 모르는 상태가 된다. 운영 입장에서도 스택트레이스만 남고 입력값이 뭐였는지 모르면 재현이 어렵다.
이번에는 예외를 잡아서 에러 응답으로 변환하는 방향을 선택했다. 비즈니스 예외와 시스템 예외를 구분해서, 비즈니스 예외는 사용자에게 안내 메시지를 내려주고 시스템 예외는 INFO/WARN 로그를 남기고 일반 에러로 처리했다. 어느 쪽이 항상 옳다기보다는 해당 화면의 UX와 운영 모니터링 방식에 맞춰 선택하는 문제다.
// 예외 분기 처리 - 패턴 예시
try {
commissionService.settle(param);
} catch (CommissionBusinessException e) {
// 사용자에게 안내 가능한 오류
model.addAttribute("errorMsg", e.getMessage());
return "commission/error";
} catch (Exception e) {
log.warn("커미션 정산 처리 실패 - id={}", param.getId(), e);
return "common/serverError";
}
SQL과 결과 매핑
MyBatis는 SQL을 XML에서 관리하기 때문에 쿼리를 수정하면 XML과 Java 양쪽을 같이 봐야 한다. resultMap 컬럼 이름 오타, parameterType 불일치가 컴파일 타임에는 전혀 안 잡히고 런타임에서야 터지는 게 흔한 함정이다. 정산 쿼리는 집계 함수나 조인이 끼는 경우가 많아서, 결과 매핑이 실제 쿼리 컬럼과 일치하는지 꼼꼼하게 확인했다. 특히 컬럼 별칭을 AS로 맞춰줬는데 resultMap이 원래 컬럼명을 가리키는 경우가 있어서 주의했다.
로그
운영 중에 이상한 값이 들어왔을 때 추적할 수 있도록 주요 입출력을 INFO로 남겼다. 금액 같은 민감 값을 로그에 그대로 찍는 건 서비스 성격에 따라 제한이 있을 수 있으니, 그럴 경우 처리 건 식별자나 집계된 형태로만 남기는 방식을 쓴다. 이번에는 처리 건 식별자와 상태 코드 정도를 남겨서 운영 중 흐름 추적이 가능하게 했다.
배포까지, 그리고 남는 것
로컬 기동 확인 → 개발 DB 반영 → 배포 순서로 처리했다. 정산 로직 수정은 특히 개발 DB에서 실제 케이스에 가까운 데이터로 한 번 더 확인하는 게 안전하다. 엣지 케이스가 로컬에서는 재현이 안 되고 개발 환경 데이터에서만 나오는 경우가 꽤 있다. 커미션 계산 조건이 특정 데이터 조합에서만 다르게 동작하는 경우가 대표적이다.
이번 작업 자체는 크지 않았다. 하지만 정산 흐름에서 작은 오류가 조용히 쌓이다가 한꺼번에 드러나는 상황이 제일 피하기 어렵다. 당장 티가 안 나는 것들 - 트랜잭션 경계 불명확, 예외 묵살, 검증 누락 - 이런 게 시간이 지나면서 문제가 된다. 기능이 사용자에게 직접 보이지 않는 영역일수록 정확성이 더 중요한 이유가 거기 있다.
댓글 0
첫 댓글 달아줘.