공개 명함 API 맥락
스펙 원본은 frontmatter
spec:. 필드 단위 계약 전체는 as-built 통합 구현 명세가 기준 (계획 문서와 코드가 다르면 as-built 기준 커밋의 코드가 우선). 이 문서는 세 층 구조의 이유, 개인정보 경계, 연동 gotcha만.
한눈에
| 엔드포인트 | 역할 한 줄 |
|---|---|
GET /card/{slug} | 상태별 OG 태그 + React 부트스트랩 HTML (크롤러·브라우저 공용) |
GET /v1/internal/business-cards/{slug}/share-preview | 서버 간 최소 projection (서비스 Bearer token) |
GET /v1/public/business-cards/{slug} | React 화면 전체 데이터 (JWT optional → viewer) |
GET /v1/public/business-cards/{slug}/og-image | 현재 PUBLIC 확인 후 1200×630 PNG 온디맨드 생성 |
GET /v1/public/business-cards/{slug}/vcard | 공개 프로필 vCard 3.0 — iOS 연락처 바로 추가용 (미발행·미존재 404) |
GET /v1/public/og/default.png | 개인정보 없는 공통 PNG (30일 캐시) |
PATCH /v1/me/business-card/slug | 사용자 지정 slug 변경 (낙관적 version + HMAC 영구 예약) |
왜 이렇게 생겼나 — 세 층 분리
같은 명함 데이터가 노출 범위가 다른 세 소비자에게 나간다. 층마다 API를 분리한 이유는 데이터 최소화:
- OG/HTML 층 (
/card/*, share-preview): 이름·직함·회사·테마. 허용 필드만 직접 select(“전체 DTO 만들고 필드 제거” 금지). 예외(2026-07-28): OG 이미지 렌더는 공개 명함의 전화번호·아바타·소개도 projection에서 읽어 이미지에 baking한다 — share-preview 응답·HTML 텍스트로는 안 나가고 PNG 안에만, PRIVATE은 전부 null (be as-built §8.1·§12). - React 전체 층 (
/v1/public/business-cards/{slug}): 연락처 포함 전체 공개 데이터 +viewer(로그인·소유자 여부). 방문자마다 응답이 달라 항상no-store. - 이력: 원안(FE Edge가 HTML 생성)은 과거 제안(해체됨, git 이력 참조)이었으나, 구현은 BE가
/card/{slug}HTML을 직접 생성하는 것으로 바뀜(같은 프로세스에서 projection UseCase 호출). Edge 분리가 다시 필요해지면 내부 share-preview HTTP API를 쓰면 된다 — 그래서 내부 API가 남아 있다.
불변조건
- 공개 응답에 내부
businessCardId없음.slug+revision+ 상대shareUrl만. - 삭제·탈퇴·없는 slug는 외부에서 구분 불가능한 동일 404 (
PUBLIC_CARD_NOT_FOUND). - 운영 장애(5xx)를
PRIVATE/NOT_FOUND로 바꾸지 않는다 — 재시도 가능 여부를 클라이언트가 구분해야 함. /card/*HTML의 CSP nonce: 헤더와meta[name="csp-nonce"]가 반드시 같은 값, 응답마다 새로 생성. FE는 이 값을 Emotion cache에 전달.style-src 'unsafe-inline'없음 — 이 커플링이 깨지면 스타일 전체가 죽는다.- og-image는 DB에서 현재 PUBLIC 확인 후에만 개인화 캐시를 읽는다. stale 이미지 허용은 “PUBLIC 확인 성공 + 렌더러만 실패” 단 한 경우. 비공개 전환 뒤 메모리에 이미지가 남아도 다시 노출되지 않는 근거.
엣지케이스 · gotcha
v(revision)는 과거 스냅샷 조회 키가 아니다. 외부 플랫폼 캐시 갱신용. 오래된v=1URL도 항상 현재 공개 상태를 재조회 — 현재 비공개면 개인화 OG 안 나감.- revision은 현재
business_cards.version재사용 → 공유와 무관한 수정에도 공유 URL의v가 바뀐다 (알려진 제한, 별도sharePreviewRevision분리는 후속). - 브라우저는
window.location.href를 공유하지 않는다 — BE가 내려준shareUrl을 사이트 origin과 결합.og:url·canonical에는 query 안 붙임. - reverse proxy 평가 순서가 계약이다:
/card/*→ BE를 SPA fallback보다 먼저. 순서가 바뀌면 크롤러가 정적 SPA HTML을 받아 OG가 전부 기본값이 된다. - slug HMAC 예약 키 회전: 이전 키를
previous-keys에서 제거하면 그 키로 예약된 과거 slug 충돌을 못 찾는다 — 이전 키는 영구 유지. - slug 예약어 목록에
card,nfc,login등 라우트명 포함 — 새 최상위 라우트를 만들면 예약어에도 추가할 것. - OG 캐시는 인스턴스 로컬 Caffeine — 다중 인스턴스·재시작 간 공유 없음(알려진 제한).
- 크롤러 요청은
PAGE_VIEW를 만들지 않는다(React 미실행이므로 자연 성립). - vCard의
Content-Disposition: inline이 계약이다 — attachment로 바꾸면 iOS Safari가 QuickLook(연락처 추가 시트) 대신 다운로드로 빠져 “연락처 저장”이 죽는다. Android는 다운로드 파일을 자동 실행하지 않는 정책이라 FE가 공유시트 경로를 쓰고 이 엔드포인트를 타지 않는다. 데이터는 React 전체 층과 같은 공개 데이터의 재포맷(새 노출 없음), URL 필드는 쿼리 없는 영구 URL(FE contactUrl 규칙과 동일). - og:title은 행동 유도 카피 “{이름}님의 명함을 TAPLE에서 확인하세요”(2026-07-28).
<title>·og·twitter 공용이고 페이지가noindex라 브라우저 탭까지 같은 톤(SEO 영향 없음). - FE 공유하기가 Kakao SDK를 로드해 카카오톡으로 바로 공유 →
/card/*CSP에script-src https://t1.kakaocdn.net·connect-src https://kapi.kakao.com허용됨. Kakao 공유 실동작은 FEVITE_KAKAO_JS_KEY+ Kakao 콘솔 Web 플랫폼 도메인 등록이 있어야 하고, 없으면 native share/링크 복사로 폴백한다(친구목록/메시지 API·검수 불필요,Kakao.Share.sendDefault방식). 탭 시트 없음.
관련 문서
- 구현 심화(스포크): as-built 통합 구현 명세
- FE 배포: public-card-document-deployment.md
- CSP 상세: as-built §6.3
- 유입 경로: nfc-card.md · 이벤트: analytics.md