공개 명함 접근·공유 통합 구현 명세
상태: 구현 완료(as-built)
기준일: 2026-07-22
백엔드 기준 커밋: 3d1b3a8, 098fdc4, 5cf3f16
프런트엔드 기준 커밋: 063f269, dcaef29, 07a711f
이 문서는 공개 명함 공유 미리보기 제안 이후 실제로 구현된 동작을 기록한다. 계획 문서와 코드가 다르면 이 문서에 표시된 기준 커밋의 코드가 우선한다.
1. 변경 결과
| 영역 | 변경 전 | 현재 구현 |
|---|---|---|
| NFC 진입 | 상태 분기가 조회 로직에 섞여 있음 | 상태 판정을 NfcTagResolutionPolicy로 통합하고 CONNECTED, NEEDS_CONNECT, UNAVAILABLE만 반환 |
| NFC 분석 | 프런트 재시도 때 중복 가능 | 세션 단위 visitId를 nfc:{visitId} 이벤트 키로 사용해 재시도 중복 제거 |
| 공개 전체 API | 내부 명함 ID를 공개 응답에 포함 | 내부 ID를 제거하고 slug, revision, 공식 shareUrl 제공 |
| 비공개 명함 | 공개 화면 계약이 불명확 | HTTP 200을 유지하되 visibility와 방문자 상태 외 명함 개인정보 제거 |
| 공유 미리보기 | 공개 전체 API를 사용할 위험 | 이름·직함·회사·테마만 조회하는 단일 projection과 내부 서비스 인증 추가 |
| OG 문서 | 정적 SPA HTML | 백엔드가 /card/{slug}에서 상태별 OG 태그와 React 부트스트랩 HTML 생성 |
| OG 이미지 | TAPLE 공통 이미지 | 현재 PUBLIC 상태를 확인한 뒤 명함별 1200×630 PNG를 온디맨드 생성 |
| 이미지 캐시 | 없음 | (slug, revision) 인스턴스 메모리 캐시와 개인정보 안전 조건을 둔 stale fallback 추가 |
| 공개 URL | 서버 발급 slug만 사용 | 사용자가 3~32자 slug를 변경할 수 있고 사용 이력은 HMAC으로 영구 예약 |
| analytics | 내부 명함 ID 중심 요청 | 공개 cardSlug, clientEventId, SHARE, shareRevision 중심의 schema v2로 변경 |
| React 화면 | 공유 revision·유입 경로 연동 없음 | 공식 공유 URL, NFC/SHARE 유입, 저장·공유·링크 클릭 이벤트 연동 |
2. 전체 흐름
flowchart TD A[NFC 카드 태그] --> B["/nfc/{token}"] B --> C["GET /v1/public/nfc-tags/{token}?visitId=..."] C --> D{resolve 결과} D -->|CONNECTED + slug| E["/card/{slug}?source=nfc"] D -->|NEEDS_CONNECT| F["같은 /nfc/{token}에서 연결 안내"] D -->|UNAVAILABLE| G["/card-unavailable"] D -->|네트워크 또는 5xx| H[재시도 화면] F --> I{로그인 상태} I -->|비로그인| J["복귀 경로 저장 후 /login"] J --> B I -->|로그인| K["POST /v1/me/nfc-cards/connect"] K -->|성공| E K -->|실패| F L["직접 방문 또는 공유 링크<br/>/card/{slug}"] --> M E --> M[PublicCardDocument] M --> N[공유 전용 projection] N --> O{현재 명함 상태} O -->|PUBLIC| P["200 명함별 OG + React 부트스트랩"] O -->|PRIVATE| Q["200 기본 OG + 비공개 화면"] O -->|NOT_FOUND| R["404 기본 OG + 명함 없음 화면"] O -->|저장소 장애| S["503 기본 OG + Retry-After"] P --> T{접근 주체} T -->|크롤러| U[최초 HTML의 OG 태그 사용] T -->|브라우저| V[React 실행] V --> W["GET /v1/public/business-cards/{slug}"] V --> X[저장·공유·링크 클릭] X --> Y["POST /v1/analytics/events"] X --> Z["/card/{slug}?source=share&v={revision}"]
/card/{slug} 컨트롤러는 같은 프로세스 안에서 공유 projection UseCase를 호출한다. 별도의 문서
서버나 Edge 함수가 필요할 때는 내부 share-preview HTTP API를 사용할 수 있다.
3. HTTP 경로 요약
| Method | 경로 | 인증 | 정상 결과 | 응답 캐시 |
|---|---|---|---|---|
| GET | /v1/public/nfc-tags/{token} | 공개 | NFC 상태 JSON | 명시적 캐시 없음 |
| GET | /card/{slug} | 공개, JWT optional | 상태별 HTML | no-store |
| GET | /v1/internal/business-cards/{slug}/share-preview | 서비스 Bearer token | 최소 공유 projection | no-store |
| GET | /v1/public/business-cards/{slug} | 공개, JWT optional | React 화면 전체 데이터 | no-store |
| GET | /v1/public/business-cards/{slug}/og-image | 공개 | 명함별 또는 기본 PNG | 결과별 상이 |
| GET | /v1/public/og/default.png | 공개 | 공통 TAPLE PNG | public 30일 |
| PATCH | /v1/me/business-card/slug | 회원 JWT | 변경된 slug와 version | 없음 |
| POST | /v1/analytics/events | 공개, JWT optional | 202 Accepted, body 없음 | 없음 |
4. NFC resolve 계약
4.1 요청
GET /v1/public/nfc-tags/{token}?visitId={uuid-or-ulid}| 값 | 필수 | 규칙 |
|---|---|---|
token | 필수 | 물리 NFC 카드의 cardUid |
visitId | 선택 | UUID 또는 대문자 ULID. 형식이 잘못되면 resolve는 수행하지만 NFC 이벤트는 기록하지 않음 |
프런트는 sessionStorage의 analyticsNfcVisitId:{token}에 visitId를 보관한다. 같은 탭에서 React
Query가 재시도해도 같은 값을 보낸다.
4.2 응답
{
"result": "CONNECTED",
"slug": "hong-gildong",
"token": null,
"reason": null
}result | 부가 필드 | 의미 | 프런트 동작 |
|---|---|---|---|
CONNECTED | slug | 카드가 명함에 연결됐고 소유자가 활성 상태이며 명함이 공개됨 | /card/{slug}?source=nfc로 이동 |
NEEDS_CONNECT | token | 카드 상태가 UNCONNECTED | 같은 화면에서 연결 안내 |
UNAVAILABLE | reason | 공개 명함으로 연결할 수 없음 | /card-unavailable로 이동 |
UNAVAILABLE.reason은 NOT_FOUND, INACTIVE, LOST, REVOKED, NOT_PUBLISHED 중 하나다.
4.3 원시 상태 매핑
| 원시 상태 | resolve 결과 |
|---|---|
| NFC 카드 없음 | UNAVAILABLE / NOT_FOUND |
UNCONNECTED | NEEDS_CONNECT |
INACTIVE | UNAVAILABLE / INACTIVE |
LOST | UNAVAILABLE / LOST |
REVOKED | UNAVAILABLE / REVOKED |
CONNECTED, 연결 명함 ID 없음 | UNAVAILABLE / NOT_PUBLISHED |
CONNECTED, 비활성·탈퇴 소유자 또는 명함 없음 | UNAVAILABLE / NOT_PUBLISHED |
CONNECTED, 명함 비공개 또는 slug 비정상 | UNAVAILABLE / NOT_PUBLISHED |
CONNECTED, 활성 소유자와 공개 명함 | CONNECTED + canonical slug |
네트워크 오류와 서버 오류는 UNAVAILABLE로 변환하지 않는다. 프런트는 오류 종류에 맞는 재시도
화면을 표시한다.
4.4 NFC 이벤트
서버는 다음 조건을 모두 만족할 때만 NFC_TAP을 기록한다.
- resolve 결과가
CONNECTED - 서버가
businessCardId를 확인함 visitId가 UUID 또는 ULID 형식임
저장되는 이벤트 키는 nfc:{visitId}다. NEEDS_CONNECT, UNAVAILABLE, 잘못된 visitId에는
NFC 이벤트를 만들지 않는다. 프런트도 NFC_TAP을 별도로 보내지 않는다.
5. 공유 전용 projection API
5.1 요청
GET /v1/internal/business-cards/hong-gildong/share-preview HTTP/1.1
Authorization: Bearer {SHARE_PREVIEW_SERVICE_TOKEN}
X-Request-Id: 018f47a0-7b9c-7d3a-8f24-123456789abc- 현재 token과 교체 기간의 이전 token을 모두 허용한다.
- token 비교는 SHA-256 digest의 constant-time 비교를 사용한다.
X-Request-Id는 UUID 또는 ULID만 유지한다. 없거나 잘못되면 서버가 UUID를 생성한다.- 사용자 Cookie와 JWT는 조회 결과에 영향을 주지 않으며
Set-Cookie를 반환하지 않는다. - 읽기 트랜잭션 timeout은 1초다.
5.2 PUBLIC 응답
HTTP/1.1 200 OK
Cache-Control: no-store
X-Request-Id: 018f47a0-7b9c-7d3a-8f24-123456789abc{
"status": "PUBLIC",
"slug": "hong-gildong",
"revision": 12,
"preview": {
"name": "홍길동",
"jobTitle": "Product Designer",
"company": "TAPLE",
"imageUrl": "/v1/public/business-cards/hong-gildong/og-image?v=12"
}
}share-preview API 응답은 명함 공개 여부, slug, version, 이름, 직함, 회사, 공개 화면 테마만 노출한다.
공유 projection(findSharePreviewBySlug)은 OG 이미지 렌더용으로 전화번호·아바타 자산·소개 + 명함 앞면 값
(cardTheme·frontBrandName·frontRoleTitle·frontDisplayName)도 함께 읽지만,
이 값들은 share-preview 응답 필드로 나가지 않고 OG 이미지에만 합성된다(§8.1). PRIVATE는 여전히
visibility 외 전 필드(신규 포함) null. 이메일, 링크, 콘텐츠 블록, 사용자·명함 ID, 방문자 상태는 조회·응답하지 않는다.
문자열 정리 규칙:
- Unicode NFC 정규화
- C0·C1 제어문자 제거
- 연속 Unicode 공백을 한 칸으로 축소
name최대 40 grapheme,jobTitle·company최대 80 grapheme- 빈 선택 필드는
null - PUBLIC에서 name, slug, revision, theme가 없거나 잘못되면 projection 오류
5.3 PRIVATE와 오류 응답
PRIVATE는 정확히 개인정보 없는 상태만 반환한다.
{"status":"PRIVATE"}| 상황 | HTTP | body | 추가 헤더 |
|---|---|---|---|
| credential 없음·불일치 | 401 | {"code":"UNAUTHORIZED","requestId":"..."} | 없음 |
| 없는·삭제된 명함, 탈퇴 소유자, 잘못된 slug | 404 | {"code":"PUBLIC_CARD_NOT_FOUND","requestId":"..."} | 없음 |
| query timeout·일시적 DB 장애 | 503 | {"code":"SHARE_PREVIEW_UNAVAILABLE","requestId":"..."} | Retry-After: 30 |
| PUBLIC projection 필수값 오류 | 500 | {"code":"INTERNAL_SERVER_ERROR","requestId":"..."} | 없음 |
| 예상하지 못한 오류 | 500 | {"code":"INTERNAL_SERVER_ERROR","requestId":"..."} | 없음 |
모든 내부 API 응답은 Cache-Control: no-store다.
6. 공개 명함 HTML 문서
6.1 요청과 상태
GET /card/{slug}?source={nfc|share}&v={revision}
Accept: text/html백엔드는 query parameter를 HTML 생성 조건으로 사용하지 않는다. 항상 path의 현재 slug와 현재 공개
상태를 다시 조회한다. source와 v는 React가 유입 분석에만 사용한다.
| 현재 상태 | HTTP | OG 데이터 | 브라우저 화면 |
|---|---|---|---|
| PUBLIC | 200 | 이름·직함·회사와 명함별 이미지 | React 공개 명함 |
| PRIVATE | 200 | TAPLE 기본 제목·설명·이미지 | 개인정보 없는 비공개 안내 |
| NOT_FOUND | 404 | TAPLE 기본 제목·설명·이미지 | 명함 없음 안내 |
| projection 또는 저장소 장애 | 503 | TAPLE 기본 제목·설명·이미지 | 재시도 화면 |
503에는 Retry-After: 30이 포함된다. 모든 HTML은 Cache-Control: no-store다.
6.2 PUBLIC 메타데이터
title = {name}님의 명함을 TAPLE에서 확인하세요
description = {jobTitle} · {company}
image = {PUBLIC_API_ORIGIN}/v1/public/business-cards/{slug}/og-image?v={revision}
canonical = {PUBLIC_SITE_ORIGIN}/card/{slug}직함과 회사 중 값이 있는 것만 description에 사용한다. 둘 다 없으면
한 번의 태그로 건네는 디지털 명함을 사용한다. title, description, URL은 HTML escape한다.
문서에는 Open Graph, Twitter large image, og:image 1200×630 크기, query 없는 canonical,
noindex,nofollow를 넣는다. 검색 색인은 이번 범위에 포함하지 않는다.
6.3 React 부트스트랩과 보안 헤더
HTML은 다음 DOM과 고정 자산을 제공한다.
<div id="app" data-prerendered="false"></div>
<div id="modal-root"></div>
<meta name="csp-nonce" content="{requestNonce}" />
<link rel="stylesheet" href="{PUBLIC_ASSET_ORIGIN}/assets/app.css" />
<script type="module" src="{PUBLIC_ASSET_ORIGIN}/assets/app.js"></script>명함 본문을 서버에서 SSR하지 않는다. 크롤러는 최초 HTML의 OG 태그를 사용하고, 일반 브라우저는 React가 실행된 뒤 공개 전체 API를 조회한다.
응답 헤더:
Content-Security-Policy: self, 설정된 asset/API origin, 요청별style-src 'nonce-{requestNonce}', 그리고 FE 공유하기의 Kakao 바로 공유용script-src https://t1.kakaocdn.net·connect-src https://kapi.kakao.com허용 (2026-07-28).PUBLIC_MEDIA_ORIGIN(S3 미디어 origin)이 설정되면connect-src에 추가 — FE 명함 이미지 저장이 S3 미디어를fetch로 읽기 때문(미설정이면 생략, 2026-07-31)Referrer-Policy: strict-origin-when-cross-originX-Content-Type-Options: nosniff
백엔드는 PUBLIC, PRIVATE, NOT_FOUND, UNAVAILABLE 문서마다 256비트 nonce를 새로 만든다. 같은 응답의
CSP 헤더와 meta[name="csp-nonce"]는 반드시 같은 값을 사용하며, 프런트는 이 값을 Emotion cache에
전달한다. 정상 정책에는 style-src 'unsafe-inline'을 포함하지 않는다.
7. 공개 명함 전체 API
7.1 요청
GET /v1/public/business-cards/{slug}JWT는 선택 사항이다. 로그인 사용자는 viewer 계산에만 사용한다. 방문자에 따라 응답이 달라질 수
있으므로 응답은 항상 no-store다.
7.2 PUBLIC 응답 핵심 계약
{
"card": {
"slug": "hong-gildong",
"visibility": "PUBLIC",
"revision": 12,
"shareUrl": "/card/hong-gildong?source=share&v=12",
"cardTheme": "LEMON_BLUE",
"livePreviewTheme": "OCEAN",
"logoUrl": null,
"brandName": "TAPLE",
"tagline": "한 번의 태그로 연결합니다",
"roleTitle": "Product Designer",
"displayName": "홍길동"
},
"profile": {
"name": "홍길동",
"jobTitle": "Product Designer",
"company": "TAPLE",
"phone": "010-0000-0000",
"email": "hello@example.com",
"description": "소개",
"avatarUrl": null
},
"links": [],
"textBlocks": [],
"imageBlocks": [],
"viewer": {
"isAuthenticated": false,
"isOwner": false,
"hasOwnCard": false
}
}- 공개 응답에서 내부
businessCardId를 제거했다. revision은 현재business_cards.version이다.shareUrl은 서버가 만든 상대 URL이며 프런트가 사이트 origin과 결합한다.v는 캐시 갱신과 분석용 값이며 과거 version 조회를 지원하지 않는다.
7.3 PRIVATE와 NOT_FOUND
- PRIVATE: HTTP 200.
card.visibility=PRIVATE,viewer를 반환하고 profile은 null, 콘텐츠 배열은 비운다. slug, revision, shareUrl, 표시 정보는 반환하지 않는다. - NOT_FOUND: HTTP 404와 빈 body. 존재하지 않는 slug와 비활성·탈퇴 소유자의 명함을 포함한다.
8. 명함별 OG 이미지
8.1 생성 규칙
GET /v1/public/business-cards/{slug}/og-image?v={revision}- query의
v는 서버 조회 조건이 아니다. 서버는 항상 현재 명함 version을 사용한다. - 현재 PUBLIC임을 DB에서 확인한 뒤에만 개인화 캐시를 조회한다.
- 이름, 직함, 회사, 소개, 전화번호, 아바타 자산 + 명함 앞면 값(
CardThemeKey, frontBrandName·frontRoleTitle·frontDisplayName)을 사용한다(모두 공개 명함이 스스로 공개한 값).LivePreviewThemeKey는 더 이상 이미지에 쓰지 않는다. - Java2D로 1200×630 PNG를 만든다(2026-07-28 재디자인). 고정 다크 배경 위에: ① 블러 처리한 이름 워터마크, ② 살짝 기울인 실제 명함 앞면 카드 —
CardThemeKey별 배경색(FEcardThemes.front대응, DEFAULT_CARD는 다크 그라데이션)에 frontBrandName·frontRoleTitle·frontDisplayName + NFC 링, ③ 하단 반투명 프로필 패널 — 아바타·이름·직함·소개·전화번호. (CardThemeKey.CUSTOM_IMAGE는 커스텀 이미지 fetch 미구현, 기본 다크 카드로 폴백 — 후속.) - 아바타는 미디어 자산 URL을 HTTP로 내려받아 합성한다 — 캐시 미스 렌더 시에만 fetch(타임아웃·크기 상한), 실패 시 모노그램 폴백. 그 외 외부 URL·로고·업로드 이미지는 읽지 않는다. (2026-07-28: “업로드를 읽지 않는 결정적 렌더러” 불변식은 아바타 합성으로 완화됨 — 아바타 없음 경로만 결정적.)
- 렌더 자체 실패는 §8.2의 stale→개인정보 없는 default로 폴백한다.
- Docker 이미지에는
font-noto-cjk를 설치한다.
8.2 캐시와 fallback
개인화 이미지는 프로세스별 Caffeine 캐시에 보관한다.
| 항목 | 값 |
|---|---|
| cache key | (slug, revision) |
| 최대 항목 수 | 2,000 |
| 만료 | 마지막 접근 후 6시간 |
| latest index | 같은 프로세스의 slug별 마지막 성공 key |
| 결과 | 조건 | HTTP | Cache-Control | X-Taple-Preview-Fallback |
|---|---|---|---|---|
| 현재 이미지 | exact cache hit 또는 렌더 성공 | 200 | public 5분 | none |
| stale 이미지 | 현재 PUBLIC 확인 성공, 새 렌더 실패, 같은 slug의 이전 이미지 존재 | 200 | public 30초 | stale |
| 기본 이미지 | 현재 PUBLIC 확인 성공, 렌더 실패, 이전 이미지 없음 | 200 | no-store | default |
| PRIVATE/NOT_FOUND | 현재 공개 확인 실패 | 404 | no-store | default |
| DB timeout·일시 장애 | 현재 공개 상태를 확인할 수 없음 | 503 | no-store | default |
개인화 응답의 ETag는 실제 반환 이미지 revision을 사용한 "og-{revision}"이다. 503에는
Retry-After: 30을 추가한다.
가장 중요한 제한은 stale 허용 시점이다. DB가 현재 PUBLIC을 확인한 뒤 렌더러만 실패한 경우에만 같은 slug의 stale 이미지를 반환한다. DB 장애, 비공개, 삭제 상태에서는 메모리에 이미지가 남아 있어도 개인화 캐시를 읽지 않는다.
공통 이미지는 다음 경로에서 public 30일 캐시한다.
GET /v1/public/og/default.png9. 사용자 지정 slug와 영구 예약
9.1 변경 API
PATCH /v1/me/business-card/slug
Authorization: Bearer {member_access_token}
Content-Type: application/json
{
"expectedVersion": 12,
"slug": "my-card"
}성공 응답:
{
"slug": "my-card",
"version": 13,
"updatedAt": "2026-07-22T12:00:00"
}9.2 정규화와 검증
- 앞뒤 공백 제거
- Unicode NFC 정규화
Locale.ROOT기준 소문자 변환^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])$검사
결과는 3~32자의 영문 소문자·숫자·하이픈이어야 하며 하이픈으로 시작하거나 끝날 수 없다.
예약어:
admin, api, auth, card, card-unavailable, login, logout,
nfc, robots, sitemap, support, www| 상황 | HTTP | code |
|---|---|---|
| Bean Validation 실패 | 400 | 기존 공통 invalid request code |
| 형식 또는 예약어 위반 | 422 | SLUG_INVALID |
| 현재 사용 중이거나 과거 예약된 slug | 409 | SLUG_UNAVAILABLE |
expectedVersion 불일치 | 409 | VERSION_CONFLICT |
현재 slug와 정규화 결과가 같으면 version을 증가시키지 않고 현재 값을 반환한다.
9.3 HMAC 예약 저장소
slug_reservations는 다음 값만 저장한다.
slug_hmac varchar(64) primary key
key_version integer not null check (key_version > 0)- digest는
HmacSHA256(key, normalizedSlug)의 64자 hex다. - 원문 slug, 회원 ID, 명함 ID, 예약 시각을 저장하지 않는다.
- 기존 slug를 먼저 idempotent하게 예약한 뒤 새 slug를 선점한다.
- 로그인 시 기본 명함 slug도 예약한다.
- 계정 탈퇴로 명함을 삭제하기 전에 현재 slug를 예약한다.
- DB insert는
ON CONFLICT DO NOTHING을 사용한다. - 전체 변경은 명함 version 갱신과 같은 트랜잭션에 포함된다.
키 회전 시 현재 키의 version을 올리고 기존 키를 app.slug-reservation.previous-keys에 유지해야 한다.
새 slug 선점은 현재 키와 모든 이전 키의 digest를 검사한다. 이전 키를 제거하면 그 키로 예약된 과거
slug 충돌을 찾을 수 없다.
10. analytics schema v2
10.1 공개 수집 API
POST /v1/analytics/events
Content-Type: application/json
{
"clientEventId": "018f47a0-7b9c-7d3a-8f24-123456789abc",
"cardSlug": "hong-gildong",
"eventType": "LINK_CLICK",
"sourceType": "SHARE",
"cardLinkId": "link-1",
"visitorId": "visitor-id",
"sessionId": "session-id",
"shareRevision": 12
}정상 처리와 이미 처리된 clientEventId 모두 HTTP 202와 빈 body를 반환한다.
10.2 필드 계약
| 필드 | 필수 | 규칙 |
|---|---|---|
clientEventId | 필수 | UUID 또는 대문자 ULID |
cardSlug | 필수 | 1~64자 영문·숫자·하이픈 형식, 현재 공개 명함이어야 함 |
eventType | 필수 | PAGE_VIEW, NFC_TAP, LINK_CLICK, CARD_DOWNLOAD, SHARE_CLICK |
sourceType | 선택 | DIRECT, NFC, QR, LINK, SEARCH, SOCIAL, SHARE, UNKNOWN; 기본 DIRECT |
cardLinkId | 선택 | 최대 40자. 전달하면 현재 공개 명함 소유 링크인지 확인 |
visitorId | 선택 | 최대 128자 |
sessionId | 선택 | 최대 128자 |
shareRevision | 선택 | 1 이상의 정수 |
공개 요청은 businessCardId, nfcCardId, 원문 referrer, city를 받지 않는다. 서버는 cardSlug로
현재 공개 명함 ID를 찾고, cardLinkId가 있으면 소유 관계까지 검증한다. 대상이 없으면 404다.
10.3 저장과 중복 제거
Flyway V6는 analytics_events에 다음 컬럼을 추가한다.
event_key varchar(255), unique index
schema_version integer not null default 1
share_revision integer nullable신규 이벤트는 schemaVersion=2로 저장한다. 공개 이벤트의 event_key는 clientEventId, 서버가
기록하는 NFC 이벤트는 nfc:{visitId}다. 저장 전 존재 여부를 확인하고 DB unique index가 최종
중복 저장을 막는다.
10.4 프런트 이벤트 규칙
| 사용자 행동 | eventType | sourceType | shareRevision |
|---|---|---|---|
| 직접 공개 명함 진입 | PAGE_VIEW | DIRECT | 없음 |
| NFC로 진입 | PAGE_VIEW | NFC | 없음 |
| 공유 URL로 진입 | PAGE_VIEW | SHARE | URL의 v |
| 명함 이미지 저장 | CARD_DOWNLOAD | 현재 유입 source | 공유 유입이면 URL의 v |
| 링크 클릭 | LINK_CLICK | 현재 유입 source | 공유 유입이면 URL의 v |
| 공유 버튼 클릭 | SHARE_CLICK | 현재 유입 source | 현재 명함 revision |
분석 전송은 fire-and-forget이다. 실패해도 공개 명함 화면 동작을 막지 않는다. visitor ID는
localStorage, session ID는 sessionStorage에 저장한다.
11. React 공개 화면과 공유 URL
/card/$slug는source=nfc|share만 허용하고v는 양의 정수만 유지한다.- React는
/v1/public/business-cards/{slug}를 조회해 PUBLIC, PRIVATE, 404, network/5xx를 구분한다. - 명함 소유자는 편집 액션, 로그인한 타인은 로그아웃, 비로그인 방문자는 로그인 액션을 본다.
- 공유 버튼은 공개 전체 API의
card.shareUrl을 사이트 origin과 결합한다. - Web Share API를 우선 사용하고 미지원 환경은 clipboard 복사로 대체한다.
- 라이브 스튜디오에서 slug 변경에 성공하면 version과 Query cache의 slug를 함께 갱신한다.
- slug 변경이 실패하면 오류 toast를 표시하고 주소 편집기를 닫지 않는다.
- Vite production build는
/assets/app.js,/assets/app.css를 고정 엔트리로 만들고 route chunk와 font에는 hash를 유지한다.
12. 개인정보와 보안 경계
공유 projection과 HTML에 허용
- 이름
- 직함
- 회사·소속
- 공개 slug
- 현재 revision
- 공개 화면 theme
- 위 값으로 서버가 만든 OG 이미지 URL
OG 이미지에 한해 추가 허용 (2026-07-28 —)
- 전화번호, 자유 입력 소개
- 프로필 아바타 사진
- 명함 앞면 값: 카드 테마(
CardThemeKey), frontBrandName·frontRoleTitle·frontDisplayName
공개 명함이 스스로 공개한 값. share-preview 응답이나 HTML 텍스트로는 노출하지 않고, 서버가 만든 OG PNG 안에만 렌더한다. PRIVATE·렌더 실패 default 이미지에는 없다. 아바타는 미디어 자산 URL을 서버가 HTTP로 읽어 합성(§8.1).
공유 projection과 기본 상태 HTML에서 금지
- 이메일
- 링크, 텍스트 블록, 이미지 블록
- 로고, 그 외 사용자 업로드 이미지
- 회원 ID, 내부 명함 ID, NFC 카드 ID와 token
- 로그인·소유자·명함 보유 여부
내부 공유 API의 관측 로그는 request ID, 결과, HTTP status, duration, PUBLIC revision, HMAC 처리한 slug만 기록한다. 원본 slug와 profile 값은 기록하지 않는다.
메트릭:
share_preview_requests_total{result=public|private|not_found|unauthorized|error}share_preview_duration_secondsshare_preview_projection_errors_total
13. 배포 설정
13.1 reverse proxy
다음 순서로 경로 소유권을 설정한다.
/card/* -> taple-be
/v1/* -> taple-be
/assets/* -> taple-fe dist/assets
그 외 -> taple-fe 정적 SPA/card/*를 프런트 정적 fallback보다 먼저 평가해야 크롤러가 동적 OG HTML을 받는다.
13.2 환경 변수
| 변수 | 필수 | 용도 |
|---|---|---|
PUBLIC_SITE_ORIGIN | 운영 필수 | canonical과 공식 공유 URL origin |
PUBLIC_API_ORIGIN | 운영 필수 | OG 이미지 절대 URL origin |
PUBLIC_ASSET_ORIGIN | 운영 필수 | React JS/CSS origin |
PUBLIC_MEDIA_ORIGIN | 운영 필수 | S3 미디어 origin — CSP connect-src에 추가(명함 이미지 저장 fetch 허용) |
PUBLIC_SPA_SCRIPT_PATH | 선택 | 기본 /assets/app.js |
PUBLIC_SPA_STYLESHEET_PATH | 선택 | 기본 /assets/app.css |
SHARE_PREVIEW_SERVICE_TOKEN | 필수 | 내부 projection 현재 token |
SHARE_PREVIEW_PREVIOUS_SERVICE_TOKEN | 선택 | token 교체 기간 이전 token |
SHARE_PREVIEW_LOG_HMAC_KEY | 필수 | 관측 로그용 slug HMAC |
SLUG_RESERVATION_HMAC_KEY | 필수 | 최소 32자의 예약 HMAC 현재 키 |
SLUG_RESERVATION_HMAC_KEY_VERSION | 필수 | 1 이상의 현재 키 version |
이전 slug 키 목록은 환경 변수 문자열이 아니라 Spring 설정의
app.slug-reservation.previous-keys 배열로 구성한다.
13.3 DB migration
배포 전에 Flyway V6와 V7 적용 권한을 확인한다.
V6__version_analytics_events.sql: analytics event key, schema version, share revisionV7__reserve_business_card_slugs.sql:slug_reservations
14. 검증 방법
백엔드
./gradlew test ktlintCheck
./gradlew :infrastructure:bootJar프런트엔드
cd ../taple-fe
pnpm quality:check
pnpm build
test -f dist/assets/app.js
test -f dist/assets/app.css연동 확인
공개된 테스트 slug와 실제 서비스 token으로 다음 순서대로 확인한다.
curl -i -H "Authorization: Bearer ${SHARE_PREVIEW_SERVICE_TOKEN}" \
http://localhost:8080/v1/internal/business-cards/{slug}/share-preview
curl -i http://localhost:8080/card/{slug}
curl -i http://localhost:8080/v1/public/business-cards/{slug}/og-image?v=1
curl -i "http://localhost:8080/v1/public/nfc-tags/{token}?visitId=123e4567-e89b-12d3-a456-426614174000"확인 항목:
- PRIVATE 내부 응답이
{"status":"PRIVATE"}인지 - HTML source에 명함별 OG와
/assets/app.js,/assets/app.css가 있는지 - PRIVATE/404/503 HTML과 이미지에 이전 이름·직함·회사가 없는지
- OG PNG가 1200×630인지
- 동일
visitId재시도 후NFC_TAP이 한 건인지 - 공유 URL 방문 이벤트가
sourceType=SHARE,shareRevision=v로 저장되는지
15. 현재 구현의 명시적 제한과 후속 항목
다음은 이번 구현에 포함되지 않았거나 운영 계층에서 별도 적용해야 한다.
- 내부 share-preview rate limit 없음: 서비스 credential별 429 정책은 아직 구현하지 않았다.
- 인스턴스 로컬 OG 캐시: Caffeine이므로 재시작·다중 인스턴스 사이에 캐시와 stale 후보를 공유하지 않는다. queue, worker, object storage, CDN purge도 없다.
- 검색 노출 없음: 모든 공개 문서가
noindex,nofollow다. sitemap과 검색 색인 정책은 별도다. - 별도 share preview revision 없음: 현재 명함 전체
version을 재사용하므로 미리보기와 무관한 수정에도 공유 URL의v가 증가할 수 있다. - ingress·APM path 마스킹은 저장소 밖의 운영 설정: 내부 애플리케이션 관측 로그는 slug를 HMAC 처리하지만 reverse proxy와 외부 APM 설정은 별도 확인이 필요하다.
- 브라우저 UUID fallback:
crypto.randomUUID가 없는 환경의 프런트 fallback 값은 백엔드의 UUID/ULID validation을 통과하지 못할 수 있다. - 동시 중복 이벤트 경쟁: 저장 전 존재 확인과 DB unique index를 사용한다. 서로 다른 인스턴스가 같은 event key를 완전히 동시에 저장하면 한 요청은 unique constraint 오류가 될 수 있다.
v는 과거 스냅샷이 아님: 오래된 공유 URL도 항상 현재 공개 상태와 현재 콘텐츠를 조회한다.