Skip to content

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

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

xeoxxn wants to merge 17 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)이 납니다

❓ 왜 해결해야 하나요?

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

⭐ 어떻게 해결했나요?

납부 흐름

  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, amount(요청 시점 금액), proof_image_key, reject_reason. 요청 시각은 행이 생긴 시각(created_at)으로 갈음합니다
  • 회원의 지금 상태 = 최신 요청(PK 최대) 입니다. 반려가 여러 개 쌓이고 최신 행 자체가 반려일 수 있어 상태로는 고를 수 없고, created_at은 초 단위라 겹칠 수 있어 PK로 고릅니다
  • 새 요청은 요청이 없거나 최신 요청이 REJECTED일 때만 받습니다. 그 외에는 409 FEE_REQUEST_ALREADY_REQUESTED입니다
  • 동시 요청 방어: active_member_id = IF(status IN ('PENDING','PAID'), member_id, NULL) 생성 컬럼에 UNIQUE를 겁니다. 진행 중·완료 요청은 회원당 하나만 들어가고 반려 행은 몇 개든 남습니다. 두 번째 INSERT의 제약 위반은 saveAndFlush + ConstraintViolationUtil로 409로 바꿉니다(사물함 신청과 같은 패턴)
  • 운영진은 회원의 최신 요청만 처리할 수 있습니다. 지난 요청이면 409 FEE_REQUEST_NOT_LATEST입니다

도메인 객체 (fee/domain)

  • RemainingSemesters: 남은 학기 수(1~8) 검증과 금액 계산(남은 학기 수 × 학기당 회비)
  • FeeAccount: 납부 계좌·학기당 회비와 허용 은행 22개. 운영진이 저장할 때(create)만 은행을 검증하고, 저장된 설정을 읽을 때(of)는 복원만 합니다(예전 PUT /link가 은행 목록을 검사하지 않아서입니다)
  • TransferGuide: 학생에게 지금 안내할 남은 학기 수·금액·송금 링크. 조회할 때마다 계산하고 저장하지 않습니다
  • FeeSubmitCommand: 납부 요청(남은 학기 수 + 캡처 키)
  • StudentTransferStatus: 다시 낼 수 있는지(isRequestable), 납부 확인 시각(getConfirmedAt), 반려 사유 검증(확인필요면 필수·255자 이하, 앞뒤 공백 제거)을 스스로 판단합니다

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

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

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

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

입력 검증과 에러 코드

종류 예 어디서 응답 코드
입력 형식 필수값, 계좌번호 형식, 양수, 길이 Request record 어노테이션 + @Valid INVALID_INPUT
도메인 정책 남은 학기 1~8, 허용 은행 RemainingSemesters, FeeAccount INVALID_REMAINING_SEMESTERS, INVALID_BANK
상태에 따른 규칙 확인필요면 반려 사유 필수 StudentTransferStatus.review INVALID_REJECT_REASON
다른 도메인 조회 캡처 파일의 존재·업로더·카테고리 FeeSubmitUseCase INVALID_PROOF_IMAGE
  • 추가: 위 네 코드와 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)

마이그레이션 (V23~V25)

파일 하는 일
V23__add_file_key_index_to_files files.file_key prefix(255) 인덱스
V24__add_request_columns_and_active_unique_to_student_transfer_statuses 새 컬럼 4개, status의 DEFAULT 'UNPAID' 제거, (member_id) 인덱스, 회원당 유니크 → 진행 중 요청 유니크(생성 컬럼)
V25__convert_unpaid_transfer_statuses_to_rejected 기존 UNPAID 정리. 운영진이 처리한 적 있는 행(main에서 반려된 행)은 REJECTED + 기본 반려 사유, 나머지는 삭제

main 데이터 기준 순효과: PENDING·PAID 행은 그대로이고, 반려돼 UNPAID가 된 행은 REJECTED(기본 반려 사유)가 됩니다. MySQL 8.4 컨테이너에 main 상태 샘플 데이터를 넣고 적용해 결과를 확인했습니다.

커밋 구성

커밋 내용
f77f80a 학점이수현황 캡처 파일 카테고리와 키 조회
fc3f1d7 납부 요청 이력화 + 사용자·관리자 API 개편
0b34f7f 납부 여부 변경 아웃박스 기록 (3712c94에서 뺌, 하지 않기로 함)
3d4c93d ~ abb0122 리뷰 반영 (각 리뷰 코멘트에 커밋을 달아 두었습니다)

모든 커밋은 하나씩 체크아웃해서 ./gradlew check가 통과하는 것을 확인했습니다.

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

  • 납부 여부 변경 이벤트(아웃박스)와 members.is_fee_paid는 만들지 않기로 했습니다. 납부 여부는 지금처럼 payers 명부로 판정하고, 조회 부담이 생기면 나중에 Redis 캐싱을 검토합니다
  • 회비 납부 확인은 아직 대여에만 있습니다. 대여는 지금처럼 RentalApplyUseCase가 PayerService.validatePayer로 확인합니다. 아래 두 곳도 신청할 때 회비 납부를 확인해야 하는데, 이 PR에는 없습니다
    • 사물함 신청: 회비를 낸 회원만 신청할 수 있습니다
    • 행사 신청: 회비를 낸 회원만 신청할 수 있는 행사는 신청할 때 납부를 확인해야 합니다. 지금 행사에는 이를 구분하는 값이 없습니다(target은 자유 문구)
    • event 도메인은 welfare를 모르므로, 두 신청 모두 app-api에 UseCase를 두고 PayerService.validatePayer를 조합해야 합니다(지금은 컨트롤러가 서비스를 직접 부릅니다)
  • 관리자 목록에는 납부를 요청한 회원만 나옵니다. 관리자 콘솔에도 "납부 전" 상태가 없습니다. 한 회원의 요청 이력을 보는 API도 아직 없습니다
  • 기존 행은 amount·remaining_semesters가 비어 있어 금액이 null로 보입니다
  • 금액은 요청 시점에 서버가 다시 계산합니다. 링크를 받은 뒤 요청 전에 운영진이 학기당 회비를 바꾸면 저장 금액과 실제 송금액이 다를 수 있습니다(운영진이 캡처와 대조)
  • 지난 요청의 인증 캡처(학번·이름이 찍힌 이미지)는 계속 남고, 요청이 막혀 아무도 가리키지 않는 캡처를 지우는 배치도 없습니다. 캡처 URL은 다른 이미지처럼 공개 스토리지 주소로 내려갑니다. 보관 기간·접근 정책이 필요하면 따로 정해야 합니다
  • 검증 범위: 커밋마다 ./gradlew check(ModularityTests · DomainImplAccessTests, 서버 전체 컨텍스트를 띄우는 RentalApplyConcurrencyTest — MySQL 컨테이너에 Flyway V25까지 적용, ddl-auto=validate). R2 환경변수는 연결하지 않는 더미 값입니다. 회비 쪽 단위 테스트와 동시 요청 409 변환 테스트는 없습니다

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

  • 앱·관리자 API 계약이 바뀝니다. 프론트와 배포 순서를 맞춰야 합니다
    • 앱: GET /me 파라미터(grade·semester → remainingSemesters)와 응답, POST / 요청·응답
    • 관리자: PUT /link·PUT /amount 제거 → PUT /account, GET /account 신규, 목록·처리 응답 필드 변경, 상태 문자열 UNPAID는 400이고 REJECTED가 새로 생깁니다
    • 형식 오류는 INVALID_INPUT + 필드 메시지로 내려갑니다
  • student_transfer_statuses에 같은 회원의 행이 여러 개 생깁니다. 이 테이블을 회원 기준으로 읽는 곳은 모두 최신 요청 기준으로 바꿨습니다. payers는 그대로 회원당 1행입니다
  • member에 findSignedUpIds(가입 완료 회원 ID 일괄 조회)가 추가됩니다. 기존 isSignedUp과 같은 기준입니다
  • 마이그레이션 번호: V22는 [Feat/#139] 열린피드백 관리자 API 추가 #140(열린피드백)이 씁니다. #140을 먼저 머지한 뒤 이 PR을 머지해야 합니다. 예전 V20~V26을 적용한 로컬 DB는 flyway clean이 필요합니다

🔀 Edge Case & 실패 시나리오

상황 동작
계좌 미설정 상태에서 GET /me·POST / 404 FEE_ACCOUNT_NOT_FOUND (저장 전에 막음)
남은 학기 수가 1~8 밖 400 INVALID_REMAINING_SEMESTERS
필수값 누락·형식 오류·숫자가 아닌 값 400 INVALID_INPUT
남의 파일·다른 용도로 올린 파일 키 400 INVALID_PROOF_IMAGE
PENDING·PAID인데 다시 POST 409 FEE_REQUEST_ALREADY_REQUESTED
같은 회원이 동시에 두 번 요청 하나만 저장되고 나머지는 409
반려 → 재납부 → 반려 → 재납부 REJECTED 2행 + PENDING 1행. 화면·목록은 최신 행
운영진이 지난 요청 ID로 PATCH 409 FEE_REQUEST_NOT_LATEST
REJECTED로 바꾸며 반려 사유가 없거나 255자 초과 400 INVALID_REJECT_REASON
목록에 탈퇴 회원의 요청이 섞임 "(탈퇴한 회원)"·isSignedUp: false로 정상 표시
운영진이 확인필요 건을 바꾸는 순간 학생이 새 요청 늦게 저장되는 쪽이 409 FEE_REQUEST_ALREADY_REQUESTED
payers 동기화 실패 상태 변경이 함께 롤백

📋 검토한 대안과 선택 이유

  • 2단계 요청(정보 제출 → 송금 확인 요청): "제출만 하고 이탈한" 상태를 저장해야 하고, 링크 계산만 하면 될 단계를 저장하게 됩니다
  • 회원당 한 행 + 덮어쓰기: 반려 이력이 사라집니다
  • 락으로 동시 요청 방어: 두 요청 모두 새 행을 넣기만 하고 첫 요청은 잠글 행이 없어 낙관적 락은 쓸 수 없고, 비관적 락은 갭 락 교착 위험이 있어 생성 컬럼 유니크를 골랐습니다
  • 설정값을 읽을 때도 은행 검증: 기존에 목록 밖 은행이 저장돼 있으면 학생 조회까지 막혀서, 저장할 때만 검증합니다

💬 리뷰 포인트

  • [r] 앱·관리자 API 계약 변경 — 프론트 배포 순서
  • [r] #140을 먼저 머지해야 마이그레이션 번호가 맞습니다
  • [c] member findSignedUpIds 추가 — 기존 hasAgreedRequiredTerms와 같은 판정을 쓰도록 공통 부분만 묶었습니다

@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.

@tnals0924 tnals0924 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR 설명이 꼼꼼해서 스택으로 나뉘어 있던 작업을 한 번에 따라가기 편했어요. 감사합니다!

꼭 반영이 필요해 보이는 건 아래 네 가지예요. 자세한 내용은 각 줄 코멘트에 적었습니다.

  1. 탈퇴 회원의 요청이 페이지에 있으면 관리자 목록이 통째로 404가 나요 (AdminFeeSearchUseCase, isStreamMember가 아직 필요한지도 여쭤봤어요)
  2. 상태 변경 요청에 status가 없으면 400이 아니라 500이 나요 (FeeStatusUpdateRequest)
  3. 기존 행의 requestedAt이 모두 null로 내려가요 (V20)
  4. 입력값 검증은 컨벤션(coding-style.md 3절)대로 Request record의 Bean Validation으로 하고, 도메인 정책 검증은 VO로 옮기면 좋겠어요 (FeeSubmitRequest)

리뷰 포인트 주신 내용에 대한 의견은 해당 줄에 달아 두었어요.

  • 프론트 배포 순서는 말씀하신 대로 맞추면 될 것 같아요. 다만 4번을 반영하면 형식 오류의 응답 코드도 INVALID_INPUT으로 바뀌니, 프론트에 같이 공유해 주시면 좋겠습니다.

그 밖에 사소한 것 두 가지예요.

  • [a] 3·4번 커밋이 단독으로 컴파일되지 않는다고 적어주셨는데, 저희가 실제로는 Merge commit으로 머지하고 있어서 그 커밋들이 main 이력에 그대로 남아요. 나중에 git bisect를 할 때 걸릴 수 있으니, 수정하시면서 3~5번을 컴파일되는 단위로 다시 묶어주시면 좋을 것 같아요.
  • [a] .env gitignore 커밋은 회비 작업과 관계가 없어서, 따로 올리면 이력이 더 깔끔할 것 같습니다.

membersById.get(transferStatus.getMemberId()),
// 가입 여부는 member가 아는 사실이라 member의 공개 메서드에 묻는다.
// 줄마다 한 번씩 부르므로 목록이 커지면 벌크 조회를 member 쪽에 요청할 자리다
memberService.isSignedUp(transferStatus.getMemberId())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[r] isSignedUp이 내부에서 getById를 거쳐 findByIdAndIsDeletedFalse로 조회하고 있어요. 그래서 탈퇴(소프트 삭제)한 회원이면 MEMBER_NOT_FOUND가 나요. AdminFeeSearchResponse는 member == null을 placeholder로 처리하도록 되어 있는데 이 줄에서 먼저 터지다 보니, 검색어 없이 목록을 열었을 때 탈퇴 회원의 요청이 한 건만 섞여도 페이지 전체가 404가 될 것 같아요. 행마다 회원과 약관을 다시 조회해서 한 페이지에 쿼리가 40개쯤 더 나가는 것도 있고요.

그런데 고치기 전에 isStreamMember가 지금 목록에 꼭 필요한지 여쭤보고 싶어요. #126에서는 목록이 회원 기준이라 요청하지 않은 회원, 이관만 되고 가입하지 않은 회원까지 나와서 구분할 의미가 있었던 것 같아요. 이번 PR에서는 납부를 요청한 회원만 나오는데, 요청하려면 앱에 로그인해야 하고 앱은 로그인 응답의 signUpRequired로 가입 화면부터 보여주니 거의 항상 true일 것 같아요. false가 되는 건 필수 약관이 늘어 재동의 전인 경우 정도인데, 이건 납부 확인과는 관계가 없어 보이고요.

화면에서 이 컬럼이 빠져도 된다면 필드와 이 조회를 같이 지우는 게 가장 간단하고, 위 두 문제도 함께 없어져요. 꼭 필요하다면 member 쪽에 가입을 마친 회원 ID를 한 번에 돌려주는 메서드(예: findSignedUpIds(List<Long>))를 두고 탈퇴 회원은 false로 처리해 주시면 좋겠어요. 그리고 회비 목록에서 isStreamMember는 "학생회 회원(=납부자)"으로도 읽힐 수 있어서, 남긴다면 isSignedUp처럼 판정 기준이 드러나는 이름이 나을 것 같습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2eb5ef1, 6b98eb3 수정 완료했습니다!

가입 여부는 화면에 필요해서 남기고, member에 findSignedUpIds를 추가해 한 페이지의 회원을 한 번에 조회하도록 바꿨어요. 탈퇴 회원은 false로 내려가고, 필드 이름도 말씀대로 isSignedUp으로 바꿨습니다.

@NotBlank(message = "처리 상태를 입력해 주세요.")
String status
@Schema(description = "변경할 납부 상태", example = "REJECTED")
String status,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[r] 기존 @NotBlank가 빠지면서 status 없이 요청이 오면 500이 나는 것 같아요. 흐름은 이래요.

  1. TransferStatus.from(null)은 목록 필터용으로 만든 거라 null을 돌려줘요.
  2. validateAdminNote는 REJECTED가 아니라서 그냥 통과해요.
  3. 도메인 review(null, ...)가 상태를 null로 바꿔요.
  4. 저장할 때 status NOT NULL 위반이 나는데, 유니크 제약 위반이 아니라서 throw e로 그대로 나가요.

main에서는 400 INVALID_INPUT이었어요. 어노테이션을 다시 붙여주시면 좋겠습니다.

public record FeeStatusUpdateRequest(
    @NotBlank(message = "처리 상태를 입력해 주세요.")
    String status,

    String adminNote
) {}

컨트롤러에도 @Valid를 다시 붙여주시면 됩니다. 안내 문구는 상태에 따라 필수 여부가 달라져서 어노테이션보다 validate~ 메서드가 맞을 것 같아요. 둘 자리는 AdminFeeReviewUseCase 코멘트에 따로 적었어요.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3d4c93d 수정 완료했습니다!

공지 카테고리처럼 status를 TransferStatus enum + @NotNull로 받도록 했어요. 없는 값이면 본문을 읽는 단계에서 INVALID_INPUT으로 막힙니다.

ADD COLUMN remaining_semesters INT NULL,
ADD COLUMN proof_image_key VARCHAR(255) NULL,
ADD COLUMN admin_note VARCHAR(255) NULL,
ADD COLUMN requested_at DATETIME NULL;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[r] 예전 응답은 요청 시각으로 created_at을 썼는데, 이제는 requested_at만 읽어요(FeeResponse.java:53, AdminFeeSearchResponse.java:49). 이 컬럼이 백필 없이 NULL로 추가되다 보니 기존 행은 전부 requestedAt: null로 내려가요. 배포 시점에 처리를 기다리던 PENDING 건도 관리자 목록에서 요청일이 비어 보일 것 같아요. PR 한계에 amount·remaining_semesters는 적어주셨는데 이건 빠져 있어서 말씀드려요.

이번 PR부터는 요청마다 새 행을 쌓으니 requested_at은 항상 created_at과 같은 값이에요. 지워진 주석에도 "requested_at 컬럼을 두지 않기로 했다(ERD 기준)"고 되어 있었고요. 그때는 한 행을 재사용해서 필요했던 컬럼인데, 지금 구조에서는 오히려 없어도 될 것 같아요. 컬럼을 빼고 created_at을 쓰는 방향을 제안드려요. 남기신다면 UPDATE student_transfer_statuses SET requested_at = created_at WHERE requested_at IS NULL로 백필해 주시면 좋겠습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

497a897, 3c2bd38 수정 완료했습니다!

requested_at 컬럼을 빼고, 응답의 requestedAt은 created_at에서 채우도록 했어요.

* 검증에 Bean Validation을 쓰지 않는다 — 명세가 항목마다 전용 에러 코드를 요구하는데
* 애노테이션으로는 공통 INVALID_INPUT이 되기 때문이다(coding-style 검증 규칙, fee-domain-study 08번 문서).
*/
public record FeeSubmitRequest(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[r] 입력값 검증은 coding-style.md 3절대로 Request record에 Bean Validation 어노테이션을 붙이면 좋겠어요. 항목마다 전용 코드를 주고 싶으신 마음은 이해하는데, 다른 API는 모두 INVALID_INPUT과 필드 메시지로 가고 있어서 회비만 다르면 프론트도 두 방식을 같이 다뤄야 하더라고요. 주석에 근거로 적어주신 "fee-domain-study 08번 문서"도 저장소에는 없는 문서라(docs/plans가 gitignore 대상이에요) 다른 분들이 찾아볼 수 없어요.

기준은 이렇게 나누면 어떨까요?

  • 입력 형식(필수·길이·패턴·양수): Request record 어노테이션 → INVALID_INPUT
  • 도메인 정책(남은 학기 1~8, 허용 은행 목록): core:domain VO의 정적 팩토리에서 검증 → FeeErrorCode
  • 상태에 따라 필수 여부가 달라지는 규칙(확인필요면 안내 문구 필수): 도메인 객체 안의 validate~ 메서드 (AdminFeeReviewUseCase 코멘트)
  • 다른 도메인을 조회해야 하는 검증(인증 이미지의 존재·업로더·카테고리): 지금처럼 UseCase

남은 학기 수는 예전 GradeSemester나 FeedbackPeriod.of처럼 정적 팩토리에서 검증하는 VO로 두면, 금액 계산까지 한 곳에 모을 수 있어서 FeeAmountCalculator를 지울 수 있을 것 같아요.

// core:domain:welfare — fee/domain
public record RemainingSemesters(int value) {

    private static final int MIN = 1;
    private static final int MAX = 8;

    public static RemainingSemesters of(int value) {
        validateRange(value);
        return new RemainingSemesters(value);
    }

    public long calculateAmount(long amountPerSemester) {
        return (long) value * amountPerSemester;
    }

    private static void validateRange(int value) {
        if (value < MIN || value > MAX) {
            throw new BusinessException(FeeErrorCode.INVALID_REMAINING_SEMESTERS);
        }
    }
}
public record FeeSubmitRequest(
    @NotNull(message = "남은 학기 수를 입력해 주세요.")
    Integer remainingSemesters,

    @NotBlank(message = "학점이수현황 캡처를 올려 주세요.")
    @Size(max = 255, message = "이미지 키는 255자 이하여야 합니다.")
    String proofImageKey
) {
    public FeeSubmitCommand toCommand() {
        return new FeeSubmitCommand(RemainingSemesters.of(remainingSemesters), proofImageKey);
    }
}

컨트롤러에는 @Valid @RequestBody를 붙여주시면 됩니다. @NotBlank로 막으면 FeeSubmitUseCase.validateProofImage의 null·공백 검사도 지워도 될 것 같아요.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3d4c93d, b673e59 수정 완료했습니다!

형식은 어노테이션, 남은 학기 범위는 RemainingSemesters VO, 요청은 FeeSubmitCommand로 나눴고 FeeAmountCalculator는 지웠습니다.

* 검증에 Bean Validation을 쓰지 않는다 — 항목마다 전용 에러 코드를 내려줘야 하는데
* 애노테이션으로는 공통 INVALID_INPUT이 되기 때문이다(fee-domain-study 08번 문서).
*/
public record FeeAccountUpdateRequest(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[c] FeeSubmitRequest 코멘트와 같은 기준으로, 형식 검증은 어노테이션으로 옮기고 FeeAccountValidator는 지워도 될 것 같아요.

public record FeeAccountUpdateRequest(
    @NotBlank(message = "은행을 선택해 주세요.")
    String bank,

    @NotBlank(message = "계좌번호를 입력해 주세요.")
    @Pattern(regexp = "^\\d{10,14}$", message = "계좌번호는 하이픈 없이 숫자 10~14자리로 입력해 주세요.")
    String accountNo,

    @NotNull(message = "학기당 회비를 입력해 주세요.")
    @Positive(message = "학기당 회비는 1원 이상이어야 합니다.")
    Integer amount
) {}

허용 은행 22개는 토스 송금 링크와 묶인 도메인 정책이라, FeeAccount 코멘트처럼 VO 쪽에 두면 좋겠습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영하도록 하겠습니다!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3d4c93d, c7e81f8 수정 완료했습니다!

-- UNPAID 행 중 운영진이 처리한 적 있는 행(reviewed_at이 있음)은 돌려보낸 요청이었으므로 REJECTED로 남기고,
-- 처리된 적 없는 행(V21이 전 회원에게 만든 행, 제출만 하고 확인 요청을 안 한 행)은 지운다.
UPDATE student_transfer_statuses
SET status = 'REJECTED'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[c] main에서 반려된 행이 여기서 문구 없이 REJECTED가 되다 보니, 앱에서는 "확인필요"인데 안내 문구가 비어 보이고 프론트도 그 경우를 따로 처리해야 할 것 같아요. 새로 처리하는 반려는 항상 문구가 있으니, 기존 행에도 기본 문구를 채워두면 "REJECTED면 문구가 있다"가 모든 행에서 성립해요.

UPDATE student_transfer_statuses
SET status = 'REJECTED',
    admin_note = '운영진이 납부를 확인하지 못했어요. 학생회에 문의해 주세요.'
WHERE status = 'UNPAID'
  AND reviewed_at IS NOT NULL;

문구는 학생회나 기획 쪽과 맞춰서 정하시면 될 것 같고, 마이그레이션을 합치신다면 합친 데이터 마이그레이션에 넣어주시면 됩니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3c2bd38 수정 완료했습니다!

우선 제안해 주신 문구로 넣었고, 학생회와 문구를 맞춰 보고 필요하면 바꾸겠습니다.


-- 최신 요청 조회용. 회원당 유니크 인덱스를 지우기 전에 먼저 만들어 member_id 조회가 인덱스를 잃지 않게 한다.
CREATE INDEX idx_student_transfer_statuses_member_id_id
ON student_transfer_statuses (member_id, student_transfer_status_id);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a] InnoDB는 보조 인덱스 끝에 PK를 자동으로 붙여서 (member_id)만으로도 같은 효과가 나요(flyway-migration.md 3-4절 노트). 이름도 idx_student_transfer_statuses_member_id로 맞추고, StudentTransferStatusJpaEntity의 @Index도 같이 바꿔주시면 될 것 같아요.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3c2bd38 수정 완료했습니다!

-- 낙관적 락으로는 막을 수 없다 — 두 요청 모두 기존 행을 고치지 않고 새 행을 넣기만 하고, 첫 요청은 행 자체가 없다.
ALTER TABLE student_transfer_statuses
ADD COLUMN active_member_id BIGINT
GENERATED ALWAYS AS (IF(status IN ('PENDING', 'PAID'), member_id, NULL)) VIRTUAL;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a] 리뷰 포인트 주신 생성 컬럼 유니크와 "최신 요청만 처리" 조합은 좋아 보여요. 최신 요청만 바꿀 수 있으니 지난 행은 항상 REJECTED로 남고, 그래서 유니크가 "진행 중·완료는 회원당 하나"를 정확히 지켜줘요. flyway-migration.md 3-4절의 generated column 패턴, 사물함의 ConstraintViolationUtil 변환과도 결이 같아서 읽기 편했습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

감사합니다!

+ "확인필요(REJECTED)로 바꿀 때는 학생에게 보여줄 안내 문구가 필요하다. "
+ "확인필요로 돌려받은 학생은 안내 문구를 보고 학기 인증부터 다시 낼 수 있다. "
+ "회원의 최신 요청만 바꿀 수 있다 — 지난 요청은 이력이다.")
@ApiErrorCode(type = FeeErrorCode.class,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a] 사소한 문서 세 가지예요.

  1. MEMBER_NOT_FOUND가 빠졌는데, AdminFeeReviewUseCase.java:34가 여전히 memberService.getById를 불러서 탈퇴 회원의 요청을 처리하면 404가 나요. 목록에 탈퇴 회원 행이 보이니 실제로 누를 수 있는 경우라 다시 넣어주시면 좋겠어요.
  2. 설명에 "확인필요가 아닌 상태로 바꿀 때는 안내 문구를 보내도 저장하지 않는다"를 한 줄 넣어주시면 좋겠어요. 응답의 adminNote가 null로 오긴 하지만, 문서에 있으면 프론트가 헷갈리지 않을 것 같아요.
  3. @Tag 이름이 "학생회비 (운영진)"으로 바뀌었는데, 다른 관리자 API는 "공지 관리" 형식이라 원래의 "학생회비 관리"로 두는 게 어떨까요?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a1d2ee5 수정 완료했습니다!

} catch (DataIntegrityViolationException e) {
// 같은 회원에게 진행 중·완료 요청이 이미 있다 — 동시에 들어온 두 번째 요청이 여기서 걸린다
if (ConstraintViolationUtil.isViolated(e, ACTIVE_REQUEST_UNIQUE_CONSTRAINT)) {
throw new BusinessException(FeeErrorCode.FEE_REQUEST_ALREADY_REQUESTED);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[a] 드문 경우라 참고로만 말씀드려요. 운영진이 최신 REJECTED 건을 PAID로 바꾸는 순간 학생이 새 요청을 넣으면 유니크가 잘 막아줘요. 그런데 운영진 쪽이 지면 학생용 문구인 "이미 납부 확인을 요청했거나 납부가 완료되었습니다"를 받게 돼요. 관리자 API 설명에 한 줄 적어두는 정도면 충분할 것 같습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a1d2ee5 수정 완료했습니다!

관리자 API 설명과 @ApiErrorCode에 같이 적어 뒀어요.

xeoxxn added 17 commits October 27, 2026 18:47
@xeoxxn
xeoxxn force-pushed the feat/#166-fee-payment-revision branch from ac556eb to abb0122 Compare October 27, 2026 09:56
@xeoxxn

xeoxxn commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

리뷰 반영했습니다! 꼼꼼하게 봐주셔서 감사합니다 🙏

  • 각 줄 코멘트에 반영한 커밋을 달아 두었어요.
  • [a] 3·4·5번 커밋은 컴파일되는 단위로 합쳤습니다(fc3f1d7). 이후 커밋도 하나씩 체크아웃해서 gradle check가 통과하는 것을 확인했어요.
  • [a] .env gitignore 커밋은 이번 PR에서 빼고 따로 올리겠습니다.
  • 이력을 다시 정리하느라 force-push 했어요. 리뷰 이후 바뀐 부분은 3d4c93d부터 보시면 됩니다.

리뷰 외에 같이 정리한 것도 있어요.

  • 59e1fff: 정적 팩토리 request → create, 서비스 request → submit
  • a1d2ee5: FEE_REQUEST_NOT_FOUND 메시지를 다른 NOT_FOUND와 같은 형식으로 바꾸고, PayerService의 낡은 주석을 고쳤어요
  • abb0122: 지운 FeeAmountCalculator를 가리키던 대여 쪽 주석

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

Labels

None yet

Projects

None yet

2 participants