3년 전 신입 때 만들고 방치해둔 블로그를 최근에 다시 손보고 있다. Next.js 13을 16으로 올리고, NextAuth도 v4에서 v5로 올리고, 에디터도 갈아끼웠다.
그러다 새 에디터로 글을 하나 써봤는데, 내 홈에 글이 없었다.

분명 글을 썼는데 내 홈엔 없다
글을 발행하고 내 블로그 홈으로 가봤는데 방금 쓴 글이 목록에 없다. 근데 전체 홈에는 멀쩡히 있다. 글이 안 써진 게 아니었다. 써지긴 했는데 내 홈에서만 안 보였다.
이상한 게 하나 더 있었다. 헤더에 뜨는 내 프로필 사진이 예전에 설정해둔 사진이 아니라 최근 구글 프로필 사진이었다. 분명 블로그에서 프로필을 바꿔뒀었는데.
이때까지는 인증 쪽 문제일 거라고 생각도 못 했다.
삽질 1: 캐시겠지
처음엔 캐시를 의심했다. "글을 삭제해도 목록에 계속 보이는" 문제도 있어보여서, 등록이랑 삭제가 화면에 반영이 안 되니까 자연스럽게 캐시 무효화 쪽을 팠다. Next.js 16 올리면서 revalidateTag 동작도 바뀐 게 있었으니 가설로는 그럴듯했다.
그런데 캐시 문제라면 시간이 지나거나 강력 새로고침하면 나와야 하는데, 내 홈의 글 목록은 끝까지 안 나왔다. stale한 게 아니라 진짜로 없는 거였다.
삽질 2: 구글이랑 이메일 둘 다 가입해서 그런가
다음 가설. 이 블로그는 이메일 회원가입과 구글 로그인을 둘 다 지원하는데, 내가 같은 이메일로 둘 다 가입해놔서 계정이 꼬였나? 가입 경로에 따라 계정이 따로 생기는지 테스트도 해봤다.
이러다가 Sanity Studio를 열어서 user 문서를 확인했는데
같은 이메일의 user 문서가 여러 개였다. 그것도 이메일 계정 하나에 구글 계정 하나, 이런 깔끔한 2개가 아니라 구글 쪽 문서가 로그인한 횟수만큼 늘어나 있었다.
크로스 가입 문제가 아니었다. 같은 구글 계정으로 로그인할 때마다 새 계정이 생기고 있었다.
글은 "어제의 나" 계정으로 써져 있고, 지금 로그인한 나는 "오늘의 나"라는 새 계정이니 내 홈엔 글이 없다. 프로필 사진도 마찬가지다. 커스텀해둔 프로필은 옛 계정에 있고, 새 계정은 구글 기본 프로필로 새로 만들어졌으니 최근 구글 사진이 뜬 거다.
진짜 원인: user.id는 구글이 주는 값이 아니었다
그럼 왜 로그인할 때마다 새 계정이 생겼을까. 유저 생성 코드는 이렇게 생겼다.
// src/service/user.ts export async function addUser({ id, username, email, image, name }: OAuthUser) { return client.createIfNotExists({ _id: id, _type: 'user', ...(중략) }); }
createIfNotExists는 이름 그대로 그 _id의 문서가 이미 있으면 아무것도 안 하는 함수다. 그러니까 "이미 가입한 유저면 새로 안 만든다"가 보장되는 것처럼 보이고, 실제로 3년간 그렇게 잘 동작해왔다.
문제는 저 id로 NextAuth의 user.id를 넘기고 있었다는 것.
나는 당연히 user.id가 구글이 주는 고정 유저 아이디인 줄 알았다. 그리고 여기가 반전인데, NextAuth v4에서는 실제로 그랬다. v4의 구글 provider는 user.id에 구글의 sub(고정 식별자)를 담아줬다. 그래서 이 코드가 3년간 아무 문제 없었던 거다.
근데 이번에 NextAuth를 v4에서 v5(Auth.js)로 올렸다. v5는 어댑터 없이 JWT 세션 전략을 쓰면 OAuth 로그인마다 user.id를 crypto.randomUUID()로 새로 발급한다. 소스 코드를 따라가 보면 "user는 provider와 독립적이어야 한다"는 의도적인 설계 주석까지 있었다(같은 혼란을 겪은 사람들의 이슈도 있다). 버그가 아니라 v5의 설계 변경이었고, 나는 마이그레이션하면서 이걸 모르고 지나갔다.
그러니까 이건 3년 묵은 버그가 아니라 몇 주 전에 내가 직접 심은 회귀였다. 재로그인을 해야만 드러나는 문제였기에, 빌드도 되고 로그인도 잘 되는 마이그레이션 직후엔 눈치챌 수가 없었다.
createIfNotExists의 "이미 있으면 안 만든다"는 넘겨주는 _id가 매번 같아야 성립한다. user.id가 매 로그인마다 랜덤이니 그 전제가 조용히 깨져 있었고, 로그인할 때마다 "처음 보는 _id네? 새로 만들어야지"가 반복된 거다.
부수 피해도 있었다. 예전 글의 author._ref는 과거 로그인 시점의 랜덤 id를 가리키고 있으니, 재로그인하면 세션의 id와 달라져서 자기가 쓴 글인데 수정/삭제 버튼이 안 뜬다. 소유권 검증이 뚫리는 방향이 아니라 전부 잠기는 방향이라 그나마 다행이었달까.
해결: 진짜 고정 식별자는 account에 있다
구글이 주는 진짜 안정적인 식별자(OAuth의 sub 클레임)는 user가 아니라 account.providerAccountId에 따로 보존돼 있다. 그래서 signIn 콜백에서 user.id 대신 account.providerAccountId 기반의 canonical id를 만들어 Sanity _id로 쓰도록 바꿨다.
// src/auth.ts (signIn 콜백) async signIn({ user: { id, name, image, email }, account }) { if (!email || !id) return false; if (account?.provider === 'google') { await addUser({ id: `google.${account?.providerAccountId}`, ...(중략) }); } return true; },
세션에 싣는 id도 같은 규칙으로 맞춰야 session.user.id == 문서 _id 정합이 유지된다.
// src/auth.ts (jwt 콜백) token.id = account?.provider === 'google' ? `google.${account.providerAccountId}` : user.id;
같은 구글 계정이면 sub는 항상 같으니, 이제 몇 번을 재로그인해도 _id는 google.<sub> 하나로 고정된다. createIfNotExists도 드디어 제 역할을 한다.
수정하고 재로그인 테스트를 해보니 user 문서 수 그대로, 내 홈에 글 잘 보이고, 예전 글의 수정/삭제 버튼도 돌아왔다.
참고로 "이메일 가입 계정과 구글 계정을 이메일 기준으로 합쳐주는" 방식도 고민했는데 안 하기로 했다. 이메일 가입과 구글 로그인은 다른 계정이라는 게 이 블로그의 방침이기도 하고, email 기준 upsert는 이 버그의 원인과도 무관했다.
근데 여기서 끝이 아니었다
계정 중복을 고치고 나니 다음 문제가 보였다. 이 블로그는 프로필 조회와 URL(/{username})이 전부 username 기준인데, 구글 로그인의 username은 email.split('@')[0]로 자동 생성되고 유일성 검사가 아예 없었다.
도메인만 다른 이메일이면(boram@gmail.com, boram@naver.com) username이 둘다 boram이라 정당하게 겹칠 수 있고, 그러면 "내 블로그" 링크가 남의 블로그로 열릴 수도 있는 구조다.
다른 서비스들은 이걸 어떻게 풀고 있나 찾아봤다. 크게 세 갈래였다.
- 가입 시점에 유일성 강제: 이미 있는 핸들이면 가입을 막고 다른 이름을 고르게 하는 방식. 제일 깔끔한데, 이미 자동 생성으로 가입된 유저들이 있는 상태에선 소급 적용이 어렵다. (GitHub, 트위터, velog)
- 이름 + 식별자 조합: 표시 이름은 중복을 허용하고, 뒤에 붙는 값과의 조합으로만 유일성을 보장하는 방식. (예전 Discord의
이름#1234태그) - 충돌 시 접미사 자동 부여: 충돌 나면 시스템이 알아서 접미사를 붙여주는 방식. (WordPress가 같은 제목 글에
-2를 붙이는 식)
내 경우엔 첫 번째가 어려웠다. 구글 로그인 유저의 username은 본인이 고른 게 아니라 이메일에서 자동 생성된 값이라, "이미 있는 이름입니다"를 보여줄 가입 폼 자체가 없다. 그렇다고 username을 강제로 바꿔버리면 이미 그 이름으로 쓰던 유저의 표시 이름이 멋대로 바뀌고.
그래서 두 번째랑 세 번째를 섞었다. username은 표시 전용으로 남겨 중복을 허용하고(Discord의 표시 이름처럼), 유일성이 보장되는 slug 필드를 새로 만들어 조회·라우팅·캐시 키를 전부 slug 기준으로 옮겼다. 신규 가입 시 slug가 이미 누가 쓰는 값이면 뒤에 랜덤 문자열을 붙인다(WordPress식 자동 접미사).
// src/service/user.ts export async function generateUniqueSlug(base: string): Promise<string> { const taken = await checkSlugValid(base); return taken ? `${base}-${Math.random().toString(36).slice(2, 6)}` : base; }
boram이 이미 있으면 boram-x3k2 같은 식이다.
"그냥 _id를 URL에 쓰면 되지 않나?"도 잠깐 생각했는데, /google.107682... 같은 URL은 못생긴 데다 기존 URL(/boram)이 전부 깨진다. 기존 유저들은 slug = username으로 백필해서 예전 URL도 그대로 살렸다.
배운 점
"계정을 식별하는 값"과 "세션마다 새로 발급되는 값"은 다르다. 이번 버그의 본질은 이 둘을 구분 못 한 거였다. 영속성이 필요한 곳(문서 _id, 소유권 검증)에는 재로그인해도 변하지 않는 값(account.providerAccountId)만 써야 한다. createIfNotExists가 3년간 중복을 막아준 것도 결국 넘기는 _id가 매번 같았기 때문이지 함수가 알아서 막아준 게 아니었고, "이 함수가 막아주겠지" 믿기 전에 그 입력이 안정적인지부터 의심했어야 했다.
메이저 버전 마이그레이션은 "빌드 통과 + 로그인 됨"으로 끝나는 게 아니었다. v4에서 v5로 올린 직후엔 다 멀쩡해 보였다. 근데 user.id의 의미가 바뀐 건 타입 에러도 런타임 에러도 안 낸다. 마이그레이션 가이드의 breaking change 목록을 꼼꼼히 읽을 것, 그리고 "재로그인" 같은 반복 시나리오까지 스모크 테스트에 넣을 것.
증상이 아니라 데이터부터 봤어야 했다. 캐시 의심하면서 한참 헤맸는데, Sanity Studio에서 user 문서를 직접 열어본 순간에야 방향이 잡혔다. 화면이 이상하면 화면 코드보다 저장소의 실제 데이터를 먼저 확인하는 게 빠르다.
REFERENCE
- Auth.js 공식 문서, Migrating to v5 — v4에서 v5로 올릴 때 꼭 읽어야 하는 공식 마이그레이션 가이드
- Auth.js 공식 가이드, Extending the Session — jwt/session 콜백으로 세션에 id를 싣는 공식 패턴 (이 글의
token.id코드가 이 패턴) - Sanity 공식 문서, Creating and updating documents —
createIfNotExists의 동작 설명 - Auth.js v5 with Next.js 16: The Complete Authentication Guide — v5 전반의 설정은 이 글이 자세하다