들어가며
Supabase는 PostgreSQL 기반 백엔드 서비스로, Firebase의 오픈소스 대체제로 불린다. 이 글에서 다룰 인증 외에도 실시간 구독, 자동 API 생성 등을 지원한다. 이번 글에서는 Supabase Client 인스턴스를 Next.js의 서버 환경과 클라이언트 환경에서 각각 관리하는 방법, 그리고 Supabase 로그인 정보를 크롬 익스텐션과 공유하는 방법에 대해 알아보자.
사실 처음에는 "그냥 Supabase Client 하나 만들어서 쓰면 되는 거 아닌가?" 싶었는데, Next.js에서 SSR을 제대로 구현하려다 보니 생각보다 신경 써야 할 부분이 많더라. 특히 서버와 클라이언트가 왜 Supabase Client를 따로 만들어야 하는지, 토큰 관리를 어떻게 해야 하는지 등이 처음엔 헷갈렸다. 그래서 이번 글에서는 내가 겪었던 시행착오를 바탕으로 실무에서 바로 적용할 수 있는 방법들을 공유하려고 한다.
1. Supabase 세팅 - 웹
Next.js 같은 SSR 프레임워크에서 Supabase Client를 사용하기 위해서는 @supabase/ssr을 사용해야 한다. 일반적인 @supabase/supabase-js만으로는 SSR 환경에서 제대로 동작하지 않는다. 그리고 이 라이브러리를 사용하면서 반드시 알아야 할 핵심 포인트들이 있다.
- 서버와 클라이언트 각각의 환경에서 Supabase Client를 만들어야한다.
서버와 클라이언트는 Supabase Client를 공유할 수 없다. 이게 처음엔 좀 이상하게 느껴질 수 있는데, 사실 당연한 이야기다. 서버는 Node.js 환경에서 돌아가고, 클라이언트는 브라우저에서 돌아간다. 각 환경이 다르니까 쿠키에 접근하는 방법도 완전히 다르다.
Node.js에서는 HTTP 요청 객체를 통해 쿠키에 접근한다(request.cookies.get(name)?.value). 반면 브라우저에서는 document.cookie로 접근한다. 이렇게 근본적으로 다른 환경이다 보니, Supabase Client도 각 환경에 맞게 따로 만들어줘야 한다. 서버에서는 createServerClient를, 클라이언트에서는 createBrowserClient를 사용하는 이유가 바로 이것이다.
- 클라이언트에서는 하나의 Supabase Client 인스턴스를 공유해야한다.
클라이언트에서 여러 개의 Supabase Client 인스턴스를 만들면 콘솔에 경고 메시지가 뜬다. 이건 단순히 권장사항이 아니라 실제로 문제를 일으킬 수 있다.
상상해보자. A 인스턴스는 로그인 정보를 업데이트했는데 B 인스턴스는 아직 업데이트하지 않은 상태라면? A를 사용한 API 요청은 성공하지만 B를 사용한 요청은 실패할 것이다. 같은 페이지에서 어떤 API는 되고 어떤 API는 안 되는 이상한 상황이 벌어지는 거다. 또한 인스턴스 하나만 관리하면 인증 정보 체크와 업데이트도 한 번만 하면 되니까 리소스 측면에서도 효율적이다.
그래서 나는 React Query를 이용해 하나의 인스턴스를 캐싱해서 관리했다. 싱글톤 패턴을 직접 구현할 수도 있지만, React Query를 쓰면 상태 관리와도 자연스럽게 통합되어서 더 편하더라.
- SSR을 위해 쿠키에서 토큰 관리를 해야한다.
서버는 사용자 브라우저의 로컬 스토리지에 접근할 수 없다. 당연한 이야기지만, 서버는 브라우저가 아니니까. 하지만 쿠키는 다르다. 쿠키는 HTTP 요청 헤더에 자동으로 포함되어 서버로 전달되기 때문에 서버에서도 쿠키 값을 읽을 수 있다.
그래서 Supabase는 SSR 환경에서 로그인 정보를 쿠키로 관리할 것을 권장한다. 쿠키 설정은 createServerClient나 createBrowserClient의 세 번째 인자인 options 객체의 auth.storage에서 할 수 있다.
- 서버에서 토큰을 재발급하기 위해서는 middleware.ts를 사용해야한다.
Next.js에서 서버 쪽 쿠키를 수정하려면 Server Action, Route Handler, 또는 middleware를 사용해야 한다.
- **Server Action**: 'use server'를 선언한 서버에서 실행되는 비동기 함수
- **Route Handler**: `app/api` 폴더에 만드는 API 라우트
- **middleware**: 사용자 요청을 가로채서 처리하는 미들웨어
이 중에서 토큰 재발급에 가장 적합한 곳은 middleware다. middleware는 사용자가 페이지에 접속할 때마다 실행되면서 요청을 가로챌 수 있다. 그래서 사용자에게 페이지를 전달하기 전에 토큰을 체크하고 필요하면 갱신할 수 있다. 이게 가장 자연스러운 흐름이다.
브라우저에서 supabase/js의 createClient가 아니라, 굳이 supabase/ssr의 createBrowserClient를 사용해야 하는 이유
createBrowserClient는 supabase/js의 createClient를 래핑한 메서드이다. createClient를 래핑해 어떤 기능을 추가했냐면
- cookies기능을 확장한다.
const { storage } = createStorageFromOptions(
{
...options,
cookieEncoding: options?.cookieEncoding ?? "base64url",
},
false,
);
//
src/cookies.ts
export function createStorageFromOptions(options){
const cookies = options.cookies ?? null;
if (cookies) {
if ("get" in cookies) {
// ..
}
}
supabase/js의 creatClient는 기본적으로 localStorage에 토큰을 저장한다.
- 브라우저 환경이 아니라면 토큰 재발급, URL에서 세션을 탐지하는 역할을 false로 설정한다.
autoRefreshToken: isBrowser(),
detectSessionInUrl: isBrowser(),
정리하면, supabase/ssr의 createBrowserClient는 supabase/js의 createClient에서 쿠키 설정이나 서버에서 리프래시 토큰 재발급하지 않는 등 SSR에 필요한 기능을 추가하여 래핑한 메서드라고 보면 된다.
1) middleware에서 토큰 관리
middleware에서는 클라이언트에 페이지를 제공하기 전에 토큰을 체크하고, 만료되었으면 재발급하는 로직을 처리한다. 코드를 단계별로 살펴보자.
- Supabase 클라이언트를 생성한다.
// src/middleware.ts
const response = NextResponse.next({
request,
headers: request.headers,
});
const supabaseClient = createServerClient(CONFIG.supabaseUrl, CONFIG.supabaseAnonKey, {
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => {
request.cookies.set(name, value);
response.cookies.set(name, value);
});
},
},
});
여기서 핵심은 cookies 옵션이다. getAll에서는 쿠키를 어떻게 읽어올지, setAll에서는 쿠키를 어떻게 설정할지를 정의한다.
재밌는 건 request와 response 둘 다 쿠키를 설정한다는 점이다. request는 현재 실행 중인 코드에서 바로 사용할 쿠키 값을 설정하는 거고, response는 클라이언트에게 보낼 응답 헤더에 쿠키를 설정하는 거다. 둘 다 필요하다.
토큰 만료 체크와 재발급은 Supabase가 알아서 해준다. 우리는 그냥 "쿠키는 이렇게 읽고 이렇게 쓰면 돼"라고 알려주기만 하면 된다.
NextRequest와 NextResponse 이해하기
NextRequest: Web Request API를 확장한 것으로,
cookies,nextUrl같은 편의 메서드를 제공한다.request.cookies.set('token','…') request.nextUrl.pathnameNextResponse: Web Response API를 확장한 것으로,
cookies,json(),redirect(),rewrite(),next()같은 메서드를 제공한다.cookies, json(), redirect(), rewrite(), next() 등을 활용할 수 있다.
let response = NextResponse.next() response.cookies.set('show-banner', 'false') return responseconst loginUrl = new URL('/login', request.url) loginUrl.searchParams.set('from', request.nextUrl.pathname) return NextResponse.redirect(loginUrl)const newHeaders = new Headers(request.headers) newHeaders.set('x-version', '123') return NextResponse.next({ request: { headers: newHeaders, }, })
next()메서드는 middleware에서 특히 유용한데, 쿠키를 수정하면서 동시에 라우팅을 계속 진행할 수 있게 해준다.간단히 말하면, NextRequest와 NextResponse는 클라이언트의 요청과 서버의 응답을 쉽게 다룰 수 있도록 도와주는 도구들이다.
- 사용자의 로그인 상태를 확인한다.
const isUserLogin = await new AuthService(supabaseClient).checkUserLogin();
// ..
export class AuthService {
supabaseClient: MemoSupabaseClient;
constructor(supabaseClient: MemoSupabaseClient) {
this.supabaseClient = supabaseClient;
}
checkUserLogin = async () => {
const user = await this.supabaseClient.auth.getUser();
return !!user?.data?.user;
};
// ..
}
사용자가 로그인했는지 확인하는 로직은 별도 서비스 클래스로 분리했다. 이렇게 하면 코드 재사용도 쉽고 관리하기도 편하다.
- 현재 로그인이 필요한 페이지인지 확인한다.
const isNeedAuthPage = NEED_AUTH_PAGES.includes(request.nextUrl.pathname);
// ..
export const NEED_AUTH_PAGES = [PATHS.memos, PATHS.memosSetting];
export const PATHS = {
memos: '/memos',
memosSetting: '/memos/setting',
// ..
};
인증이 필요한 페이지 목록을 상수로 관리하면, 나중에 페이지를 추가하거나 수정할 때 편하다.
- 사용자가 로그인하지 않았고 로그인이 필요한 페이지라면 로그인 페이지로 redirect한다.
if (!isUserLogin && isNeedAuthPage) {
const url = request.nextUrl.clone();
url.pathname = PATHS.login;
return NextResponse.redirect(url);
}
로그인하지 않은 사용자가 인증이 필요한 페이지에 접근하면 로그인 페이지로 리다이렉트한다.
- 이제 response를 반환해 사용자에게 응답 헤더를 전달한다.
return response;
모든 처리가 끝나면 response를 반환해서 사용자에게 응답 헤더를 전달한다. 이 과정에서 토큰이 갱신되었다면 새로운 토큰이 쿠키에 담겨서 전달된다.
2) 서버 단에서 토큰 관리
Next.js에서는 서버와 클라이언트 각각의 환경에서 Supabase Client를 따로 만들어야 한다. SSR, React Server Component, Server Action, Route Handler에서의 API 호출은 전부 Next.js 서버에서 동작하기 때문이다. 여기서 중요한 건, SSR 중에는 토큰을 재발급하면 안 된다는 점이다. 서버 쿠키는 Server Action, Route Handler, middleware에서만 수정할 수 있기 때문이다. 그래서 우리가 앞서 middleware에서 토큰을 관리했던 것이다.
그럼 서버 단 Supabase Client는 언제 쓰냐고? React Server Component, Server Action, Route Handler에서 API 요청할 때 쓴다. 즉, SSR로 HTML을 로드한 후에 동작하는 서버 액션들에서 사용하는 거다.
"어? React Server Component에서도 쿠키를 수정할 수 없는데 괜찮은 거야?"라고 생각할 수 있다. 괜찮다. middleware가 React Server Component보다 먼저 실행되기 때문에, 토큰 재발급이 필요하면 middleware에서 이미 처리했을 것이다. 그래서 React Server Component에서는 토큰 재발급을 걱정하지 않아도 된다.
그럼 이제 React Server Component, Server Actions, Route Handler같은 서버에서 API 요청을 수행할 때 사용할 Supabase Client를 만들어보자.
// utils.server.ts
let supabaseClient: MemoSupabaseClient;
export const getSupabaseClient = () => {
if (supabaseClient) return supabaseClient;
const cookieStore = cookies();
return createServerClient<Database>(CONFIG.supabaseUrl, CONFIG.supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
},
},
db: { schema: SUPABASE.table.memo },
});
};
- 이미 생성된 Supabase Client 인스턴스가 존재하는지 확인한다. 만약 존재한다면 해당 인스턴스를 사용한다.
let supabaseClient: MemoSupabaseClient;
export const getSupabaseClient = () => {
if (supabaseClient) return supabaseClient;
// ..
}
- 만약 Supabase Client인스턴스가 없다면 새로운 인스턴스를 만든다.
// utils.server.ts
import { cookies } from 'next/headers';
export const getSupabaseClient = () => {
// ..
const cookieStore = cookies();
return createServerClient<Database>(CONFIG.supabaseUrl, CONFIG.supabaseAnonKey, {
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
},
},
db: { schema: SUPABASE.table.memo },
});
};
서버 환경에서는 next/headers의 cookies()를 사용해 쿠키에 접근한다. 이게 Next.js에서 제공하는 표준 방법이다.
3) 클라이언트 단에서 토큰 관리
SSR, React Server Component, Server Action, Route Handler 외의 모든 데이터 요청은 클라이언트에서 이뤄진다. 이때는 클라이언트 단에서 만든 Supabase Client를 사용해야 한다.
클라이언트에서는 next/headers의 cookies()를 사용할 수 없다. 그건 서버 전용이니까. 대신 브라우저의 document.cookies에 직접 접근해야 한다.
export const getSupabaseClient = () => {
return createBrowserClient<Database>(CONFIG.supabaseUrl, CONFIG.supabaseAnonKey, {
auth: {
storage: {
getItem: key => {
return document.cookie.match(new RegExp(`(^| )${key}=([^;]+)`))?.[2] ?? '';
},
setItem: (key, value) => {
document.cookie = `${key}=${value}; path=/; max-age=31536000; SameSite=Strict; Secure`;
},
removeItem: key => {
document.cookie = `${key}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
},
},
},
db: { schema: SUPABASE.schema.memo },
});
};
쿠키를 다루는 로직이 좀 raw하게 보일 수 있는데, 브라우저 환경에서는 이게 정석이다. 정규표현식으로 쿠키를 파싱하고, 문자열로 쿠키를 설정한다.
여기서 주의할 점은 쿠키 옵션들이다.
path=/: 모든 경로에서 접근 가능max-age=31536000: 1년 동안 유지 (초 단위)SameSite=Strict: CSRF 공격 방지Secure: HTTPS에서만 전송
이 옵션들은 보안을 위해 꼭 설정해주는 게 좋다.
2. Supabase 세팅 - 익스텐션
나는 사용자가 웹에서 로그인하면 크롬 익스텐션에서도 자동으로 로그인되기를 원했다. 매번 익스텐션에서 따로 로그인하는 건 사용자 입장에서 너무 귀찮을 것이다.
크롬 익스텐션은 chrome.cookies API를 제공한다. 이걸 사용하면 특정 웹사이트의 쿠키에 접근할 수 있다. 그래서 익스텐션이 웹사이트의 쿠키를 읽어서 토큰을 가져오고, 이걸 익스텐션의 스토리지에 저장하면 되는 거다.
순서도로 먼저 살펴보면 다음과 같다.
flowchart TD
A[시작] --> B[Supabase Client 생성]
B --> C{익스텐션 스토리지에서 로그인 확인}
C -->|로그인 됨| D[Supabase Client 인스턴스 반환]
C -->|로그인 안됨| E[웹사이트 쿠키 확인]
E --> F{웹 쿠키에서 토큰 존재?}
F -->|없음| G[로그인 요청 메시지 표시]
F -->|있음| H[익스텐션 스토리지에 세션 저장]
H --> D
G --> I[종료]
D --> I
흐름을 순서도로 보면 이해가 더 쉽다. 간단하게 요약하면 "익스텐션 스토리지 확인 → 없으면 웹 쿠키 확인 → 있으면 복사" 이 세 단계다.
- Supabase Client를 생성한다.
import { createClient } from '@supabase/supabase-js';
const supabaseClientInstance = createClient<Database, 'memo'>(CONFIG.supabaseUrl, CONFIG.supabaseAnonKey, {
db: { schema: SUPABASE.schema.memo },
auth: {
storage: {
getItem: async key => {
return (await chrome.storage.sync.get(key)) ?? null;
},
setItem: async (key, value) => {
return await chrome.storage.sync.set({ [key]: value });
},
removeItem: async key => {
return await chrome.storage.sync.remove(key);
},
},
},
});
익스텐션에서는 chrome.storage.sync를 스토리지로 사용한다. sync는 사용자의 구글 계정에 동기화되는 스토리지라서, 다른 기기에서도 같은 데이터를 쓸 수 있다는 장점이 있다.
여기서는 @supabase/ssr이 아니라 일반 @supabase/supabase-js의 createClient를 사용한다. 익스텐션은 SSR 환경이 아니니까 굳이 ssr 버전을 쓸 필요가 없다.
- 사용자가 로그인을 했는지 체크한다.
const isUserLogin = await new AuthService(supabaseClient).checkUserLogin();
// ..
export class AuthService {
supabaseClient: MemoSupabaseClient;
constructor(supabaseClient: MemoSupabaseClient) {
this.supabaseClient = supabaseClient;
}
checkUserLogin = async () => {
const user = await this.supabaseClient.auth.getUser();
return !!user?.data?.user;
};
// ..
}
이 코드는 웹에서 쓰던 것과 동일하다. 서비스 클래스를 재사용할 수 있어서 편하다.
- 사용자가 로그인을 했다면 1번에서 생성한 Supabase Client 인스턴스를 반환한다.
if (isUserLogin) return supabaseClientInstance;
익스텐션 스토리지에 이미 토큰이 있으면 그냥 쓰면 된다. 굳이 웹 쿠키를 확인할 필요가 없다.
- 사용자가 로그인을 하지 않았다면 웹사이트로부터 쿠키 값을 가져온다.
const accessTokenFromWeb = await chrome.cookies.get({
name: COOKIE_KEY.accessToken,
url: CONFIG.webUrl,
});
const refreshTokenCookieFromWeb = await chrome.cookies.get({
name: COOKIE_KEY.refreshToken,
url: CONFIG.webUrl,
});
chrome.cookies.get()으로 특정 웹사이트의 쿠키를 읽을 수 있다. 여기서는 access token과 refresh token 두 개를 가져온다.
- 만약 사용자가 웹사이트에서도 로그인을 하지 않았다면 로그인을 먼저 해달라는 문구를 사용자에게 보여준다.
if (!accessTokenFromWeb || !refreshTokenCookieFromWeb) throw new Error('로그인을 먼저 해주세요.');
웹에서도 로그인하지 않았으면 익스텐션에서도 로그인할 방법이 없다. 사용자에게 웹에서 먼저 로그인하라고 안내한다.
- 만약 사용자가 웹사이트에서 로그인을 했다면 해당 쿠키를 기반으로 익스텐션의 스토리지에 저장한다.
await supabaseClientInstance.auth.setSession({
access_token: accessTokenFromWeb.value,
refresh_token: refreshTokenCookieFromWeb.value,
});
return supabaseClientInstance;
웹에서 가져온 토큰으로 세션을 설정하면, Supabase Client가 알아서 익스텐션 스토리지에 저장한다. 앞서 auth.storage에서 setItem을 정의해뒀으니까.
이렇게 하면 사용자가 웹에서 한 번만 로그인하면, 익스텐션에서도 자동으로 로그인된 상태가 된다. 사용자 경험이 훨씬 좋아진다.
맺으며
처음에는 "그냥 클라이언트 하나 만들어서 쓰면 되는 거 아니야?"라고 생각했는데, 막상 구현하다 보니 SSR 환경의 특성상 신경 써야 할 부분이 꽤 많았다. 특히 서버와 클라이언트가 왜 Supabase Client를 따로 만들어야 하는지, 토큰 재발급을 어디서 처리해야 하는지 같은 부분들이 처음엔 헷갈렸다.
핵심을 정리하면 이렇다. Next.js에서는 서버와 클라이언트 환경에서 각각 Supabase Client를 만들어야 한다. 실행 환경이 다르기 때문에 쿠키에 접근하는 방식도 다르다. 서버에서는 next/headers의 cookies()를, 클라이언트에서는 document.cookie를 사용한다. 그리고 토큰 재발급은 middleware에서 처리하는 게 가장 깔끔하다. middleware는 사용자 요청을 가로채서 토큰을 체크하고 갱신할 수 있는 최적의 지점이다.
익스텐션에서는 chrome.cookies API로 웹사이트의 쿠키에 접근해서, 이걸 익스텐션 스토리지에 복사하는 방식으로 로그인 정보를 공유했다. 덕분에 사용자가 웹에서 로그인하면 익스텐션에서도 자동으로 로그인되는 매끄러운 경험을 만들 수 있었다.
이렇게 Supabase Client를 정리해두면 서버·클라이언트·익스텐션 어디서든 로그인이 안정적으로 돈다. 처음엔 복잡하지만, 환경마다 제약을 알고 나면 왜 이렇게 짜야 하는지 납득이 된다.
아직 Supabase에 관해 하고 싶은 이야기들이 많다. 다음 번에는 Supabase Database를 어떻게 설계하고, Supabase와 관련된 메서드를 클래스로 관리하는 방법에 대해 이야기할 것을 기약하며 이번 글은 마치고자 한다.