feat: Clerk + Supabase 통합 시스템 구현 완료
주요 변경사항: • Clerk 인증 시스템 통합 및 설정 • Supabase 데이터베이스 스키마 설계 및 적용 • JWT 기반 Row Level Security (RLS) 정책 구현 • 기존 Appwrite 인증을 Clerk로 완전 교체 기술적 개선: • 무한 로딩 문제 해결 - Index.tsx 인증 로직 수정 • React root 마운팅 오류 수정 - main.tsx 개선 • CORS 설정 추가 - vite.config.ts 수정 • Sentry 에러 모니터링 통합 추가된 컴포넌트: • AuthGuard: 인증 보호 컴포넌트 • SignIn/SignUp: Clerk 기반 인증 UI • ClerkProvider: Clerk 설정 래퍼 • EnvTest: 개발환경 디버깅 도구 데이터베이스: • user_profiles, transactions, budgets, category_budgets 테이블 • Clerk JWT 토큰 기반 RLS 정책 • 자동 사용자 프로필 생성 및 동기화 Task Master: • Task 11.1, 11.2, 11.4 완료 • 프로젝트 관리 시스템 업데이트 Note: ESLint 정리는 별도 커밋에서 진행 예정 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,435 +1,213 @@
|
||||
import { create } from "zustand";
|
||||
import { devtools, persist } from "zustand/middleware";
|
||||
import { Models } from "appwrite";
|
||||
import {
|
||||
AppwriteInitializationStatus,
|
||||
AuthResponse,
|
||||
SignUpResponse,
|
||||
ResetPasswordResponse,
|
||||
} from "@/contexts/auth/types";
|
||||
import {
|
||||
initializeAppwrite,
|
||||
createSession,
|
||||
createAccount,
|
||||
deleteCurrentSession,
|
||||
getCurrentUser,
|
||||
sendPasswordRecoveryEmail,
|
||||
} from "@/lib/appwrite/setup";
|
||||
import { User } from "@clerk/clerk-react";
|
||||
import { authLogger } from "@/utils/logger";
|
||||
import { clearUser, trackEvent } from "@/lib/sentry";
|
||||
|
||||
/**
|
||||
* Zustand 인증 스토어 상태 타입
|
||||
* Clerk + Supabase 인증 스토어 상태 타입
|
||||
*/
|
||||
interface AuthState {
|
||||
// 상태
|
||||
session: Models.Session | null;
|
||||
user: Models.User<Models.Preferences> | null;
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
appwriteInitialized: boolean;
|
||||
isSignedIn: boolean;
|
||||
clerkLoaded: boolean;
|
||||
|
||||
// 액션
|
||||
reinitializeAppwrite: () => AppwriteInitializationStatus;
|
||||
signIn: (email: string, password: string) => Promise<AuthResponse>;
|
||||
signUp: (
|
||||
email: string,
|
||||
password: string,
|
||||
username: string
|
||||
) => Promise<SignUpResponse>;
|
||||
signOut: () => Promise<void>;
|
||||
resetPassword: (email: string) => Promise<ResetPasswordResponse>;
|
||||
|
||||
// 내부 액션 (상태 관리용)
|
||||
setUser: (user: User | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: Error | null) => void;
|
||||
setSession: (session: Models.Session | null) => void;
|
||||
setUser: (user: Models.User<Models.Preferences> | null) => void;
|
||||
setAppwriteInitialized: (initialized: boolean) => void;
|
||||
setIsSignedIn: (isSignedIn: boolean) => void;
|
||||
setClerkLoaded: (loaded: boolean) => void;
|
||||
signOut: () => Promise<void>;
|
||||
|
||||
// 세션 관련
|
||||
initializeAuth: () => Promise<void>;
|
||||
validateSession: () => Promise<void>;
|
||||
clearAuthData: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 Zustand 스토어
|
||||
*
|
||||
* Context API의 복잡한 상태 관리를 Zustand로 단순화
|
||||
* - 자동 세션 검증 (5초마다)
|
||||
* - localStorage 영속성
|
||||
* - 에러 핸들링
|
||||
* - Appwrite 클라이언트 초기화 상태 관리
|
||||
* Zustand 인증 스토어 - Clerk 기반으로 재구성
|
||||
*/
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// 초기 상태
|
||||
session: null,
|
||||
user: null,
|
||||
loading: false,
|
||||
loading: true, // 초기 로딩 상태
|
||||
error: null,
|
||||
appwriteInitialized: false,
|
||||
isSignedIn: false,
|
||||
clerkLoaded: false,
|
||||
|
||||
// 상태 설정 액션들
|
||||
setUser: (user: User | null) => {
|
||||
authLogger.debug("인증 상태 업데이트:", {
|
||||
userId: user?.id,
|
||||
hasUser: !!user,
|
||||
});
|
||||
set((state) => ({
|
||||
...state,
|
||||
user,
|
||||
isSignedIn: !!user,
|
||||
}));
|
||||
},
|
||||
|
||||
// 로딩 상태 설정
|
||||
setLoading: (loading: boolean) => {
|
||||
set({ loading }, false, "setLoading");
|
||||
set((state) => ({ ...state, loading }));
|
||||
},
|
||||
|
||||
// 에러 상태 설정
|
||||
_setError: (error: Error | null) => {
|
||||
set({ error }, false, "setError");
|
||||
},
|
||||
|
||||
// 세션 설정
|
||||
setSession: (session: Models.Session | null) => {
|
||||
set({ session }, false, "setSession");
|
||||
// 윈도우 이벤트 발생 (기존 이벤트 기반 통신 유지)
|
||||
window.dispatchEvent(new Event("auth-state-changed"));
|
||||
},
|
||||
|
||||
// 사용자 설정
|
||||
setUser: (user: Models.User<Models.Preferences> | null) => {
|
||||
set({ user }, false, "setUser");
|
||||
},
|
||||
|
||||
// Appwrite 초기화 상태 설정
|
||||
_setAppwriteInitialized: (initialized: boolean) => {
|
||||
set(
|
||||
{ appwriteInitialized: initialized },
|
||||
false,
|
||||
"setAppwriteInitialized"
|
||||
);
|
||||
},
|
||||
|
||||
// Appwrite 재초기화
|
||||
reinitializeAppwrite: (): AppwriteInitializationStatus => {
|
||||
try {
|
||||
const result = initializeAppwrite();
|
||||
get()._setAppwriteInitialized(result.isInitialized);
|
||||
if (result.error) {
|
||||
get()._setError(result.error);
|
||||
}
|
||||
authLogger.info("Appwrite 재초기화 완료", {
|
||||
isInitialized: result.isInitialized,
|
||||
setError: (error: Error | null) => {
|
||||
if (error) {
|
||||
authLogger.error("인증 오류:", error);
|
||||
trackEvent("auth_error", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorObj =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Appwrite 재초기화 실패");
|
||||
get()._setError(errorObj);
|
||||
authLogger.error("Appwrite 재초기화 실패", errorObj);
|
||||
return { isInitialized: false, error: errorObj };
|
||||
}
|
||||
set((state) => ({ ...state, error, loading: false }));
|
||||
},
|
||||
|
||||
// 로그인
|
||||
signIn: async (
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<AuthResponse> => {
|
||||
const { setLoading, _setError, setSession, setUser } = get();
|
||||
|
||||
setLoading(true);
|
||||
_setError(null);
|
||||
|
||||
try {
|
||||
authLogger.info("로그인 시도", { email });
|
||||
|
||||
const sessionResult = await createSession(email, password);
|
||||
|
||||
if (sessionResult.error) {
|
||||
authLogger.error("로그인 실패", sessionResult.error);
|
||||
_setError(new Error(sessionResult.error.message));
|
||||
return { error: sessionResult.error };
|
||||
}
|
||||
|
||||
if (sessionResult.session) {
|
||||
setSession(sessionResult.session);
|
||||
|
||||
// 사용자 정보 가져오기
|
||||
const userResult = await getCurrentUser();
|
||||
if (userResult.user) {
|
||||
setUser(userResult.user);
|
||||
authLogger.info("로그인 성공", { userId: userResult.user.$id });
|
||||
return { user: userResult.user, error: null };
|
||||
}
|
||||
}
|
||||
|
||||
const error = new Error(
|
||||
"세션 또는 사용자 정보를 가져올 수 없습니다"
|
||||
);
|
||||
_setError(error);
|
||||
return { error: { message: error.message, code: "AUTH_ERROR" } };
|
||||
} catch (error) {
|
||||
const errorObj =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("로그인 중 알 수 없는 오류가 발생했습니다");
|
||||
authLogger.error("로그인 에러", errorObj);
|
||||
setError(errorObj);
|
||||
return {
|
||||
error: { message: errorObj.message, code: "UNKNOWN_ERROR" },
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setIsSignedIn: (isSignedIn: boolean) => {
|
||||
set((state) => ({ ...state, isSignedIn }));
|
||||
},
|
||||
|
||||
// 회원가입
|
||||
signUp: async (
|
||||
email: string,
|
||||
password: string,
|
||||
username: string
|
||||
): Promise<SignUpResponse> => {
|
||||
const { setLoading, _setError } = get();
|
||||
|
||||
setLoading(true);
|
||||
_setError(null);
|
||||
|
||||
try {
|
||||
authLogger.info("회원가입 시도", { email, username });
|
||||
|
||||
const result = await createAccount(email, password, username);
|
||||
|
||||
if (result.error) {
|
||||
authLogger.error("회원가입 실패", result.error);
|
||||
setError(new Error(result.error.message));
|
||||
return { error: result.error, user: null };
|
||||
}
|
||||
|
||||
authLogger.info("회원가입 성공", { userId: result.user?.$id });
|
||||
return { error: null, user: result.user };
|
||||
} catch (error) {
|
||||
const errorObj =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("회원가입 중 알 수 없는 오류가 발생했습니다");
|
||||
authLogger.error("회원가입 에러", errorObj);
|
||||
setError(errorObj);
|
||||
return {
|
||||
error: { message: errorObj.message, code: "UNKNOWN_ERROR" },
|
||||
user: null,
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setClerkLoaded: (clerkLoaded: boolean) => {
|
||||
set((state) => ({ ...state, clerkLoaded }));
|
||||
},
|
||||
|
||||
// 로그아웃
|
||||
signOut: async (): Promise<void> => {
|
||||
const { setLoading, _setError, setSession, setUser } = get();
|
||||
|
||||
setLoading(true);
|
||||
_setError(null);
|
||||
|
||||
signOut: async () => {
|
||||
try {
|
||||
authLogger.info("로그아웃 시도");
|
||||
authLogger.info("로그아웃 시작");
|
||||
set((state) => ({ ...state, loading: true }));
|
||||
|
||||
await deleteCurrentSession();
|
||||
// Clerk에서 로그아웃은 ClerkProvider에서 처리됨
|
||||
// 여기서는 로컬 상태만 정리
|
||||
get().clearAuthData();
|
||||
|
||||
// 상태 초기화
|
||||
setSession(null);
|
||||
setUser(null);
|
||||
|
||||
authLogger.info("로그아웃 성공");
|
||||
authLogger.info("로그아웃 완료");
|
||||
trackEvent("user_logout");
|
||||
} catch (error) {
|
||||
const errorObj =
|
||||
const authError =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("로그아웃 중 오류가 발생했습니다");
|
||||
authLogger.error("로그아웃 에러", errorObj);
|
||||
setError(errorObj);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
: new Error("로그아웃 중 오류 발생");
|
||||
authLogger.error("로그아웃 오류:", authError);
|
||||
get().setError(authError);
|
||||
}
|
||||
},
|
||||
|
||||
// 비밀번호 재설정
|
||||
resetPassword: async (
|
||||
email: string
|
||||
): Promise<ResetPasswordResponse> => {
|
||||
const { setLoading, _setError } = get();
|
||||
|
||||
setLoading(true);
|
||||
_setError(null);
|
||||
|
||||
// 인증 초기화
|
||||
initializeAuth: async () => {
|
||||
try {
|
||||
authLogger.info("비밀번호 재설정 요청", { email });
|
||||
authLogger.info("[AUTH] 스토어 초기화 시작");
|
||||
set((state) => ({ ...state, loading: true, error: null }));
|
||||
|
||||
const result = await sendPasswordRecoveryEmail(email);
|
||||
authLogger.info("[AUTH] 인증 초기화 시작");
|
||||
|
||||
if (result.error) {
|
||||
authLogger.error("비밀번호 재설정 실패", result.error);
|
||||
setError(new Error(result.error.message));
|
||||
return { error: result.error };
|
||||
}
|
||||
// Clerk 로딩 완료까지 대기하는 로직은 ClerkProvider에서 처리
|
||||
authLogger.info("[AUTH] Clerk 재초기화 완료", {
|
||||
isInitialized: true,
|
||||
});
|
||||
|
||||
authLogger.info("비밀번호 재설정 이메일 발송 성공");
|
||||
return { error: null };
|
||||
authLogger.info("[AUTH] 스토어 초기화 완료");
|
||||
} catch (error) {
|
||||
const errorObj =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("비밀번호 재설정 중 오류가 발생했습니다");
|
||||
authLogger.error("비밀번호 재설정 에러", errorObj);
|
||||
setError(errorObj);
|
||||
return {
|
||||
error: { message: errorObj.message, code: "UNKNOWN_ERROR" },
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const initError =
|
||||
error instanceof Error ? error : new Error("초기화 중 오류 발생");
|
||||
authLogger.error("[AUTH] 스토어 초기화 오류:", initError);
|
||||
get().setError(initError);
|
||||
}
|
||||
},
|
||||
|
||||
// 인증 초기화 (앱 시작시)
|
||||
initializeAuth: async (): Promise<void> => {
|
||||
const {
|
||||
setLoading,
|
||||
_setError,
|
||||
setSession,
|
||||
setUser,
|
||||
_setAppwriteInitialized,
|
||||
reinitializeAppwrite,
|
||||
} = get();
|
||||
|
||||
setLoading(true);
|
||||
_setError(null);
|
||||
|
||||
// 세션 검증
|
||||
validateSession: async () => {
|
||||
try {
|
||||
authLogger.info("인증 초기화 시작");
|
||||
const { user, clerkLoaded } = get();
|
||||
|
||||
// Appwrite 초기화
|
||||
const initResult = reinitializeAppwrite();
|
||||
if (!initResult.isInitialized) {
|
||||
authLogger.warn("Appwrite 초기화 실패, 게스트 모드로 진행");
|
||||
if (!clerkLoaded) {
|
||||
authLogger.debug("[AUTH] Clerk 아직 로딩 중");
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 사용자 확인
|
||||
const userResult = await getCurrentUser();
|
||||
if (userResult.user && userResult.session) {
|
||||
setUser(userResult.user);
|
||||
setSession(userResult.session);
|
||||
authLogger.info("기존 세션 복원 성공", {
|
||||
userId: userResult.user.$id,
|
||||
if (user) {
|
||||
authLogger.debug("[AUTH] 유효한 세션 확인됨", {
|
||||
userId: user.id,
|
||||
});
|
||||
} else {
|
||||
authLogger.info("저장된 세션 없음");
|
||||
authLogger.info("[AUTH] 세션 없음");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorObj =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("인증 초기화 중 오류가 발생했습니다");
|
||||
authLogger.error("인증 초기화 에러", errorObj);
|
||||
setError(errorObj);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
authLogger.error("[AUTH] 세션 검증 오류:", error);
|
||||
}
|
||||
},
|
||||
|
||||
// 세션 검증 (주기적 호출용)
|
||||
validateSession: async (): Promise<void> => {
|
||||
const { session, setSession, setUser, _setError } = get();
|
||||
// 인증 데이터 정리
|
||||
clearAuthData: () => {
|
||||
authLogger.info("[AUTH] 인증 데이터 정리");
|
||||
|
||||
if (!session) return;
|
||||
// Sentry 사용자 정보 정리
|
||||
clearUser();
|
||||
|
||||
try {
|
||||
const userResult = await getCurrentUser();
|
||||
set({
|
||||
user: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
isSignedIn: false,
|
||||
// clerkLoaded는 유지 (Clerk는 여전히 사용 가능)
|
||||
});
|
||||
|
||||
if (userResult.user && userResult.session) {
|
||||
// 세션이 유효한 경우 상태 업데이트
|
||||
setUser(userResult.user);
|
||||
setSession(userResult.session);
|
||||
} else {
|
||||
// 세션이 무효한 경우 상태 초기화
|
||||
authLogger.warn("세션 검증 실패, 상태 초기화");
|
||||
setSession(null);
|
||||
setUser(null);
|
||||
}
|
||||
} catch (error) {
|
||||
// 세션 검증 실패시 조용히 처리 (주기적 검증이므로)
|
||||
authLogger.debug("세션 검증 실패", error);
|
||||
setSession(null);
|
||||
setUser(null);
|
||||
}
|
||||
authLogger.info("[AUTH] 인증 데이터 정리 완료");
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "auth-store", // localStorage 키
|
||||
name: "auth-store",
|
||||
// 민감한 데이터는 localStorage에 저장하지 않음
|
||||
partialize: (state) => ({
|
||||
// localStorage에 저장할 상태만 선택
|
||||
session: state.session,
|
||||
user: state.user,
|
||||
appwriteInitialized: state.appwriteInitialized,
|
||||
clerkLoaded: state.clerkLoaded,
|
||||
}),
|
||||
}
|
||||
),
|
||||
{
|
||||
name: "auth-store", // DevTools 이름
|
||||
name: "auth-store",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// 주기적 세션 검증 설정 (Context API와 동일한 5초 간격)
|
||||
// 스토어 초기화 함수
|
||||
export const initializeAuthStore = async () => {
|
||||
const store = useAuthStore.getState();
|
||||
await store.initializeAuth();
|
||||
};
|
||||
|
||||
// 세션 검증 인터벌 설정 (5분마다)
|
||||
let sessionValidationInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
export const startSessionValidation = () => {
|
||||
if (sessionValidationInterval) return;
|
||||
if (sessionValidationInterval) {
|
||||
clearInterval(sessionValidationInterval);
|
||||
}
|
||||
|
||||
sessionValidationInterval = setInterval(async () => {
|
||||
const { validateSession, session, appwriteInitialized } =
|
||||
useAuthStore.getState();
|
||||
sessionValidationInterval = setInterval(
|
||||
() => {
|
||||
const store = useAuthStore.getState();
|
||||
store.validateSession();
|
||||
},
|
||||
5 * 60 * 1000
|
||||
); // 5분
|
||||
|
||||
// 세션이 있고 Appwrite가 초기화된 경우에만 검증
|
||||
if (session && appwriteInitialized) {
|
||||
await validateSession();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
authLogger.info("세션 검증 인터벌 시작");
|
||||
authLogger.info("[AUTH] 세션 검증 인터벌 시작");
|
||||
};
|
||||
|
||||
export const stopSessionValidation = () => {
|
||||
if (sessionValidationInterval) {
|
||||
clearInterval(sessionValidationInterval);
|
||||
sessionValidationInterval = null;
|
||||
authLogger.info("세션 검증 인터벌 중지");
|
||||
authLogger.info("[AUTH] 세션 검증 인터벌 중지");
|
||||
}
|
||||
};
|
||||
|
||||
// 컴포넌트에서 사용할 편의 훅들
|
||||
export const useAuth = () => {
|
||||
const {
|
||||
session,
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
appwriteInitialized,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
resetPassword,
|
||||
reinitializeAppwrite,
|
||||
} = useAuthStore();
|
||||
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
appwriteInitialized,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
resetPassword,
|
||||
reinitializeAppwrite,
|
||||
};
|
||||
};
|
||||
|
||||
// 인증 상태만 필요한 경우의 경량 훅
|
||||
export const useAuthState = () => {
|
||||
const { session, user, loading } = useAuthStore();
|
||||
return { session, user, loading };
|
||||
};
|
||||
export default useAuthStore;
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
// Auth Store
|
||||
export {
|
||||
useAuthStore,
|
||||
useAuth,
|
||||
useAuthState,
|
||||
initializeAuthStore,
|
||||
startSessionValidation,
|
||||
stopSessionValidation,
|
||||
} from "./authStore";
|
||||
|
||||
// 호환성을 위한 alias
|
||||
export { useAuthStore as useAuth } from "./authStore";
|
||||
|
||||
// Budget Store
|
||||
export {
|
||||
useBudgetStore,
|
||||
|
||||
Reference in New Issue
Block a user