익명 세션 → 인증 계정 이관 베스트 프랙티스
작성: 2026-06-13 | 대상: crowny.org (Gemini 기반 AI 채팅)
개요
사용자가 브라우저의 비밀 ownerToken 으로 익명 대화를 시작한 후, 회원가입/로그인으로 계정을 연결할 때 진행 중인 대화 히스토리, 상태, 환경설정을 손실 없이 이관하되 중복·충돌·보안 위험을 차단하는 아키텍처.
핵심 요구사항
- ✅ 기존 대화 히스토리 보존 (익명 세션의 모든 메시지)
- ✅ 계정 매칭 시 멱등성 (재로그인 시에도 데이터 중복 없음)
- ✅ 보안 (토큰 검증, CSRF, 토큰 혼입 방지)
- ✅ 충돌 처리 (같은 email로 이미 가입된 계정 발견 시)
1. 계정 이관 아키텍처
1.1 익명 세션 구조 (현재)
javascript// 클라이언트: public/index.html
const ownerToken = getOrCreateOwnerToken(); // 브라우저별 비밀 토큰
// 모든 요청 헤더: Authorization: Bearer {ownerToken}
문제점: 이 토큰은 익명 브라우저 식별용일 뿐, 서버는 사용자를 정확히 식별 불가.
1.2 권장 이관 흐름 (3단계)
[1단계: 익명 대화 진행]
브라우저 ownerToken
↓
서버 저장: conversations[ownerToken] = [msg1, msg2, ...]
↓
[2단계: 회원가입/로그인]
POST /api/auth/signup | /api/auth/login
헤더: Authorization: Bearer {ownerToken}
바디: { email, password, ... }
↓
[3단계: 계정 매칭 & 이관]
서버:
1. ownerToken 검증 (유효한 익명 세션인가?)
2. email 충돌 확인 (이미 가입된 계정?)
3. 새 계정 생성 + scrypt(password) 저장
4. conversations[ownerToken] → accounts[userId].conversations 이관
5. 새 authToken 발급
6. 응답: { authToken, migratedMessages: N, ... }
↓
[4단계: 이후 요청]
헤더: Authorization: Bearer {authToken} (ownerToken 폐기)
2. 대화 히스토리 보존 전략
2.1 데이터베이스 설계
익명 세션 테이블
sqlCREATE TABLE anonymous_conversations (
ownerToken TEXT PRIMARY KEY,
messages JSON, -- [{ role, content, timestamp }, ...]
settings JSON, -- { model, temperature, ... }
created_at TIMESTAMP,
last_activity TIMESTAMP,
ttl_days INT DEFAULT 30 -- 이관 전 자동 삭제
);
계정 테이블 (기존)
sqlCREATE TABLE accounts (
userId TEXT PRIMARY KEY,
email TEXT UNIQUE,
passwordHash TEXT, -- scrypt
ownerTokenMigrated TEXT, -- 익명 세션 추적 (중복 방지)
conversations JSON, -- 통합 히스토리
created_at TIMESTAMP
);
2.2 이관 로직 (Node.js)
javascript// engine/auth.js
async function migrateAnonymousSession(ownerToken, email, passwordHash) {
// 1. 익명 세션 조회
const anonData = await db.get('anonymous_conversations', { ownerToken });
if (!anonData) {
return { error: 'No anonymous session found', status: 404 };
}
// 2. 중복 이관 방지 (멱등성)
const existingAccount = await db.get('accounts', { email });
if (existingAccount && existingAccount.ownerTokenMigrated === ownerToken) {
// 이미 이관됨 → 그냥 authToken 발급
return {
authToken: generateAuthToken(existingAccount.userId),
migrationStatus: 'already_migrated',
messageCount: existingAccount.conversations.length
};
}
if (existingAccount && existingAccount.ownerTokenMigrated !== ownerToken) {
// email은 같지만 다른 ownerToken → 충돌
return {
error: 'Account already exists with this email',
status: 409,
suggestion: 'Use existing account or register with different email'
};
}
// 3. 새 계정 생성
const userId = generateUserId();
const account = {
userId,
email,
passwordHash,
ownerTokenMigrated: ownerToken,
conversations: anonData.messages || [], // 히스토리 이관
settings: anonData.settings || {},
created_at: new Date()
};
await db.insert('accounts', account);
// 4. 익명 세션 표시 (선택: 즉시 삭제 또는 7일 후 자동 삭제)
await db.update('anonymous_conversations',
{ ownerToken },
{ migrated_to_userId: userId, migrated_at: new Date() }
);
return {
success: true,
authToken: generateAuthToken(userId),
messagesMigrated: anonData.messages.length,
userId
};
}
3. 멱등성 & 충돌 처리
3.1 재시도 안전성 (Idempotency)
문제: 네트워크 오류로 요청이 2번 도착하면 계정 2개 생성?
해결:
javascript// 클라이언트: 각 auth 요청에 Idempotency-Key 헤더 추가
POST /api/auth/signup
Idempotency-Key: {ownerToken}-{timestamp}-{hash}
javascript// 서버: 요청 중복 탐지
const idempotencyCache = new Map(); // 또는 Redis
function (req, res) {
const key = req.headers['idempotency-key'];
if (idempotencyCache.has(key)) {
return res.json(idempotencyCache.get(key)); // 캐시된 응답 반환
}
// 정상 처리
const result = await migrateAnonymousSession(...);
idempotencyCache.set(key, result); // 5분 TTL
setTimeout(() => idempotencyCache.delete(key), 5 * 60 * 1000);
res.json(result);
}
3.2 이메일 충돌 처리
| 상황 | 판단 | 처리 |
|---|---|---|
| email 미등록 | ✅ | 새 계정 생성, 히스토리 이관 |
| email 등록됨, ownerToken 동일 | ✅ 재로그인 | authToken 재발급 (새 이관 스킵) |
| email 등록됨, ownerToken 다름 | ❌ 충돌 | 409 Conflict + "다른 이메일 사용" 제안 |
javascriptasync signup(email, password) {
const ownerToken = getFromHeader(); // 요청의 ownerToken
// 충돌 확인
const existing = await db.get('accounts', { email });
if (existing) {
if (existing.ownerTokenMigrated === ownerToken) {
// 재로그인: 새로운 authToken만 발급
return { authToken: generateAuthToken(existing.userId) };
} else {
// 충돌: 다른 사용자가 이미 가입
return { error: 'Email already in use', status: 409 };
}
}
// 정상 경로: 새 계정 생성
return await migrateAnonymousSession(ownerToken, email, hashPassword(password));
}
4. 보안 고려사항
4.1 토큰 검증
현재 코드 문제점 (engine/auth.js 검토 필수):
- ownerToken 유효성 검증 누락? → 중요
- 익명 세션 데이터 암호화? → 권장
javascript// 1. ownerToken 형식 검증
function isValidOwnerToken(token) {
// Format: base64(uuid + timestamp + signature)
return /^[A-Za-z0-9+/=]{40,}$/.test(token);
}
// 2. 서명 검증 (클라이언트가 만든 토큰 위조 방지)
function verifyOwnerTokenSignature(token) {
const [payload, signature] = token.split('.');
const expected = hmac_sha256(payload, SECRET_KEY);
return signature === expected;
}
// 3. 시간 검증 (토큰이 너무 오래되면 거부)
function isTokenExpired(token) {
const [payload] = token.split('.');
const { timestamp } = JSON.parse(base64decode(payload));
return Date.now() - timestamp > 90 * 24 * 60 * 60 * 1000; // 90일
}
4.2 CSRF 방지
form-based signup의 경우:
html<form action="/api/auth/signup" method="POST">
<input type="hidden" name="_csrf" value="{csrfToken}">
<input type="email" name="email">
<input type="password" name="password">
</form>
SPA (JSON)의 경우:
javascript// 요청 헤더에 CSRF 토큰 추가
fetch('/api/auth/signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(),
'Authorization': 'Bearer ' + ownerToken
},
body: JSON.stringify({ email, password })
});
4.3 토큰 혼입 방지
위험: 익명 ownerToken을 authToken처럼 사용하면?
javascript// ❌ 나쁜 예
if (req.headers.authorization.startsWith('Bearer ')) {
const token = req.headers.authorization.slice(7);
const user = await getUserByToken(token); // ownerToken도 통과?
}
// ✅ 좋은 예
const authToken = req.headers.authorization?.slice(7);
const session = await db.get('auth_sessions', { token: authToken });
if (!session || session.type !== 'authenticated') {
return res.status(401).json({ error: 'Invalid auth token' });
}
토큰 타입 명시:
javascript// 발급 시
{
token: 'auth_...', // 접두사로 타입 표시
type: 'authenticated',
userId: '...',
expiresAt: ...
}
// vs
{
ownerToken: 'owner_...',
type: 'anonymous',
createdAt: ...
}
5. 업계 사례 & 베스트 프랙티스
5.1 Firebase Authentication
공식 문서: Account Linking
javascript// 익명 사용자가 이메일 가입
firebase.auth().currentUser.linkWithCredential(credential)
.then(() => {
// 계정 이관 완료
// 기존 anonymousUser.uid → authenticatedUser.uid로 통합
// Realtime DB: /users/{uid}/messages 그대로 유지
});
특징:
- UID 변경 없음 (데이터 자동 이관)
- 이미 가입된 이메일 감지 → linkWithPopup 제안
- 멱등성 보장
5.2 Auth0
권장 흐름: Universal Login + Account Linking
javascript// 1. 익명: localStorage에 sessionToken 저장
const anonToken = generateUUID();
localStorage.setItem('anonToken', anonToken);
// 2. 로그인 후
auth0Client.loginWithPopup().then(user => {
// 3. 서버: anonToken ↔ user.sub 매핑
POST /api/link-account
{ anonToken, user_id: user.sub }
// 4. 기존 데이터 이관
});
특징:
- 중복 이메일 자동 감지
- 멀티팩터 인증(MFA) 선택적 보호
- 계정 머징 정책 설정 가능
5.3 Stripe (결제 SaaS)
javascript// 익명 구매 후 계정 생성
POST /stripe/customers
{
email: "user@example.com",
metadata: {
anonymous_session_id: ownerToken
}
}
// 응답
{
id: "cus_...",
email: "user@example.com",
metadata: { ... }
}
// 이후 같은 email로 재가입 시 → 자동으로 같은 cus_ 사용
특징:
- Email 중복 불가 (고유 제약)
- Metadata로 추적
- API 호출 멱등성 보장 (idempotency_key)
5.4 GitHub / GitLab
패턴: OAuth → Local Account Linking
[1] OAuth 로그인 (GitHub)
→ 서버: github_id, email 저장
[2] 나중에 email/password 가입 시도
→ 기존 github_id와 매칭
→ 두 계정 통합 제안 또는 자동 통합
6. Crowny.org 구현 로드맵
6.1 즉시 (Phase 1: MVP)
필수:
- ✅ engine/auth.js 수정
migrateAnonymousSession(ownerToken, email, password) 함수 추가
- 토큰 형식 검증 강화
- 중복 이관 방지 (멱등성)- ✅ DB 스키마
- ✅ public/index.html
검증:
bash# 테스트 케이스
1. 익명 대화 3개 → 회원가입 → 히스토리 보존 확인
2. 같은 이메일로 재로그인 → 중복 이관 없음
3. 다른 브라우저서 같은 이메일 가입 시도 → 409 Conflict
6.2 고도화 (Phase 2)
- CSRF 보호: X-CSRF-Token 헤더 추가
- Idempotency: Idempotency-Key 캐시 구현
- 토큰 타입 분리: auth_ vs owner_ 접두사
- 감시: 중복 이관 시도 로깅
6.3 장기 (Phase 3)
- OAuth (Google, GitHub): 자동 계정 링킹
- MFA: 선택적 2단계 인증
- 계정 통합: 여러 identity (이메일 + OAuth) 지원
7. 핵심 체크리스트
- engine/auth.js에서 ownerToken 검증 로직 확인
- 토큰 시그니처 또는 TTL 검증 추가 필요?
- DB 트랜잭션: 계정 생성 + 히스토리 이관이 원자적인가?
- 에러 응답: 409 Conflict, 401 Unauthorized 명확히 구분
- public/index.html: 로그인 후 새 authToken 적용 여부
- 테스트: 멀티 브라우저, 재시도, 이메일 충돌 케이스
- 모니터링: 중복 이관 시도, 토큰 검증 실패 로그
8. 참고 출처
| 자료 | 출처 | 관련 섹션 |
|---|---|---|
| Firebase Account Linking | firebase.google.com/docs | 1.2, 5.1 |
| Auth0 Best Practices | auth0.com/docs | 5.2 |
| RFC 6749 OAuth 2.0 | tools.ietf.org | 4.1 (토큰 검증) |
| OWASP Session Management | owasp.org/www-community | 4.2 (CSRF) |
| Stripe Idempotency | stripe.com/docs/api/idempotent_requests | 3.1 |
결론
익명→인증 이관의 핵심 3가지:
- 투명한 데이터 이관: 대화 히스토리를 손실 없이 accounts 테이블로 옮기되, ownerTokenMigrated로 중복 방지
- 멱등성 보장: 네트워크 재시도, 재로그인 등에서 중복 생성 불가
- 보안 검증: 토큰 타입 분리, CSRF, 시그니처 검증로 혼입 차단