OAuth와 이메일 가입을 하나의 온보딩 플로우로 통합하기

계정 생성과 조직 설정을 분리하면 OAuth·이메일 두 가입 경로가 하나의 온보딩 페이지로 모입니다. 미들웨어 한 줄이 이 통합을 가능하게 합니다.

OAuth와 이메일 가입을 하나의 온보딩 플로우로 통합하기

1. 문제 상황

B2B SaaS에 카카오·Google OAuth 로그인을 붙이기로 한 순간, 가입 플로우가 두 갈래로 벌어졌습니다.

기존 이메일 가입 — 3단계

Step 1: 이메일 + 비밀번호 입력
Step 2: 이메일 인증 코드 입력
Step 3: 회사명 + 이름 + 약관 동의 → Company·User·Member 생성

새로 추가할 OAuth 가입 — ?

카카오·Google은 Step 1·2를 건너뛰고 곧바로 이메일과 이름을 넘겨줍니다. 그런데 저희 앱은 회사에 소속되지 않은 User가 존재해서는 안 된다는 전제로 돌아갑니다. 모든 쿼리가 where: { companyId }로 테넌트 격리를 하는데, companyId = null 사용자는 이 필터에 걸리지도 않고 안 걸리지도 않아서 어디서든 크래시의 씨앗이 됩니다.

즉, OAuth도 어딘가에서 "회사 정보 수집" 단계를 거쳐야 하는데, 기존 이메일 가입과 다른 길로 만들면 다음 문제가 생깁니다.

  • 회사 정보 수집 폼이 두 벌로 존재 (이메일 Step3 + OAuth 전용 페이지)
  • 약관 동의 처리도 두 벌
  • 서버 API도 두 벌 (POST /api/register + POST /api/oauth/complete)
  • 유지보수 시 두 곳을 모두 바꿔야 함

그리고 한 가지 더, 이메일 가입이 3단계라는 자체가 가입 전환율에 나쁩니다. 이메일 → 인증코드 → 회사정보 → 자동로그인 → 대시보드까지 도달하는 길이 너무 깁니다. OAuth를 추가하는 김에 이메일 가입도 더 짧게 만들 기회였습니다.


2. 원인 분석

2.1 가입 플로우를 "계정 생성"과 "회사 설정"으로 분리

기존에는 "한 번의 가입 과정"에 두 가지 일이 섞여 있었습니다.

단계 하는 일 데이터 누가 제공
Step 1 계정 생성 이메일, 비밀번호 사용자
Step 2 이메일 인증 6자리 코드 이메일로 전송 후 사용자 입력
Step 3 회사 설정 회사명, 이름, 약관 사용자

이 중 Step 1·2는 "계정"의 영역이고 Step 3는 "회사"의 영역입니다. OAuth 가입은 1·2가 프로바이더에서 일어나고 3만 남습니다. 이 두 영역을 분리하면 OAuth와 이메일의 공통 지점이 생깁니다.

[계정 생성 영역]
  ├─ 이메일 가입 → 이메일 + 비번 + 인증코드 → User 레코드
  └─ OAuth 가입 → 카카오/Google → User 레코드 (companyId=null)

               ↓ 공통 지점: "User는 있으나 Company가 없다"

[회사 설정 영역]
  └─ /onboard → 회사명 + 이름 + 약관 → Company + User.companyId + Member

2.2 "User가 있는데 Company가 없는" 상태를 감지하는 책임

온보딩이 필요한 사용자를 어떻게 구분할까요? 답은 session.user.companyId === null 입니다. 이걸 미들웨어가 감지해서 /onboard로 리다이렉트하면 끝입니다.

const needsOnboarding =
  isLoggedIn && !req.auth?.user?.companyId && !isPlatformAdmin;

PLATFORM_ADMIN은 회사에 소속되지 않는 역할이라 예외입니다. 그 외 모든 사용자는 companyId가 반드시 있어야 대시보드에 진입할 수 있습니다.


3. 해결 방법

3.1 이메일 가입: 3단계 → 2단계

기존 Step3(회사 설정)를 가입 폼에서 떼어내 /onboard로 이동시켰습니다. 남은 이메일 가입은 두 단계입니다.

Step 1: 이메일 + 비밀번호
  ↓ POST /api/auth/register → User 레코드 생성 (companyId=null)
  ↓ 인증 코드 이메일 발송
Step 2: 인증 코드 입력
  ↓ POST /api/auth/verify-email → 자동 로그인
  ↓ middleware: companyId=null 감지 → /onboard 리다이렉트

이메일 가입 사용자도 /onboard에서 회사 정보를 입력하게 됩니다. 즉, /onboard이메일 가입의 Step3이자 OAuth 가입의 유일한 회사 설정 단계입니다.

3.2 회원가입 API 단순화

기존에는 register API가 User, Company, Member를 한 트랜잭션에 만들고 있었지만, 분리 후에는 User만 만듭니다.

// app/api/auth/register/route.ts (After)
import { withErrorHandler } from '@/lib/core/api-handler';
import { prisma } from '@/lib/core/db';
import bcrypt from 'bcryptjs';
import { z } from 'zod';

const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
});

export const POST = withErrorHandler(async (request) => {
  const body = await parseJsonBody(request);
  const data = registerSchema.parse(body);

  const passwordHash = await bcrypt.hash(data.password, 12);

  // User만 생성 — Company와 Member는 /onboard에서 처리
  const user = await prisma.user.create({
    data: {
      email: data.email,
      password: passwordHash,
      // companyId: null,  // ← 기본값
      // role: VIEWER,     // ← 기본값, 온보딩 시 SUPER_ADMIN으로 승격
    },
  });

  // 인증 코드 이메일 발송...
  await sendVerificationEmail(user.email);

  return NextResponse.json({ success: true, userId: user.id });
});

Company·Member 생성 로직은 완전히 제거했습니다.

3.3 /onboard 페이지: OAuth와 이메일 공통

// app/(auth)/onboard/page.tsx
'use client';

import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useSession } from 'next-auth/react';
import { Building2, User, ArrowRight, Loader2, AlertCircle } from 'lucide-react';
import { FormInput } from '@/components/shared/ui/FormInput';
import { captureClientError } from '@/lib/core/sentry-client';

export default function OnboardPage() {
  const router = useRouter();
  const { update } = useSession();
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [formData, setFormData] = useState({
    companyName: '',
    name: '',
    terms: false,
    marketing: false,
  });

  const canSubmit = formData.companyName && formData.name && formData.terms;

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError(null);
    setIsLoading(true);

    try {
      const response = await fetch('/api/auth/onboarding', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData),
      });

      const data = await response.json();
      if (!response.ok) {
        throw new Error(data.error || '회사 설정 중 오류가 발생했습니다.');
      }

      // 세션 갱신: companyId, role, 권한 업데이트
      await update({
        companyId: data.companyId,
        companyName: data.companyName,
        role: 'SUPER_ADMIN',
        canEditRules: true,
        canChangeRoles: true,
        canViewRecords: true,
        canEditRecords: true,
      });

      router.push('/dashboard');
    } catch (err) {
      const message =
        err instanceof Error ? err.message : '회사 설정 중 오류가 발생했습니다.';
      setError(message);
      captureClientError(err, 'onboarding');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <FormInput
        label="회사명"
        id="companyName"
        value={formData.companyName}
        onChange={(e) =>
          setFormData((p) => ({ ...p, companyName: e.target.value }))
        }
        placeholder="회사명을 입력하세요"
        required
        autoFocus
        icon={Building2}
      />
      <FormInput
        label="이름"
        id="name"
        value={formData.name}
        onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
        placeholder="이름을 입력하세요"
        required
        icon={User}
      />

      {/* 약관 동의 */}
      <div className="space-y-2.5 pt-1">
        <label className="flex items-start gap-2.5 cursor-pointer">
          <input
            type="checkbox"
            checked={formData.terms}
            onChange={(e) =>
              setFormData((p) => ({ ...p, terms: e.target.checked }))
            }
            required
          />
          <span>
            <Link href="/terms" target="_blank">이용약관</Link> 및{' '}
            <Link href="/privacy" target="_blank">개인정보처리방침</Link>에
            동의합니다
          </span>
        </label>

        <label className="flex items-start gap-2.5 cursor-pointer">
          <input
            type="checkbox"
            checked={formData.marketing}
            onChange={(e) =>
              setFormData((p) => ({ ...p, marketing: e.target.checked }))
            }
          />
          <span>할인 및 혜택 정보를 받겠습니다 (선택)</span>
        </label>
      </div>

      {error && <ErrorBanner message={error} />}

      <button
        type="submit"
        disabled={isLoading || !canSubmit}
        className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-primary text-white font-semibold rounded-xl"
      >
        {isLoading ? (
          <>
            <Loader2 className="h-4 w-4 animate-spin" />
            <span>설정 중...</span>
          </>
        ) : (
          <>
            <span>시작하기</span>
            <ArrowRight className="h-4 w-4" />
          </>
        )}
      </button>
    </form>
  );
}

이 컴포넌트는 OAuth로 들어온 사용자와 이메일로 가입한 사용자가 동일하게 보게 됩니다. 두 경로를 구분하는 코드는 한 줄도 없습니다. 이것이 통합 온보딩의 핵심입니다.

3.4 서버 온보딩 API

// app/api/auth/onboarding/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/core/db';
import { auth } from '@/lib/core/auth';
import { applyRateLimit } from '@/lib/core/rate-limit';
import { z } from 'zod';
import {
  withErrorHandler,
  parseJsonBody,
  requireAuth,
} from '@/lib/core/api-handler';
import { ConflictError } from '@/lib/core/errors';

const onboardingSchema = z.object({
  companyName: z.string().min(1).max(100),
  name: z.string().min(1).max(50),
  terms: z.literal(true, { message: '이용약관에 동의해주세요.' }),
  marketing: z.boolean().optional().default(false),
});

export const POST = withErrorHandler(async (request: NextRequest) => {
  const rateLimitResponse = applyRateLimit(request, 'register');
  if (rateLimitResponse) return rateLimitResponse;

  const session = await auth();
  requireAuth(session);

  // 이미 회사가 있는 사용자는 온보딩 불가
  if (session!.user.companyId) {
    throw new ConflictError('이미 회사가 설정되어 있습니다.');
  }

  const body = await parseJsonBody(request);
  const data = onboardingSchema.parse(body);

  const userId = session!.user.id;

  const result = await prisma.$transaction(async (tx) => {
    const company = await tx.company.create({
      data: { name: data.companyName },
    });

    const user = await tx.user.update({
      where: { id: userId },
      data: {
        name: data.name,
        companyId: company.id,
        role: 'SUPER_ADMIN', // ← 가입자는 회사 대표
        canEditRules: true,
        canChangeRoles: true,
        canViewRecords: true,
        canEditRecords: true,
        marketingConsent: data.marketing,
      },
      include: { company: true },
    });

    // 본인 Member 레코드 자동 생성
    await tx.member.create({
      data: {
        companyId: company.id,
        name: data.name,
        email: user.email,
        joinedAt: new Date(),
        status: 'ACTIVE',
        userId: userId,
      },
    });

    return { company, user };
  });

  return NextResponse.json({
    success: true,
    companyId: result.company.id,
    companyName: result.company.name,
  });
});

여기서 주목할 부분 몇 가지:

requireAuth(session): 온보딩 API는 이미 로그인된 사용자만 호출할 수 있습니다. OAuth 콜백에서 바로 호출하든, 이메일 인증 후 자동 로그인된 상태에서 호출하든, 공통적으로 세션이 있어야 합니다.

ConflictError 방어: 이미 companyId가 있는 사용자가 실수로(혹은 의도적으로) 이 API를 다시 호출하면 거부합니다. 두 번째 회사를 만드는 걸 막는 안전장치입니다.

트랜잭션: Company 생성 → User 업데이트 → Member 생성을 한 트랜잭션에 묶어서, 중간에 실패하면 전체가 롤백됩니다. 반쪽 상태(Company는 있고 Member는 없음)를 방지합니다.

Rate limit 키 'register' 재활용: 기존 이메일 가입에 쓰던 rate limit 키를 재활용해 동일한 가입 시도 한도를 공유합니다.

3.5 미들웨어의 온보딩 리다이렉트

// src/middleware.ts (발췌)
export default auth((req) => {
  const hasValidSession = !!(req.auth?.user?.id && req.auth?.user?.role);
  const isLoggedIn = hasValidSession;
  const userRole = req.auth?.user?.role;
  const isPlatformAdmin = userRole === 'PLATFORM_ADMIN';
  const { pathname } = req.nextUrl;

  const protectedPaths = [
    '/dashboard',
    '/schedules',
    '/reports',
    '/members',
    '/settings',
    '/platform',
  ];
  const isProtectedPath = protectedPaths.some((p) => pathname.startsWith(p));

  // ... 비로그인 → /login 리다이렉트 생략

  // ─── 온보딩 플로우 ────────────────────────────────
  const needsOnboarding =
    isLoggedIn && !req.auth?.user?.companyId && !isPlatformAdmin;
  const isOnboardPath = pathname === '/onboard';

  // 1) 온보딩 필요 + 보호 경로 접근 → /onboard로
  if (isLoggedIn && needsOnboarding && isProtectedPath && !isOnboardPath) {
    return NextResponse.redirect(new URL('/onboard', req.nextUrl));
  }

  // 2) 온보딩 완료 사용자가 /onboard 접근 → 대시보드로
  if (isLoggedIn && !needsOnboarding && isOnboardPath) {
    return NextResponse.redirect(
      new URL(getDefaultRedirectPath(userRole), req.nextUrl)
    );
  }

  // ... 나머지 2FA 체크 등 생략
  return NextResponse.next();
});

이 두 가지 리다이렉트가 온보딩 플로우 전체를 관장합니다. OAuth 콜백이든 이메일 자동 로그인이든, companyId가 없는 상태로 대시보드에 접근하면 /onboard로 튕기고, 온보딩이 끝난 사용자가 /onboard에 실수로 다시 접근하면 대시보드로 튕깁니다. 클라이언트 코드에 "온보딩 필요 여부" 체크를 둘 필요가 없습니다.

3.6 await update()의 중요성

블로그 2편에서 언급했지만 다시 강조할 가치가 있습니다. 온보딩 API 응답을 받은 직후 클라이언트는 반드시 await update()로 세션을 갱신해야 합니다. 갱신 없이 router.push('/dashboard')를 호출하면 대시보드가 렌더링될 때 세션의 companyId가 여전히 null이라 미들웨어가 /onboard로 다시 보냅니다.

// ❌ Wrong — 세션 갱신 없이 이동
await fetch('/api/auth/onboarding', { /* ... */ });
router.push('/dashboard'); // ← 무한 루프

// ✅ Right — update 후 이동
const response = await fetch('/api/auth/onboarding', { /* ... */ });
const data = await response.json();
await update({ companyId: data.companyId, /* ... */ });
router.push('/dashboard'); // ← 미들웨어 통과

4. UX 관점의 결정들

4.1 왜 회사명·이름을 Step1에 넣지 않았나?

이메일 가입의 Step1(이메일 + 비밀번호)은 입력이 가장 적어야 합니다. 사용자가 "가입하려면 뭘 적어야 하지?"를 고민하지 않고 바로 이메일만 치면 됩니다. 회사명을 Step1에 넣으면 이 심리적 장벽이 올라갑니다. Step1은 **"이메일 인증만 받고 나중에 그만둘 수도 있다"**는 낮은 몰입도로 시작하게 해야 가입 전환율이 나옵니다.

4.2 왜 약관 동의를 Step1이 아닌 /onboard에 두었나?

약관 동의는 법적으로 가입 시점에 받아야 하지만, OAuth 가입자는 카카오·Google에서 넘어오는 순간 이미 "계정"이 만들어집니다. 이 시점에 약관 동의 없이 계정만 들어오면, 사용자가 그 다음 페이지에서 아무 액션 없이 이탈해도 "어카운트만 만들고 떠난 사용자"로 남습니다. 약관 동의를 /onboard에서 받으면 "회사 설정 = 실제 사용 시작"이라는 시점과 맞물려 법적 의미도 명확해집니다.

4.3 3개월 무료 체험 안내 배치

Onboarding 폼 하단에 가입 시 Basic 3개월 무료 체험 텍스트가 있습니다. 사용자가 "시작하기" 버튼을 누르기 직전에 마지막으로 혜택을 상기시켜 전환을 밀어주는 카피입니다.


5. 핵심 개념 정리

개념 설명
계정 생성과 회사 설정의 분리 User 레코드는 companyId=null로 먼저 만들고, Company·Member는 온보딩에서 생성
미들웨어 기반 리다이렉트 companyId === null 체크 한 줄로 OAuth·이메일 공통 플로우 달성
세션 업데이트 트리거 온보딩 후 useSession().update()로 토큰 갱신
트랜잭션으로 3-entity 생성 Company·User·Member를 한 번에 생성해 반쪽 상태 방지
requireAuth + ConflictError 중복 온보딩 방지 방어 코드

Before/After 비교

항목 Before After
이메일 가입 단계 3단계 2단계
회사 설정 코드 가입 Step3 + OAuth 전용 페이지 /onboard 한 곳
서버 API POST /api/register (모든 걸 처리) POST /api/register + POST /api/auth/onboarding
미들웨어 로직 companyId 체크 없음 needsOnboarding 플래그 추가
OAuth 사용자 처리 (없음) 기존 /onboard로 자연 유입

6. 베스트 프랙티스

  • [ ] "계정"과 "조직"을 분리하는 스키마 설계. User는 혼자서 존재할 수 있어야 합니다. Organization FK를 nullable로 두고 미들웨어에서 감지하세요.
  • [ ] 온보딩 페이지는 미들웨어로 리다이렉트 처리. 클라이언트 코드에서 if (!companyId) router.push('/onboard') 같은 코드를 피하세요. 렌더 타이밍에 따라 깜빡임이 생깁니다.
  • [ ] 트랜잭션으로 다중 entity 생성. Company·User·Member가 모두 성공해야 온보딩 완료입니다. 한 개라도 실패하면 전체 롤백.
  • [ ] requireAuth + ConflictError 방어. 이미 온보딩 완료된 사용자가 이 API를 다시 호출하면 명시적으로 거부하세요. 실수 + 악의 양쪽을 막습니다.
  • [ ] await update() 누락 주의. 세션 갱신 없이 네비게이션하면 미들웨어가 다시 리다이렉트하는 무한 루프가 생깁니다.
  • [ ] /onboard는 "로그인된 사용자만" 접근 가능한 레이아웃에 둘 것. (auth) 라우트 그룹이지만 로그인 체크는 미들웨어가 책임져야 합니다.

7. FAQ

Q1. 미들웨어 대신 클라이언트 사이드에서 companyId 체크해도 되지 않나요?

A. 기술적으로는 가능하지만 UX가 나빠집니다. 클라이언트 체크는 페이지가 일단 렌더되고 나서 useSession() 결과를 보고 리다이렉트하므로, 대시보드 UI가 순간적으로 깜빡였다가 /onboard로 넘어갑니다. 미들웨어는 HTTP 응답 단계에서 리다이렉트하므로 깜빡임이 없고 JS 실행 전에 차단됩니다.

Q2. 온보딩 중간에 사용자가 브라우저를 닫고 나중에 다시 오면?

A. 재접속 시 세션이 살아 있으면(쿠키 유효) 미들웨어가 다시 /onboard로 보냅니다. 세션이 만료됐으면 /login으로 보내고, 로그인 후 다시 /onboard로 보냅니다. 어느 시나리오든 자동으로 복귀됩니다.

Q3. 왜 /onboard(auth) 라우트 그룹에 두었나요? 대시보드 레이아웃이 더 자연스럽지 않나요?

A. /onboard는 회사 정보가 없는 상태이므로 대시보드 레이아웃의 사이드바(회사명, 멤버 수, 구독 상태 등)가 렌더될 수 없습니다. 로그인·회원가입과 같은 중앙 정렬 미니멀 레이아웃을 재사용하는 (auth) 그룹이 적합합니다.

Q4. 카카오에서 이메일 동의를 거부한 사용자는 어떻게 되나요?

A. auth/index.tssignIn 콜백에서 profile?.email이 없으면 false를 반환해 로그인 자체를 거부합니다. 사용자는 "이메일 제공에 동의해야 가입할 수 있습니다"라는 에러와 함께 /login?error=AccessDenied 페이지로 돌아갑니다.

Q5. 온보딩 완료 후 사용자는 기본적으로 SUPER_ADMIN인데, 초대받은 사용자는 어떻게 되나요?

A. 초대받은 사용자는 온보딩을 거치지 않습니다. 초대 링크가 이미 companyId와 role(ADMIN, VIEWER 등)을 정해둔 상태로 계정을 생성하므로, 첫 로그인 후 미들웨어의 needsOnboarding 체크를 통과하고 바로 대시보드로 진입합니다. 온보딩은 "첫 사용자(회사 대표)" 전용 플로우입니다.


8. 참고 자료


9. 다음 단계

인증 3부작은 여기까지입니다. 다음 시리즈는 결제(billing) 시스템입니다. 가장 먼저 다룰 주제는 "왜 스케줄 결제는 Invoice를 선생성해야 하는가" — 운영에서 배운 아픈 교훈을 정리합니다.

시리즈 목차 (인증):

  1. NextAuth PrismaAdapter + 암호화된 이메일: Blind Index 커스텀 Adapter
  2. NextAuth v5 JWT 콜백: OAuth와 Credentials 사용자 분기 처리
  3. OAuth와 이메일 가입을 하나의 온보딩 플로우로 통합하기 ← 현재 글