주인 없는 NFC 카드 첫-태그 선점(self-claim) 백엔드 API 명세
주인이 배정되지 않은 재고 카드(IN_STOCK, assignedMemberId=null, businessCardId=null)를
로그인 회원이 처음 태그하면 그 회원이 소유자가 되도록 하는 백엔드 계약을 정의한다.
상태: 구현 완료 · main 머지 (tapple-be, 마이그레이션 V9).
기준일: 2026-07-23
1. 목적
현재 미배정 재고 카드를 태그하면 resolve가 UNAVAILABLE / NOT_ISSUED(막다른 길)로 응답한다.
회원에게 카드를 연결하려면 관리자가 먼저 assign으로 배정해야 한다. 이 명세는 그 전제를 뒤집어,
미배정 재고 카드를 “선점 가능” 상태로 노출하고 첫 인증 태그로 소유권을 확정하는 계약을 정의한다.
완료 조건
- 미배정 유효 재고 카드는 resolve에서
CLAIMABLE로 반환된다. - 로그인 회원이
CLAIMABLE카드를 연결하면 그 회원이 소유자로 원자적으로 확정되고, 회원의 명함에 연결되어CONNECTED가 된다. - 동시에 여러 명이 같은 카드를 태그해도 정확히 한 명만 소유자가 되고, 나머지는
wrongMember를 받는다. - 이미 다른 회원이 소유한 카드는 기존과 동일하게
wrongMember로 거절된다. - 비로그인 태그는 소유권을 만들지 않는다(로그인 후에만 선점).
INACTIVE,LOST,REVOKED, 존재하지 않는 카드는 선점 대상이 아니다.- 사전 배정용(예약) 재고는 선점 대상이 아니다.
self_claim_enabled=false인 미배정 카드는CLAIMABLE로 노출되지 않고, 관리자assign으로만 소유자가 정해진다(§9 옵션 B). - 소유권 확정 경로(
SELF_CLAIM/ADMIN)와 시각을 감사 로그로 남긴다.
2. 범위
포함
- resolve 응답에
CLAIMABLE상태 신규 추가 POST /me/nfc-cards/connect의 원자적 선점 로직- 동시성(first-tap-wins) 보장 요구사항
nfc_cards소유권 감사 컬럼 + 선점 개방 플래그(self_claim_enabled)- 관리자 발급(
POST /admin/nfc-cards)에 선점 개방 플래그 추가(§9 옵션 B) - 오류 응답, 계약 테스트
제외
- 프론트 화면 구현(
/nfc/{token}CLAIMABLE 화면, 명함 생성 유도, 관리자 발급 토글 UI) - 관리자
assign/connect계약 변경 - 명함 자동 생성 로직(가입 시 이미 존재, §3 참고)
- 분석 이벤트(
NFC_TAP,PAGE_VIEW) 개편
3. 전제(불변식)
- 회원은 명함을 정확히 1개 갖는다. 회원가입 시 명함이 자동 생성되므로, 연결 시점에 회원의
명함은 항상 존재한다. 명함 누락은 데이터 비정상이며
NF0006으로 처리한다(기존 계약 유지). → 이 불변식 때문에 “선점했으나 연결할 명함이 없는” 정상 상태는 존재하지 않으며, connect는 단일CONNECTED성공 응답으로 충분하다. - 물리 카드의
cardUid가 resolve/connect의token이다. 프론트는memberId,businessCardId를 보내지 않는다.
4. 용어와 소유 생명주기
flowchart TD A["IN_STOCK<br/>주인 없음"] -->|self-claim<br/>첫 인증 태그| B["OWNED · UNCONNECTED<br/>주인 있음 · 명함 미연결"] A -->|admin assign| B B -->|connect<br/>명함 링크| C["CONNECTED"] A -.->|self-claim 시 원자적으로<br/>claim+connect 동시 수행| C
- 선점(claim) = 카드의 소유자를 회원으로 확정(
assigned_member_id설정). 소유권 개념. - 연결(connect) = 카드를 회원의 명함에 링크(
business_card_id설정). 명함 링크 개념. - self-claim 경로에서는 §3 불변식에 따라 선점과 연결이 한 요청 안에서 원자적으로 함께 일어난다.
- 위 다이어그램의
IN_STOCK → self-claim전이는self_claim_enabled=true인 개방 재고에만 적용된다. 예약 재고(false)는admin assign경로로만 소유자가 정해진다(§9 옵션 B). - 관리자
assign(선점만)은 그대로 유지되어, resolveNEEDS_CONNECT(주인 있음·미연결)의 원천이 된다.
5. resolve 계약 — GET /public/nfc-tags/{token}
공개 엔드포인트(비로그인 호출 허용). 뷰어가 아니라 카드 자체 상태를 반환한다.
응답 스키마
type NfcTagResolveResponse =
| { result: 'CONNECTED'; slug: string }
| { result: 'CLAIMABLE'; token: string } // ★ 신규
| { result: 'NEEDS_CONNECT'; token: string }
| { result: 'UNAVAILABLE'; reason: NfcTagUnavailableReason }
type NfcTagUnavailableReason =
| 'NOT_FOUND'
| 'NOT_ISSUED'
| 'INACTIVE'
| 'LOST'
| 'REVOKED'
| 'NOT_PUBLISHED'
// 'NOT_ISSUED'는 self_claim_enabled=false인 예약 재고(선점 불가·미배정)에 계속 사용한다.원시 상태 → resolve 결과 매핑
미배정(IN_STOCK) 카드는 self_claim_enabled 플래그에 따라 갈린다(§9 옵션 B).
| 서버의 원시 상태 | 기대하는 resolve 결과 |
|---|---|
CONNECTED + 공개 명함 | CONNECTED + slug |
주인 없음(IN_STOCK, 유효, self_claim_enabled=true) | CLAIMABLE + token ★ |
주인 없음(IN_STOCK, 유효, self_claim_enabled=false) | UNAVAILABLE + NOT_ISSUED(예약 재고) |
UNCONNECTED(관리자 배정, 미연결) | NEEDS_CONNECT + token |
INACTIVE | UNAVAILABLE + INACTIVE |
LOST | UNAVAILABLE + LOST |
REVOKED | UNAVAILABLE + REVOKED |
| 명함 비공개 | UNAVAILABLE + NOT_PUBLISHED |
| 명함 삭제·탈퇴 소유자 | UNAVAILABLE + NOT_PUBLISHED |
| 카드 없음 | UNAVAILABLE + NOT_FOUND |
예시
GET /public/nfc-tags/8f3a...c1?visitId=...
200 { "result": "CLAIMABLE", "token": "8f3a...c1" }
6. connect 계약 — POST /me/nfc-cards/connect
인증 필요. 요청에는 resolve가 반환한 token만 보낸다.
요청
POST /me/nfc-cards/connect
Authorization: Bearer <accessToken>
{ "token": "8f3a...c1" }
서버 로직(의사코드)
card := SELECT * FROM nfc_cards WHERE card_uid = :token
if card == null -> 404 NF0001 (notFound)
if card.status in (INACTIVE, LOST, REVOKED) -> 409 NF0002 (unavailable)
if card.assigned_member_id == null:
if card.self_claim_enabled == false -> 409 NF0005 (notIssued) -- 예약 재고, 선점 불가
-- 원자적 선점: first-tap-wins (self_claim_enabled=true 인 미배정 카드만)
n := UPDATE nfc_cards
SET assigned_member_id = :me,
assigned_at = now(),
assigned_via = 'SELF_CLAIM'
WHERE id = :cardId
AND assigned_member_id IS NULL
AND self_claim_enabled = true
if n == 0 -> 409 NF0004 (wrongMember) -- 경합에서 남이 먼저 선점
elif card.assigned_member_id != :me:
-> 403 NF0004 (wrongMember) -- 이미 타인 소유
-- (assigned_member_id == :me 이면 그대로 진행: 재시도 멱등)
myCard := 회원의 명함
if myCard == null -> 409 NF0006 (missingBusinessCard) -- 데이터 비정상(§3)
link(card, myCard) -- business_card_id 설정, status = CONNECTED (멱등)
return 200 {
nfcCardId, status: 'CONNECTED', businessCardId, slug
}
성공 응답 (단일 형태)
type ConnectNfcCardResponse = {
nfcCardId: string
status: 'CONNECTED'
businessCardId: string
slug: string
}§3 불변식에 따라 “선점됐지만 명함이 없어 연결 못 한” 정상 성공 분기는 없다. 판별 유니온 없이 단일
CONNECTED로 응답한다. 명함 부재는 정상 흐름이 아니라NF0006오류다.
자동 발행 (as-built)
연결에 성공하면 회원의 미발행 명함을 즉시 발행한다(publishedAt=now). 셀프 선점한 신규 회원도
방문자에게 NOT_PUBLISHED로 막히지 않고 바로 공개된다. 이미 발행된 명함은 발행 시각을 유지한다
(의도적 비공개 존중). 프론트는 연결 후 공개 명함으로 이동하기만 하면 되며 추가 처리는 없다.
오류 응답
오류 봉투는 기존과 동일하다: HTTP status + { code, message, data? }. 프론트는 영문 message를
노출하지 않고 status/code만 화면 상태로 변환한다.
| HTTP | code | 의미 | 프론트 처리 |
|---|---|---|---|
| 404 | NF0001 | 카드 없음 | 사용 불가 안내 |
| 409 | NF0002 | 사용 불가 상태(INACTIVE/LOST/REVOKED) | 사용 불가 안내 |
| 403 | NF0004 | 이미 타인 소유 | ”다른 계정으로 로그인” 안내 |
| 409 | NF0004 | 경합 패배(그새 남이 선점) | 위와 동일 |
| 409 | NF0005 | 예약 재고(self_claim_enabled=false, 미배정) | 사용 불가 안내(발급 전) |
| 409 | NF0006 | 명함 데이터 비정상 | ”다시 시도” (기존 유지) |
| 422 | VALIDATION_FAILED | 요청 검증 실패 | 검증 오류 |
| 401 | — | 미인증 | 로그인 유도 |
| 5xx | — | 서버 오류 | 재시도 화면 |
NF0005 (notIssued)는self_claim_enabled=false인 예약 재고에서 유지된다. 이 카드는 resolve가 애초에CLAIMABLE이 아니라UNAVAILABLE / NOT_ISSUED로 응답하므로 정상 흐름에선 connect까지 도달하지 않지만, 방어적으로 connect에서도 거절한다.
7. 동시성 요구사항 (first-tap-wins)
- 선점은 조건부 갱신
UPDATE ... WHERE id = :id AND assigned_member_id IS NULL으로만 확정한다. 영향 행 수가 0이면 경합 패배로 판정해NF0004를 반환한다. 애플리케이션 레벨 read-then-write 분기(예:SELECT로 null 확인 후UPDATE)는 경합을 만들므로 금지한다. - 또는 동등한 보장을 주는 단일 트랜잭션 + 행 잠금(
SELECT ... FOR UPDATE)을 사용한다. - 같은 회원의 재시도는 멱등해야 한다(이미 내 소유·내 명함 연결 → 같은
CONNECTED응답).
8. 데이터 모델 변경 — nfc_cards
| 컬럼 | 타입 | 설명 |
|---|---|---|
self_claim_enabled | boolean, default false | 미배정 상태에서 셀프 선점 개방 여부(§9 옵션 B) |
assigned_via | enum(SELF_CLAIM, ADMIN) nullable | 소유권 확정 경로 |
assigned_at | timestamp nullable | 소유권 확정 시각 |
self_claim_enabled는 발급(issue) 시 결정되는 카드 속성이며 소유권 상태와 직교한다. 기본값false(opt-in) — 명시적으로 켠 공개 배포용 재고만CLAIMABLE로 열린다.- 관리자
assign경로는assigned_via='ADMIN'으로 기록한다. - 기존 배정 카드의
assigned_via는 백필하거나 nullable로 둔다. 기존 재고의self_claim_enabled는false로 백필한다(현행 동작 유지).
9. 재고 카드의 선점 개방 범위 — 옵션 B 확정
관리자 assign(사전 배정) 워크플로가 함께 존재한다. 특정 회원/법인용으로 발급했으나 아직
assign 전인 IN_STOCK 카드가 무조건 CLAIMABLE로 열리면 배정 전에 제3자가 선점할 수 있다.
이를 막기 위해 카드별 선점 개방 플래그 self_claim_enabled로 공개 배포용 재고만 개방한다.
- 공개 배포용 재고:
self_claim_enabled=true→ 미배정 시CLAIMABLE, 첫 태그로 셀프 선점. - 사전 배정용(예약) 재고:
self_claim_enabled=false(기본) →CLAIMABLE로 노출되지 않고, 관리자assign으로만 소유자가 정해진다. 미배정 상태 태그는NOT_ISSUED/NF0005.
관리자 발급 계약 변경 — POST /admin/nfc-cards
type IssueAdminNfcCardRequest = {
label: string
businessCardId: string | null
selfClaimable?: boolean // ★ 신규, 미지정 시 false. businessCardId가 있으면 무시
}businessCardId=null(재고 발급)일 때만 의미가 있으며,selfClaimable=true면 공개 배포용 선점 카드로 발급된다.- 응답
AdminNfcCard에selfClaimEnabled: boolean을 포함해 관리자 화면이 개방 여부를 표시한다. - 프론트 관리자 발급 다이얼로그(
IssueCardDialog)에 “공개 배포용(셀프 선점 허용)” 토글이 필요하다 — 프론트 작업(§2 제외).
10. 프론트가 의존하는 계약 불변식
- resolve는
result별 필수 필드(CONNECTED.slug,CLAIMABLE.token,NEEDS_CONNECT.token,UNAVAILABLE.reason)를 항상 채워 반환한다. 프론트는 이를 런타임 검증하며 위반 시 재시도 오류로 처리한다. - connect 성공 응답은
status='CONNECTED'와 non-nullslug/businessCardId/nfcCardId를 보장한다. - 오류는 status +
code만으로 분기 가능해야 한다(영문 message 비의존). - 소유권 경합/불일치는 항상
NF0004로 수렴한다(403 타인 소유, 409 경합 패배 모두 동일 code).
11. 검증 시나리오 / 계약 테스트
- 미배정 유효 카드(
self_claim_enabled=true) resolve →CLAIMABLE + token. - 미배정 예약 재고(
self_claim_enabled=false) resolve →UNAVAILABLE + NOT_ISSUED. - 예약 재고 connect →
409 NF0005(소유권 생성 없음). - 로그인 회원 connect(개방 미배정) →
CONNECTED,assigned_via='SELF_CLAIM', 카드가 그 회원 소유·명함 연결. - 같은 회원 connect 재시도 → 동일
CONNECTED(멱등). - 동시 2인 connect(같은 개방 미배정 카드) → 1인
CONNECTED, 1인NF0004. 소유자는 정확히 1명. - 타인 소유 카드 connect →
403 NF0004. INACTIVE/LOST/REVOKED카드 → resolveUNAVAILABLE, connect409 NF0002.- 명함 누락 회원 connect →
409 NF0006(단, 선점은 이미 확정되어 남는다). - 비로그인 connect →
401(소유권 생성 없음). - 관리자
issue(selfClaimable=true,businessCardId=null) →self_claim_enabled=true재고 생성. - 관리자
issue(selfClaimable미지정) →self_claim_enabled=false(기본).