결제 플랫폼 잔액 관리 기능 추가
목차
결제 플랫폼에서 잔액 관리 기능을 붙이는 작업이었다. 규모가 크지 않아서 반나절이면 끝날 것 같았는데, 막상 건드리다 보면 항상 예상치 못한 부분이 튀어나온다. 이번도 마찬가지였다.
결제 도메인 특성상 잔액 조회나 차감 같은 흐름은 사용자가 실제로 자주 밟는 경로다. 운영 중에 발견된 개선 포인트를 반영한 작업이었고, 기능 자체보다 그 기능이 기존 흐름에 얼마나 안전하게 녹아드느냐가 핵심이었다.
구현 흐름과 고민한 지점
Spring MVC + MyBatis 구조라서 역할 분리가 명확하게 되어 있다. 내부 클래스에서 요청 파라미터를 받아 바인딩하고, 비즈니스 로직을 처리하고, SQL XML에서 쿼리를 관리한 다음 JSP가 뷰 레이어로 결과를 렌더링하는 구조.
이번 작업에서 가장 신경 쓴 건 세 가지였다.
트랜잭션 범위. 잔액을 건드리는 로직은 DB 연산이 중간에 실패했을 때 롤백 경계가 어디냐가 중요하다. @Transactional 어노테이션 범위를 명확히 잡지 않으면 부분 커밋이 일어날 수 있다. 특히 MyBatis에서는 SqlSession 생명주기와 트랜잭션 범위가 맞지 않으면 의도치 않게 auto-commit으로 동작하는 경우가 있어서 확인이 필요했다.
@Transactional(rollbackFor = Exception.class)
public BalanceResult adjustBalance(BalanceRequest request) {
validate(request);
BalanceResult result = balanceMapper.deduct(request);
auditMapper.insertLog(request, result); // 로그도 같은 트랜잭션 안에
return result;
}
로그 삽입까지 같은 트랜잭션에 묶어야 하는 케이스였다. 잔액 차감은 성공했는데 감사 로그가 누락되면 운영에서 추적이 불가능해지기 때문이다.
예외 처리 전략. RuntimeException을 그냥 전파할 건지, 아니면 잡아서 응답 포맷에 맞게 변환할 건지. 내부 API라면 그냥 던지는 게 스택 트레이스 보존 측면에서 낫지만, 외부로 나가는 응답이 섞여 있으면 클라이언트한테 500을 그대로 내보내는 게 곤란하다. 이번엔 컨트롤러 레벨에서 @ExceptionHandler로 잡아서 공통 에러 응답 포맷으로 감쌌다.
null·빈 값·비정상 입력 처리. 잔액 관련 파라미터는 입력 검증을 느슨하게 두면 나중에 데이터 오염으로 이어진다. 요청 바인딩 시점에 검증을 끝내고, 이후 로직에서는 null 체크를 최소화하는 방향으로 정리했다.
| 케이스 | 처리 방식 |
|---|---|
| 금액 파라미터 누락 | 바인딩 단계에서 필수값 검증 후 400 응답 |
| 음수 금액 | 비즈니스 예외로 분리, 로그 남긴 후 에러 응답 |
| 잔액 부족 | DB 조회 후 서비스 레이어에서 판단, 명시적 에러 코드 반환 |
| null 잔액 레코드 | 없으면 0 처리 vs. 초기화 레코드 생성 중 선택 |
null 잔액 레코드 케이스는 기존 데이터 상태에 따라 달라지는 부분이라서 기존 로직이 어떻게 동작하고 있었는지 먼저 확인하고 거기에 맞췄다. 여기서 임의로 동작을 바꾸면 다른 쪽에서 예상하는 상태값이 틀어질 수 있다.
SQL 쪽은 MyBatis XML에서 조건 분기를 동적으로 처리했다. 잔액 조회와 업데이트를 같은 매퍼에서 관리하다 보면 쿼리가 점점 복잡해지는 경향이 있어서, 이번엔 조회/변경 쿼리를 명확히 분리해뒀다.
<update id="deductBalance" parameterType="BalanceRequest">
UPDATE balance
SET amount = amount - #{amount},
updated_at = NOW()
WHERE user_id = #{userId}
AND amount >= #{amount}
</update>
WHERE amount >= #{amount} 조건을 쿼리에 걸어두면 잔액 부족 상태에서 음수 전환을 DB 레벨에서 막을 수 있다. 업데이트된 rows가 0이면 서비스 레이어에서 잔액 부족으로 판단하는 구조다. 애플리케이션 레벨에서 조회 후 차감하는 방식보다 race condition에 덜 취약하다.
CI/CD에서 이번에도 걸린 것들
기능 자체는 로컬 기동 확인 후 개발 DB에 반영하고 배포하는 순서로 진행했다. 그런데 파이프라인에서 몇 가지 또 발목을 잡았다.
SSH 타임아웃은 빌드가 길어지면 항상 나오는 문제다. command_timeout을 명시해두지 않으면 조용히 연결이 끊기고 빌드가 실패했는지 성공했는지도 불분명하게 된다. Gradle 캐시 충돌도 마찬가지. CI 환경에서 캐시가 꼬이면 --no-daemon 플래그 붙이거나 캐시 디렉토리 비워주는 게 빠르다. 재빌드로 해결하려다 시간 날리는 것보다 낫다.
환경변수 누락은 가장 짜증스러운 케이스다. 배포는 성공으로 찍히는데 앱이 안 뜨는 상황. Secrets 설정을 빠뜨린 게 원인인데, 로그 보기 전까지는 왜 안 뜨는지 바로 잡히지 않는다. 이건 배포 후 헬스체크를 파이프라인 마지막 단계에 넣어두는 게 답이다. 앱이 실제로 응답하는지까지 확인하고 성공으로 처리하도록.
키 인증 오류는 개행 문자 포함 여부가 원인인 경우가 많다. 키를 환경변수로 넘길 때 복붙 과정에서 trailing newline이 붙으면 인증 실패로 나온다. 에러 메시지가 직관적이지 않아서 처음 만나면 한참 헤매게 된다.
작업 후기
작업 규모 자체는 크지 않았다. 내부 클래스 몇 개 수정, 쿼리 추가, 화면 반영 정도. 근데 결제 도메인이라는 특성상 트랜잭션 하나 잘못 잡으면 데이터 정합성이 깨지고, 예외 하나 잘못 흘려보내면 운영에서 원인 추적이 힘들어진다.
로그는 중요 입출력값을 INFO 레벨로 남겨두는 걸 원칙으로 하고 있다. 운영에서 문제 생겼을 때 DEBUG 레벨은 대부분 꺼져 있어서 INFO에 남긴 것 위주로 추적하게 된다. 너무 많이 남기면 노이즈가 되고, 너무 적으면 아무것도 안 보인다. 어느 쪽이든 나중에 고생하는 건 마찬가지라서, 입력 파라미터와 분기 결과 정도는 반드시 찍어두는 습관을 유지하고 있다.
코드 리뷰할 때 수정한 로직이 기존 흐름에 어떤 영향을 주는지 추적하는 게 이런 작업에서 제일 중요하다. 기능 추가보다 기존 코드와의 호환성 유지가 더 까다로운 경우가 많다. 작은 수정처럼 보여도 호출 경로를 따라가다 보면 생각보다 여러 곳에 닿아 있는 경우가 있다.
댓글 0
첫 댓글 달아줘.