개발 slecs

계정 정지와 출금 정지를 분리해 독립적으로 작동하도록 개편

목차

계정 정지와 출금 정지를 한 덩어리로 묶어두면 언젠가 꼭 문제가 생긴다. 이번에 그 "언젠가"가 왔고, 결국 정지 유형을 이분화하는 구조 개편을 했다.

기존 구조는 단순했다. tb_suspend_master 에 레코드가 있으면 정지, 없으면 정상. 계정 자체를 막는 게 목적이었으니 처음엔 충분했음. 그런데 출금만 막아야 하는 케이스가 생기기 시작했다. 로그인은 되어야 하고, 다른 기능도 써야 하는데 출금만 홀드해야 하는 상황. 이걸 기존 정지 테이블로 처리하면 로그인까지 막혀버리는 부작용이 생긴다. 타입 없이 정지 여부만 boolean으로 쓰던 구조의 한계.

DB 스키마 + 쿼리 계층 정비

제일 먼저 건드린 건 tb_suspend_mastersuspend_type 컬럼 추가. 기본값은 ACCOUNT로 설정해서 기존 레코드가 전부 계정 정지로 취급되도록 했음. 이 방식을 택한 이유가 있는데, 신규 컬럼 추가 시 기존 데이터에 기본값을 어떻게 박느냐가 항상 고민이다.

옵션은 크게 셋이다.

방식 장점 단점
DEFAULT 값 설정 (이번 채택) 마이그레이션 단순, 기존 코드 오작동 없음 기본값이 비즈니스 의미를 담아야 함
NULL 허용 후 코드에서 처리 유연함 쿼리·코드 곳곳에 null 체크 필요
배치로 기존 row 전부 업데이트 명시적 데이터 양 따라 락 이슈, 롤백 복잡

기존 정지가 전부 계정 정지였다는 게 명확하니 DEFAULT 'ACCOUNT'가 제일 깔끔했다. 애매한 NULL이 끼어들면 나중에 쿼리 조건이 지저분해진다.

쿼리 계층은 8개를 손봤다. 전부 id 필터를 받도록 수정했고, 핵심은 기존 isSuspended() 조회 로직에 <choose> 분기를 넣어서 ACCOUNT 유형만 필터링하도록 제약한 것. MyBatis 기준으로 보면 대략 이런 구조다.

<select id="isSuspended" resultType="boolean">
  SELECT COUNT(*) > 0
  FROM tb_suspend_master
  WHERE member_id = #{memberId}
    AND use_yn = 'Y'
    <choose>
      <when test="suspendType != null">
        AND suspend_type = #{suspendType}
      </when>
      <otherwise>
        AND suspend_type = 'ACCOUNT'
      </otherwise>
    </choose>
</select>

suspendType을 넘기지 않으면 기본적으로 ACCOUNT만 본다. 이렇게 해두면 출금 정지 레코드가 로그인 차단으로 오작동하는 걸 방어할 수 있음. 타입 필터링 없이 전체 정지 레코드를 보는 구조였다면, 출금 정지 추가하는 순간 기존 로그인 로직이 오염됐을 거다.

업무 로직 계층 확장

서비스 계층에 메서드 3개를 추가했다.

  • isWithdrawalSuspended(memberId) - 회원 단건 출금 정지 여부
  • isWithdrawalSuspended(memberId, systemFlag) - 시스템 레벨 출금 정지 포함 오버로딩
  • getWithdrawalSuspendInfo() - 정지 상세 정보 조회

기존 isSuspended()를 그냥 재활용하고 싶은 유혹이 있었는데, 파라미터 하나 추가해서 분기하는 방식은 결국 메서드가 두 가지 역할을 섞어서 하게 된다. 호출부에서 타입을 매번 신경 써야 하고, 시간이 지나면 어디서 어떤 타입으로 부르는지 추적이 힘들어짐. 전담 메서드를 따로 두는 게 장기적으로 이득이다. 이름만 봐도 뭘 하는지 명확하고, 나중에 출금 정지 로직이 복잡해지더라도 격리된 상태로 확장할 수 있음.

API + UI 레이어

GET /api/pay/withdraw-check 엔드포인트를 신규로 구성했다. 회원 출금 정지와 시스템 레벨 출금 정지를 한 번에 체크해서 응답하는 역할. 기존 requestWithdraw 로직 앞에 이 사전 체크를 끼워넣었음.

사전 체크 패턴을 쓴 이유는 출금 요청 자체를 처리하기 전에 빠르게 블로킹할 수 있어서다. 무거운 출금 처리 로직 끝까지 갔다가 마지막에 정지 여부 확인해서 튕기는 건 낭비고, 사용자 입장에서도 응답이 늦어지는 경험이 생긴다. 또 사전 체크 API가 분리되어 있으면 프론트에서 페이지 진입 시점에 미리 상태를 물어볼 수 있다. 폼 다 채우고 제출 버튼 눌렀을 때 "출금 정지 상태입니다"가 뜨는 UX보다, 페이지 들어오자마자 frozen 상태를 보여주는 게 훨씬 낫다.

관리 화면 쪽은 list/create/history 핸들러에 id 파라미터를 추가해서 유형 필터링이 가능하도록 함. 관리자가 계정 정지 목록과 출금 정지 목록을 따로 볼 수 있어야 하니까. 프론트엔드 관리 화면에는 유형 필터 드롭다운, 테이블 내 유형 배지 컬럼을 추가했고, 등록 모달에서 정지 유형 선택 UI도 넣었다. 테이블 colspan도 10에서 11로 조정.

사용자 출금 페이지는 진입 시점에 신규 API를 호출해서 출금 정지 여부를 먼저 확인하고, 정지 상태면 frozen 아이콘 UI를 띄우고 출금 폼 자체를 숨긴다. 폼을 disabled로 두는 게 아니라 아예 숨기는 방식을 택했는데, disabled 상태의 폼은 사용자가 뭔가 할 수 있을 것 같은 착각을 줄 수 있다. 출금이 안 되는 상태라면 폼이 없는 게 더 명확한 커뮤니케이션이다.

마지막에 tn-components.css FAQ 아코디언 클래스명 버그 픽스도 같이 묶어서 처리했다. 작은 거지만 방치해두면 컴포넌트 재사용 때마다 걸린다.


이번 개편에서 배운 건, 단순한 boolean 플래그 구조는 요구사항이 늘어나기 시작하는 순간 금방 한계에 부딪힌다는 것. 처음부터 suspend_type 같은 유형 컬럼을 두고 설계했다면 이번 작업이 훨씬 가벼웠을 텐데. 물론 처음엔 한 종류만 있으니까 과설계처럼 보일 수도 있었겠지만, "정지"라는 개념 자체가 이미 여러 차원을 가질 수 있다는 걸 설계 단에서 인지했어야 했다.

쿼리 기본값 처리, 메서드 분리, 사전 체크 API 분리, 이 세 가지 결정이 나중에 유지보수 비용을 크게 줄일 거라 본다. 당장은 파일 수도 늘고 코드량도 늘어난 것처럼 보이지만, 각 레이어의 책임이 명확해졌음.

댓글 0

첫 댓글 달아줘.