공개 명함 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를 분리한 이유는 데이터 최소화:

  1. 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).
  2. React 전체 층 (/v1/public/business-cards/{slug}): 연락처 포함 전체 공개 데이터 + viewer(로그인·소유자 여부). 방문자마다 응답이 달라 항상 no-store.
  3. 이력: 원안(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=1 URL도 항상 현재 공개 상태를 재조회 — 현재 비공개면 개인화 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 공유 실동작은 FE VITE_KAKAO_JS_KEY + Kakao 콘솔 Web 플랫폼 도메인 등록이 있어야 하고, 없으면 native share/링크 복사로 폴백한다(친구목록/메시지 API·검수 불필요, Kakao.Share.sendDefault 방식). 탭 시트 없음.

관련 문서