개발 slecs

쿠폰 입금 정산 기능 구현

목차

쿠폰 입금 정산 기능은 운영 중에 생각보다 자주 문제가 생기는 영역이다. 쿠폰 사용 이벤트와 실제 입금 이벤트가 시간차로 발생하고, 그 사이에 취소나 부분 환불 같은 예외 상황이 끼어들면 정산 테이블이 생각대로 맞지 않는다. 이번 작업은 그 정산 흐름을 코드로 표현하는 작업이었다.

Spring MVC + MyBatis 기반에서 내부 클래스 구조로 구현했다. 기능 볼륨 자체는 크지 않았는데, 정산 도메인 특성상 고려해야 하는 엣지 케이스가 많아서 설계에서 시간을 더 썼다.

파라미터 바인딩부터 SQL 결과 매핑까지

요청 파라미터 검증은 항상 가장 먼저 나오는데, 정산 조회에서 이게 특히 중요하다. 날짜 범위나 상태값 조건이 빠진 채로 쿼리가 나가면 정산 테이블 풀스캔이 뜰 수 있고, 운영 DB에서 그게 한 번 터지면 주변 쿼리까지 밀린다. 그래서 필수 파라미터 누락 시 컨트롤러 레벨에서 바로 반환하도록 처리했다.

JSP 폼에서 넘어오는 값은 빈 문자열로 오는데, MyBatis 동적 쿼리에서 null 체크와 isEmpty 체크를 함께 처리해야 한다. 이 둘을 따로 구분 안 하면 조건 분기가 의도와 다르게 동작한다.

<!-- null과 빈 문자열을 함께 체크 -->
<if test="couponCode != null and couponCode != ''">
    AND coupon_code = #{couponCode}
</if>
<if test="depositStartDate != null and depositStartDate != ''">
    AND deposit_date >= #{depositStartDate}
</if>

SQL 결과 매핑은 ResultMap을 쓰는 게 나중에 컬럼 추가나 DTO 변경할 때 편하다. 자동 매핑에 의존하면 컬럼 이름이 카멜케이스 컨벤션과 다를 때 조용히 null로 매핑되는 경우가 생겨서 찾기 까다로워진다. 정산 금액처럼 0과 null을 구분해야 하는 필드는 ResultMap에서 명시적으로 지정해두는 게 맞다.

트랜잭션 범위와 예외 처리 전략

정산 기능에서 트랜잭션 범위 설정은 묘하게 고민이 많다. 조회 위주 작업이라도 정산 상태 업데이트나 정산 로그 기록이 포함되면 쓰기 트랜잭션이 묶여야 하는데, 그 범위를 너무 크게 잡으면 락 홀딩 시간이 늘어나고 너무 좁게 잡으면 데이터 정합성이 깨진다.

이번에 적용한 방식:

메서드 유형 트랜잭션 설정 이유
정산 목록 조회 @Transactional(readOnly = true) DB 세션 최적화, 실수로 CUD 방지
정산 상태 업데이트 @Transactional 상태 변경 + 로그 기록 원자성 보장
외부 연동 포함 로직 트랜잭션 범위 밖 외부 호출 실패가 DB 롤백에 영향 주지 않도록

readOnly = true 설정이 실질적으로 성능에 크게 영향을 주는 건 DBCP 구성에 따라 다르지만, 읽기 전용임을 코드로 표현하는 의미 자체가 유지보수할 때 도움이 된다. 나중에 이 메서드 건드리는 사람이 "여기서 insert 하면 안 되는구나"를 바로 알 수 있다.

예외 처리는 RuntimeException 전파 vs 잡아서 응답 변환 사이에서 선택해야 했다. 정산 도메인에서 예외가 나면 클라이언트한테 의미 있는 메시지를 줘야 하는 경우가 많아서 무조건 전파시키기가 애매하다.

public ReconcileResult reconcileDeposit(ReconcileParam param) {
    try {
        validate(param);
        int updated = couponMapper.updateDepositStatus(param);
        couponMapper.insertReconcileLog(param);
        return ReconcileResult.success(updated);
    } catch (InvalidParamException e) {
        // 입력값 오류는 응답 메시지로 변환
        return ReconcileResult.fail(e.getMessage());
    }
    // RuntimeException(DB 오류 등)은 전파 -> 공통 에러 핸들러 처리
}

DB 연결 실패나 예상치 못한 시스템 오류는 공통 에러 핸들러로 올리고, 입력값 오류나 정산 불일치 같은 비즈니스 오류는 잡아서 변환했다. 모든 걸 잡아버리면 진짜 장애를 놓치고, 모든 걸 올려버리면 사용자가 무의미한 서버 에러 화면만 본다.

운영에서 쓸 수 있는 로그 전략

정산 기능은 운영에서 이슈가 생겼을 때 추적이 안 되면 수습이 복잡해진다. 어떤 조건으로 조회했는지, 정산 처리된 건수, 어떤 건이 스킵됐는지가 로그에 남아 있어야 거슬러 올라갈 수 있다.

이번에 챙긴 로그 전략:

  • 입력 파라미터는 중요 필드만 마스킹 처리 후 INFO로 찍기. 금액이나 코드값 전체 노출은 지양
  • 정산 처리 건수는 반드시 INFO로 남기기. "정산 처리 완료: N건" 정도면 운영 이상 감지에 충분
  • 스킵된 케이스가 있으면 WARN으로 이유와 함께 기록
  • 상세 DTO 내용은 DEBUG로 찍고, 운영 레벨은 INFO로 유지

DEBUG로 상세 내용을 충분히 남겨두면 운영에서 갑자기 로그 레벨 내려야 할 때 재배포 없이 확인 가능하다. 정산 도메인에서 건수 로그 하나가 없어서 오랫동안 삽질하는 경우가 실제로 있어서, 이번엔 처음부터 챙겨뒀다.

작업 후 로컬 기동 확인 → 개발 DB 반영 → 배포 순서로 진행했다. 정산 기능은 데이터 정합성 이슈가 운영에서 바로 나오는 종류라 개발 DB에서 실데이터 유사 케이스를 먼저 충분히 돌려봐야 한다. null 케이스, 빈 값, 날짜 경계값, 특히 월말이나 연말 기준 처리는 로컬에서 손으로 넣어서 확인하는 과정을 건너뛰면 안 된다.

기존 로직과의 호환성 확인은 수정한 SQL이 기존에 조인하던 테이블 구조를 건드리지 않는지 위주로 봤다. MyBatis XML에서 공통으로 재사용하는 쿼리 조각이 있으면 그걸 수정했을 때 다른 매퍼에서도 같은 조각을 쓰는지 반드시 확인해야 한다. IDE의 find usages가 XML 조각까지 잘 안 잡히는 경우가 있어서 직접 grep으로 한 번 더 확인하는 게 안전하다.

작업 볼륨은 작았지만 정산 기능은 처음에 설계를 제대로 안 해두면 나중에 수습 비용이 크게 오른다. 트랜잭션 범위 하나, 예외 처리 전략 하나가 나중에 운영 이슈 여부를 갈라놓는 경우가 실제로 있다. 이런 종류의 작업일수록 천천히 설계 시간을 더 쓰는 게 맞다고 다시 확인한 작업이었다.

댓글 0

첫 댓글 달아줘.