- 역할
- 프론트엔드 (데이터 모델·API는 백엔드 설계)
- 버튼을 정하는 축
- 역할 × 상태 맵 + 접수 마감
- 접수 마감 후 막는 동작
- 승인 · 결제만
- 일괄 처리가 멈춘 이유
- 4가지 (이어서 호출 2 · 안내 2)
문제
복식 종목에는 혼자 나가고 싶은데 짝이 없는 사람이 늘 있습니다. 기존 구조에서는 팀 단위로만 신청할 수 있었고 팀비는 대표자 한 명이 결제했습니다. 아는 사이라면 괜찮지만, 모르는 사람끼리는 누가 먼저 돈을 낼지에서 이야기가 멈춥니다. 그래서 혼자 신청한 사람끼리 팀을 맺고, 팀비를 각자 나눠 내는 파트너 찾기를 열었습니다.
데이터 모델과 API는 백엔드가 거의 다 정했고, 저는 그 모델을 화면으로 옮기는 프론트엔드를 맡았습니다. 이 글은 정해진 모델을 받아 화면에서 무엇을 판단해야 했는지에 대한 기록입니다.
백엔드가 정한 모델
모집글은 별도 엔티티가 아니라 참가 신청 그 자체입니다. 필요 인원(requiredMemberCount)과 현재 인원(memberCount)이 다른 신청이 모집 중인 팀이고, 모집글 ID도 participantId를 그대로 씁니다. 매칭이 성사되면 새 팀이 생기는 게 아니라 기존 팀에 멤버가 채워집니다.
신청은 일곱 가지 상태를 거치고, 여기에 모집자(RECRUITER)와 신청자(APPLICANT)라는 역할이 곱해집니다.
REQUESTED → APPROVED → PAID
│ │
├ REJECTED ├ EXPIRED (승인 후 기한 안에 결제하지 않음)
├ CANCELLED
└ CLOSED (모집 종료)
금액은 멤버별 몫(shareAmount)과 운영 수수료(feeAmount), 둘을 더한 실제 결제 금액(totalAmount)으로 나뉘어 오고, 팀이 취소되면 멤버마다 자기가 낸 금액을 따로 돌려받습니다. 목록 응답에는 모집자와 나의 복식 DUPR 차이(duprDiff)가 함께 오는데, 로그인하지 않았거나 한쪽 기록이 없으면 null이라 화면에서는 실력 차가 0인 경우와 구분해 보여 줍니다.
역할과 상태로 버튼 정하기
화면에서 먼저 풀어야 했던 건 신청 하나에 어떤 버튼을 보여 줄지였습니다. 같은 APPROVED 상태라도 모집자에게는 승인 취소 버튼이, 신청자에게는 결제 버튼이 보여야 합니다. 상태 일곱 가지와 역할 두 가지를 조건문으로 엮으면 상태가 하나 늘 때마다 분기를 전부 다시 읽어야 해서, 역할과 상태로 버튼을 찾는 맵 하나로 정리했습니다.
const ACTION_BAR_BY_STATUS = {
recruiter: { pending: 'decide', approved: 'revoke' },
applicant: { pending: 'cancel', approved: 'pay' },
};
// 접수가 끝나면 이 두 동작만 안내 문구로 바뀐다
const CLOSED_ON_REGISTRATION_END = new Set(['decide', 'pay']);
접수 마감이라는 두 번째 축
접수가 끝나면 서버가 승인과 결제를 막으므로 화면도 맞춰야 했습니다. 다만 모든 버튼을 막으면 안 됩니다. 마감 뒤에도 신청 취소와 승인 철회는 할 수 있어야 하기 때문에, 승인(decide)과 결제(pay)만 골라 안내로 바꿨습니다. 맵 아래의 집합 하나가 그 역할을 합니다.
이벤트 정보를 아직 불러오는 중이라 마감 여부를 모를 때(isRegistrationEnded === undefined)는 승인·결제 바를 그리지 않습니다. 모르는 채로 결제 버튼을 보여 주면 누른 뒤에야 서버에서 거부되기 때문입니다.
협상과 결제를 한 화면에
승인·거절·결제 버튼은 채팅방 안에 두었습니다. 대화로 상대를 확인하고, 승인하고, 결제하는 과정이 하나의 흐름이라, 중간에 목록 화면으로 돌아가게 하면 그 사이에 이탈할 여지가 생깁니다.
채팅은 이미 출처(ChatOriginRef)로 대화 상대를 정하는 구조였기 때문에, 파트너 찾기는 출처 한 갈래를 더하는 것으로 붙었습니다(글 11).
일괄 처리 결과를 화면에서 읽기
화면 쪽에서 가장 신경 쓴 건 주최자용 일괄 작업이었습니다. 접수가 끝나면 짝을 찾지 못한 참가자가 남고, 주최자는 남은 사람끼리 랜덤으로 묶거나 환불하고 정리합니다. 두 작업 모두 중간에 결제사 호출이 섞여 있어서, 30팀을 환불하는 도중 결제사 응답이 느려져도 이미 처리한 환불을 되돌릴 수는 없습니다.
그래서 백엔드는 일괄 작업을 처리한 만큼 확정하고, 남은 팀 수(remaining)와 멈춘 이유(stoppedReason)를 돌려주는 형태로 설계했습니다. 저는 이 두 값을 읽어 화면이 다음에 무엇을 할지 정하는 쪽을 맡았습니다. 멈춘 이유는 네 가지이고, 한 필드에 있어도 대응이 둘로 갈립니다.
| 값 | 뜻 | 화면의 대응 |
|---|---|---|
BATCH_LIMIT | 한 번에 30팀까지 | 바로 이어서 호출 |
TIME_BUDGET | 10초를 넘김 | 바로 이어서 호출 |
TOSS_UNAVAILABLE | 결제사 연속 장애 | 기다렸다 재시도 안내 |
TOSS_UNAUTHORIZED | 가맹점 설정 오류 | 재시도해도 소용없음, 설정 확인 안내 |
앞의 둘은 정해 둔 상한에 닿아 정상적으로 끊긴 것이고, 뒤의 둘은 사고입니다. 그래서 앞의 둘이면 이어서 호출하고, 뒤의 둘은 화면에서 서로 다르게 안내합니다. 결제사가 잠시 불안정한 것과 가맹점 설정이 잘못된 것은 기다려서 풀리느냐 사람이 고쳐야 하느냐가 다르기 때문입니다.
랜덤 매칭 결과도 묶인 팀(matchedTeams), 묶이지 못한 팀(unmatched), 애초에 대상에서 뺀 팀(excluded)으로 나뉘어 옵니다. 승인된 신청자의 결제를 기다리는 팀(AWAITING_PAYMENT)은 excluded에 들어갑니다. 매칭에 실패한 팀이 아니라 지금 건드리면 안 되는 팀이므로, 화면에서도 실패 목록과 섞지 않았습니다. 섞이면 주최자가 실패로 읽고 매칭을 다시 돌릴 수 있습니다.
끄기 전에 환불 규모를 보여 주기
파트너 찾기를 끄면 아직 팀이 되지 못한 사람들에게 돈을 돌려줘야 합니다. 백엔드가 설정 조회 응답에 지금 끄면 환불될 팀 수, 멤버 수, 총액(refundOnDisable)을 함께 내려 주기 때문에, 화면에서는 끄기 전에 "팀 N개, 멤버 M명, 총 X원이 환불됩니다"를 확인받습니다.
끄기 응답에 실패한 환불(failedRefunds)이 남아 있으면 같은 요청을 다시 보냅니다. 이미 환불된 건은 서버가 다시 처리하지 않으므로, 화면에서 실패한 건만 골라낼 필요가 없습니다.