주인 없는 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. 범위

포함

  1. resolve 응답에 CLAIMABLE 상태 신규 추가
  2. POST /me/nfc-cards/connect의 원자적 선점 로직
  3. 동시성(first-tap-wins) 보장 요구사항
  4. nfc_cards 소유권 감사 컬럼 + 선점 개방 플래그(self_claim_enabled)
  5. 관리자 발급(POST /admin/nfc-cards)에 선점 개방 플래그 추가(§9 옵션 B)
  6. 오류 응답, 계약 테스트

제외

  • 프론트 화면 구현(/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(선점만)은 그대로 유지되어, resolve NEEDS_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
INACTIVEUNAVAILABLE + INACTIVE
LOSTUNAVAILABLE + LOST
REVOKEDUNAVAILABLE + 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만 화면 상태로 변환한다.

HTTPcode의미프론트 처리
404NF0001카드 없음사용 불가 안내
409NF0002사용 불가 상태(INACTIVE/LOST/REVOKED)사용 불가 안내
403NF0004이미 타인 소유”다른 계정으로 로그인” 안내
409NF0004경합 패배(그새 남이 선점)위와 동일
409NF0005예약 재고(self_claim_enabled=false, 미배정)사용 불가 안내(발급 전)
409NF0006명함 데이터 비정상”다시 시도” (기존 유지)
422VALIDATION_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_enabledboolean, default false미배정 상태에서 셀프 선점 개방 여부(§9 옵션 B)
assigned_viaenum(SELF_CLAIM, ADMIN) nullable소유권 확정 경로
assigned_attimestamp nullable소유권 확정 시각
  • self_claim_enabled는 발급(issue) 시 결정되는 카드 속성이며 소유권 상태와 직교한다. 기본값 false(opt-in) — 명시적으로 켠 공개 배포용 재고만 CLAIMABLE로 열린다.
  • 관리자 assign 경로는 assigned_via='ADMIN'으로 기록한다.
  • 기존 배정 카드의 assigned_via는 백필하거나 nullable로 둔다. 기존 재고의 self_claim_enabledfalse로 백필한다(현행 동작 유지).

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면 공개 배포용 선점 카드로 발급된다.
  • 응답 AdminNfcCardselfClaimEnabled: boolean을 포함해 관리자 화면이 개방 여부를 표시한다.
  • 프론트 관리자 발급 다이얼로그(IssueCardDialog)에 “공개 배포용(셀프 선점 허용)” 토글이 필요하다 — 프론트 작업(§2 제외).

10. 프론트가 의존하는 계약 불변식

  1. resolve는 result별 필수 필드(CONNECTED.slug, CLAIMABLE.token, NEEDS_CONNECT.token, UNAVAILABLE.reason)를 항상 채워 반환한다. 프론트는 이를 런타임 검증하며 위반 시 재시도 오류로 처리한다.
  2. connect 성공 응답은 status='CONNECTED'와 non-null slug/businessCardId/nfcCardId를 보장한다.
  3. 오류는 status + code만으로 분기 가능해야 한다(영문 message 비의존).
  4. 소유권 경합/불일치는 항상 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 카드 → resolve UNAVAILABLE, connect 409 NF0002.
  • 명함 누락 회원 connect → 409 NF0006(단, 선점은 이미 확정되어 남는다).
  • 비로그인 connect → 401(소유권 생성 없음).
  • 관리자 issue(selfClaimable=true, businessCardId=null) → self_claim_enabled=true 재고 생성.
  • 관리자 issue(selfClaimable 미지정) → self_claim_enabled=false(기본).