출금 가능 잔액 조회에 쿠폰 유형 지원 추가
목차
출금 가능 잔액 조회 로직에 COUPON 유형을 추가하는 작업이었다. 규모 자체는 크지 않았는데, 막상 건드려보니 생각해야 할 맥락이 꽤 있었다.
잔액 조회는 단순해 보이지만 실제로는 "어떤 금액을 출금 가능한 것으로 볼 것인가"를 정의하는 로직이다. 포인트·캐시·쿠폰 같은 자산 유형마다 출금 가능 여부 조건이 다를 수 있고, 신규 유형이 추가될 때마다 조회 쿼리와 집계 로직이 달라진다. COUPON을 지원한다는 건 단순히 쿼리 한 줄 추가가 아니라, 이 자산이 기존 잔액 계산 흐름 어디에 끼워지는지를 먼저 정리해야 한다는 뜻이다.
구현 흐름
Spring MVC + MyBatis 구조라 요청은 컨트롤러 내부 클래스에서 받아서 서비스 클래스로 넘기고, SQL은 XML 매퍼에서 관리한다. 이 작업에서 손댄 파일도 내부 클래스 두 곳이었다. 하나는 요청 파라미터 바인딩과 유효성 검증, 나머지 하나는 비즈니스 로직과 결과 가공.
파라미터 레벨에서 COUPON 유형을 받을 수 있도록 바인딩을 수정하고, SQL 쪽에서는 assetType 조건 분기를 추가했다. MyBatis의 동적 쿼리 기능으로 처리했는데, 대략 이런 구조다.
<select id="selectWithdrawableBalance" resultType="BalanceDto">
SELECT SUM(amount) AS total
FROM user_asset
WHERE user_id = #{userId}
AND status = 'ACTIVE'
<if test="assetType != null and assetType != ''">
AND asset_type = #{assetType}
</if>
<choose>
<when test="assetType == 'COUPON'">
AND coupon_withdraw_yn = 'Y'
</when>
<otherwise>
AND withdraw_yn = 'Y'
</otherwise>
</choose>
</select>
쿠폰은 출금 가능 조건 컬럼이 기존 자산 유형과 달랐다. 기존 withdraw_yn 컬럼 대신 coupon_withdraw_yn을 봐야 했고, 이 부분을 <choose> 분기로 처리했다. 처음엔 애플리케이션 레이어에서 분기하는 방식도 고려했는데, 쿼리가 단순한 집계라 DB에서 처리하는 게 낫다고 판단했다.
챙겨야 했던 부분들
트랜잭션은 조회 전용이라 @Transactional(readOnly = true)로 명시했다. 쓰기 없는 조회에 readOnly를 빠뜨리는 경우가 종종 있는데, 커넥션 힌트로 쓰기 락 경합을 피할 수 있고 DB 복제 환경에서는 읽기 전용 슬레이브로 라우팅될 수 있다. 작은 차이지만 명시하는 게 맞다.
예외 처리는 서비스에서 잡아서 응답 객체로 변환하는 방식을 유지했다. 조회 실패 시 빈 결과나 오류 코드를 내려줘야 하는데, RuntimeException을 그대로 올리면 컨트롤러에서 일관된 응답 포맷이 깨진다. 이 프로젝트는 이미 그 패턴이 잡혀 있어서 따라갔다.
로그는 입력 assetType, 조회 결과 금액을 INFO로 남겼다. 운영에서 "쿠폰 잔액이 왜 0원이에요"류 문의가 올 때 로그 없으면 추적이 안 된다. 쿼리 파라미터와 결과 정도는 반드시 찍어두는 게 맞다.
기존 코드와의 호환성도 확인했다. assetType이 null이거나 빈 문자열일 때 기존처럼 전체 유형 합산으로 동작해야 하고, COUPON만 분기가 추가되는 구조여야 한다. 아래처럼 서비스 레벨에서 방어 코드를 두고 테스트 시나리오로 확인했다.
public BalanceDto getWithdrawableBalance(String userId, String assetType) {
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("userId is required");
}
BalanceParam param = new BalanceParam();
param.setUserId(userId);
param.setAssetType(assetType); // null 허용 - 전체 조회
BalanceDto result = balanceMapper.selectWithdrawableBalance(param);
log.info("[출금가능잔액조회] userId={}, assetType={}, total={}",
userId, assetType, result != null ? result.getTotal() : 0);
return result != null ? result : new BalanceDto();
}
CI/CD 배포 중 마주친 것들
로컬 기동 → 개발 DB 반영 → 배포 순으로 진행했는데, 배포 파이프라인에서 몇 가지가 걸렸다.
| 문제 | 원인 | 조치 |
|---|---|---|
| SSH 타임아웃 | 빌드 시간이 길어지며 연결 끊김 | command_timeout 명시 |
| 키 인증 오류 | 개행 문자 포함 여부 문제 | 키 재등록 및 줄바꿈 제거 확인 |
| Gradle 캐시 충돌 | CI 환경 캐시 상태 불일치 | --no-daemon 옵션 또는 캐시 클리어 |
| 환경변수 누락 | Secrets 설정 빠짐 | 배포 전 필수 변수 체크리스트 |
SSH 타임아웃은 빌드가 무거운 프로젝트에서 주기적으로 터진다. ServerAliveInterval과 ServerAliveCountMax를 CI 설정에 추가해두면 어느 정도 방어가 된다. 환경변수 누락은 배포 자체는 성공으로 찍히는데 앱이 뜨지 않는 케이스라 찾는 데 시간이 걸린다. 시작 시점에 필수 환경변수 존재 여부를 확인하는 로직을 앱 부트 단계에 넣어두는 게 장기적으로 낫다.
작업 자체는 작았지만, 자산 유형이 늘어날수록 이 패턴을 얼마나 잘 설계해두느냐가 유지보수 비용을 가른다는 걸 다시 확인했다. 분기가 코드 여러 레이어에 흩어지면 나중에 또 새 유형 추가할 때 빠뜨리는 곳이 생긴다. 유형별 정책을 한 곳에서 관리하는 구조로 갈 수 있다면 그게 맞는 방향이다.
댓글 0
첫 댓글 달아줘.