Firebase 의 인증 기능을 활용해 회원 관리를 하면서 마주한 문제를 해결한 과정을 정리한 문서입니다.
세션 관리 설계
웹 페이지에 로그인한 유저는 3시간 간격으로 세션 유지 여부를 알림으로 확인하고, 미응답 시 자동 로그아웃 처리합니다.
웹 서비스 내부적으로 사용되는 React Query 는 지속적으로 Firestore 의 데이터를 fetch 하며, Firestore 의 Realtime update 기능은 Firestore 의 데이터가 변경될 때 마다 데이터를 받아옵니다.
따라서 접속중이지만 미응답 상태인 유저들은 자동 로그아웃을 통해 서버 리소스의 사용량을 최소화합니다.
Firebase Auth API 를 베이스로사용합니다.
ID Token : JWT 형태로 제공되며, UID 와 기본적인 유저 프로필 정보를 담고 있습니다. 1시간의 만료시간을 가집니다.
Refresh Token : ID Token 이 만료되면 재발급을 위해 사용되는 토큰입니다. 따로 만료 시간이 존재하지 않습니다.
문제점
ID Token 의 기본 만료 시간이 1시간이기 때문에 3시간 단위로 세션을 관리하기 위한 방식을 구현해야 합니다.
Firebase 에서 제공하는 세션 관리 메서드 onAuthStateChanged 의 문제점을 발생시키는 엣지케이스를 발견했습니다.
사용자가 여러 탭을 동시에 켜놓은 상태로 로그인할 경우, 현재 사용중인 탭에서만 전달되는 유저 객체가 null (로그인 전) ⇒ User 객체(로그인 후) ⇒ null(User 객체 갱신 직후) 순서로 갱신되는 문제가 발생합니다.
문제 해결 과정
1. 세션 만료 시간 설정 (3시간)
접속중인 세션 정보가 확인될 경우 아래의 순서도에 따라 세션 만료 로직을 적용합니다.
타이머 설정은 setTimeout 메서드를 활용해 알림을 띄울 2시간 55분 타이머, 자동 로그아웃이 실행되는 3시간 타이머를 설정했습니다.
세션 유지 선택창은 sonner 패키지의 toast 메서드를 활용해서 유지를 선택하면 세션 타이머를 초기화시키도록 했습니다.
코드 구현은 다음과 같습니다.
세션 유지 선택창과 자동 로그아웃 타이머를 설정하는 setSessionTimer 메서드
/**
* 세션 유지 알람의 "타이머"를 설정한다.
*
* @param uid - Firebase Authentication 의 회원 uid.
* @param timestamp - 로그아웃 타이머, 알람 타이머에 활용될 타임스탬프(기본값은 SESSION_INTERVAL 을 참조)
* - localStorage 에 이미 저장된 로그아웃 타임스탬프가 있는 경우에 시간을 직접 설정하게 된다
* @param isSettingLocalStorage - localStorage 의 타임아웃 타임스탬프 수정 여부.
* - 브라우저 윈도우에서 초기 타이머 세팅 시 사용
*/
const setSessionTimer = useCallback(
({
uid,
timestamp = SESSION_INTERVAL,
isSettingLocalStorage = false,
}: {
uid: string;
timestamp?: number;
isSettingLocalStorage?: boolean;
}) => {
// 기존 타이머 클리어
if (sessionAlarmRef.current) clearTimeout(sessionAlarmRef.current);
if (sessionTimeoutRef.current) clearTimeout(sessionTimeoutRef.current);
// 3시간 후 자동 로그아웃
sessionAlarmRef.current = setTimeout(() => {
confirmSessionKeepAlive(uid);
}, timestamp - SESSION_WARNING_OFFSET);
sessionTimeoutRef.current = setTimeout(() => {
logout();
}, timestamp);
if (isSettingLocalStorage)
localStorage.setItem(
`sessionTimestamp:${uid}`,
(Date.now() + SESSION_INTERVAL).toString(),
);
},
[],
);
localStorage 를 사용하는 이유?
동시에 여러 탭에서 접속중인 경우, 세션 유지 정책을 동기화하기 위해서 localStorage 에 세션 유지 시간을 저장하고, storage 이벤트 리스너를 통해서 다른 탭에서도 변화를 감지해 새로 타이머를 설정합니다.
/**
* LocalStorage 에서 변화가 발생하는 경우의 이벤트 핸들러
* 특정 유저의 uid 를 포함한 key 값을 사용한다.
* Key 형태 : "sessionTimestamp:uid"
* @param event LocalStorage 의 이벤트 객체
*/
const handleStorageChange = (event: StorageEvent) => {
const uid = auth.currentUser!.uid;
if (event.key === `sessionTimestamp:${uid}`) {
setSessionTimer({ uid });
}
};
사용자로부터 세션 유지 선택창을 출력하는 confirmSessionKeepAlive 메서드
/**
* toast 의 action 을 사용해 사용자의 세션 유지 여부를 입력받는 메서드.
* action 옵션을 활용해 유지 버튼을 클릭 시 {@link setSessionTimer} 세션 타이머를 초기화한한다.
*/
const confirmSessionKeepAlive = (uid: string) => {
toast('로그인 상태를 유지하시겠습니까?', {
description:
'이 알림은 로그인 시 "로그인 유지" 를 체크하지 않은 경우 3시간 간격으로 출력되며, 응답이 없을 시 자동으로 로그아웃됩니다.',
action: {
label: '유지',
onClick: () => setSessionTimer({ uid, isSettingLocalStorage: true }),
},
duration: SESSION_WARNING_DURATION,
closeButton: false,
});
};
2. 세션 관리 메서드 onAuthStateChanged 의 엣지 케이스 해결
기존의 코드에서는 동시에 여러 탭을 켠 상태로 로그인 시 유저 객체 정보가 초기화되는 문제가 발생했습니다.
해결 과정은 다음과 같습니다.
1. 로그인 정보를 인증 관련 커스텀 훅에 전달하여 현재 탭에서만 직접 재로그인
정확한 원인을 파악하지 못해 사용만 할 수 있도록 임시 조치한 코드입니다.
useFirebaseAuth.tsx
/**
* 로그인 정보 저장 형태
*/
type LoginInfo = {
email: string;
password: string;
isMaintainingSession: boolean;
};
const loginInfoRef = useRef<LoginInfo>({
email: '',
password: '',
isMaintainingSession: false,
});
/**
* onAuthStateChanged 에 전달되는 콜백 메서드
*/
const handleUser = async (user: User | null) => {
if (user) {
// ...
} else {
// ...
// 유저 객체가 초기화된 경우 직접 로그인
signInWithEmailAndPassword(
auth,
loginInfoRef.current.email,
loginInfoRef.current.password,
);
}
}
2. Firebase Auth API 의 설정을 변경
원인 : Firebase Auth API 에서 제공하는 세션 지속성 설정 메서드가 유저 객체를 null 로 초기화한다!
Stackoverflow 의 답변에서는 initializeAuth 메서드로 인증 객체 초기화 옵션 중 세션 지속성을 직접 설정하는 방식을 사용했습니다.
이 방식은 팝업창을 사용하는 써드파티 로그인 기능과 충돌이 있기 때문에 대신 세션 지속성을 초기화 과정에서 미리 설정해두는 방식을 사용하면 되는데, 내 프로젝트의 경우 기본 지속성을 사용하기 때문에 이러한 과정이 불필요합니다.
결과적으로 로그인 로직에서 세션 지속성을 설정하는 코드만 삭제함으로써 문제를 해결했습니다.
const submitLogic: SubmitHandler<SigninFormDataType> = async (data) => {
try {
if (data.isMaintainChecked)
localStorage.setItem('soljik_maintain_session', 'maintain');
// 문제가 된 코드 삭제
// await auth.setPersistence(browserSessionPersistence);
await signInWithEmailAndPassword(auth, data.email, data.password);
} catch (error: unknown) {
if (error instanceof FirebaseError) {
if (error.code === 'auth/invalid-credential') {
toast.error('잘못된 ID 혹은 비밀번호입니다.');
} else {
toast.error(error.message);
}
} else {
toast.error((error as Error).message);
}
}
};
결과
Firebase Auth API 에서 제공하는 세션 관리 방식에서 정한 1시간 단위의 세션 갱신 방식을 개발자 의도대로 3시간으로 연장하고, 사용자로부터 세션 유지 여부를 입력받도록 구현하였습니다.
후기
서버 사이드에서 직접 세션 쿠키를 생성하며 만료 시간 같은 옵션도 설정할 수 있는것을 확인했고, 추가적인 옵션들도 설정할 수 있을 것 같습니다.
다만 이번 프로젝트는 BaaS 환경이기 때문에, 직접 세션 쿠키를 만들고 로그인 요청에 대응하는 로직을 작성할 수 없어서 도입은 불가능하지만, 서버를 직접 운영하는 다른 프로젝트에서 Firebase Auth API 를 사용하는 경우에는 이 방식으로 세션 관리가 가능하다는 것을 참조해주세요.