Skip to content

[Feat/#166] 학생회비 납부 API 개편 - #167

Open
xeoxxn wants to merge 6 commits into
mainfrom
feat/#166-fee-payment-revision
Open

xeoxxn wants to merge 6 commits into
mainfrom
feat/#166-fee-payment-revision

Conversation

@xeoxxn

@xeoxxn xeoxxn commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

#️⃣연관된 이슈

🎯 해결하려는 문제가 무엇인가요?

학생회비 납부 기획과 화면(학생 앱·관리자 콘솔)이 바뀌었는데, 지금 API는 예전 흐름에 맞춰져 있습니다.

  • 금액을 학년·학기로 추정합니다. GET /v1/app/fee/me가 grade·semester를 받아 "(8학기 − 이수 학기) × 학기당 회비"로 계산합니다. 휴학·초과학기 등으로 실제 남은 학기와 어긋날 수 있고, 근거 자료도 받지 않습니다
  • 반려된 학생이 사유를 모르고, 다시 내는 흐름도 없습니다. 운영진 반려는 상태를 UNPAID로 되돌릴 뿐 안내 문구를 남길 곳이 없습니다
  • 처리한 건을 되돌릴 수 없습니다. 이미 처리된 요청은 FEE_REQUEST_ALREADY_REVIEWED로 막혀 있어, 잘못 처리하면 고칠 방법이 없습니다
  • 요청 이력이 남지 않습니다. 회원당 한 행이라 다시 요청하면 이전 요청을 덮어씁니다
  • 관리자 목록에 금액·인증 자료가 없습니다. 계좌와 금액 설정은 PUT /link, PUT /amount 두 API로 갈려 있는데, 화면은 한 폼에서 같이 저장합니다
  • 계좌가 설정되지 않았으면 500(FEE_TRANSFER_LINK_INVALID)이 납니다. 서버 오류가 아니라 설정 누락입니다

❓ 왜 해결해야 하나요?

바뀐 납부 흐름은 남은 학기 수 입력 + 학점이수현황 캡처 → 토스 송금 → "송금 완료했어요" 입니다. 학생이 실제로 결정하는 순간은 마지막 한 번이라 그때 요청을 한 번에 받아야 합니다. 운영진은 캡처와 금액을 보고 납부를 확인·반려·정정할 수 있어야 합니다.

또 후속 작업에서 회원의 납부 여부(members.is_fee_paid)를 member 도메인이 스스로 맞추게 할 예정이라, 납부 여부가 바뀌는 순간을 기록해 둬야 합니다.

⭐ 어떻게 해결했나요?

납부 흐름

  1. 학생이 남은 학기 수를 입력하면 GET /v1/app/fee/me?remainingSemesters=6이 금액과 토스 송금 링크를 계산해 줍니다. 아무것도 저장하지 않습니다
  2. 송금을 마친 학생이 POST /v1/app/fee로 남은 학기 수와 캡처 키를 보내면, 요청 시점 금액과 함께 새 요청 행이 바로 납부확인중(PENDING)으로 저장됩니다
  3. 운영진이 납부완료(PAID)나 확인필요(REJECTED, 안내 문구 필수)로 처리합니다. 처리한 건도 다시 바꿀 수 있습니다
  4. 확인필요를 받은 학생은 안내 문구를 보고 1번부터 다시 냅니다. 이전 요청은 이력으로 남고 새 행이 생깁니다

상태와 저장 (student_transfer_statuses)

  • 상태는 PENDING(납부확인중) / PAID(납부완료) / REJECTED(화면 표기 "확인필요")입니다. 한 번도 요청하지 않은 회원은 행이 없고, 앱 응답에서만 feeStatus: "UNPAID"로 내려갑니다
  • 요청당 한 행으로 쌓습니다. 추가 컬럼: remaining_semesters, proof_image_key, amount(요청 시점 금액 — 운영진이 학기당 회비를 바꿔도 그대로), admin_note, requested_at
  • 회원의 지금 상태 = 최신 요청(PK 최대) 입니다. 반려가 여러 개 쌓이고 최신 행 자체가 반려일 수 있어 상태로는 고를 수 없고, requested_at은 초 단위라 겹칠 수 있어 PK로 고릅니다
  • 새 요청은 요청이 없거나 최신 요청이 REJECTED일 때만 받습니다. 그 외에는 409 FEE_REQUEST_ALREADY_REQUESTED입니다
  • 동시 요청 방어: active_member_id = IF(status IN ('PENDING','PAID'), member_id, NULL) 생성 컬럼에 UNIQUE를 겁니다. 진행 중·완료 요청은 회원당 하나만 들어가고, 반려 행은 NULL이라 몇 개든 남습니다. 두 번째 INSERT의 제약 위반은 saveAndFlush + ConstraintViolationUtil로 409로 바꿉니다(사물함 신청과 같은 패턴)
  • 운영진은 회원의 최신 요청만 처리할 수 있습니다. 지난 요청이면 409 FEE_REQUEST_NOT_LATEST입니다. 지난 요청을 되살리면 진행 중 요청이 둘이 될 수 있어서입니다

사용자 API (/v1/app/fee)

엔드포인트 지금 (main) 이 PR
GET /me grade·semester로 계산한 송금 링크와 상태 remainingSemesters(선택, 1~8). 계좌·상태를 주고, 납부 전·확인필요인 학생이 학기 수를 보내면 금액·링크를 계산합니다. 납부확인중·납부완료면 요청 때 남긴 금액만 주고 링크는 주지 않습니다("또 보내라"로 읽히지 않게)
POST / grade·semester → 납부확인중 {remainingSemesters, proofImageKey} → 새 요청을 납부확인중으로 저장. 응답은 GET /me와 같은 FeeResponse
  • FeeResponse: studentTransferStatusId, feeStatus, bank, accountNo, remainingSemesters, transferAmount, transferLinkUrl, requestedAt, confirmedAt(납부완료일 때의 처리 시각), adminNote(확인필요일 때)
  • 캡처 키는 실재하는 파일인지, 본인이 올렸는지, FEE_TRANSCRIPT_IMAGE 카테고리인지까지 봅니다. 다른 용도로 올린 파일 키를 넘기는 것을 막기 위해서입니다. 이를 위해 FileCategory.FEE_TRANSCRIPT_IMAGE와 FileService.findByFileKey(+ files.file_key prefix 인덱스)를 추가했습니다
  • 다시 내도 이전 캡처를 지우지 않습니다. 반려된 이전 요청이 계속 가리키기 때문입니다

관리자 API (/v1/admin/fee)

엔드포인트 변경
GET /requests 회원별 최신 요청만 보여줍니다. 응답에 isStreamMember, remainingSemesters, transferAmount(요청 시점 값), proofImageUrl, confirmedAt, adminNote를 추가하고, status → feeStatus로 바꿨습니다
PATCH /requests/{studentTransferStatusId} {status, adminNote}를 받습니다. 처리한 건도 다시 바꿀 수 있고 PAID도 종결 상태가 아닙니다. REJECTED는 안내 문구 1~255자가 필수입니다. 최신 요청만 바꿀 수 있습니다
GET /account 신규. 은행·계좌번호·학기당 회비와 금액 없는 기본 송금 링크. 설정 전이면 은행·계좌는 null, 회비는 기본값입니다
PUT /account PUT /link와 PUT /amount를 대체합니다. 은행은 허용 목록 22개(FeeBanks), 계좌번호는 숫자 10~14자리, 회비는 1 이상이어야 합니다
  • 상태 처리와 payers 명부 반영은 지금처럼 한 트랜잭션입니다

납부 여부 변경을 아웃박스에 기록

  • core:common에 MemberFeePaid, MemberFeePaymentCanceled 이벤트를 추가했습니다 (memberId, studentTransferStatusId, occurredAt)
  • 운영진 처리로 납부 여부가 바뀔 때만 남깁니다. PENDING → PAID는 MemberFeePaid, PAID → REJECTED·PENDING(정정)은 MemberFeePaymentCanceled이고, 나머지 전이는 남기지 않습니다
  • 상태 UPDATE · 아웃박스 INSERT · payers 동기화가 AdminFeeReviewUseCase 한 트랜잭션에서 함께 커밋·롤백됩니다. OutboxWriter는 트랜잭션 밖에서 부르면 바로 실패합니다

에러 코드 (FeeErrorCode)

  • 추가: INVALID_REMAINING_SEMESTERS, INVALID_PROOF_IMAGE, INVALID_BANK, INVALID_ACCOUNT_NO, INVALID_TRANSFER_AMOUNT, INVALID_ADMIN_NOTE(400), FEE_ACCOUNT_NOT_FOUND(404), FEE_REQUEST_ALREADY_REQUESTED, FEE_REQUEST_NOT_LATEST(409)
  • 제거: FEE_REQUEST_ALREADY_REVIEWED, FEE_TRANSFER_LINK_INVALID(500 → 계좌 미설정은 FEE_ACCOUNT_NOT_FOUND 404)
  • 항목마다 전용 코드를 내려줘야 해서 요청 검증은 Bean Validation 대신 직접 합니다(애노테이션으로는 공통 INVALID_INPUT이 됩니다)

마이그레이션 (V20~V26)

스택에서 만든 파일을 그대로 옮겼습니다. 아직 배포되지 않은 번호입니다.

파일 하는 일
V20 납부 정보 컬럼(remaining_semesters, proof_image_key, admin_note, requested_at) 추가, files.file_key prefix(255) 인덱스
V21 전 회원에게 UNPAID 행을 만듭니다 — V25에서 다시 지웁니다(중간 설계의 흔적)
V23 NEEDS_CHECK → REJECTED 이름 변경 (main 데이터에는 해당 행이 없습니다)
V24 amount BIGINT 추가
V25 UNPAID 정리: 운영진이 처리한 적 있는 행(main에서 반려된 행)은 REJECTED로 남기고, 나머지(V21이 만든 행)는 지웁니다
V26 회원당 유니크 제거, (member_id, student_transfer_status_id) 인덱스, 진행 중 요청 유니크(생성 컬럼)

main 데이터 기준 순효과: PENDING·PAID 행은 그대로이고, 운영진이 반려해 UNPAID가 된 행은 REJECTED(안내 문구 없음)가 됩니다. 새 컬럼은 기존 행에서 NULL입니다.

커밋 구성

최종 상태 기준으로 작업 단위 6개로 다시 쌓았습니다. 3번에서 서비스 시그니처가 바뀌므로 3·4번만 따로 체크아웃하면 api 모듈이 컴파일되지 않고, 5번부터 전체가 맞습니다.

  1. chore: .env를 git 추적에서 제외
  2. feat: 학점이수현황 캡처 파일 카테고리와 키 조회 추가
  3. feat: 학생회비 납부 요청을 학기 인증과 함께 이력으로 쌓도록 개편 — 도메인·DB·마이그레이션
  4. feat: 학생회비 납부 사용자 API 개편
  5. feat: 학생회비 납부 관리자 API 개편
  6. feat: 운영진 처리로 납부 여부가 바뀌면 아웃박스에 기록

🧩 이 PR의 한계 & 트레이드오프

  • 아웃박스 리스너가 아직 없습니다. 이벤트는 기록되자마자 릴레이가 발행 완료로 처리하고 7일 뒤 지워집니다. 후속 member PR(members.is_fee_paid 컬럼·리스너)에 회원별 최신 요청이 PAID인지로 채우는 백필이 반드시 필요합니다
  • 관리자 목록에는 납부를 요청한 회원만 나옵니다. 관리자 콘솔에도 "납부 전" 상태가 없습니다. 한 회원의 요청 이력을 보는 API도 아직 없습니다(화면에도 없음)
  • 기존 행은 amount·remaining_semesters가 비어 있어 금액이 null로 보이고, V25로 REJECTED가 된 행은 안내 문구가 없습니다
  • 금액은 요청 시점에 서버가 다시 계산합니다. 학생이 링크를 받은 뒤 요청하기 전에 운영진이 학기당 회비를 바꾸면, 저장 금액과 실제 송금액이 다를 수 있습니다(운영진이 캡처와 대조)
  • 지난 요청의 인증 캡처(학번·이름이 찍힌 이미지)는 계속 남고, 요청이 막혀 아무도 가리키지 않는 캡처를 지우는 배치도 없습니다. 보관 기간·정리 정책이 필요하면 따로 정해야 합니다
  • FeeAccount record가 app-api와 admin-api에 각각 있습니다. common-api는 welfare·internal을 모르는 모듈이라 공유할 자리가 없습니다. 설정 키(FeeConfigKeys)는 하나를 같이 써서 값이 어긋나지는 않습니다
  • 목록의 가입 여부(isStreamMember)를 행마다 memberService.isSignedUp으로 묻습니다. 목록이 커지면 member 쪽 벌크 조회가 필요합니다
  • 검증 범위: ./gradlew check 통과 — ModularityTests · DomainImplAccessTests, 서버 전체 컨텍스트를 띄우는 RentalApplyConcurrencyTest(MySQL 컨테이너에 Flyway V26까지 적용, ddl-auto=validate 통과. R2 환경변수는 연결하지 않는 더미 값) 포함. 회비 쪽 단위 테스트는 없고, 동시 요청의 409 변환과 이벤트가 outbox_events에 쌓이는지는 테스트로 확인하지 않았습니다

⛓️ 기존 기능에 미치는 영향

  • 앱·관리자 API 계약이 바뀝니다. 프론트와 배포 순서를 맞춰야 합니다
    • 앱: GET /me 파라미터(grade·semester → remainingSemesters)와 응답, POST / 요청·응답
    • 관리자: PUT /link·PUT /amount 제거 → PUT /account, GET /account 신규, 목록·처리 응답 필드 변경, 상태 문자열 UNPAID는 400이고 REJECTED가 새로 생깁니다
  • student_transfer_statuses에 같은 회원의 행이 여러 개 생깁니다. 이 테이블을 회원 기준으로 읽는 곳은 모두 최신 요청 기준으로 바꿨습니다. payers는 그대로 회원당 1행입니다
  • 납부 요청 저장이 saveAndFlush라 INSERT·UPDATE가 즉시 나갑니다(같은 트랜잭션이라 원자성은 그대로입니다)
  • 운영진 처리 트랜잭션에 outbox_events INSERT가 최대 1건 늘어납니다

🔀 Edge Case & 실패 시나리오

상황 동작
계좌 미설정 상태에서 GET /me·POST / 404 FEE_ACCOUNT_NOT_FOUND (저장 전에 막음)
남은 학기 수가 없거나 1~8 밖 400 INVALID_REMAINING_SEMESTERS
남의 파일·다른 용도로 올린 파일 키 400 INVALID_PROOF_IMAGE
PENDING·PAID인데 다시 POST 409 FEE_REQUEST_ALREADY_REQUESTED
같은 회원이 동시에 두 번 요청 하나만 저장되고 나머지는 409
반려 → 재납부 → 반려 → 재납부 REJECTED 2행 + PENDING 1행. 화면·목록은 최신 행
PENDING·PAID인데 GET /me에 remainingSemesters 무시하고 요청 때 남긴 값을 보여줌
운영진이 지난 요청 ID로 PATCH 409 FEE_REQUEST_NOT_LATEST
REJECTED로 바꾸며 안내 문구가 없거나 255자 초과 400 INVALID_ADMIN_NOTE
PAID → PAID, REJECTED → REJECTED(문구 수정) 저장되고 이벤트 없음
납부완료 → 정정 → 다시 납부완료 MemberFeePaid → MemberFeePaymentCanceled → MemberFeePaid 3건 (occurredAt으로 순서 판별)
payers 동기화 실패 상태 변경·아웃박스 행이 함께 롤백
400·409로 막힌 요청이 올린 캡처 어느 요청도 가리키지 않는 파일로 남습니다. 지금은 이런 파일을 정리하는 배치가 없습니다

📋 검토한 대안과 선택 이유

  • 2단계 요청(정보 제출 → 송금 확인 요청): "제출만 하고 이탈한" 상태를 따로 저장해야 하고, 링크 계산만 하면 될 단계를 저장하게 됩니다
  • 회원당 한 행 + 덮어쓰기: 반려 이력이 사라지고, 이벤트가 "어느 요청이 납부 여부를 바꿨는지"를 가리킬 수 없습니다
  • 락으로 동시 요청 방어: 두 요청 모두 새 행을 넣기만 하고 첫 요청은 잠글 행이 없어 낙관적 락은 쓸 수 없습니다. 비관적 락(갭 락)은 교착으로 끝나기 쉬워 생성 컬럼 유니크를 골랐습니다
  • is_latest 플래그 컬럼: 새 행마다 이전 행 UPDATE가 따라와 쓰기가 둘이 되고 서로 어긋날 여지가 생깁니다
  • members에 회비 상태 사본 두기: 주인이 아닌 member에 welfare 상태를 심고, 같은 트랜잭션에서 맞출 책임을 양쪽에 지웁니다. 대신 아웃박스 이벤트로 member가 스스로 맞추게 합니다
  • 이벤트 하나에 boolean paid / 상태가 바뀔 때마다 발행: member는 납부 여부만 알면 되고, "냈다 / 취소됐다"가 각각 하나의 사실로 읽히는 쪽을 골랐습니다
  • 마이그레이션을 최종 스키마 하나로 합치기: V21 생성 → V25 삭제 같은 흔적은 사라지지만, 최종 상태에 없던 변경을 새로 검증해야 해서 그대로 옮겼습니다

💬 리뷰 포인트

  • [r] 앱·관리자 API 계약 변경 — 프론트 배포 순서
  • [r] 마이그레이션 번호 — V22는 [Feat/#139] 열린피드백 관리자 API 추가 #140(열린피드백)이 씁니다. Flyway outOfOrder가 꺼져 있어 이 PR이 먼저 머지되면 #140의 V22를 뒤 번호로 옮겨야 합니다(#140이 먼저면 그대로 됩니다)
  • [c] 생성 컬럼 유니크로 동시 요청을 막는 방식과, 최신 요청만 처리하게 한 것
  • [c] 이벤트를 둘로 나눈 것과 occurredAt에 LocalDateTime을 쓴 것
  • [a] FeeAccount 중복 — common-api로 올릴지, 지금처럼 둘지

@xeoxxn xeoxxn self-assigned this Oct 25, 2026
@coderabbitai

coderabbitai Bot commented Oct 25, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

Run configuration
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant