챗봇 공부 노트

[17편] Part 1 구글 유저 통합 저장

frontend-diary-log 2026. 2. 5. 23:05

 

📘 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 테이블에 저장
  • 모든 유저를 하나의 테이블에서 관리
  • 인증 방식은 컬럼으로만 구분
  • 실서비스 기준에서 확장 가능한 구조