프로그래밍/개발 일지

[Supabase + Next.js App Router] 구글 OAuth 구현하기 (SSR 기반 세션관리 포함)

jtw7977 2025. 6. 15. 16:25

1. 글의 목적

이번 글에서는 내가 토이 프로젝트를 개발하다가, admin 라우터는 일반 유저가 접근 못하도록 보호하고 싶어서 SSR 기반 Supabase 세션 관리까지 적용해본 내용을 정리하려 한다.
보통 supabase-js에서는 세션을 브라우저의 localStorage에 저장하는데, 이걸 서버에서도 안전하게 관리하고 보호라우터까지 구현하는 방법을 검색하고 공부한 내용을 공유한다.

 

2. 사용한 기술스택 & 디렉토리 경로

기술스택

nextjs (^15.1.0) - app라우터

react, react-dom (^19.0.0)

@supabase/ssr (^0.6.1)

@supabase/supabase-js (^2.48.1)

typescript (^5)

그외 리덕스, tailwind, ui라이브러리

 

디렉터리 구조

project/
├── app/ 
│   ├── api
│   │   └── auth
│   │       └── callback
│   │            └── route.ts
│   ├── auth
│   │   ├── auth.tsx
│   │   └── page.tsx
│   └── lib
│       ├── supabaseClient.ts
│       └── supabaseServer.ts
└── middleware.ts

 

3. Supabase 프로젝트 생성 및 Google OAuth 설정

3-1-0. 만약 supabase가 처음이라면 supabase에 로그인후 Organizations만들기

3-1-1 그후 만든 Organizations에서 new Project만들기

3-1-2. 만든 프로젝트를 클릭해서 선택후 왼쪽 패널에서 Authentication클릭

3-1-3. Sign In / Providers클릭

 

3-1-4 페이지를 내려서 Google찾기, 그후 ">" 버튼 클릭 ( 원래는 disabled이 나와있음. 제가 이미 켜둬서.. )

3-1-5. enable Sign In with Google을 활성화 하기 

3-1-6 일단 callback URL만 복사하기

 

 

3-2 구글에서 로그인 관련 세팅하기

3-2-1. 구글클라우드 접속 (계정 없으면 구글로 로그인) > https://console.cloud.google.com/

3-2-2. 프로젝트 만들기

새프로젝트 클릭

3-2-3. 해당 프로젝트 접속후 왼쪽위의 석삼버튼 눌러서 "사용 설정된 API 및 서비스" 클릭하기

3-2-4. 로드된후 왼쪽 패널에서 사용자인증정보 클릭

3-2-5 위쪽에서 사용자 인증 정보 만들기 -> OAuth 클라이언트 ID만들기 클릭

3-2-6 애플리케이션 유형, 이름입력한뒤 3-1-5,6에서 복사한 callback URL을 승인된 리다이액션 URL에 입력하기

3-2-7. 그후 아래쪽에서 만들기 버튼 누르기

3-2-8. 확인후 나오는 것을 supabase에 입력하기

클라이언트 ID, 보안 비밀번호 (보안 비밀번호는 절대 노출되지 않게 하세요!)를 복사후 

클라이언트 IDS, Client Secret를 입력후 Save버튼 누르기

 

 

4. Next.js에 Supabase 연동

browser(client용) 만들기

supabase대시보드에서 Connect를 클릭한후 App Frameorks 누른후 나타난 것을 Copy한후 .env.local에 입력하기

 

 

그후 lib/supabaseClient.ts (경로나 파일명은 마음대로)에 다음코드를 이용하여 브라우저용 객체를 싱글톤으로 선언해주자. 

import { createBrowserClient } from '@supabase/ssr';
import type { Database } from '../types/database.types'

export const supabase = createBrowserClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);

Database 타입을 얻고 싶으면 공식문서(https://supabase.com/docs/reference/javascript/typescript-support)를 참고 하자.

 

5. 로그인/로그아웃 구현

5-1. 로그인 서버 콜백처리

api/auth/callback/route.ts (경로는 상관없다. 하지만 만든경로가 callback 주소가 될테니까 알아두자)에 다음코드로 로그인 처리를 할 수 있게 하자.

// api/auth/callback/route.ts
import { createSupabaseServerClient } from '@/app/lib/supabaseServer';
import { NextResponse } from 'next/server'

export async function GET(req: Request) {
    try {
        const supabase = await createSupabaseServerClient();

        // 코드를 세션으로 교환
        const { error: exchangeError } = await supabase.auth.exchangeCodeForSession(
            new URL(req.url).searchParams.get('code') || ''
        );

        if (exchangeError) {
            console.error('Code exchange error:', exchangeError);
            return NextResponse.redirect(new URL('/auth', req.url));
        }

        return NextResponse.redirect(new URL('/auth', req.url));
    } catch (error) {
        console.error('Unexpected error:', error);
        return NextResponse.redirect(new URL('/auth', req.url));
    }
}
// lib/supabaseServer.ts
import { Database } from '@/app/types/database.types';
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export const createSupabaseServerClient = async () => {
    const cookieStore = await cookies();

    return createServerClient<Database>(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
        {
            cookies: {
                getAll() {
                    return cookieStore.getAll()
                },
                setAll(cookiesToSet) {
                    try {
                        cookiesToSet.forEach(({ name, value, options }) =>
                            cookieStore.set(name, value, options)
                        )
                    } catch {
                        // The `setAll` method was called from a Server Component.
                        // This can be ignored if you have middleware refreshing
                        // user sessions.
                    }
                },
            },
        }
    );
};

 

 

5-2 로그인/로그아웃 클라이언트 페이지 만들기

// auth/auth.tsx 
"use client";
import { supabase } from "../lib/supabaseClient";

const AuthPage = () => {
	const signInWithGoogle = async () => {
    	const { error: err } = await supabase.auth.signInWithOAuth({
            provider: "google",
            options: {
                redirectTo: `${location.origin}/api/auth/callback`, // 이부분은 자신의 경로에 맞게 바꿔야 한다. 
            },
        });
    }
    // 디자인은 자유롭고 수정은 알아서 하자!
    return <button onClick={signInWithGoogle}>Login with Google</button>;
}

 

import { supabase } from "./lib/supabaseClient";

const handleLogout = async () =>{
	await supabase.auth.signOut();
}

// 디자인 넣고 하는건 직접!

 

5-3. supabase에서 인증처리하는 부분 등록

supabase 대시보드에서 URL Configurtion을 클릭한후 리다이액션 url등록

 

경로는 자신것에 맞게 해야한다. (이 프로젝트를 vercel으로 배포해서 넣어놨다)

 

5-4. 로그인 테스트 해보기 + 확장

로그인이 잘되는지 테스트해보자

유저 세션 정보가 좀 필요하다면 redux를 이용해서 유저 정보를 저장하면 되겠다.

 

6. 미들웨어에서 보호된 라우터에 관해 설정

middleware.ts을 프로젝트 "루트" (app 폴더 안 말고)에 만들기.

 

사용자 권한에 대한 테이블 정보는 다음과 같다. (사용하려면 처음 회원가입일때 users테이블에 삽입해주는 것을 트리거로 구현하던가 해야한다.)

create table public.users(
	id uuid not null,
  	nickname text not null,
    role public.role_level not null default 'r1'::role_level,
    constraint users_pkey primary key (id),
  	constraint users_nickname_key unique (nickname),
  	constraint users_id_fkey foreign KEY (id) references auth.users (id) on delete CASCADE
) TABLESPACE pg_default;

 

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { createServerClient } from '@supabase/ssr';
import { Database } from './app/types/database.types';

export async function middleware(request: NextRequest) {
    const isProtectedRoute = request.nextUrl.pathname.startsWith('/admin')
    if (!isProtectedRoute){
        return NextResponse.next();
    }

    let supabaseResponse = NextResponse.next({request,})

    const supabase = createServerClient<Database>(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
        {
            cookies: {
                getAll() {
                    return request.cookies.getAll()
                },
                setAll(cookiesToSet) {
                    cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
                    supabaseResponse = NextResponse.next({
                        request,
                    })
                    cookiesToSet.forEach(({ name, value, options }) =>
                        supabaseResponse.cookies.set(name, value, options)
                    )
                },
            },
        }
    )

   

    const { data: { user } } = await supabase.auth.getUser();
    if (!user){
        return NextResponse.rewrite(new URL('/not-found', request.url)); 
    }

    const {data, error} = await supabase.from('users').select('role').eq('id', user.id).maybeSingle();

    if ((!data || error || !['r4','admin'].includes(data.role))){
        return NextResponse.rewrite(new URL('/not-found', request.url));
    }
    return supabaseResponse;

}

// 필요한 경로만 지정
export const config = {
    matcher: ['/admin/:path*'],
};

 

/not-found로 rewrite하면 404페이지가 뜨게된다.

 

 

7. 마무리 & 느낀점

SSR 기반이라 Next.js의 서버 컴포넌트랑 잘 맞음

supabase/ssr가 쿠키기반으로 세션처리를 깔끔하게 해줘서 서버사이드 보호라우터 만들기 쉬움

물론 이거 한다고 몇시간 동안 헤매기도 했다...

 

 

8. 참고사항

쿠키는 http only가 안된다. (supabase-ssr에서 관리를 해야하므로 js가 접근할수 있어야 함)

내가 만든 코드는 여기서 볼수 있다.

https://github.com/hafskjfha/kkuko-utils/tree/main

/auth, /api/auth/callback, middleware.ts