사이드프로젝트 slecs

출금 계좌 실패 처리 안정성 개선

목차

출금 계좌 관련 실패 처리를 손봤다. 금액이 오가는 흐름에서 예외 케이스가 조용히 묻히면 나중에 디버깅이 훨씬 힘들어진다 - 그게 이번 작업의 핵심 동기였다.

수정 대상은 내부 클래스였고, Spring MVC + MyBatis 구조 위에서 파라미터 바인딩부터 SQL 결과 매핑, 응답 구성까지 한 흐름을 정리했다. 작업 규모 자체는 크지 않았지만, 이런 류의 수정이 쌓이면 운영 안정성이 눈에 띄게 달라진다는 걸 경험상 알고 있어서 꼼꼼하게 챙겼다.

출금 실패 처리가 중요한 이유

출금은 대부분의 플랫폼에서 가장 민감한 도메인이다. 잔액 차감, 계좌 검증, 외부 연동 - 각 단계에서 어떤 순서로 실패가 발생하느냐에 따라 시스템 상태가 달라진다. 특히 중간에서 실패했을 때 앞 단계가 이미 반영됐는지 여부가 트랜잭션 처리의 핵심이 된다.

이번 작업에서 건드린 건 그 중 "계좌 실패" 케이스였다. 등록된 출금 계좌가 유효하지 않거나, 요청 파라미터 자체가 비정상일 때 어떻게 처리할 건지. 기존 코드에서 이 경로가 예외를 삼키거나 잘못된 응답을 내리는 구석이 있었고, 그게 운영에서 표면에 드러날 여지가 있었다.

실패 처리를 설계할 때 항상 두 가지 방향에서 같이 봐야 한다고 생각한다.

첫째는 사용자 응답이다. 막연한 500 에러보다 실패 원인을 알 수 있는 응답 코드가 훨씬 낫다. 계좌 번호 오류인지, 계좌 상태 문제인지, 요청 파라미터 형식 문제인지 - 클라이언트가 그 차이를 알아야 적절하게 핸들링할 수 있다. 특히 출금 관련 실패는 사용자가 직접 조치할 수 있는 케이스가 많아서, 명확한 에러 메시지가 CS 문의 자체를 줄여준다.

둘째는 운영 추적성이다. 실패가 발생했을 때 로그만 봐도 어느 단계에서 무슨 값으로 들어와서 어떻게 실패했는지 파악 가능해야 한다. 이게 안 되면 CS 대응 시간이 불필요하게 늘어나고, 재현을 위해 다시 로직을 뒤지는 데 시간을 쓰게 된다. 이번 작업 전에는 이 두 가지가 모두 불충분한 상태였다.

내부 클래스 수정과 예외 처리 전략

Spring MVC에서 내부 클래스(inner class)는 특정 컨트롤러나 서비스 내부에 VO나 처리 단위로 쓰이는 경우가 많다. 구조가 단순할 때는 편하지만, 비즈니스 로직이 얽히기 시작하면 테스트나 재사용 측면에서 금방 한계가 온다.

파라미터 검증 위치 정리가 먼저였다. 컨트롤러 진입 시점에서 바인딩이 실패하면 빠르게 400을 돌려주고, 비즈니스 레이어까지 내려가지 않도록 했다. 이전에는 서비스 내부에서 null 체크를 하다 보니 로직 중간에서 NPE가 날 수 있는 구석이 있었다. 검증을 앞에서 끊어주면 서비스 레이어에서는 "여기 들어오는 파라미터는 유효하다"는 가정 하에 로직을 짤 수 있어서 코드가 훨씬 깔끔해진다.

다음으로 RuntimeException 처리 방향을 결정해야 했다. 예외를 전파할지, 잡아서 응답 변환을 할지는 항상 선택이 필요한 지점이다.

예외를 상위로 그냥 올리면 글로벌 핸들러(@ControllerAdvice)에서 일관되게 처리할 수 있다. 그런데 그 예외가 의미 있는 사용자 응답으로 변환되지 않으면 클라이언트는 500만 받게 된다. 계좌 실패처럼 "이건 사용자에게 명시적으로 알려줘야 하는 케이스"는 로컬에서 잡아서 응답 코드와 메시지를 직접 구성하는 게 낫다고 판단했다.

try {
    withdrawService.validateAccount(param);
} catch (InvalidAccountException e) {
    log.info("[출금계좌검증실패] accountNo={}, reason={}",
             param.getAccountNo(), e.getMessage());
    return ResponseEntity.badRequest()
                         .body(ErrorResponse.of("INVALID_ACCOUNT", e.getMessage()));
}

로그는 ERROR가 아니라 INFO로 남겼다. 계좌 실패는 사용자 입력 오류에 가까운 케이스라, ERROR로 쌓이게 하면 알람이 노이즈가 된다. INFO로 남기되 accountNo와 실패 사유는 반드시 포함해야 CS 대응할 때 바로 찾을 수 있다. 로그 레벨 선택이 사소해 보여도 운영에서 알람 피로도에 직접적인 영향을 준다.

글로벌 핸들러에서 커버하는 범위와 로컬에서 잡는 범위를 명확히 나누는 것도 같이 챙겼다. 보통은 이렇게 나눈다.

  • 글로벌 핸들러: 예상 못한 RuntimeException, 인프라 레벨 오류, 공통 응답 포맷 변환
  • 로컬에서 직접 처리: 비즈니스 의미가 있는 실패(계좌 유효성 실패, 파라미터 형식 오류 등), 응답에 맥락이 필요한 케이스

이 경계가 흐릿하면 같은 종류의 예외가 어떤 경우에는 글로벌에서 잡히고 어떤 경우에는 로컬에서 잡혀서 응답 형식이 달라지는 상황이 생긴다. 클라이언트 쪽에서 에러 처리 로직을 짤 때 혼란이 온다.

트랜잭션 범위와 MyBatis 매핑 정리

DB를 건드리는 구간은 트랜잭션 범위를 명확히 해야 한다. MyBatis + Spring 조합에서 @Transactional 범위를 잘못 잡으면 커밋이 예상 시점보다 늦거나 일부만 반영되는 상황이 생긴다.

특히 실패 이력을 기록하는 부분은 트랜잭션 롤백 대상에서 제외할지 여부를 명시적으로 결정해야 한다. 메인 트랜잭션이 롤백될 때 실패 로그마저 함께 사라지면 운영에서 추적이 안 된다. 이 케이스는 REQUIRES_NEW 전파 속성으로 별도 트랜잭션을 열거나, 비동기로 분리하는 방식을 쓰기도 한다.

이번 작업에서 정리한 트랜잭션 범위는 아래와 같다.

처리 단계 트랜잭션 포함 여부 비고
파라미터 검증 미포함 DB 접근 없음
계좌 유효성 조회 포함 읽기 전용이나 범위 내 유지
실패 이력 기록 별도 트랜잭션 롤백 시에도 이력은 남아야 함
응답 구성 미포함 트랜잭션 종료 후 처리

SQL XML 쪽은 resultMap 매핑을 다시 맞춰줬다. 기존 코드가 카멜케이스 자동 변환(mapUnderscoreToCamelCase)에 의존하고 있었는데, 특정 컬럼이 그 규칙에서 벗어나 있어서 매핑이 조용히 null로 빠지는 케이스가 있었다. 명시적으로 <result column="ACCT_NO" property="accountNo"/> 형태로 써주는 게 번거롭지만 훨씬 안전하다. 자동 변환은 편하지만 그 규칙에서 벗어난 컬럼이 있을 때 디버깅이 생각보다 오래 걸린다. null이 떨어지는 필드를 찾아서 SQL XML까지 타고 들어가야 하니까.

배포는 로컬 기동 확인 → 개발 DB 반영 → 배포 순서로 처리했다. 출금처럼 민감한 도메인은 개발 DB에서 실패 케이스를 직접 재현해보는 게 의미 있다. 파라미터를 일부러 빈 값이나 잘못된 형식으로 넣어보고, 응답 코드와 로그가 예상대로 찍히는지 확인했다. 트랜잭션 롤백 시 실패 이력 테이블에는 기록이 남는지도 같이 체크했다.

코드 리뷰는 기존 흐름과의 호환성에 집중했다. 내부 클래스 수정이 같은 클래스를 참조하는 다른 흐름에 영향을 주지 않는지, 메서드 시그니처가 바뀌지 않았는지. IDE의 usage 검색을 꼼꼼하게 돌려보는 게 제일 빠르다. 수정 범위가 작아 보여도 같은 클래스를 여러 곳에서 참조하고 있으면 사이드이펙트가 생각 밖에서 튀어나온다.

금액이 오가는 도메인에서 실패 처리 하나를 제대로 잡아두면 운영 부담이 확실히 줄어든다. 실패 경로가 명확하게 처리되고, 로그가 적절히 남아있고, 트랜잭션 범위가 예상대로 동작하면 - 문제가 생겼을 때 대응 시간이 완전히 달라진다. 거창한 기능 추가보다 이런 안정화 작업이 장기적으로 더 영향이 크다.

댓글 0

첫 댓글 달아줘.