트래픽이 늘 때 페이지 응답이 느려지고 MongoDB 커넥션 사용량이 비정상적으로 높아지는 현상이 있었습니다. 이상한 건 읽기 페이지(글 목록이나 상세 화면)인데도 유저 관련 DB 쿼리가 계속 찍혔다는 점입니다. 글을 읽는 데 왜 유저 컬렉션을 조회하고 있었을까요?
범인은 인증 계층, 정확히는 NextAuth의 session 콜백이었습니다.
session 콜백은 "페이지마다" 실행된다
기존의 코드는 대략 이랬습니다.
// 기존의 비효율적인 코드 예시
callbacks: {
async session({ session, token }) {
if (session.user && token.sub) {
const User = await getUserModel();
const dbUser = await User.findOne({ googleId: token.sub }); // 매 호출마다 DB 왕복
if (dbUser) {
session.user.id = dbUser._id.toString();
session.user.name = dbUser.name;
session.user.level = dbUser.level;
session.user.role = dbUser.role || "user";
}
}
return session;
},
}
"로그인할 때 한 번 도는 콜백"이라고 착각하기 쉽지만 아닙니다. session 콜백은 세션을 확인할 때마다 실행됩니다. 서버 컴포넌트에서 getServerSession()을 부르거나, 클라이언트에서 useSession()이 뜰 때마다 호출됩니다. 즉 레이아웃, 페이지, API에서 세션을 조회하는 횟수만큼 User.findOne()이 실행되는 구조였습니다.
결과적으로 한 명이 페이지 몇 개만 돌아다녀도 유저 컬렉션을 조회하는 양이 트래픽에 비례해 배수로 늘어납니다.
요청 1번에 DB 쿼리가 몇 번인가
고치기 전에 먼저 측정을 진행했습니다. loadUserClaims 같은 조회 함수 앞뒤로 로그를 찍거나, MongoDB Atlas의 Profiler 또는 explain 기능으로 googleId로 필터하는 find 쿼리가 요청당 몇 번 도는지 세면 됩니다.
이때 중요한 건 "이 데이터가 매 요청마다 최신이어야 하는가?"
유저의 role, level, name 같은 값은 자주 바뀌지 않습니다. 초 단위 최신성이 필요 없는 데이터를 매 요청 DB에서 읽는 것은 명백한 낭비입니다.
읽기는 JWT에서, 갱신은 주기적으로
NextAuth를 session: { strategy: "jwt" }로 쓰면 세션 정보는 암호화된 JWT(쿠키) 안에 들어갑니다. 그렇다면 자주 안 바뀌는 클레임(role, level, name)을 토큰 안에 캐싱해두고, session 콜백은 DB 조회 없이 토큰 값만 복사하면 됩니다.
문제는 "그럼 중간에 role이 바뀌어도 반영이 안 되지 않나?"라는 의문이 든다는 점입니다. 이를 해결하기 위해 갱신 주기를 둡니다.
-
최초 로그인 시: DB에서 클레임 로드
-
명시적 프로필 변경(trigger === "update") 시: 재로드
-
그 외의 경우: N분마다 한 번만 DB 재조회 (저희 팀은 10분으로 설정했습니다)
이렇게 하면 읽기는 0회, 갱신은 10분당 1회로 줄어듭니다.
구현 ① jwt 콜백에서 클레임을 토큰에 심기
import { NextAuthOptions } from "next-auth";
import getUserModel from "@/models/User";
// role/level 등은 자주 안 바뀌므로 이 주기마다만 DB에서 재조회
const CLAIMS_REFRESH_INTERVAL_MS = 10 * 60 * 1000; // 10분
async function loadUserClaims(providerId: string) {
const User = await getUserModel();
const dbUser = await User.findOne({ googleId: providerId })
.select("name image level exp role") // 필요한 필드만
.lean();
if (!dbUser) return null;
return {
userId: dbUser._id.toString(),
name: dbUser.name,
picture: dbUser.image ?? undefined,
level: dbUser.level,
// ⚠️ JWT 예약 클레임 exp와 충돌하므로 경험치는 expPoints로 관리합니다.
expPoints: dbUser.exp || 0,
role: dbUser.role ?? ("user" as const),
};
}
export const authOptions: NextAuthOptions = {
session: { strategy: "jwt" },
callbacks: {
async jwt({ token, account, trigger, session }) {
if (account) token.sub = account.providerAccountId;
// 명시적 세션 업데이트(예: 닉네임 변경) 반영
if (trigger === "update" && session?.name) {
token.name = session.name;
}
const claimsAge =
typeof token.claimsRefreshedAt === "number"
? Date.now() - token.claimsRefreshedAt
: Number.POSITIVE_INFINITY;
const shouldRefreshClaims =
Boolean(account) || // 최초 로그인
trigger === "update" || // 명시적 갱신
!token.userId || // 클레임 없는 기존 토큰(마이그레이션 대비)
claimsAge > CLAIMS_REFRESH_INTERVAL_MS; // 주기 만료
if (shouldRefreshClaims && token.sub) {
try {
const claims = await loadUserClaims(token.sub);
if (claims) {
token.userId = claims.userId;
token.name = claims.name;
token.picture = claims.picture;
token.level = claims.level;
token.expPoints = claims.expPoints;
token.role = claims.role;
token.claimsRefreshedAt = Date.now();
}
} catch (error) {
// DB 장애 시 기존 토큰으로 계속 동작하게 하여 다음 요청에서 재시도 유도
console.error("JWT 클레임 갱신 실패:", error);
}
}
return token;
},
},
};
구현 ② session 콜백은 토큰만 복사 (DB 조회 0)
async session({ session, token }) {
// ✅ DB 조회 없이 토큰에 캐싱된 클레임만 복사합니다.
if (session.user && typeof token.userId === "string") {
session.user.id = token.userId;
if (typeof token.name === "string") session.user.name = token.name;
if (typeof token.level === "number") session.user.level = token.level;
session.user.exp =
typeof token.expPoints === "number" ? token.expPoints : 0;
// role은 화이트리스트로 좁혀 신뢰 (임의 값 방지)
session.user.role = token.role === "admin" ? "admin" : "user";
}
return session;
},
여기서 session 콜백은 이제 단순한 동기적 객체 복사 역할만 수행합니다. DB 조회도, 네트워크 통신도 일어나지 않습니다.
진행 과정에서 만난 함정 3가지
함정 ① JWT 예약 클레임 exp 충돌
JWT 표준에서 exp는 토큰 만료 시간(Unix timestamp)을 의미하는 예약어입니다. 여기에 게임 시스템 등의 '경험치(exp)'를 그대로 대입하면 토큰 만료 로직과 충돌하여 세션이 즉시 만료되거나 세션 정보가 깨질 수 있습니다. 저희는 이름을 expPoints로 바꿔서 이 문제를 우회했습니다. iat, nbf, sub, aud 등 다른 예약 클레임도 동일하게 주의해야 합니다.
함정 ② 클레임 없는 기존 토큰 마이그레이션
코드를 배포하는 시점에 이미 로그인해 있던 기존 유저들은 아직 userId 클레임이 없는 이전 토큰을 들고 요청을 보냅니다. 갱신 조건에 !token.userId를 추가하여, 옛 토큰이 처음 들어왔을 때 즉시 새 구조로 채워지도록 조치했습니다. 이 처리가 없으면 기존 로그인 유저의 role이나 level이 undefined가 되어 권한 페이지 접근이 깨질 수 있습니다.
함정 ③ DB 장애 시 우아한 예외 처리
클레임 갱신 처리가 실패했다고 해서 바로 에러를 throw해 버리면, DB가 잠깐 흔들릴 때 사이트 전체 로그인이 먹통이 됩니다. 따라서 try/catch로 감싸준 뒤, 실패하더라도 기존에 보관 중이던 토큰 값으로 일단 서비스를 계속 이용할 수 있게 만들었습니다. 다음 요청이나 다음 주기 때 자연스럽게 갱신 재시도가 이루어집니다. 인증 영역에서는 '최신성'보다 '가용성'이 더 중요한 경우가 많기 때문입니다.
Before / After — 요청당 DB 쿼리 수 비교
-
세션 확인 1회당 유저 DB 조회
-
변경 전: 1회 (findOne)
-
변경 후: 0회
-
-
페이지 1개 방문 시 (세션 N회 확인)
-
변경 전: N회
-
변경 후: 0회
-
-
클레임 최신화 주기
-
변경 전: 매번 조회
-
변경 후: 10분당 1회
-
-
DB 장애 발생 시 로그인 유지 여부
-
변경 전: 실패 위험 존재
-
변경 후: 기존 토큰으로 안정적 유지
-
유저가 페이지를 읽어오는 경로에서 유저 컬렉션 조회가 사실상 완전히 사라졌습니다. 자주 변경되지 않는 데이터를 매 요청마다 다시 읽지 않는다는 기본 원칙을 적용한 것만으로, 트래픽에 비례해 요동치던 DB 부하를 안정적인 상수 수준으로 낮출 수 있었습니다.
적용 체크리스트
-
session: { strategy: "jwt" } 설정이 제대로 되어 있는가
-
jwt 콜백에서 클레임을 심고, session 콜백은 복사만 처리하는가
-
exp 등 JWT 예약 클레임과 필드 이름이 겹치지 않는가
-
기존 토큰 마이그레이션을 위한 조건(!token.userId)이 들어가 있는가
-
클레임 로드 실패 시 로그인 전체가 마비되지 않도록 예외 처리를 했는가
-
role 같은 중요 권한 값은 화이트리스트 검증을 거쳐 신뢰하는가
-
커스텀 클레임 사용을 위한 next-auth.d.ts 타입 확장을 마쳤는가
Q. session 콜백은 원래 매 요청마다 실행되나요?
네, 맞습니다. 로그인할 때 최초 1회만 도는 것이 아니라 세션을 검증할 때마다 실행됩니다. getServerSession()이나 useSession() 호출 횟수에 맞춰 작동하므로, 이 안에서 DB 조회를 수행하면 트래픽 증가에 따라 대량의 부하가 발생하게 됩니다.
Q. database 세션 전략을 사용하면 어떻게 되나요?
strategy: "database"를 선택하면 세션 데이터 자체를 DB에 저장하고 관리하므로, 세션을 확인할 때마다 어쩔 수 없이 DB 조회가 일어납니다. 본 글에서 다룬 JWT 클레임 캐싱은 'JWT 전략'을 채택했을 때 유효한 최신화 방법입니다. 세션 무효화나 엄격한 감사 기능이 절대적으로 중요한 프로젝트라면 database 전략이 유리할 수 있으므로, 비즈니스 요건에 맞춰 트레이드오프를 판단하셔야 합니다.
Q. role 값을 JWT에 그냥 담아두면 사용자가 위조할 수 있지 않나요?
NextAuth가 생성하는 JWT는 서버에 비공개로 저장된 AUTH_SECRET을 기반으로 서명 및 암호화 처리가 이루어집니다. 따라서 클라이언트 측에서 임의로 구조를 변조하는 것은 불가능합니다. 다만 더 안전한 방어 코드를 위해, session 콜백 단계에서 한번 더 role 값을 화이트리스트("admin" | "user") 구조로 좁혀서 매핑해 주는 방식을 추천합니다.
Q. 백엔드에서 role을 수정했는데 클라이언트에 바로 반영이 안 됩니다.
설정해 둔 갱신 주기(예: 10분)가 지나지 않아서 그렇습니다. 관리자 권한 부여 등 즉시 반영이 필수적인 비즈니스 로직을 수행할 때는, 해당 가동 액션의 마무리 단계에서 NextAuth가 제공하는 세션 update() 메소드를 강제로 호출해 주시면 됩니다. trigger === "update" 분기를 타고 즉시 최신 정보가 동기화됩니다.
Q. 캐시 갱신 주기는 몇 분 정도가 적당한가요?
유저의 등급(level)이나 권한(role)처럼 변동 주기가 길고 완만한 데이터라면 5분에서 15분 사이가 가장 무난합니다. 주기가 짧아질수록 데이터의 실시간성은 올라가지만 그만큼 DB가 감당해야 할 부하도 늘어납니다. 실시간성이 극도로 요구되는 특정 값이 있다면 주기 관리에 의존하기보다 명시적인 update() 트리거를 설계하는 편이 좋습니다.