사이드프로젝트 slecs

출금 검증 로직 추가

목차

출금 요청이 들어오는 흐름은 생각보다 위험 지점이 많다. 금액, 계좌, 사용자 상태 같은 필드 중 하나라도 검증을 빠뜨리면 그냥 넘어가는 게 아니라 실제 자금이 잘못 처리될 수 있다. 이번 작업은 그 검증 로직을 추가하는 거였고, 수정한 건 내부 클래스 하나지만 그래서 더 꼼꼼하게 봤다.

Spring MVC + MyBatis 구조에서 출금 흐름을 따라가면 대략 이렇다. 컨트롤러가 HTTP 요청을 받아 파라미터를 커맨드 객체에 바인딩하고, 내부 클래스(혹은 서비스 레이어)에서 비즈니스 로직을 돌리고, MyBatis가 SQL XML을 실행해 결과를 가져오면, 그걸 뷰(JSP)나 API 응답으로 내보낸다. 이 파이프라인 중간 어디에서든 검증이 빠지면 잘못된 데이터가 DB까지 내려가거나, 예외가 제대로 안 잡혀서 사용자한테 500이 터진다.

검증 위치 선택

검증을 어디에 두느냐는 항상 선택의 문제다. 너무 앞에 두면 비즈니스 맥락 없이 형식만 보게 되고, 너무 뒤에 두면 불필요한 DB 왕복이 생긴 후에야 실패한다.

위치 장점 단점
컨트롤러 진입 시점 빠른 실패, DB 부하 없음 비즈니스 의미 없는 형식 체크만 가능
서비스/내부 클래스 비즈니스 규칙 반영 가능 DB 조회가 포함될 경우 트랜잭션 범위 관리 필요
DB 제약조건 최후 방어선 에러 핸들링 어려움, 로그 추적 힘듦

이번엔 내부 클래스에서 처리했다. 출금 가능 여부를 판단하려면 단순 형식 체크가 아니라 현재 잔액이나 한도 같은 상태를 DB에서 읽어야 하기 때문이다. 컨트롤러에 넣기엔 로직이 너무 무거워지고, 관심사 분리도 깨진다.

실제 코드 패턴은 이 방향으로 정리했다.

public WithdrawResult processWithdraw(WithdrawRequest req) {
    // 기본 null/빈값 체크
    if (req.getAmount() == null || req.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
        throw new IllegalArgumentException("출금 금액이 유효하지 않습니다.");
    }

    // 비즈니스 검증 - 잔액 확인 (SELECT FOR UPDATE로 동시성 제어)
    AccountInfo account = accountDao.selectForUpdate(req.getAccountId());
    if (account == null) {
        throw new BusinessException("계좌 정보를 찾을 수 없습니다.");
    }
    if (account.getBalance().compareTo(req.getAmount()) < 0) {
        throw new BusinessException("잔액이 부족합니다.");
    }

    // 실제 출금 처리
    int affected = withdrawDao.executeWithdraw(req);
    if (affected != 1) {
        throw new BusinessException("출금 처리 중 오류가 발생했습니다.");
    }

    log.info("[출금] accountId={}, amount={}, balance_after={}",
        req.getAccountId(), req.getAmount(),
        account.getBalance().subtract(req.getAmount()));

    return new WithdrawResult(true, account.getBalance().subtract(req.getAmount()));
}

selectForUpdate를 쓴 이유가 있다. 잔액 체크와 차감 사이에 다른 트랜잭션이 끼어들면 같은 잔액을 두 번 읽고 둘 다 출금을 처리해버리는 상황이 생긴다. 출금이라는 특성상 이런 레이스 컨디션은 그냥 넘어갈 수 없다. FOR UPDATE로 해당 로우를 잠가두면 첫 번째 트랜잭션이 커밋 혹은 롤백될 때까지 두 번째 트랜잭션이 대기한다.

트랜잭션 범위와 예외 전파

트랜잭션 경계 설정에서 실수가 잦은 지점이 두 개다.

첫째, Spring AOP 프록시 구조에서 같은 빈 내부의 this.method() 호출은 프록시를 타지 않는다. 그래서 내부 메서드에 @Transactional을 붙여도 실제 트랜잭션이 시작되지 않는다. 이번 작업에서도 이걸 명시적으로 확인하고 진행했다. 트랜잭션 경계는 외부에서 호출되는 서비스 메서드에 두고, 내부 헬퍼 메서드에는 붙이지 않는 방식으로 정리했다.

둘째, 예외 전파 전략이다. @Transactional은 기본적으로 RuntimeException이 상위로 올라갈 때 롤백을 트리거한다. 여기서 커스텀 BusinessExceptionchecked exception으로 만들어두면, 명시적으로 @Transactional(rollbackFor = BusinessException.class)를 달지 않는 한 롤백이 안 된다. 이번엔 BusinessExceptionRuntimeException의 하위 클래스로 두고, 글로벌 예외 핸들러에서 잡아서 사용자 친화적인 응답 메시지로 변환하는 방식을 택했다.

두 방향을 비교하면 이렇다.

  • RuntimeException 그대로 전파: 롤백 자동, 운영 로그에 스택트레이스 남음, 사용자 응답은 별도 핸들러에서 가공 필요
  • checked exception 잡아서 변환: 응답 메시지 제어 쉬움, rollbackFor 설정 빠뜨리면 출금 실패했는데 커밋되는 상황 발생

이번엔 전자 방향으로 갔고, 글로벌 핸들러에서 BusinessException을 잡아 응답 객체로 바꿨다. 묵시적 동작에 의존하면 나중에 예외 클래스 상속 구조가 바뀔 때 모르고 넘어가는 경우가 생기기 때문에, rollbackFor도 어노테이션에 명시적으로 박아뒀다.

로그는 입력값과 처리 결과를 INFO 레벨로 남겼다. 운영 중에 "출금이 안 된다"는 제보가 들어왔을 때 로그를 뒤지면 어느 단계에서 막혔는지 바로 보여야 한다. DEBUG로 남기면 운영 환경에서 꺼져 있어서 아무 도움이 안 된다. 민감한 개인정보나 계좌 전체 번호는 마스킹하고 남기는 것도 이번에 챙긴 부분이다.

배포 순서와 롤백

출금 관련 로직을 바꾸는 거라 배포 순서가 기능 자체만큼 신경 쓰였다. 트래픽이 처리되는 도중에 버전이 교체되면 의도치 않은 상태가 생길 수 있기 때문이다.

로컬 기동 확인 → 개발 DB 반영 → 배포 순서로 진행했다. 배포는 신규 버전을 완전히 기동하고 헬스체크 응답이 정상으로 나오는 걸 확인한 뒤에 프록시 설정을 전환하는 방식이었다. 헬스체크 엔드포인트가 없으면 서버 프로세스는 뜬 것처럼 보여도 스프링 컨텍스트 초기화가 완료되기 전에 트래픽이 들어오는 상황이 생긴다. 초기화 중에 DB 커넥션 풀이나 빈 의존성이 아직 준비 안 됐으면 처음 요청 몇 개가 에러로 나간다.

이전 버전 빌드 산출물은 배포 후에도 바로 지우지 않고 유지해뒀다. 출금 로직이 운영에서 이상하게 동작하는 게 보이면 빠르게 되돌릴 수 있어야 한다. 롤백 시나리오까지 포함해서 배포를 설계하는 게 이런 민감한 영역에선 기본이라고 생각한다.

코드 변경 자체는 작았다. 하지만 검증 위치, 트랜잭션 경계, 예외 전파 방식, 동시성 제어, 로그 레벨, 배포 순서까지 챙겨야 할 레이어가 생각보다 여러 층이었다. 출금처럼 실제 자원이 오가는 흐름은 이 중 하나라도 빠뜨리면 운영에서 꼭 터진다.

댓글 0

첫 댓글 달아줘.