
싱글톤은 애플리케이션의 특정 실행 범위 안에서 어떤 객체를 하나만 만들고, 이후에는 같은 객체를 계속 재사용하는 패턴이다.
즉, 핵심은 다음 한 문장이다.
생성 함수를 여러 번 호출해도 매번 새 객체를 만들지 않고 이미 만든 객체를 반환한다.
Supabase 브라우저 클라이언트를 예로 들면, 컴포넌트가 다시 렌더링될 때마다 createBrowserClient()를 새로 실행하는 대신 브라우저 런타임에서 한 번 만든 클라이언트를 계속 돌려주는 구조가 싱글톤이다.
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 동작과 연결된다. 그래서 브라우저 클라이언트는 “필요할 때마다 새로 만드는 값”보다 “한 번 만들고 공유하는 런타임 인프라”에 가깝다.
현재 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;
동작 순서는 다음과 같다.
createBrowserClient<Database>()를 실행한다.const browserClient에 저장한다.createClient()가 호출되면 새로 만들지 않고 browserClient를 반환한다.이 줄이 싱글톤의 핵심이다.
const browserClient = createBrowserClient<Database>(supabaseUrl, supabaseAnonKey);
ES 모듈은 같은 브라우저 런타임에서 한 번 평가된 뒤 캐시된다. 따라서 browserClient는 모듈 평가 시 한 번 생성되고, 이후 createClient() 호출은 같은 객체 참조를 반환한다. 함수 호출 안에서 let 캐시를 갱신하지 않기 때문에 불필요한 변경 가능 모듈 상태도 만들지 않는다.
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 내부의 변경 가능한 마운트 플래그로 상태 업데이트 가능 여부를 추적하지 않는다.
렌더 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 렌더링은 같은 클라이언트 참조를 보고, 인증 구독도 안정된 객체를 기준으로 동작한다.
이 문서에서 말하는 싱글톤은 브라우저 클라이언트 싱글톤이다.
범위는 다음과 같다.
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY를 사용하는 공개 브라우저 클라이언트에 적용된다.중요한 구분은 다음이다.
브라우저 클라이언트: 한 사용자 브라우저 런타임에서 재사용해도 된다.
서버 클라이언트: 요청, 쿠키, 사용자 컨텍스트가 섞일 수 있으므로 요청 단위 격리가 필요하다.
따라서 src/shared/lib/supabase/server.ts 같은 서버용 클라이언트에는 같은 방식의 전역 싱글톤을 적용하면 안 된다.
싱글톤은 다음 조건에 맞을 때 적합하다.
Supabase 브라우저 클라이언트는 이 조건에 잘 맞는다. 한 사용자의 브라우저 안에서 인증 상태를 읽고, auth 이벤트를 구독하고, Supabase API를 호출하는 공통 진입점이기 때문이다.
다음 경우에는 싱글톤이 위험하다.
특히 서버 환경에서는 “전역으로 하나만 만든다”가 사용자 간 상태 공유 버그로 이어질 수 있다. 브라우저 클라이언트에만 적용하고 서버 클라이언트에는 적용하지 않는 이유가 여기에 있다.
이 작업에서 “Supabase 인스턴스를 싱글톤으로 고정한다”는 말의 의미는 다음이다.
createClient()라는 public API는 유지하되,
모듈 스코프에서는 createBrowserClient()를 최초 1회만 실행하고,
이후 호출부터는 같은 Supabase 브라우저 클라이언트 인스턴스를 반환하게 만든다.
그 결과 AuthProvider는 안정된 Supabase 클라이언트를 기준으로 초기 인증 상태 처리와 인증 상태 구독을 한 번 구성할 수 있다. 렌더링이 반복되어도 클라이언트 객체 참조가 흔들리지 않기 때문에 중복 구독과 불필요한 effect 재실행 가능성이 줄어든다.