공개 명함 접근·공유 통합 구현 명세

상태: 구현 완료(as-built)
기준일: 2026-07-22
백엔드 기준 커밋: 3d1b3a8, 098fdc4, 5cf3f16
프런트엔드 기준 커밋: 063f269, dcaef29, 07a711f

이 문서는 공개 명함 공유 미리보기 제안 이후 실제로 구현된 동작을 기록한다. 계획 문서와 코드가 다르면 이 문서에 표시된 기준 커밋의 코드가 우선한다.

1. 변경 결과

영역변경 전현재 구현
NFC 진입상태 분기가 조회 로직에 섞여 있음상태 판정을 NfcTagResolutionPolicy로 통합하고 CONNECTED, NEEDS_CONNECT, UNAVAILABLE만 반환
NFC 분석프런트 재시도 때 중복 가능세션 단위 visitIdnfc:{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상태별 HTMLno-store
GET/v1/internal/business-cards/{slug}/share-preview서비스 Bearer token최소 공유 projectionno-store
GET/v1/public/business-cards/{slug}공개, JWT optionalReact 화면 전체 데이터no-store
GET/v1/public/business-cards/{slug}/og-image공개명함별 또는 기본 PNG결과별 상이
GET/v1/public/og/default.png공개공통 TAPLE PNGpublic 30일
PATCH/v1/me/business-card/slug회원 JWT변경된 slug와 version없음
POST/v1/analytics/events공개, JWT optional202 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 이벤트는 기록하지 않음

프런트는 sessionStorageanalyticsNfcVisitId:{token}visitId를 보관한다. 같은 탭에서 React Query가 재시도해도 같은 값을 보낸다.

4.2 응답

{
  "result": "CONNECTED",
  "slug": "hong-gildong",
  "token": null,
  "reason": null
}
result부가 필드의미프런트 동작
CONNECTEDslug카드가 명함에 연결됐고 소유자가 활성 상태이며 명함이 공개됨/card/{slug}?source=nfc로 이동
NEEDS_CONNECTtoken카드 상태가 UNCONNECTED같은 화면에서 연결 안내
UNAVAILABLEreason공개 명함으로 연결할 수 없음/card-unavailable로 이동

UNAVAILABLE.reasonNOT_FOUND, INACTIVE, LOST, REVOKED, NOT_PUBLISHED 중 하나다.

4.3 원시 상태 매핑

원시 상태resolve 결과
NFC 카드 없음UNAVAILABLE / NOT_FOUND
UNCONNECTEDNEEDS_CONNECT
INACTIVEUNAVAILABLE / INACTIVE
LOSTUNAVAILABLE / LOST
REVOKEDUNAVAILABLE / 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"}
상황HTTPbody추가 헤더
credential 없음·불일치401{"code":"UNAUTHORIZED","requestId":"..."}없음
없는·삭제된 명함, 탈퇴 소유자, 잘못된 slug404{"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와 현재 공개 상태를 다시 조회한다. sourcev는 React가 유입 분석에만 사용한다.

현재 상태HTTPOG 데이터브라우저 화면
PUBLIC200이름·직함·회사와 명함별 이미지React 공개 명함
PRIVATE200TAPLE 기본 제목·설명·이미지개인정보 없는 비공개 안내
NOT_FOUND404TAPLE 기본 제목·설명·이미지명함 없음 안내
projection 또는 저장소 장애503TAPLE 기본 제목·설명·이미지재시도 화면

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-origin
  • X-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
결과조건HTTPCache-ControlX-Taple-Preview-Fallback
현재 이미지exact cache hit 또는 렌더 성공200public 5분none
stale 이미지현재 PUBLIC 확인 성공, 새 렌더 실패, 같은 slug의 이전 이미지 존재200public 30초stale
기본 이미지현재 PUBLIC 확인 성공, 렌더 실패, 이전 이미지 없음200no-storedefault
PRIVATE/NOT_FOUND현재 공개 확인 실패404no-storedefault
DB timeout·일시 장애현재 공개 상태를 확인할 수 없음503no-storedefault

개인화 응답의 ETag는 실제 반환 이미지 revision을 사용한 "og-{revision}"이다. 503에는 Retry-After: 30을 추가한다.

가장 중요한 제한은 stale 허용 시점이다. DB가 현재 PUBLIC을 확인한 뒤 렌더러만 실패한 경우에만 같은 slug의 stale 이미지를 반환한다. DB 장애, 비공개, 삭제 상태에서는 메모리에 이미지가 남아 있어도 개인화 캐시를 읽지 않는다.

공통 이미지는 다음 경로에서 public 30일 캐시한다.

GET /v1/public/og/default.png

9. 사용자 지정 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 정규화와 검증

  1. 앞뒤 공백 제거
  2. Unicode NFC 정규화
  3. Locale.ROOT 기준 소문자 변환
  4. ^[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
상황HTTPcode
Bean Validation 실패400기존 공통 invalid request code
형식 또는 예약어 위반422SLUG_INVALID
현재 사용 중이거나 과거 예약된 slug409SLUG_UNAVAILABLE
expectedVersion 불일치409VERSION_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_keyclientEventId, 서버가 기록하는 NFC 이벤트는 nfc:{visitId}다. 저장 전 존재 여부를 확인하고 DB unique index가 최종 중복 저장을 막는다.

10.4 프런트 이벤트 규칙

사용자 행동eventTypesourceTypeshareRevision
직접 공개 명함 진입PAGE_VIEWDIRECT없음
NFC로 진입PAGE_VIEWNFC없음
공유 URL로 진입PAGE_VIEWSHAREURL의 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/$slugsource=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_seconds
  • share_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 적용 권한을 확인한다.

  1. V6__version_analytics_events.sql: analytics event key, schema version, share revision
  2. V7__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. 현재 구현의 명시적 제한과 후속 항목

다음은 이번 구현에 포함되지 않았거나 운영 계층에서 별도 적용해야 한다.

  1. 내부 share-preview rate limit 없음: 서비스 credential별 429 정책은 아직 구현하지 않았다.
  2. 인스턴스 로컬 OG 캐시: Caffeine이므로 재시작·다중 인스턴스 사이에 캐시와 stale 후보를 공유하지 않는다. queue, worker, object storage, CDN purge도 없다.
  3. 검색 노출 없음: 모든 공개 문서가 noindex,nofollow다. sitemap과 검색 색인 정책은 별도다.
  4. 별도 share preview revision 없음: 현재 명함 전체 version을 재사용하므로 미리보기와 무관한 수정에도 공유 URL의 v가 증가할 수 있다.
  5. ingress·APM path 마스킹은 저장소 밖의 운영 설정: 내부 애플리케이션 관측 로그는 slug를 HMAC 처리하지만 reverse proxy와 외부 APM 설정은 별도 확인이 필요하다.
  6. 브라우저 UUID fallback: crypto.randomUUID가 없는 환경의 프런트 fallback 값은 백엔드의 UUID/ULID validation을 통과하지 못할 수 있다.
  7. 동시 중복 이벤트 경쟁: 저장 전 존재 확인과 DB unique index를 사용한다. 서로 다른 인스턴스가 같은 event key를 완전히 동시에 저장하면 한 요청은 unique constraint 오류가 될 수 있다.
  8. v는 과거 스냅샷이 아님: 오래된 공유 URL도 항상 현재 공개 상태와 현재 콘텐츠를 조회한다.

16. 관련 문서