Supabase 브라우저 클라이언트로 이해하는 싱글톤

contability·2026년 6월 19일
post-thumbnail

1. 싱글톤이란

싱글톤은 애플리케이션의 특정 실행 범위 안에서 어떤 객체를 하나만 만들고, 이후에는 같은 객체를 계속 재사용하는 패턴이다.

즉, 핵심은 다음 한 문장이다.

생성 함수를 여러 번 호출해도 매번 새 객체를 만들지 않고 이미 만든 객체를 반환한다.

Supabase 브라우저 클라이언트를 예로 들면, 컴포넌트가 다시 렌더링될 때마다 createBrowserClient()를 새로 실행하는 대신 브라우저 런타임에서 한 번 만든 클라이언트를 계속 돌려주는 구조가 싱글톤이다.

2. 왜 문제가 되는가

React 컴포넌트 안에서 아래처럼 클라이언트를 직접 만들면 렌더링이 일어날 때마다 새 Supabase 클라이언트가 만들어질 수 있다.

export const AuthProvider = ({ children }: AuthProviderProps) => {
  const supabase = createClient();

  useEffect(() => {
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, session) => {
      setUser(session?.user ?? null);
    });

    return () => {
      subscription.unsubscribe();
    };
  }, [supabase]);

  return <AuthContext.Provider value={{ user, loading }}>{children}</AuthContext.Provider>;
};

이 코드에서 createClient()가 호출마다 새 객체를 반환하면 다음 문제가 생긴다.

  • supabase 객체 참조가 렌더마다 바뀐다.
  • useEffect의 [supabase] 의존성이 계속 바뀐 것으로 판단된다.
  • 인증 상태 구독이 불필요하게 다시 만들어질 수 있다.
  • 초기 인증 상태 처리도 반복 실행될 수 있다.
  • 비동기 작업이 끝나기 전에 컴포넌트가 언마운트되면 상태 업데이트 타이밍이 꼬일 수 있다.

Supabase 클라이언트 자체는 인증 세션, 쿠키, 요청 헬퍼, auth 이벤트 구독 같은 상태ful 동작과 연결된다. 그래서 브라우저 클라이언트는 “필요할 때마다 새로 만드는 값”보다 “한 번 만들고 공유하는 런타임 인프라”에 가깝다.

3. 이 저장소의 싱글톤 구현

현재 src/shared/lib/supabase/client.ts는 모듈 스코프에서 browserClient를 한 번 만들고, createClient()가 그 인스턴스를 그대로 반환한다.

import { createBrowserClient } from '@supabase/ssr';

import type { Database } from './database.types';
import type { SupabaseClient } from '@supabase/supabase-js';

const supabaseUrl = process.env['NEXT_PUBLIC_SUPABASE_URL'];
const supabaseAnonKey = process.env['NEXT_PUBLIC_SUPABASE_ANON_KEY'];

if (!supabaseUrl || !supabaseAnonKey) {
  throw new Error('Missing required Supabase env: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY');
}

const browserClient = createBrowserClient<Database>(supabaseUrl, supabaseAnonKey);

export const createClient = (): SupabaseClient<Database> => browserClient;

동작 순서는 다음과 같다.

  1. 모듈이 처음 로드될 때 createBrowserClient<Database>()를 실행한다.
  2. 생성된 클라이언트를 모듈 스코프 const browserClient에 저장한다.
  3. createClient()가 호출되면 새로 만들지 않고 browserClient를 반환한다.

이 줄이 싱글톤의 핵심이다.

const browserClient = createBrowserClient<Database>(supabaseUrl, supabaseAnonKey);

ES 모듈은 같은 브라우저 런타임에서 한 번 평가된 뒤 캐시된다. 따라서 browserClient는 모듈 평가 시 한 번 생성되고, 이후 createClient() 호출은 같은 객체 참조를 반환한다. 함수 호출 안에서 let 캐시를 갱신하지 않기 때문에 불필요한 변경 가능 모듈 상태도 만들지 않는다.

4. AuthProvider에서의 사용

AuthProvider는 싱글톤 클라이언트를 일반 상수로 조회한다.

export const AuthProvider = ({ children }: AuthProviderProps) => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const supabase = createClient();

  useEffect(() => {
    const {
      data: { subscription },
    } = supabase.auth.onAuthStateChange((_event, session) => {
      setUser(session?.user ?? null);
      setLoading(false);
    });

    return () => {
      subscription.unsubscribe();
    };
  }, [supabase]);

  return <AuthContext.Provider value={{ user, loading }}>{children}</AuthContext.Provider>;
};

여기서 createClient()를 바로 호출해도 되는 이유는 두 가지다.

  • createClient()는 이미 모듈 스코프 싱글톤 browserClient를 반환한다.
  • supabase는 React state가 아니라 외부 인프라 클라이언트 참조다.

결과적으로 useEffect는 안정된 Supabase 클라이언트에만 의존하고, 인증 상태 변경 구독만 등록한다.

useEffect(() => {
  // onAuthStateChange
}, [supabase]);

supabase 참조가 안정적이면 인증 구독 effect도 불필요하게 반복 실행되지 않는다. cleanup에서는 구독만 해제하고, effect 내부의 변경 가능한 마운트 플래그로 상태 업데이트 가능 여부를 추적하지 않는다.

5. 싱글톤 적용 전후 비교

적용 전

렌더 1 -> createClient() -> Supabase client A 생성
렌더 2 -> createClient() -> Supabase client B 생성
렌더 3 -> createClient() -> Supabase client C 생성

이 구조에서는 렌더링과 클라이언트 생성이 강하게 묶인다. 렌더는 UI 계산이어야 하는데, 인증 클라이언트 생성과 구독 준비까지 같이 흔들릴 수 있다.

적용 후

모듈 로드 -> createBrowserClient() -> Supabase client A 생성
첫 호출 -> createClient() -> Supabase client A 반환
다음 호출 -> createClient() -> Supabase client A 반환
다음 호출 -> createClient() -> Supabase client A 반환

이 구조에서는 클라이언트 생성이 모듈 스코프 인스턴스에 의해 한 번으로 제한된다. React 렌더링은 같은 클라이언트 참조를 보고, 인증 구독도 안정된 객체를 기준으로 동작한다.

6. 이 싱글톤의 범위

이 문서에서 말하는 싱글톤은 브라우저 클라이언트 싱글톤이다.

범위는 다음과 같다.

  • 같은 브라우저 런타임에서 재사용된다.
  • React 컴포넌트 렌더링마다 새 클라이언트를 만들지 않는다.
  • NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY를 사용하는 공개 브라우저 클라이언트에 적용된다.
  • 서버 요청별 격리가 필요한 서버 Supabase 클라이언트에는 적용하지 않는다.

중요한 구분은 다음이다.

브라우저 클라이언트: 한 사용자 브라우저 런타임에서 재사용해도 된다.
서버 클라이언트: 요청, 쿠키, 사용자 컨텍스트가 섞일 수 있으므로 요청 단위 격리가 필요하다.

따라서 src/shared/lib/supabase/server.ts 같은 서버용 클라이언트에는 같은 방식의 전역 싱글톤을 적용하면 안 된다.

7. 언제 싱글톤이 적합한가

싱글톤은 다음 조건에 맞을 때 적합하다.

  • 생성 비용이 있거나 반복 생성이 불필요하다.
  • 여러 곳에서 같은 인프라 객체를 공유해야 한다.
  • 객체가 특정 실행 범위 안에서 하나여도 의미가 맞다.
  • 사용자별 또는 요청별로 격리되어야 하는 민감한 상태를 전역으로 들고 있지 않다.

Supabase 브라우저 클라이언트는 이 조건에 잘 맞는다. 한 사용자의 브라우저 안에서 인증 상태를 읽고, auth 이벤트를 구독하고, Supabase API를 호출하는 공통 진입점이기 때문이다.

8. 언제 싱글톤을 피해야 하는가

다음 경우에는 싱글톤이 위험하다.

  • 서버에서 사용자별 세션, 쿠키, 권한 정보를 객체 내부에 들고 있는 경우
  • 테스트마다 독립된 인스턴스가 필요한 경우
  • 호출자가 서로 다른 설정으로 여러 인스턴스를 만들어야 하는 경우
  • 전역 상태 때문에 변경 순서나 초기화 순서가 숨겨지는 경우

특히 서버 환경에서는 “전역으로 하나만 만든다”가 사용자 간 상태 공유 버그로 이어질 수 있다. 브라우저 클라이언트에만 적용하고 서버 클라이언트에는 적용하지 않는 이유가 여기에 있다.

9. 정리

이 작업에서 “Supabase 인스턴스를 싱글톤으로 고정한다”는 말의 의미는 다음이다.

createClient()라는 public API는 유지하되,
모듈 스코프에서는 createBrowserClient()를 최초 1회만 실행하고,
이후 호출부터는 같은 Supabase 브라우저 클라이언트 인스턴스를 반환하게 만든다.

그 결과 AuthProvider는 안정된 Supabase 클라이언트를 기준으로 초기 인증 상태 처리와 인증 상태 구독을 한 번 구성할 수 있다. 렌더링이 반복되어도 클라이언트 객체 참조가 흔들리지 않기 때문에 중복 구독과 불필요한 effect 재실행 가능성이 줄어든다.

0개의 댓글