Supabase 세션 관리가 복잡해진 이유: 함수 호출마다 setSession을 했던 실수
한스푼 익스텐션에서 Supabase로 데이터를 동기화할 때, 세션 설정 코드가 여러 곳에 흩어져 있었습니다.
처음에는 “필요한 곳에서 그냥 호출하면 되지”라고 생각했지만, 호출 지점이 늘어나면서 문제가 드러났습니다. 이 글은 세션 관리를 중앙화하고, 타입 추론 문제를 해결하면서 배운 것들을 정리한 글입니다.
문제 상황
한스푼 익스텐션은 사용자가 웹페이지에서 하이라이트한 내용을 Supabase에 동기화하는 기능이 있다. 이를 위해서는 Supabase 클라이언트에 사용자 세션(access_token, refresh_token)을 설정해야 한다.
초기 구현에서는 syncPostToSupabase 함수 내부에서 직접 세션을 설정했다:
export async function syncPostToSupabase(
postId: string,
session: Session | null,
) {
if (!session) {
throw new Error("로그인 세션이 없습니다");
}
const { error: sessionError } = await supabase.auth.setSession({
access_token: session.access_token,
refresh_token: session.refresh_token,
});
if (sessionError) {
console.error("Supabase 세션 설정 실패:", sessionError);
return;
}
// 이하 동기화 로직...
}
이 방식은 동기화 함수가 하나뿐일 때는 문제가 없었다.
하지만 기능이 늘어나면서
- 하이라이트 삭제 시에도 동기화 필요
- 공유 링크에서 하이라이트 추가 시에도 동기화 필요
- background script에서도 세션 접근 필요
결국 setSession을 호출해야 하는 곳이 계속 늘어났고, 동일한 세션 설정 로직이 여러 파일에 중복되기 시작했다.
내가 처음 한 생각
“Supabase API를 호출하기 전에 setSession만 해주면 되니까, 필요한 곳에서 각자 호출하면 되겠지.”
이 생각에는 두 가지 맹점이 있었다.
- 중복 코드 문제: 세션 설정 로직이 파일마다 반복됨
- 타입 추론 문제: browser.storage.local.get()의 반환 타입이 any에 가까워서, Session 타입을 매번 직접 캐스팅해야 했음
// 매번 이렇게 타입을 지정해야 했다
const { session } = await browser.storage.local.get<{ session: Session }>(
"session",
);
실제로 해 본 선택
- initSupabaseSession으로 초기화 로직 분리
세션 설정을 한 곳에서 관리하도록 initSupabaseSession 함수를 만들었다:
async function setSupabaseSession(session: Session | null) {
if (!session) {
await supabase.auth.signOut();
return;
}
const { error } = await supabase.auth.setSession({
access_token: session.access_token,
refresh_token: session.refresh_token,
});
if (error) {
console.error("Supabase 세션 설정 실패:", error);
}
}
export async function initSupabaseSession() {
const { session } = await browser.storage.local.get<{ session: Session }>(
"session",
);
if (session) {
await setSupabaseSession(session);
}
// 세션 변경 감지 - 로그인/로그아웃 시 자동 반영
browser.storage.onChanged.addListener((changes) => {
if ("session" in changes) {
setSupabaseSession(changes.session.newValue as Session | null);
}
});
}
이제 background script 초기화 시점에 initSupabaseSession()을 한 번만 호출하면, 이후에는 세션이 자동으로 유지된다. 개별 함수에서는 더 이상 setSession을 호출할 필요가 없다.
- 타입 안전한 브라우저 스토리지 래퍼
browser.storage.local의 타입 추론 문제를 해결하기 위해 제네릭 래퍼를 만들었다.
export const storage = {
get: async function get<T>(key: string): Promise<T | undefined> {
const result: Record<string, T> = await browser.storage.local.get(key);
return result[key];
},
set: async function set<T>(key: string, value: T): Promise<void> {
await browser.storage.local.set({ [key]: value });
},
remove: async function remove(key: string): Promise<void> {
await browser.storage.local.remove(key);
},
subscribe: function subscribe<T>(
key: string,
listener: (newValue: T | undefined, oldValue: T | undefined) => void,
): () => void {
const handleChange = (
changes: Record<string, { oldValue?: T; newValue?: T }>,
) => {
if (key in changes) {
listener(changes[key].newValue, changes[key].oldValue);
}
};
browser.storage.onChanged.addListener(handleChange);
return () => browser.storage.onChanged.removeListener(handleChange);
},
};
이제 세션을 가져올 때 타입이 자동으로 추론된다.
// Before
const { session } = await browser.storage.local.get<{ session: Session }>(
"session",
);
// After
const session = await storage.get<Session>("session");
지금 다시 해본다면?
처음부터 이런 사고의 흐름으로 갔어야 했나라는 생각이 스치기도 하였다. 하지만 생각해보면 초반에는 딱 한 군데에서만 사용했기에 굳이 setSession을 따로 중앙화까지는 할 필요는 없었을 것 같다.
대신 browser.storage.local의 타입 추론 문제는 내가 놓쳤던 부분이었기 때문에, 앞으로 이런 경우 랩퍼를 미리 구축해두어야 겠다는 생각을 했다.
비고
-
세션(Session)이 필요한 이유
- Supabase는 Row Level Security(RLS)를 통해 “이 데이터는 이 사용자만 접근 가능”을 DB 레벨에서 강제한다. 이를 위해 API 요청 시 사용자가 누구인지 증명해야 하는데, 이때 사용되는 것이 세션이다.
-
세션에는 다음이 포함된다.
- access_token: JWT 형태, 사용자 ID와 권한 정보를 담고 있음. 짧은 만료 시간(보통 1시간)
- refresh_token: access_token이 만료됐을 때 새 토큰을 발급받기 위한 토큰. 긴 만료 시간
-
브라우저 익스텐션에서의 세션 저장
- 일반 웹앱에서는 Supabase 클라이언트가 localStorage에 세션을 자동 저장한다.
- 하지만 브라우저 익스텐션은…
- content script, background script, popup 등 여러 컨텍스트가 분리되어 있음
- 각 컨텍스트 간 상태 공유가 자동으로 되지 않음
- browser.storage.local을 사용해야 컨텍스트 간 세션 공유 가능
추가 이슈
subscribe 패턴을 사용하는 이유
처음에는 async 컴포넌트에서 await storage.get()으로 세션을 가져왔다.
// 문제가 있던 코드
export const PostCard = async ({ post }: { post: LocalPost }) => {
const session = await storage.get("session");
const isLoggedIn = !!session;
// ...
};
하지만 이 방식은 컴포넌트가 마운트될 때 한 번만 값을 가져온다. 이후 사용자가 로그인해서 storage의 session 값이 변경되어도, 이미 마운트된 컴포넌트는 re-render되지 않는다. React 상태가 아니기 때문이다.
이를 해결하려면 storage 변경을 감지해서 React 상태를 업데이트해야 한다.
// 수정된 코드
export const PostCard = ({ post }: { post: LocalPost }) => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
useEffect(() => {
// 초기값 설정
storage.get("session").then((session) => {
setIsLoggedIn(!!session);
});
// storage 변경 시 상태 업데이트 → re-render
return storage.subscribe("session", (newValue) => {
setIsLoggedIn(!!newValue);
});
}, []);
// ...
};
storage.subscribe가 storage 변경을 감지하고 setState를 호출하면, React가 컴포넌트를 re-render하여 UI가 업데이트된다.
실제 변경 내역
- https://github.com/hanspoon/hanspoon-frontend/pull/6/commits/8033792fd303886cba73f499ac29a8ddd6ad3e46
- https://github.com/hanspoon/hanspoon-frontend/pull/6/commits/36bb18376c07eae4b961080e71f81aa0bdb335c2
- https://github.com/hanspoon/hanspoon-frontend/pull/6/commits/bd712d01f3bdaaeef12513aebda62c4e31687f9e