보안 & 폴백 정책
택클은 분배만 합니다. 분배가 실패해도 호스트 앱은 절대 깨지지 않습니다. 이 페이지는 택클이 지금 코드 레벨에서 어떤 보안·폴백 처리를 보장하는지 약속 형태로 정리한 문서입니다. 새로운 기능을 권하는 글이 아니라, 이미 구현되어 운영 중인 동작을 옮긴 것입니다.
1. API 키 처리
- 생성 —
tkl_로 시작하는 랜덤 문자열. 발급 시점부터 기본 365일간 유효합니다. - 저장 — 원본 키는 어디에도 저장하지 않고, 해시(SHA-256)만 DB에 둡니다. 평문 키는 발급 직후 화면에 단 한 번만 보여지고, 이후엔 어떤 화면에서도 다시 볼 수 없어요.
- 검증 — 모든 SDK 요청은 헤더의 키를 해시해서 DB와 비교합니다. 키가 잘못됐거나 만료·회수된 경우 즉시 인증 실패. 검증 결과는 우리 서버 캐시에 60초 보관해서 같은 키가 자주 와도 부담이 적습니다.
- 회수 — 유출이 의심되면 /api-keys에서 "회수" 버튼 한 번. 회수된 키는 영구 무효이고, 호스트 앱에는 즉시 인증 실패로 응답합니다. 새 키를 다시 발급해서 배포해야 해요.
- 교체 — "교체" 버튼은 기존 키를 30일 유예 기간 동안 유지한 채 새 키를 발급합니다. 그 사이 호스트 앱이 새 키로 천천히 롤아웃할 수 있어요. 30일 후 기존 키는 자동 만료.
- 허용 도메인 (선택, 권장) — 발급 시 입력해두면 그 도메인(origin)에서만 키가 동작하고 다른 곳에서 호출하면 브라우저가 CORS로 막습니다. 콤마·공백·줄바꿈으로 구분해 여러 개 등록 가능(예:
http://localhost:3000, https://www.example.com). 비워두면 전체 허용이 아니라 운영 기본 도메인(인프런 계열)만 허용되므로, 로컬 개발(http://localhost:3000)이나 외부 사이트에서 쓸 거면 그 주소를 직접 추가해야 합니다. 스킴 필수, 끝 슬래시 금지, 포트·서브도메인은 정확히 일치해야 함. - 일일 한도 (v0.2+, 선택) — 발급 시 입력해두면 매일 UTC 자정 기준으로 호출 횟수가 카운트됩니다. 한도 초과 시 호출 한도 초과 응답을 받고, 응답 헤더에 다시 호출 가능한 시각이 적혀 있어요. 비우면 제한 없음(어드민 내부용에만 권장). 외부 사이트에 발급하는 키는 반드시 설정하세요.
- 즉시 반영 권한 (v0.2+) — 발급 시 즉시 반영 체크박스가 꺼져 있으면, 이 키로는 항상 연결 유지 모드를 쓸 수 없습니다. 사용자가 많을수록 비용이 크게 늘기 때문에 기본은 꺼짐. 어드민 미리보기처럼 1~2명만 쓰는 키만 켜는 게 안전합니다.
- 일시정지 (v0.2+) — 회수와 다르게 토글 가능한 차단입니다. "일시정지" 버튼을 누르면 키가 잠시 인증 실패 응답을 받고, "재활성"을 누르면 즉시 복귀해요. 사고 대응 시 사용. 회수는 영구라 잠시 막고 싶을 때는 일시정지를 쓰세요.
- 만료 알림 — 만료 30일 전부터 /api-keys 목록에 노란 배지가 표시됩니다.
2. 요청 인증 & 호출 한도
모든 SDK 요청은 아래 단계를 순서대로 거칩니다:
- 봇 차단 (선택) — 봇으로 의심되는 트래픽을 키 검증 이전에 차단합니다. 처음 24시간은 차단 없이 기록만 하다가 검수 후 차단 모드로 전환.
- 키 검증 — 헤더의 API 키를 해시해서 DB와 비교. 잘못된 키, 만료·회수·일시정지된 키는 즉시 인증 실패 응답.
- 도메인 확인 — 키에 허용 도메인 목록이 있으면 그 안에 있는 도메인에서만 동작. 없으면 권한 없음 응답.
- 호출 한도 확인 — 키별 일일 한도 + 키와 IP 조합별 최근 1분간 호출 한도를 확인합니다. 우리 캐시에 일시 장애가 있어도 가용성을 위해 한도 검사는 건너뛰고 통과시킵니다.
한도를 넘으면 호출 한도 초과 응답과 다시 호출 가능한 시각이 응답 헤더에 적힙니다. 이때 SDK는 마지막에 받은 플래그 값으로 계속 동작해서 호스트 앱은 영향이 없어요.
| 엔드포인트 | 키+IP당 한도 | 창 | 초과 시 |
|---|---|---|---|
GET /api/sdk/version (v0.2+) | 200 req | 60 s | 429 + 2s 엣지 캐시로 흡수 |
GET /api/sdk/definitions (v0.2+) | 200 req | 60 s | 429 + 30s 엣지 캐시로 흡수 |
POST /api/sdk/evaluate (legacy) | 200 req | 60 s | 429 + Retry-After |
POST /api/sdk/identify | 60 req | 60 s | 429 + Retry-After |
POST /api/sdk/exposure | 600 req | 60 s | 429 + Retry-After (드물게 — 클라이언트 배치 송신) |
위 분당 한도와 별도로 키별 일일 한도가 우선 검사됩니다 (v0.2+). 일일 한도 초과 시 429 + X-RateLimit-Limit/Remaining/Reset 헤더가 응답에 포함됩니다.
어드민 변경 엔드포인트는 별도로 Same-Origin 가드(Origin/Referer 검사)와 Auth.js의 SameSite=Lax 쿠키로 CSRF를 막습니다. 오픈 리다이렉트는 safeRedirect()가 /로 시작하지 않거나 //로 시작하는 모든 타깃을 차단합니다.
3. 입력 검증
- 모든 SDK 엔드포인트는 body를 Zod
safeParse()로 검증합니다. 스키마 위반은400 invalid payload+issues로 응답. - JSON parsing 단계도 try/catch로 감싸 깨진 JSON은 일관되게
400 invalid json으로 떨어집니다. 절대 500이 새지 않습니다. - 응답 헤더에
Cache-Control: no-store, max-age=0을 강제해 중간 캐시에 평가 결과가 박히지 않도록 합니다.
4. 호스트 앱 안전 — "절대 안 깨진다"의 의미
SDK 내부에서 어떤 예외가 나든, 어떤 입력이 깨져 있든, 호스트 앱 코드 경로로는 던지지 않습니다. 아래 표는 모든 실패 경로와 SDK의 응답을 1:1로 정리한 것입니다.
| 상황 | SDK 동작 | 호스트 앱 영향 |
|---|---|---|
isEnabled() 내부 예외 | try/catch → caller fallback(기본 false) | 없음 |
getVariantWithReason() 내부 예외 | { variant: fallback, reason: 'fallback' } | 없음 |
| API 5xx / 네트워크 단절 | 마지막 캐시 유지(디스크/localStorage) | 없음 |
malformed 쿠키(깨진 %XX) | decodeURIComponent throw 흡수 → raw 값 반환 | 없음 |
응답 shape 깨짐(variants가 문자열 등) | refresh()가 {}으로 정규화, 직전 스냅샷 보존 | 없음 |
| 빈 snapshot / 누락 키 | fallback 반환 | 없음 |
| 빈 variants / 가중치 합 0 | fallback 반환 | 없음 |
| exposure 기록 실패(401/500/네트워크) | .catch(() => {}) — best-effort, drop | 없음 |
| Rate limit(429) | 현재 스냅샷 그대로 사용, 다음 폴링까지 대기 | 없음 |
이 표의 모든 행은 packages/sdk-core/src/fallback-safety.test.ts에서 회귀 테스트로 잠겨 있습니다. SDK 릴리스마다 자동으로 검증됩니다.
5. 다운타임 동작
- 변경 확인 주기: 기본 30초(설정 가능). 즉시 반영 연결이 끊겨도 이 주기적 확인이 백업합니다.
- 콜드스타트:
init()이 첫 fetch에 실패해도 디스크/localStorage의 마지막 스냅샷으로 즉시 동작합니다.init()은 그 자체로는 throw 하지 않습니다. - fetch timeout: 모든 evaluate 호출은
AbortController로 1.5초 후 강제 종료. 백엔드가 hang해도 호스트앱은 fallback으로 즉시 진행합니다. embed.js도 동일하게 1.5초 fetch timeout + 3초 prehide hard expiry(authored markup 원복). - Circuit breaker: refresh가 연속 5회 실패하면 30초간 호출 자체를 건너뛰고
circuit-open으로 즉시 throw. 백엔드 부하를 가중시키지 않습니다. 한 번이라도 성공하면 카운터 리셋. - 즉시 반영 재연결 점진 지연: 즉시 반영 연결이 끊기면 0.5초 → 30초로 재시도 간격을 점점 늘리고 약간의 무작위 지연을 더해, 배포 직후 사용자들이 한꺼번에 서버로 몰리는 일을 막습니다.
- 점진적 복구: 서버가 살아나면 다음 폴링 사이클에서 자동 갱신됩니다. 호스트 앱은 별도 처리 불필요.
6. 브라우저 환경 안전 (SSR / Edge)
SDK는 모든 전역 객체 접근에 typeof 가드를 둡니다.
typeof window,typeof document,typeof localStorage,typeof EventSource,typeof navigator.sendBeacon전부 가드.- localStorage 접근은 추가 try/catch로 감싸 시크릿 모드/할당량 초과/quota 정책에서도 throw 하지 않습니다.
- Next.js App Router의 Server Component, Middleware, Edge Runtime 모두에서 안전하게 import 가능합니다.
7. 테스트 환경에서의 안전
createClient({ ..., recordExposures: false })로 노출 이벤트 발사를 차단할 수 있습니다. 자동화 테스트가 운영 통계를 오염시키지 않도록 권장합니다.- 분배 강제는 코드 패치 없이 테스트 쿠키로 가능합니다. variant별
testCookie값을 어드민에서 지정하고 브라우저에taekcle_<key>=<value>쿠키를 심으면 끝.
8. 시크릿 취급
- 서버 SDK(
@taekcle/node): API 키는 환경변수(TAEKCLE_KEY)에 두세요. 코드/저장소에 하드코딩 금지. - 웹 SDK(
@taekcle/web): 브라우저 번들에 키가 들어갑니다. 이는 의도된 동작이며 키 권한 자체가 분배 평가에 한정되어 있습니다. 그래도 키가 외부에 노출되면 /api-keys에서 즉시 revoke + 새 키 발급으로 회전하세요. .env.local은 절대 커밋·푸시하지 않습니다(.gitignore포함). 새 환경변수를 추가하면.env.example에 키 이름만 추가하는 것이 컨벤션입니다.
9. 데이터 취급 & 보존 정책
- 택클이 저장하는 것은 (deviceId, userId, lastSeenAt) 매핑과 분배·노출 이벤트뿐입니다. 개인정보(이름, 이메일, 결제 등)는 저장하지 않습니다.
- 식별자는 호스트 앱이 생성한 의미 없는 문자열이면 됩니다. 사용자 이메일 같은 PII를 deviceId/userId로 직접 넣지 마세요.
- 분배 결과 응답에는
Cache-Control: no-store를 강제해 공유 캐시(CDN/프록시)에 사용자별 결과가 박히지 않게 합니다. - 노출 이벤트 보존: 180일. 매월 25일 새벽 4시(KST)에 6개월 이전 데이터를 자동 삭제합니다. 장기 분석은 호스트 앱(Mixpanel 등)에서 자체적으로 한다는 전제 — 택클 DB는 단기 디버깅·운영 가시성용입니다.
- 마지막 활동 시각 갱신 주기 5분. 같은 사용자가 5분 안에 다시 들어와도 DB 쓰기를 건너뜁니다. DB 쓰기 비용을 약 99% 줄이는 대신, 어드민의 "마지막 활동" 정보가 최대 5분 늦을 수 있어요.
10. 취약점 제보
보안 이슈는 #cell-platform 채널로 비공개 제보 부탁드립니다. 공개 GitHub 이슈로 올리지 말아주세요. 보고 후 24시간 내 1차 회신을 목표로 합니다.
참고
- SDK Reference — API 메서드 시그니처와 옵션 전체
- Quickstart — 5분 안에 첫 평가
- 테스트 쿠키 — QA에서 분배를 결정적으로 강제하기