← 목록
기타 2026-06-13 13KB 읽기 16분

익명 세션 → 인증 계정 이관 베스트 프랙티스

작성: 2026-06-13 | 대상: crowny.org (Gemini 기반 AI 채팅)


개요

사용자가 브라우저의 비밀 ownerToken 으로 익명 대화를 시작한 후, 회원가입/로그인으로 계정을 연결할 때 진행 중인 대화 히스토리, 상태, 환경설정을 손실 없이 이관하되 중복·충돌·보안 위험을 차단하는 아키텍처.

핵심 요구사항

  1. ✅ 기존 대화 히스토리 보존 (익명 세션의 모든 메시지)
  2. ✅ 계정 매칭 시 멱등성 (재로그인 시에도 데이터 중복 없음)
  3. ✅ 보안 (토큰 검증, CSRF, 토큰 혼입 방지)
  4. ✅ 충돌 처리 (같은 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)

패턴: Customer object linking

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)

필수:

  1. ✅ engine/auth.js 수정
- migrateAnonymousSession(ownerToken, email, password) 함수 추가 - 토큰 형식 검증 강화 - 중복 이관 방지 (멱등성)

  1. ✅ DB 스키마
- accounts.ownerTokenMigrated 추가 컬럼 - anonymous_conversations TTL 설정 (30일)

  1. ✅ public/index.html
- 로그인 후 ownerToken → authToken 교체 - 대화 히스토리 재로드 (새 userId로)

검증:

bash# 테스트 케이스
1. 익명 대화 3개 → 회원가입 → 히스토리 보존 확인
2. 같은 이메일로 재로그인 → 중복 이관 없음
3. 다른 브라우저서 같은 이메일 가입 시도 → 409 Conflict

6.2 고도화 (Phase 2)

  1. CSRF 보호: X-CSRF-Token 헤더 추가
  2. Idempotency: Idempotency-Key 캐시 구현
  3. 토큰 타입 분리: auth_ vs owner_ 접두사
  4. 감시: 중복 이관 시도 로깅

6.3 장기 (Phase 3)

  1. OAuth (Google, GitHub): 자동 계정 링킹
  2. MFA: 선택적 2단계 인증
  3. 계정 통합: 여러 identity (이메일 + OAuth) 지원

7. 핵심 체크리스트

  • engine/auth.js에서 ownerToken 검증 로직 확인
  • 토큰 시그니처 또는 TTL 검증 추가 필요?
  • DB 트랜잭션: 계정 생성 + 히스토리 이관이 원자적인가?
  • 에러 응답: 409 Conflict, 401 Unauthorized 명확히 구분
  • public/index.html: 로그인 후 새 authToken 적용 여부
  • 테스트: 멀티 브라우저, 재시도, 이메일 충돌 케이스
  • 모니터링: 중복 이관 시도, 토큰 검증 실패 로그

8. 참고 출처

자료출처관련 섹션
Firebase Account Linkingfirebase.google.com/docs1.2, 5.1
Auth0 Best Practicesauth0.com/docs5.2
RFC 6749 OAuth 2.0tools.ietf.org4.1 (토큰 검증)
OWASP Session Managementowasp.org/www-community4.2 (CSRF)
Stripe Idempotencystripe.com/docs/api/idempotent_requests3.1

결론

익명→인증 이관의 핵심 3가지:

  1. 투명한 데이터 이관: 대화 히스토리를 손실 없이 accounts 테이블로 옮기되, ownerTokenMigrated로 중복 방지
  2. 멱등성 보장: 네트워크 재시도, 재로그인 등에서 중복 생성 불가
  3. 보안 검증: 토큰 타입 분리, CSRF, 시그니처 검증로 혼입 차단
즉시 구현 우선순위: engine/auth.js 토큰 검증 강화 → 중복 이관 방지 로직 → DB 트랜잭션 원자성 확인.