관리자 충전 취소 로직
목차
관리자 화면에서 충전 취소를 처리하는 로직은 생각보다 손댈 곳이 많다. 단순히 "취소됐습니다" 응답 하나 내려주는 게 전부가 아니라, 취소 요청의 유효성 검증, 상태 전이, DB 반영, 화면 피드백까지 한 흐름으로 묶여야 한다. 이번 작업도 그 흐름 중에 빠진 케이스를 채우거나, 운영하면서 발견한 불안정한 부분을 정리하는 게 목적이었다.
Spring MVC + MyBatis 기반이라 구조 자체는 단순하다. Controller 쪽 내부 클래스가 요청 바인딩을 담당하고, Service 레이어에서 비즈니스 로직이 돌고, Mapper XML에 쿼리가 정의되어 있다. JSP가 결과를 렌더링하거나, Ajax 요청이면 JSON 응답으로 내려간다. 익숙한 구조지만 "충전 취소"라는 도메인이 붙으면 챙겨야 할 게 늘어난다.
취소 플로우에서 트랜잭션 범위를 왜 명확히 해야 하는가
충전 취소는 대부분 두 가지 이상의 DB 작업이 하나의 단위로 묶여야 한다. 충전 내역 상태를 변경하고, 잔액이나 포인트를 조정하고, 취소 이력을 남기는 게 같은 트랜잭션 안에 있어야 한다. 하나라도 실패하면 전부 롤백돼야 정합성이 유지된다.
Spring에서는 @Transactional로 범위를 잡는데, 이게 생각보다 트릭이 많다. 같은 클래스 내에서 메서드를 직접 호출하면 프록시를 타지 않아서 트랜잭션이 안 걸린다. 그리고 RuntimeException이 아닌 checked exception을 던지면 기본 설정에서는 롤백이 안 된다.
// 잘못된 예 - 동일 클래스 내 self-invocation, 트랜잭션 미적용
public void cancel(CancelRequest req) {
this.doCancelInternal(req); // 프록시 우회
}
@Transactional
private void doCancelInternal(CancelRequest req) {
// ...
}
// 올바른 방향 - 트랜잭션 경계를 Service에서 명시적으로 선언
@Transactional(rollbackFor = Exception.class)
public void cancel(CancelRequest req) {
chargeMapper.updateStatus(req.getChargeId(), "CANCELLED");
balanceMapper.deductAmount(req.getUserId(), req.getAmount());
historyMapper.insertCancelHistory(req);
}
rollbackFor = Exception.class를 명시하는 게 귀찮아도 확실하다. checked exception이 섞일 여지가 조금이라도 있으면 기본값인 RuntimeException만 롤백하는 설정은 위험하다. 이번 작업에서도 트랜잭션 범위를 다시 확인하고 정리했다.
실제로 구현하면서 고민한 것들
파라미터 검증부터 시작했다. 관리자 화면에서 들어오는 요청이라고 해서 검증을 느슨하게 해두면 나중에 반드시 문제가 생긴다. null 체크, 빈 문자열, 음수 금액, 이미 취소된 건을 다시 취소하려는 케이스 - 이런 걸 하나씩 방어해두지 않으면 운영에서 예상 못한 에러가 터진다.
예외 처리 방향도 결정해야 했다. 선택지는 두 가지였다.
| 방식 | 장점 | 단점 |
|---|---|---|
| RuntimeException 그대로 전파 | 코드 간결, 스택트레이스 보전 | Controller에서 에러 핸들러 필요, 사용자 응답 포맷이 지저분해질 수 있음 |
| Service에서 잡아서 결과 객체로 변환 | 응답 포맷 일관성 유지 | 예외 정보 희석, 로깅 놓칠 가능성 |
이번엔 @ControllerAdvice가 이미 전역 핸들러로 있어서 RuntimeException을 그대로 올리는 방식을 택했다. 대신 로그를 꼭 남겼다. 서비스 레이어에서 logger.error로 예외 발생 시점의 입력값을 같이 찍어두면, 운영에서 "그 요청이 뭐였는지"를 알 수 있다.
로그는 이렇게 남겼다.
logger.info("[ChargeCancelService] cancel request - chargeId={}, userId={}, amount={}",
req.getChargeId(), req.getUserId(), req.getAmount());
// 처리 후
logger.info("[ChargeCancelService] cancel complete - chargeId={}", req.getChargeId());
INFO 레벨로 중요 입출력값을 남겨두는 건 디버깅 속도에 직접 영향을 준다. 에러 로그만 있으면 "왜 에러가 났는지"는 보이는데 "어떤 데이터가 들어왔는지"가 없어서 재현을 못 하는 경우가 많다.
기존 로직과의 호환성도 체크했다. 취소 관련 코드가 다른 흐름 - 예를 들어 정기 배치나 다른 관리 기능 - 에서도 참조되는 메서드를 건드리는 경우라면, 변경이 사이드이펙트를 낳을 수 있다. 이번엔 영향 범위가 좁아서 크게 걱정할 부분은 없었지만, 수정 전에 IDE에서 해당 메서드의 호출 지점을 전부 확인하는 습관을 들이는 게 맞다.
MyBatis Mapper XML에서는 취소 상태 업데이트 쿼리와 이력 insert를 작성했다. XML이라 쿼리 오타가 런타임에만 잡힌다는 단점이 있는데, 로컬 기동 후 실제 취소 시나리오를 한 번 돌려보는 것 말고는 선택지가 없다.
배포까지 가는 경로
로컬 기동 확인 → 개발 DB에 반영 → 배포 순서로 진행했다. 작은 변경이라도 이 순서는 지키는 게 맞다. 개발 DB를 건너뛰고 바로 올리면 쿼리 문제나 매핑 오류가 스테이징이나 운영에서 터진다.
CI/CD 파이프라인에서 자주 마주치는 문제들은 별도로 정리해둘 만하다.
- SSH 타임아웃: 빌드가 길어지면 연결이 끊긴다. 파이프라인 설정에서
command_timeout을 명시하거나, SSH keep-alive를 설정해두면 해결된다. - 키 인증 오류: private key 복붙할 때 마지막 개행 문자가 빠지는 경우가 있다.
cat -A로 확인하거나 base64로 인코딩해서 secrets에 넣으면 안전하다. - Gradle 캐시 충돌: CI 환경에서 캐시가 꼬이면 빌드가 이상하게 실패한다.
--no-daemon옵션을 붙이거나, 파이프라인 캐시를 주기적으로 무효화하는 게 낫다. - 환경변수 누락: Secrets 설정을 빠뜨리면 배포 자체는 성공인데 앱이 뜨지 않는 상황이 생긴다. 배포 후 헬스체크 엔드포인트 응답을 파이프라인에서 확인하도록 단계를 넣어두는 게 좋다.
작업 규모 자체는 크지 않았지만, 충전 취소처럼 금전과 연결된 로직은 예외 케이스 하나 빠뜨리면 데이터 정합성 문제로 이어진다. 트랜잭션 범위 명확히, 예외 처리 방향 결정, 로그 확보 - 이 세 가지를 체계 없이 하면 나중에 운영에서 원인 파악하는 데 시간이 배로 든다. 작은 수정이어도 이 기준은 동일하게 적용하는 게 맞다.
댓글 0
첫 댓글 달아줘.