Supabase Auth에서 Mongo로 갈아탄 삽질

처음엔 그냥 귀찮아서 Supabase Auth를 썼다. Google 로그인도 버튼 몇 번 누르면 되고, 회원 테이블도 알아서 생기고, 관리자 계정도 대충 이메일로 체크하면 됐다.
근데 나중에 보니 이게 은근히 발목을 잡더라. 사이트 본체는 MongoDB를 쓰고 있는데 로그인만 Supabase에 얹혀 있으니까, 회원 정보 하나 보려 해도 머릿속에서 계정 원장이 둘로 갈라졌다. 결국 큰맘먹고 Supabase를 걷어내고 MongoDB에 직접 auth_db를 만들기로 했다.
문제는 생각보다 단순하지 않았다.
처음 생각은 이랬다.
Supabase에 있는 회원 3명을 Mongo로 그대로 옮기면 되겠지. 기존 user.id도 유지하고, Google OAuth 연결도 새로 만들고, 관리자 여부도 roles로 바꾸면 되겠네.
말은 쉬운데, 실제로 보니까 내가 작성한 글, 댓글, 관리자 데이터가 전부 예전 Supabase user.id를 물고 있었다. 여기서 대충 새 UUID로 회원을 만들면, 로그인은 되는데 예전 내가 쓴 데이터랑 지금 로그인한 내가 서로 다른 사람이 되는 구조였다.
처음엔 이걸 좀 과하게 설계했다
처음 설계는 나름 정석대로 갔다.
auth_db
- users
- external_logins
- refresh_sessionsusers는 우리 서비스 기준 회원 원장이고, external_logins는 Google 계정과 우리 회원을 연결하는 컬렉션이다. refresh_sessions는 브라우저 로그인 세션 저장소로 썼다.대충 이런 모양이다.
// auth_db.users
{
_id: "[기존_USER_ID]",
email: "[개인정보]",
email_normalized: "[개인정보]",
display_name: "[개인정보]",
avatar_url: null,
email_verified: true,
roles: ["admin"],
legacy_user_ids: ["1"],
status: "active"
}// auth_db.external_logins
{
provider: "google",
provider_user_id: "[GOOGLE_PROVIDER_USER_ID]",
user_id: "[기존_USER_ID]",
email_at_link: "[개인정보]"
}여기서 중요한 건 provider_user_id다. Google OAuth의 sub 값인데, 이 값이 Google 계정의 고유 식별자다. 이메일은 바뀔 수도 있으니까 최종 연결 키로 쓰면 찝찝하고, sub를 기준으로 우리 회원 user_id에 연결하는 식으로 잡았다.이 구조 자체는 괜찮았다. 문제는 내가 처음부터 너무 전체 마이그레이션을 하려고 했다는 거다.
회원이 3명뿐이었다
마이그레이션 스크립트까지 만들 생각을 했다. Supabase에서 사용자 3명을 읽고, Google identity를 찾아서 Mongo external_logins에 넣고, 관리자 이메일이면 roles: ["admin"]을 넣는 방식.
근데 문득 보니 회원이 3명뿐이었다. 심지어 지금 회원 기능이 제대로 열린 것도 아니고, 실제로 보존해야 하는 건 내 관리자 계정 하나였다.
여기서 좀 허무했다.
괜히 전체 이전을 전제로 생각하고 있었다. 나머지 회원은 기능을 쓴 것도 아니고, 보존해야 할 데이터도 없는데 내가 혼자 마이그레이션 시나리오를 키우고 있었던 거다. 결국 Supabase에서 내 계정만 남기고 나머지는 수동 삭제했다.
이 판단이 맞았다.
진짜로 보존해야 했던 건 내 계정의 user.id였다
문제는 내 Google 계정으로 새 OAuth 로그인을 해버리면 Mongo에 새 UUID가 생긴다는 점이었다.
처음 로그인했을 때 Mongo에는 이런 식으로 새 계정이 생겼다.
새 Mongo user_id: [임시_USER_ID]
Google provider_user_id: [GOOGLE_PROVIDER_USER_ID]
roles: []로그인은 됐다. 닉네임 설정도 됐다. 근데 관리자도 아니고, 기존 글/댓글 작성자와도 안 맞았다.그제서야 아차 싶었다. 기존 데이터는 여전히 예전 Supabase UID를 보고 있는데, 새 로그인 계정은 완전히 다른 사람인 셈이었다.
그래서 Supabase에서 내 계정의 UID를 확인했다.
기존 Supabase user id: [기존_USER_ID]
email: [개인정보]
provider: google그리고 Mongo 쪽을 강제로 맞췄다. 임시로 생긴 새 user doc은 제거하고, 기존 Supabase UID를 그대로 Mongo auth_db.users._id로 보존했다.{
_id: "[기존_USER_ID]",
email: "[개인정보]",
display_name: "[개인정보]",
roles: ["admin"],
legacy_user_ids: ["1"],
status: "active"
}external_logins.user_id도 같은 값으로 바꿨고, 이미 만들어진 세션의 user_id도 같이 맞췄다.이걸 안 했으면 로그인은 되는데 내 글이 내 글이 아닌 이상한 상태가 됐을 거다.
관리자 판별도 이메일에서 role로 바꿨다
기존에는 관리자 여부가 Supabase 계정이나 이메일 fallback에 묶여 있었다. 개발할 때는 편한데, 나중에 보면 이게 제일 애매하다.
// 이런 식의 판단은 버렸다
user.email === process.env.ADMIN_EMAIL이제 런타임에서는 이메일을 보지 않는다. Mongo auth_db.users.roles만 본다.
user.roles.includes("admin")관리자 계정은 마이그레이션 때만 식별하고, 실제 서비스 동작 중에는 roles가 전부다. 이게 훨씬 마음이 편했다. Google 계정이 같더라도, 우리 서비스에서 admin role이 없으면 관리자가 아니다.
legacy_user_ids: ["1"]</code>도 넣었다. 예전 블로그 작성자 쪽에 legacy author id <code class="inline-code">1</code> 같은 흔적이 있어서, 그걸 관리자 user doc에 흡수하는 방식으로 갔다. 솔직히 예쁜 구조는 아닌데 기존 데이터를 깨는 것보다는 낫다.</p><p>OAuth 리디렉션 URI 때문에 또 헷갈렸다</p><p>Google Console 설정도 한 번 헷갈렸다.</p><p>처음엔 승인된 리디렉션 URI에 도메인만 넣으면 되는 줄 알았다. 근데 OAuth callback은 정확히 route까지 맞아야 한다.</p><pre class="rounded-xl bg-zinc-900 text-zinc-100 p-4 font-mono text-sm my-4"><code class="language-txt">잘못 넣기 쉬운 값
[도메인]
</code></pre><p><code class="inline-code">필요한 값
/auth/callback로컬도 마찬가지다.
/auth/callback도메인은 글에서는 빼지만, 핵심은 같다. Google OAuth는 redirect URI가 정확히 일치해야 한다. 도메인만 넣어두면 callback에서 실패한다.이건 내가 또 대충 보고 넘어가려다 걸릴 뻔했다. OAuth 쪽은 매번 같은 실수를 한다. 콘솔에서 이름은 예쁘게 만들어놓고 redirect URI를 반쪽짜리로 넣는다.
Next에서 Supabase 제거하면서 바꾼 부분
Next 쪽에서는 Supabase SDK 호출을 걷어냈다.
기존에는 이런 흐름이었다.
Supabase Auth
→ supabase.auth.getUser()
→ 이메일 기반 admin 체크
→ 화면/댓글/마이페이지에서 Supabase user 사용바꾼 뒤에는 이렇다.Google OAuth
→ /api/auth/google/start
→ /auth/callback
→ Google id_token 검증
→ auth_db.external_logins 조회
→ auth_db.users 조회
→ HttpOnly 세션 쿠키 발급세션 쿠키에는 opaque random token만 넣고, DB에는 SHA-256 hash만 저장했다. 쿠키가 털려도 DB에서 원문 토큰을 볼 수 없게 하려는 의도였다.browser cookie: random token
auth_db.refresh_sessions: sha256(token)로그아웃하면 세션을 폐기하고, 다음 요청부터는 현재 사용자를 못 찾게 된다.여기까지 해놓고 로컬에서 /admin에 들어갔는데 정상으로 열렸다. 그때야 아, 이제 Supabase 없이도 돌아가는구나 싶었다.
로그 중에 괜히 신경 쓰였던 것들
전환 후 로그를 보니 이런 게 떴다.
GET /api/auth/google/start?next=%2F 307
Failed to fetch RSC payload ... Falling back to browser navigation
GET /auth/callback?... 307
GET /admin 200처음엔 또 뭔가 잘못된 줄 알았다.근데 핵심은 /admin 200이었다. 인증과 관리자 role 체크는 정상 동작하고 있었다.
Failed to fetch RSC payload는 로그인 버튼을 next/link로 API redirect route에 보내서 생긴 로그였다. OAuth 시작 URL은 페이지 라우팅이라기보다 그냥 브라우저 이동이다. 그래서 Link보다는 <a href>가 더 맞다. 기능은 되지만 로그가 지저분해진다.
이런 것도 막상 해봐야 보인다. 로그인 버튼 하나도 Next스럽게 하겠다고 Link를 썼는데, OAuth redirect에는 오히려 일반 anchor가 낫다.
또 이런 로그도 있었다.
Data from this session is not being collected by Microsoft Clarity이건 로컬 환경에서 분석 스크립트가 수집 안 된다는 경고였다. 인증 문제랑 상관없다.blocked_automation ... curl이건 내가 확인한다고 curl로 /login 찔러본 게 보안 로직에 막힌 거였다. 내가 만든 소음이었다.Google 아이콘도 은근히 웃겼다
로컬 로그인 버튼에는 회색 원 안에 G 글자만 보이고, 배포 화면에는 컬러 Google 아이콘이 보였다.
이것도 처음엔 왜 환경마다 다르지 싶었는데, 코드를 보니 답이 너무 단순했다.
<span>
G
</span>그냥 내가 글자 G를 렌더링하고 있었다. 컬러 아이콘이 나올 리가 없다.그래서 SVG로 바꿨다.
<svg
aria-hidden="true"
className="h-5 w-5 shrink-0"
viewBox="0 0 18 18"
xmlns="http://www.w3.org/2000/svg"
>
<path fill="#4285F4" d="..." />
<path fill="#34A853" d="..." />
<path fill="#FBBC05" d="..." />
<path fill="#EA4335" d="..." />
</svg>엄청난 문제는 아닌데, 이런 작은 차이가 배포 전에 계속 눈에 밟힌다. 기능 전환만 보다가 UI 자잘한 걸 놓치기 쉽다..env.local에 없는데 왜 되냐는 착각
또 하나 웃긴 게 있었다.
로컬 .env.local에 Google OAuth 키가 없는데 로그인은 되고 있었다. 순간 뭐지 싶었다.
알고 보니 루트 .env에 있었다.
Next는 .env.local만 읽는 게 아니다. .env도 읽는다. 너무 당연한데, 그 순간엔 당연한 걸 놓쳤다.
다행히 .env*는 gitignore에 들어가 있어서 저장소에 올라가진 않았다. 그래도 확인은 했다.
.env: GOOGLE_OAUTH_CLIENT_ID 있음
.env: GOOGLE_OAUTH_CLIENT_SECRET 있음
.env.local: 없음이거 안 봤으면 괜히 OAuth 캐시니 세션이니 하면서 다른 데를 팠을 거다.내가 최종적으로 잡은 구조
결국 구조는 이렇게 됐다.
auth_db.users우리 서비스 기준 회원roles로 관리자 판단기존 내 Supabase UID 보존
auth_db.external_logins
Google 계정과 users 연결provider_user_id는 Google sub
auth_db.refresh_sessions
HttpOnly 쿠키 세션 저장원문 토큰 대신 hash 저장나머지 서비스 데이터는 기존처럼 자기 DB에 남겨두고, user_id만 auth_db.users._id를 참조한다. Mongo에는 cross-db foreign key 같은 게 없으니까 애플리케이션에서 일관성을 지켜야 한다.
이 부분은 완벽한 해결이라기보다 운영 기준이다. 계정 원장은 auth_db, 콘텐츠는 기존 DB, 라이선스 같은 별도 시스템은 또 자기 책임. 섞지 않는 게 중요했다.
가장 크게 배운 건 마이그레이션 범위였다
처음엔 전체 회원 이전부터 생각했다. 근데 실제로 필요한 건 내 관리자 계정 하나를 기존 UID 그대로 살리는 거였다.
회원이 3명뿐이고, 기능도 아직 본격적으로 열린 상태가 아니라면 굳이 모든 사용자를 보존할 이유가 없었다. 반대로 내 계정 UID는 반드시 보존해야 했다. 내가 작성한 글과 댓글과 관리자 데이터가 거기에 묶여 있었으니까.
이걸 구분 못 하고 처음에 너무 큰 그림부터 그렸다.
이번 작업에서 제일 중요한 결정은 기술 선택이 아니라 이거였다.
나머지 회원은 버린다
내 관리자 계정만 기존 user id로 보존한다
런타임 admin 판단은 email이 아니라 roles만 본다이 세 줄 정하고 나니까 작업이 갑자기 단순해졌다.Supabase를 쓰는 게 나쁜 건 아니었다. 빨리 만들 때는 편했다. 근데 서비스의 주 DB가 Mongo이고, 계정 권한과 콘텐츠 연결을 내가 직접 통제해야 하는 순간부터는 오히려 중간에 Auth 원장이 하나 더 있는 게 불편해졌다.
귀찮아서 붙였던 걸 다시 떼는 데 더 오래 걸렸다.
이런 게 제일 현실적인 기술부채인 듯하다.
댓글 0
포스트 내용에 대한 의견이나 질문을 남길 수 있습니다. 답글과 좋아요는 로그인한 사용자 기준으로 기록됩니다.
이전 글과 다음 글
현재 글을 기준으로 앞뒤 글을 바로 이동해서 볼 수 있습니다.
이 문서는 네쇼커의 처리 일시 칸에 표시되는 AI 토큰 사용량을 설명하기 위한 안내입니다. 고객에게 설명할 때는 아래 답변을 그대로 줄여서 사용해도 됩니다. 요약 처리 일시 칸은 작성 완료 시간과 AI 토큰 사용량을 함께 보여줍니다. 표시 형식은 완료시간 줄바꿈 약 n
처음엔 그냥 배포 명령 하나 바꾸면 될 줄 알았다. 기존에는 서버에서 매번 이렇게 배포하고 있었다. docker compose pull && docker compose down && docker compose up -d문제는 FastAPI