Hook
OAuth 로그인을 붙이려다 보니 302 리다이렉트, 쿠키 누락, 한글 깨짐, 아키텍처 변경까지 — 총 9번의 삽질 끝에 찾은 정답입니다.
TL;DR
- 302 + Set-Cookie는 Pages에서 불안정 → 200 HTML + JS 리다이렉트로 대체합니다
- 다중 Set-Cookie는
Headers.append()필수 → 생성자는 쉼표로 병합합니다 - SPA에서는 localStorage가 쿠키보다 안정적 → Pages Functions의 쿠키 처리 한계 때문입니다
- Secrets는 Dashboard 등록 후 재배포 필수 → env가 즉시 반영되지 않습니다
- 프론트엔드와 API는 같은 도메인에 → Pages Functions로 통합합니다
인증 아키텍처
┌──────────────────────────────────────────────────────────┐
│ my-app.pages.dev (Cloudflare Pages) │
│ │
│ index.html (SPA) functions/api/auth/[[route]].js │
│ ┌────────────────┐ ┌────────────────────────────┐ │
│ │ loginGoogle() │─────▶│ GET /api/auth/google │ │
│ │ loginKakao() │─────▶│ GET /api/auth/kakao │ │
│ │ │◀─────│ GET /api/auth/*/callback │ │
│ │ localStorage │ │ → 200 HTML 응답 │ │
│ │ user, token │ │ → JS localStorage 저장 │ │
│ └────────────────┘ │ → /?auth=success 이동 │ │
│ │ JWT HMAC-SHA256 │ │
│ │ D1: users, sessions │ │
│ └────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
- SPA에서 Google/Kakao 로그인 버튼 클릭 → Pages Function이 OAuth Provider로 리다이렉트합니다
- Provider 인증 완료 → Callback Function이 D1에 사용자를 저장하고 JWT를 생성합니다
- 200 HTML 응답에 사용자 정보를 포함해 반환 → JS가 localStorage에 저장합니다
- 이후 API 요청 시
Authorization: Bearer <JWT>헤더를 사용합니다
이슈 요약표
| # | 이슈 | 원인 | 해결 |
|---|---|---|---|
| 1 | Set-Cookie 다중 값 병합 | Headers 생성자 동일 키 병합 | Headers.append() 사용 |
| 2 | 302에서 Set-Cookie 누락 | CDN 프록시 쿠키 제거 | 200 HTML + JS 리다이렉트 |
| 3 | 쿠키 방식 전면 실패 | Pages Functions 쿠키 불안정 | localStorage 직접 저장 |
| 4 | 로그인 후 수동 새로고침 필요 | init() auth 파라미터 미확인 | URL 파라미터 우선 체크 |
| 5 | 한글 이름 깨짐 | 이중 encodeURIComponent | JSON↔URI 인코딩 체인 정리 |
| 6 | D1 바인딩 인식 불가 | wrangler.toml 설정 누락 | [[d1_databases]] 추가 |
| 7 | Secrets 접근 불가 | Dashboard Encrypt 등록 필요 | Dashboard 등록 후 재배포 |
| 8 | Worker → Pages Functions 이관 | 크로스 도메인 CORS·SameSite | Pages Functions로 통합 |
| 9 | render() 빈 문자열 덮어쓰기 | 재귀 render 후 return ‘’ | 반환값 체크 후 innerHTML |
Issue #1: Set-Cookie 다중 값이 단일 헤더로 병합됨
현상: 두 개의 쿠키를 설정했으나 브라우저에는 하나만 도달합니다. 원인: new Headers({}) 생성자가 동일 키를 쉼표로 병합합니다. 해결: Headers.append()를 사용하면 개별 엔트리로 추가됩니다.
const headers = new Headers();
headers.append('Set-Cookie', 'access_token=xxx; Path=/; HttpOnly; SameSite=Lax');
headers.append('Set-Cookie', 'refresh_token=yyy; Path=/; HttpOnly; SameSite=Lax');
Issue #2: 302 리다이렉트에서 Set-Cookie 누락
현상: 302 응답의 Set-Cookie가 브라우저에 도달하지 않습니다. 원인: Cloudflare CDN 프록시가 302 응답의 쿠키를 제거합니다. 해결: 200 상태 코드로 HTML을 반환하고 JS로 리다이렉트합니다.
return new Response(`<!DOCTYPE html><html><body>
<script>window.location.href = '/?auth=success';</script>
<p>로그인 중...</p></body></html>`, {
status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
Issue #3: 쿠키 방식 전면 폐기 → localStorage 직접 저장
현상: HttpOnly 쿠키는 SPA에서 읽을 수 없고, Pages Functions의 쿠키 읽기도 간헐적입니다. 원인: Pages Functions Edge Runtime의 쿠키 처리에 복합적 한계가 있습니다. 해결: 쿠키를 포기하고 콜백 HTML에서 localStorage에 직접 저장합니다.
const userData = encodeURIComponent(JSON.stringify({
id: user.id, name: user.name, email: user.email, provider: 'google',
}));
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
<script>
var u = JSON.parse(decodeURIComponent('${userData}'));
localStorage.setItem('user', JSON.stringify(u));
window.location.href = '/?auth=success';
</script></body></html>`;
return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
Issue #4: OAuth 로그인 후 수동 새로고침 필요
현상: /?auth=success 리다이렉트 후 랜딩 페이지가 표시되어 F5를 눌러야 합니다. 원인: init()가 auth 파라미터를 확인하기 전에 렌더링을 완료해버립니다. 해결: init() 최상단에서 URL 파라미터를 먼저 확인합니다.
function init() {
const params = new URLSearchParams(window.location.search);
if (params.get('auth') === 'success') {
currentPage = 'login';
render();
return;
}
render();
}
Issue #5: 한글 이름 깨짐 (Mojibake)
현상: Google 계정의 한글 이름이 깨져서 저장됩니다. 원인: encodeURIComponent 이중 인코딩으로 UTF-8 처리가 어긋납니다. 해결: JSON → encodeURIComponent → 전송 → decodeURIComponent → JSON.parse 체인을 일관되게 적용합니다.
const userData = encodeURIComponent(JSON.stringify({ name: user.name }));
// HTML에 삽입: var u = JSON.parse(decodeURIComponent('${userData}'));
Issue #6: D1 바인딩 인식 불가
현상: env.DB가 undefined이며 Cannot read properties of undefined 에러가 발생합니다. 원인: Pages Functions는 Worker와 달리 wrangler.toml에 명시적 D1 바인딩이 필요합니다. 해결: wrangler.toml에 바인딩을 추가합니다.
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "your-db-id"
Issue #7: Secrets 환경변수 접근 불가
현상: env.JWT_SECRET, env.GOOGLE_CLIENT_SECRET이 undefined입니다. 원인: Secrets는 wrangler.toml [vars]가 아닌 Dashboard에서 암호화 등록해야 합니다. 해결: Dashboard에서 Encrypt 옵션으로 등록 후 재배포합니다.
wrangler pages secret put JWT_SECRET --project-name my-app
wrangler pages secret put GOOGLE_CLIENT_SECRET --project-name my-app
Secrets 변경 후 기존 배포에는 자동 반영되지 않습니다. 재배포가 필수입니다.
아키텍처 이관 이야기 (Issue #8)
이것이 가장 극적인 삽질이었습니다. 처음에는 인증 API를 별도 Worker(my-api.workers.dev)에 배포했습니다. 프론트엔드(my-app.pages.dev)와 도메인이 다르니 CORS 에러가 발생하고, 쿠키는 SameSite 정책에 막히고, OAuth Redirect URI는 두 도메인을 관리해야 하고 — 하나를 고치면 다른 게 터지는 악순환이었습니다.
결국 전부 갈아엎고 Pages Functions로 통합했습니다.
Before: my-app.pages.dev → my-api.workers.dev (CORS, SameSite, 이중 배포)
After: my-app.pages.dev/api/* (동일 도메인, CORS 불필요)
my-app/
├── index.html
└── functions/
└── api/
└── auth/
└── [[route]].js
이관 후 CORS, 쿠키, 배포 복잡성 문제가 모두 해결되었습니다.
Issue #9: render() 빈 문자열 덮어쓰기
현상: OAuth 로그인 완료 후 빈 화면이 표시됩니다. 원인: renderLogin()이 내부에서 render()를 호출한 뒤 return '' → 바깥 render()가 빈 문자열로 innerHTML을 덮어씁니다. 해결: 반환값을 체크해 빈 문자열이면 innerHTML 대입을 스킵합니다.
function render() {
let html = '';
switch (currentPage) {
case 'login': html = renderLogin(); break;
}
if (html) document.getElementById('app').innerHTML = html;
}
Takeaway
- Cloudflare Pages에서 302+Set-Cookie는 믿지 마세요 — 200 HTML + JS 리다이렉트가 안전합니다
- 인증은 localStorage + Pages Functions — 별도 Worker 도메인은 CORS·쿠키 문제의 늪입니다
- Secrets 등록 후에는 반드시 재배포 — env가 즉시 반영되지 않습니다
게이트웨이 On-promise 제품 팀에서 시스템 모니터링 및 관리를 쉽게 다가갈 수 있도록 하기 위한 업무를 하고 있습니다.
Contact: lhjnano@gmail.com