공개 명함 접근·공유 통합 구현 명세
상태: 구현 완료(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"
}
}projection은 명함 공개 여부, slug, version, 이름, 직함, 회사, 공개 화면 테마만 읽는다. 전화번호, 이메일, 소개, 링크, 콘텐츠 블록, 사용자·명함 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}'만 허용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",
"cardColorTheme": "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에서 확인한 뒤에만 개인화 캐시를 조회한다.
- 이름, 직함, 회사,
LivePreviewThemeKey만 사용한다. - Java2D로 결정적인 1200×630 PNG를 만든다.
- 외부 URL, 프로필 사진, 로고, 사용자 업로드 이미지를 읽지 않는다.
- 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
공유 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_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도 항상 현재 공개 상태와 현재 콘텐츠를 조회한다.