📘 Part 1 — auth.ts 전체 코드 + 설명
(구글 유저 통합 저장)
이번 파트에서는 NextAuth 기반 인증 구조에서
구글 로그인 유저가 DB에 저장되지 않던 문제를 해결하고,
자체 로그인 유저와 구글 로그인 유저를 하나의 users 테이블로 통합 관리한 설계를 정리합니다.
✅ 변경 이유 요약
- 구글 로그인 유저가 users 테이블에 저장되지 않는 문제 해결
- 구글 로그인 / 자체 로그인 유저를 하나의 테이블로 통합
- 실제 운영 환경에서 유지보수 및 확장성 단순화
✅ auth.ts 전체 코드
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import Google from "next-auth/providers/google";
import bcrypt from "bcryptjs";
import pool from "@/lib/db";
const ensureUsersTable = async () => {
await pool.execute(`
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(50) NOT NULL,
email VARCHAR(255) NOT NULL,
phone VARCHAR(30) NOT NULL,
region VARCHAR(50) NOT NULL,
nickname VARCHAR(50) NOT NULL,
user_id VARCHAR(50) NOT NULL,
password_hash VARCHAR(255) NULL,
provider VARCHAR(50) NOT NULL DEFAULT 'credentials',
provider_user_id VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uniq_email (email),
UNIQUE KEY uniq_user_id (user_id)
)
`);
try {
await pool.execute(
"ALTER TABLE users ADD COLUMN provider VARCHAR(50) NOT NULL DEFAULT 'credentials'"
);
} catch {}
try {
await pool.execute(
"ALTER TABLE users ADD COLUMN provider_user_id VARCHAR(255) NULL"
);
} catch {}
try {
await pool.execute("ALTER TABLE users MODIFY password_hash VARCHAR(255) NULL");
} catch {}
};
const getOrCreateGoogleUser = async (params: {
provider: string;
providerAccountId: string;
email?: string | null;
name?: string | null;
}) => {
await ensureUsersTable();
const [providerRows] = await pool.execute(
"SELECT id FROM users WHERE provider = ? AND provider_user_id = ? LIMIT 1",
[params.provider, params.providerAccountId]
);
if (Array.isArray(providerRows) && providerRows.length > 0) {
return (providerRows[0] as { id: number }).id;
}
if (params.email) {
const [emailRows] = await pool.execute(
"SELECT id, provider_user_id FROM users WHERE email = ? LIMIT 1",
[params.email]
);
if (Array.isArray(emailRows) && emailRows.length > 0) {
const existingId = (emailRows[0] as { id: number }).id;
await pool.execute(
"UPDATE users SET provider = ?, provider_user_id = ? WHERE id = ?",
[params.provider, params.providerAccountId, existingId]
);
return existingId;
}
}
const displayName = params.name ?? "Google User";
const fallbackEmail =
params.email ?? `${params.providerAccountId}@google.local`;
const [result] = await pool.execute(
`
INSERT INTO users (
name,
email,
phone,
region,
nickname,
user_id,
password_hash,
provider,
provider_user_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
displayName,
fallbackEmail,
"-",
"-",
displayName,
`google_${params.providerAccountId}`,
null,
params.provider,
params.providerAccountId,
]
);
return Number((result as { insertId: number }).insertId);
};
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
} = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Credentials({
name: "Credentials",
credentials: {
identifier: { label: "Email or ID", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const identifier = String(credentials?.identifier ?? "").trim();
const password = String(credentials?.password ?? "");
if (!identifier || !password) {
return null;
}
await ensureUsersTable();
const [rows] = await pool.execute(
"SELECT id, name, email, password_hash FROM users WHERE email = ? LIMIT 1",
[identifier]
);
if (!Array.isArray(rows) || rows.length === 0) {
return null;
}
const user = rows[0] as {
id: number;
name: string;
email: string;
password_hash: string;
};
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) {
return null;
}
return {
id: String(user.id),
name: user.name,
email: user.email,
};
},
}),
],
callbacks: {
async jwt({ token, user, account }) {
if (account?.provider === "google" && account.providerAccountId) {
const internalId = await getOrCreateGoogleUser({
provider: account.provider,
providerAccountId: account.providerAccountId,
email: user?.email,
name: user?.name,
});
token.id = String(internalId);
} else if (user?.id) {
token.id = user.id;
} else if (token.sub) {
token.id = token.sub;
}
if (account?.provider) {
token.provider = account.provider;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.provider = token.provider as
| "google"
| "credentials"
| undefined;
}
return session;
},
},
});
✅ 문법 / 설계 설명 (노베이스 기준)
CREATE TABLE IF NOT EXISTS
- 테이블이 존재하지 않을 경우에만 생성합니다.
- 서버 최초 실행 시 자동으로 테이블을 준비하기 위한 안전 장치입니다.
ALTER TABLE + try / catch
- 기존 테이블에 컬럼을 추가하거나 수정합니다.
- 이미 컬럼이 존재할 경우 에러가 발생하므로, try / catch로 무시 처리합니다.
provider / provider_user_id
- OAuth(구글) 로그인 유저를 식별하기 위한 필드입니다.
- provider_user_id는 구글에서 내려주는 고유 ID입니다.
password_hash를 NULL 허용
- 구글 로그인 유저는 비밀번호가 없기 때문에 NULL을 허용합니다.
- 자체 로그인 유저만 비밀번호를 사용합니다.
✅ 왜 이런 방식으로 설계했을까요?
❌ 분리 테이블 방식 (예: oauth_users)
- 테이블이 늘어나 관리가 복잡해집니다.
- 마이페이지, 통계, 권한 처리 쿼리가 어려워집니다.
- 실무에서 점점 구조가 꼬이게 됩니다.
✅ 통합 users 테이블 방식
- 구글 로그인 / 자체 로그인 유저를 하나의 테이블로 관리
- 인증 방식은 provider 컬럼으로 구분
- 유지보수, 통계, 권한 관리가 매우 단순해집니다
- 실제 운영 서비스에서 가장 많이 사용하는 구조입니다
✅ 정리
이번 Part 1의 핵심은 다음과 같습니다.
- 구글 로그인 유저도 반드시 내부 users 테이블에 저장
- 모든 유저를 하나의 테이블에서 관리
- 인증 방식은 컬럼으로만 구분
- 실서비스 기준에서 확장 가능한 구조
'챗봇 공부 노트' 카테고리의 다른 글
| [17편] Part 3 MySQL 세션/메시지 + 오류정리 및 해결 (0) | 2026.02.05 |
|---|---|
| [17편] Part 2 GPT 컨텍스트 엔지니어링 (0) | 2026.02.05 |
| [16편] 채팅 세션 단위 메시지 저장 (1) | 2026.02.03 |
| [15편] 채팅 요약 기능 구현 + UX 고도화 (1) | 2026.02.02 |
| [14편] 날씨 기반 GPT 추천/위치 검색 연동 및 JSON 응답 고도화 (0) | 2026.02.01 |