diff --git a/AGENTS.md b/AGENTS.md
index 87b3819..83530f1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,6 +43,8 @@
(`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의
수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.**
어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다).
+- **`/builder` 는 로그인 뒤에 있다.** 자동 로그인(`AUTO_LOGIN_ID`·`PW`)은 화면 안이 아니라
+ 부팅(`app/provider.tsx`)에서 붙는다 — 가드가 먼저 판단하므로 화면 안에서 부르면 늦다.
- **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데
블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는
CDN 을 앞에 세워야 한다. 아니면 비워라.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 9e5a7ab..35a93df 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -137,10 +137,12 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
`UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다.
라우트 가드는 화면을 가리지 **번들은 못 가린다.**
★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다.
-2. **인증 모델이 갈라진다.** 빌더는 일부러 로그인을 안 세운다("만들어 보기도 전에 막힌다").
- 내부 화면은 전부 `RequireAuth` 뒤다. 한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
- → 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 /
- 내부: 실패가 곧 차단).
+2. **인증 모델이 갈라진다.** 둘 다 `RequireAuth` 뒤로 들어갔지만(2026-09-02 빌더 포함)
+ **계정이 생기는 방식**이 다르다 — 사장님은 스스로 가입하고 구글로도 들어오는 반면,
+ 내부 운영 계정은 우리가 만들고 role >= DEVELOPER 여야 한다. 한 앱에서 두 정책을 유지하면
+ 실수는 늘 **느슨한 쪽으로** 난다.
+ → 지금은 `LoginPage` 의 `selfServe` 플래그가 그 차이를 한 곳에서 드러낸다
+ (사장님: 가입 링크 + 구글 버튼 / 내부: 둘 다 없음).
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
### 백엔드 — 코드 한 벌, 진입점 둘
@@ -177,7 +179,9 @@ OWNER role=2 → 403
```
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
-`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다).
+`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도
+같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고,
+자기 회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가
@@ -222,8 +226,12 @@ admin 자기 파일만 `@admin` 이다.
### 아직 안 한 것
-- 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 —
- 그때 `solution/frontend` 의 인증 정책을 다시 본다.
+- 사장님 **"내 사이트 관리"** 화면(내 사업장 목록). 빌더는 2026-09-02 에 로그인 뒤로
+ 들어갔고, 로그인 후 도착지는 아직 `/builder?new=1`(새로 만들기) 하나뿐이다.
+- **계정 연결** — 같은 사람의 id/pw 계정과 구글 계정을 잇는 경로. 지금은 잇지 않고
+ 거절한다 → [DECISIONS.md 1-5](DECISIONS.md)
+- **비밀번호 재설정** — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다. 그래서
+ 비밀번호를 잊으면 운영자가 손으로 바꿔 주는 수밖에 없다.
- 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을
`127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.**
- **폰트 self-host** — `solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로
diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md
index b0ec3bf..0604186 100644
--- a/docs/DEVLOG.md
+++ b/docs/DEVLOG.md
@@ -3,6 +3,44 @@
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다.
결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
+## 2026-09-02 — 로그인을 붙이고, 빌더를 그 뒤로 넣었다
+
+**한 일**
+- id/pw **회원가입**(`POST /v1/auth/signup`) 과 **구글 로그인**(`POST /v1/auth/google`) 추가.
+- `company.users` 에 `provider`(AuthProvider) · `provider_uid`(구글 sub) 추가. `password` 는
+ NULL 허용, `id` 는 20 → 64자.
+- `/builder` 를 `RequireAuth` 뒤로 넣었다. 자동 로그인은 화면 안(`useAutoLogin`) 이 아니라
+ 부팅(`app/provider.tsx`)에서 붙는다 — 가드가 먼저 판단하므로 화면 안은 이제 실행되지 않는다.
+- 로그인 화면에 구글 버튼 + 가입 링크. 내부 운영 화면은 `selfServe={false}` 로 둘 다 안 뜬다.
+
+**왜 빌더를 막았나**
+빌더는 "만들어 보기 전에 막지 않으려고" 열려 있었다. 그런데 위저드 2단계부터 백엔드를 부르고,
+만든 결과는 사업장·사이트로 **계정에 귀속**된다. 로그인 없이 걸어온 사람은 3단계쯤에서
+"로그인이 만료되었습니다"를 만나고 그때까지 넣은 걸 잃었다 — 만료가 아니라 처음부터 세션이
+없었던 것이다. 문 앞에서 막는 편이 걸어 들어온 뒤에 막는 것보다 낫다.
+
+**왜 가입까지 만들었나**
+빌더가 로그인 뒤로 들어간 순간, 계정을 만들 길이 없으면 제품이 닫힌다. 계정 생성 API 가
+아예 없어서(그동안 `users` 를 손으로 INSERT 했다) 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**
+로 정의했다. `users.company_id` 가 NOT NULL 이고 모든 도메인이 company 로 스코프되기 때문이다.
+
+**밟기 쉬운 자리**
+- **`GOOGLE_CLIENT_ID` 는 백엔드와 프론트가 같아야 한다.** 백엔드는 이 값으로 구글 토큰의
+ 수신자(`aud`)를 대조한다 — 이 검사가 없으면 **다른 서비스에 발급된 진짜 구글 토큰**으로
+ 우리 계정에 들어온다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다.
+- **같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다.** 이으면 계정 선점이
+ 된다 → [DECISIONS.md 1-5](DECISIONS.md)
+- 소셜 계정은 `password` 가 NULL 이다. id/pw 로그인 경로에서 먼저 끊지 않으면 해시 검증이
+ None 을 만나 500 이 난다.
+- `provider` 에 `server_default` 를 같이 줬다. ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
+ NOT NULL 컬럼이면 그 경로가 통째로 깨진다.
+
+**이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다 — 말미의
+"기존 DB 보정(ALTER)" 섹션이 새 컬럼을 채우고 `id` 를 넓힌다. 안 하면 로그인부터 500 이다.
+
+**검증** — 백엔드 `pytest` auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·
+`email_verified` 거절 확인), 프론트 `tsc` · `eslint` · `vite build` 통과.
+
---
## 2026-09-01 — 설정을 `.env` 하나로 모았다
diff --git a/solution/frontend/src/app/provider.tsx b/solution/frontend/src/app/provider.tsx
index 4ff0817..354342d 100644
--- a/solution/frontend/src/app/provider.tsx
+++ b/solution/frontend/src/app/provider.tsx
@@ -2,13 +2,17 @@ import {QueryClientProvider} from '@tanstack/react-query';
import {useEffect, type ReactNode} from 'react';
import {Toaster} from 'sonner';
import {getAccessToken, me} from '@/api';
+import {ensureAutoSession} from '@/lib/autoSession';
import {queryClient} from '@/lib/query-client';
import {toAuthUser, useAuthStore} from '@/stores/auth';
/**
- * 저장된 액세스 토큰으로 세션을 복구한다.
- * 실패해도 앱은 뜬다 — 빌더는 로그인 없이도 도는 화면이라, 인증 실패가
- * 전체를 막으면 데모조차 못 본다. 백엔드가 필요한 화면만 가드가 막는다.
+ * 저장된 액세스 토큰으로 세션을 복구한다. 실패해도 앱은 뜬다 —
+ * 로그인 화면·가입 화면은 로그인 없이 열려야 하므로, 인증 실패가 전체를 막으면 안 된다.
+ *
+ * ★ 자동 로그인(VITE_AUTO_LOGIN_ID·PW)을 **여기서** 시도한다. 예전엔 빌더 화면 안에서
+ * 불렀는데, 빌더가 RequireAuth 뒤로 들어가면서 그 자리는 영영 실행되지 않는다 —
+ * 가드가 먼저 판단하고 로그인 화면으로 보내 버린다. 계정이 안 주입돼 있으면 즉시 끝난다.
*/
function useRestoreSession() {
const setUser = useAuthStore((s) => s.setUser);
@@ -16,24 +20,27 @@ function useRestoreSession() {
useEffect(() => {
let alive = true;
- if (!getAccessToken()) {
- finishRestore();
- return;
- }
- void me()
- .then((res) => {
- if (!alive || res.result?.success === false) return;
+ void (async () => {
+ if (!getAccessToken()) await ensureAutoSession();
+ if (!getAccessToken()) {
+ if (alive) finishRestore();
+ return;
+ }
+ // 자동 로그인이 방금 신원까지 채웠으면 me() 를 두 번 부르지 않는다.
+ if (useAuthStore.getState().user) {
+ if (alive) finishRestore();
+ return;
+ }
+ try {
+ const res = await me();
// ★ RemoveNoneResponse 라 신원 필드가 통째로 빠져 올 수 있다.
// 반쪽짜리 사용자를 세우면 화면은 로그인된 것처럼 굴면서 요청은 401 이 난다 — 세우지 않는다.
- if (!res.user_id || !res.id) return;
- setUser(toAuthUser(res));
- })
- .catch(() => {
+ if (alive && res.result?.success !== false && res.user_id && res.id) setUser(toAuthUser(res));
+ } catch {
/* 토큰이 죽었으면 비로그인 상태로 계속 간다. */
- })
- .finally(() => {
- if (alive) finishRestore();
- });
+ }
+ if (alive) finishRestore();
+ })();
return () => {
alive = false;
};
diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx
index b2206fb..89f3f7a 100644
--- a/solution/frontend/src/app/router.tsx
+++ b/solution/frontend/src/app/router.tsx
@@ -1,11 +1,14 @@
import {createBrowserRouter, Navigate} from 'react-router';
+import {RequireAuth} from '@/components/layout/RequireAuth';
import {BuilderPage} from '@/pages/BuilderPage';
import {DevShowcasePage} from '@/pages/DevShowcasePage';
import {LoginPage} from '@/pages/LoginPage';
import {NotFoundPage} from '@/pages/NotFoundPage';
+import {SignupPage} from '@/pages/SignupPage';
export const router = createBrowserRouter([
{path: '/login', element: },
+ {path: '/signup', element: },
// ★ 첫 화면은 업종 선택(위저드 1단계)이다.
// `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist),
@@ -14,15 +17,26 @@ export const router = createBrowserRouter([
{path: '/', element: },
/**
- * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
- * 만들어 보기도 전에 막힌다.
+ * ★ 빌더부터는 로그인한 사람만 들어온다.
*
- * 대신 세션은 조용히 확보한다 — VITE_AUTO_LOGIN_ID·PW 가 주입돼 있으면 useAutoLogin() 이
- * 그 계정으로 붙고, 없으면 서버가 필요한 순간(2단계 검색)에만 알린다.
+ * 예전엔 가드 없이 열어 뒀다(만들어 보기 전에 막지 않으려고). 그런데 위저드 2단계부터
+ * 백엔드를 부르고, 만든 결과는 사업장·사이트로 **계정에 귀속**된다 — 로그인 없이 걸어온
+ * 사람은 3단계쯤에서 "로그인이 만료되었습니다"를 만나고 그때까지 넣은 걸 잃었다.
+ * 문 앞에서 막는 편이 걸어 들어온 뒤에 막는 것보다 낫다.
*
- * 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조.
+ * 자동 로그인(VITE_AUTO_LOGIN_ID·PW)은 그대로 산다 — 다만 이제 화면 안이 아니라
+ * 부팅 때 붙는다(app/provider.tsx). 가드가 먼저 판단하므로 화면 안에서는 늦다.
+ *
+ * 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조.
*/
- {path: '/builder', element: },
+ {
+ path: '/builder',
+ element: (
+
+
+
+ ),
+ },
/**
* ★ 내부 운영 화면(/places, /local-content, /seo)은 여기 없다 — 최상단 `admin/` 앱으로 나갔다.
diff --git a/solution/frontend/src/hooks/useAutoLogin.ts b/solution/frontend/src/hooks/useAutoLogin.ts
deleted file mode 100644
index 2830f5d..0000000
--- a/solution/frontend/src/hooks/useAutoLogin.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import {useEffect} from 'react';
-import {ensureAutoSession} from '@/lib/autoSession';
-
-/**
- * 화면이 열리자마자 세션을 확보한다.
- *
- * 실제 로직은 `lib/autoSession` 에 있다 — 서버를 부르는 쪽(usePlaceSearch)도 같은 약속을
- * 기다려야 하기 때문에, 훅 바깥에 두고 공유한다.
- */
-export function useAutoLogin() {
- useEffect(() => {
- void ensureAutoSession();
- }, []);
-}
diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx
index 1146e6f..eb9259d 100644
--- a/solution/frontend/src/pages/BuilderPage.tsx
+++ b/solution/frontend/src/pages/BuilderPage.tsx
@@ -11,7 +11,6 @@ import {
Step5Generating,
} from '@/features/onboarding';
import {EditorLayout} from '@/features/builder';
-import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
@@ -32,14 +31,12 @@ function siteUrl(domain: string | null | undefined): string | null {
}
export function BuilderPage() {
- useAutoLogin();
/**
* 어떤 사업장을 편집할지는 쿼리스트링으로 받는다 — `/builder?placeId=`.
*
- * ★ 라우트(`/builder/:placeId`)로 받지 않는 이유: 빌더는 로그인 없이 도는 데모 경로이고
- * (router.tsx 주석), placeId 는 있을 수도 없을 수도 있는 선택값이다. 쿼리스트링이면
- * 라우트를 하나도 안 건드리고 두 경우를 같은 화면이 받는다.
- * placeId 가 없으면 아래 훅은 네트워크를 한 번도 타지 않는다 — 데모는 지금 그대로다.
+ * ★ 라우트(`/builder/:placeId`)로 받지 않는 이유: placeId 는 있을 수도 없을 수도 있는
+ * 선택값이다(새로 만들기 vs 사업장 열기). 쿼리스트링이면 라우트를 하나도 안 건드리고
+ * 두 경우를 같은 화면이 받는다. placeId 가 없으면 아래 훅은 네트워크를 타지 않는다.
*/
const [searchParams, setSearchParams] = useSearchParams();
const urlPlaceId = searchParams.get('placeId');