X(트위터)를 클론코딩하면서 Firebase로 Authentication 구현하는 과정을 정리해 보려고 한다.
주로 사용한 건 React
, TypeScript
, styled-components
이고, 사용한 버전은 다음과 같다.
현재까지의 폴더 구조는 아래와 같다.
📦src
┣ 📂components
┃ ┣ 📜auth-components.ts
┃ ┣ 📜github-btn.tsx
┃ ┣ 📜layout.tsx
┃ ┣ 📜loading-screen.tsx
┃ ┗ 📜protected-route.tsx
┣ 📂routes
┃ ┣ 📜create-account.tsx
┃ ┣ 📜home.tsx
┃ ┣ 📜login.tsx
┃ ┗ 📜profile.tsx
┣ 📜App.tsx
┣ 📜firebase.ts
┣ 📜main.tsx
┗ 📜vite-env.d.ts
우선 Firebase에서 프로젝트를 생성한 후, 앱을 추가한다. 내 경우, Web이므로 Web을 선택했다.
앱 등록이 끝나면 위와 같은 화면이 나오고, Firebase SDK 추가에 대해 설명해준다. Firebase에서 하라는대로 하면 된다.
npm install firebase
Firebase를 설치한 후 명시되어 있는 코드를 복사 붙여넣기만 하면 된다.
// firebase.ts
import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";
const firebaseConfig = {
apiKey: "AIzaSyCHW8rdTIs_La9p0JGd3va2eyZYP2X6wfQ",
authDomain: "nwitter-reloaded-d1408.firebaseapp.com",
projectId: "nwitter-reloaded-d1408",
storageBucket: "nwitter-reloaded-d1408.appspot.com",
messagingSenderId: "395719636241",
appId: "1:395719636241:web:84f98d6232e2710447c8b0",
};
const app = initializeApp(firebaseConfig);
// app에 대한 인증서비스를 이용하겠다.
export const auth = getAuth(app);
이렇게 하면 Firebase의 초기 설정은 완료됐다.
Authentication 활성화는 Firebase 콘솔에서 먼저 활성화한 다음, 코드에서 초기화하는 순서로 하면 된다.
위의 화면에서 Authentication을 클릭하면 Firebase 콘솔에서 활성화가 된다.
그 다음 로그인 provider를 선택하면 되는데 내 경우, 이메일/비밀번호와 GitHub를 활성화했다.
// App.tsx
// ...
function App() {
const [isLoading, setIsLoading] = useState(true);
const init = async () => {
// 최초 인증 상태가 완료될 때 실행되는 Promise를 return
// Firebase가 쿠키와 토큰을 읽고 백엔드와 소통해서 로그인 여부를 확인하는 동안 기다림
await auth.authStateReady();
setIsLoading(false);
};
useEffect(() => {
init();
}, []);
// ...
}
export default App;
위의 코드는 앱이 로딩되는 동안 Firebase의 초기 인증 상태를 확인하고, 이 상태가 resolve
되면 애플리케이션을 렌더링한다.
❗
authStateReady()
를 사용하는 이유
authStateReady()
는 Firebase에서 제공하는 메서드로, Firebase의 초기 인증 상태를 확인하는 데 사용된다. Firebase가 쿠키와 토큰을 읽고 백엔드와 소통하여 로그인 여부를 확인하는 동안 기다리는 Promise
를 반환한다. 이 Promise
는 초기 인증 상태가 해결될 때 즉시 resolve
된다.
즉, authStateReady()
를 사용한 이유는 애플리케이션이 시작될 때 Firebase의 초기 인증 상태를 기다리고, 해당 상태가 resolve
된 후에 애플리케이션을 렌더링하기 위해서이다.
기능을 구현하기 전에 css를 먼저 구현했다.
사용자가 자신의 이름과 이메일, 비밀번호를 입력하는 폼을 만들었다. 이름과 이메일, 비밀번호는 모두 required
로 설정했기 때문에 하나라도 작성하지 않으면 계정을 생성할 수 없다.
// create-account.tsx
// ...
export default function CreateAccount() {
const [isLoading, setIsLoading] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const {
target: { name, value },
} = e;
if (name === "name") {
setName(value);
} else if (name === "email") {
setEmail(value);
} else if (name === "password") {
setPassword(value);
}
};
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); // 화면 새로고침 방지
try {
// create an account
// set the name of the user
// redirect to the home page
} catch (e) {
// setError
} finally {
setIsLoading(false);
}
};
return (
<Wrapper>
<Title>Join 𝕏</Title>
<Form onSubmit={onSubmit}>
<Input
onChange={onChange}
name="name"
value={name}
placeholder="Name"
type="text"
required
/>
<Input
onChange={onChange}
name="email"
value={email}
placeholder="Email"
type="email"
required
/>
<Input
onChange={onChange}
name="password"
value={password}
placeholder="Password"
type="password"
required
/>
<Input
type="submit"
value={isLoading ? "Loading..." : "Create account"}
/>
</Form>
{error !== "" ? <Error>{error}</Error> : null}
</Wrapper>
);
}
❗
e.preventDefault()
사용하는 이유
이 코드에서 e.preventDefault()
는 폼이 실제로 서버로 데이터를 보내지 않도록 하고, 대신에 onSubmit
함수에서 정의된 로직을 실행한다.
e.preventDefault()
는 폼이 제출될 때 브라우저의 기본 동작을 중단시키는 역할을 한다. 기본적으로 HTML 폼이 제출되면 페이지가 새로고침되며, 이는 일반적으로 서버로 데이터를 전송하고 페이지를 다시 로드하는 동작을 수반한다. 하지만 React에서는 SPA의 일환으로, 페이지 전체를 다시 로드하지 않고도 애플리케이션의 상태를 업데이트하고 필요한 작업을 수행할 수 있다.
try
안에 위에서 주석으로 작성해 둔 부분을 구현했다.
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
// ...
// 로딩중이거나 하나라도 작성하지 않았다면 함수 종료
if (isLoading || name === "" || email === "" || password === "") return;
try {
// Firebase Authentication을 사용하여 사용자 계정 생성
const credentials = await createUserWithEmailAndPassword(
auth,
email,
password
);
// 계정 생성에 성공하면 사용자의 자격 증명을 받게 되고,
// 계정이 이미 존재하거나 패스워드가 유요하지 않은 경우에 계정 생성에 실패함
}
// ...
};
❗
createUserWithEmailAndPassword()
Firebase Authentication에서 제공하는 함수로, 이메일과 비밀번호를 사용하여 사용자 계정을 생성하는 데 사용된다. 계정 생성에 성공하면 사용자의 자격 증명을 받게 되고, 계정이 이미 존재하거나 패스워드가 유효하지 않은 경우에 계정 생성에 실패한다.
이메일과 비밀번호를 매개변수로 받기 때문에, 로딩 중이거나 name
, email
, password
중 하나라도 비어있는 경우 함수를 종료하도록 조건문을 작성했다.
사용자의 계정이 성공적으로 생성되면, 해당 사용자의 프로필을 업데이트하는 작업을 수행한다. 이 부분은 updateProfile()
메서드를 사용해서 구현했다.
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
// ...
try {
// ...
// Firebase Authentication을 사용하여 사용자 프로필 업데이트
await updateProfile(credentials.user, {
displayName: name,
});
// Firebase의 사용자는 이름을 포함해서 작은 아바타 이미지의 URL을 가지는 미니 프로필을 갖게 됨
}
// ...
};
❗
updateProfile()
Firebase Authentication에서 제공하는 함수로, 사용자의 프로필 정보를 업데이트한다. 위의 코드에서는 displayName
을 사용해서 사용자의 이름을 설정했다.
마지막으로, 사용자 계정 생성과 프로필 업데이트가 성공적으로 이뤄진 후에는 사용자를 애플리케이션의 홈 화면으로 리다이렉트해야 한다. 이를 위해 navigate("/")
를 사용해서 화면을 전환헀다.
export default function CreateAccount() {
const navigate = useNavigate();
// ...
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
try {
// ...
// 사용자가 계정 생성 및 프로필 업데이트 후에는 애플리케이션의 홈 화면으로 리다이렉트
navigate("/");
}
// ...
};
navigate("/")
는 React Router의 useNavigate
훅을 사용해서 지정된 경로로 이동시키는 역할을 한다. 따라서 사용자는 계정 생성이 성공하면 즉시 애플리케이션의 홈 화면으로 이동하게 된다.
추가된 코드는 다음과 같다.
// create-app.tsx
// ...
export default function CreateAccount() {
const navigate = useNavigate();
// ...
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isLoading || name === "" || email === "" || password === "") return;
try {
setIsLoading(true);
const credentials = await createUserWithEmailAndPassword(
auth,
email,
password
);
await updateProfile(credentials.user, {
displayName: name,
});
navigate("/");
} catch (e) {
// setError
} finally {
setIsLoading(false);
}
};
return (
// ...
);
}
현재 클론코딩 중인 앱은 사용자가 로그인한 상태여야만 Home
과 Profile
페이지로 접근할 수 있도록 설계되어 있다. 이를 구현하기 위해 ProtectedRoutes
컴포넌트가 필요하다. 이 컴포넌트는 사용자의 로그인 상태를 확인하고, 로그인되어 있지 않다면 로그인 페이지로 이동시킨다. 반대로 로그인되어 있다면, 해당 페이지로 이동을 허용한다.
// protected-route.tsx
import { Navigate } from "react-router-dom";
import { auth } from "../firebase";
export default function ProtectedRoute({
children,
}: {
children: React.ReactNode;
}) {
const user = auth.currentUser;
// 사용자가 로그인되어 있지 않은 경우, 로그인 페이지로 이동
if (user === null) {
return <Navigate to="/login" />;
}
// 사용자가 로그인되어 있는 경우, 요청된 페이지로 이동
return children;
}
❗
auth.currentUser
Firebase Authentication에서 현재 로그인한 사용자의 정보를 제공하는 속성이다. 이를 통해 현재 애플리케이션 상에서 로그인한 사용자의 정보를 가져올 수 있다. ProtectedRoute
에서는 이 정보를 활용하여 사용자가 로그인되어 있는지 여부를 확인하고, 그에 따라 페이지 이동을 제어한다.
ProtectedRoute
는 App.tsx
에서 루트 경로(path: "/"
)에 대한 보호된 레이아웃을 정의하고, 해당 레이아웃 내에서 Home
및 Profile
페이지에 접근할 때 사용된다.
// App.tsx
// ...
const router = createBrowserRouter([
{
path: "/",
element: (
<ProtectedRoute>
<Layout />
</ProtectedRoute>
),
children: [
{
path: "",
element: <Home />,
},
{
path: "profile",
element: <Profile />,
},
],
},
{
path: "/login",
element: <Login />,
},
{
path: "/create-account",
element: <CreateAccount />,
},
]);
// ...
로그인을 구현하기 전에 임시로 Home
컴포넌트에 로그아웃을 구현헀다.
// Home.tsx
import { auth } from "../firebase";
export default function Home() {
const logOut = () => {
auth.signOut();
};
return (
<h1>
<button onClick={logOut}>Log Out</button>
</h1>
);
}
❗
auth.signOut()
현재 로그인한 사용자를 로그아웃하는 역할을 한다. 위의 코드에서 logout
함수는 이 메서드를 호출해서 사용자를 로그아웃시킨다.
Firebase를 이용해서 이메일과 비밀번호로 사용자를 생성하는 작업을 try
할 때, 다양한 이유로 인해 작업이 실패할 수 있다. 사용자가 이미 존재하는 경우나 비밀번호가 규정에 맞지 않는 경우와 같은 상황에서 예외 처리를 통해 이러한 오류를 감지하고 처리할 수 있다. 이를 위해 try-catch
문을 사용하고, 이때 catch
블록에서 오류를 적절히 처리할 수 있다.
// create-account.tsx
// ...
export default function CreateAccount() {
// ...
const [error, setError] = useState("");
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
setError(""); // 기존 에러 값 지우기
try {
// ...
} catch (e) {
// 만약 오류가 FirebaseError의 인스턴스인 경우에만 처리
if (e instanceof FirebaseError) {
// setError 함수를 사용하여 상태 업데이트
setError(e.message);
}
}
// ...
};
return (
<Wrapper>
// ...
{error !== "" ? <Error>{error}</Error> : null}
// ...
</Wrapper>
);
}
❗
e instanceof FirebaseError
JavaScript에서 instanceof
연산자는 객체가 특정 클래스나 생성자의 인스턴스인지 여부를 확인하는 데 사용된다. 여기에선 e
가 Firebase
클래스의 인스턴스인지를 확인하여 해당하는 경우에 오류를 처리한다.
❗
e.message
FirebaseError가 발생한 경우, 해당 오류에 대한 메시지가 e.message
에 담겨져 있다.
이미 존재하는 이메일로 계정 생성을 시도하면 아래와 같이 에러가 표시된다.
로그인 페이지의 코드는 대부분 create-account.tsx
와 유사하게 구성되어 있다. 대신, 로그인 페이지에서는 사용자의 이름(name
)에 대한 코드가 필요하지 않기 때문에 해당 부분이 생략되었다.
// login.tsx
// ...
export default function Login() {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const {
target: { name, value },
} = e;
if (name === "email") {
setEmail(value);
} else if (name === "password") {
setPassword(value);
}
};
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError("");
if (isLoading || email === "" || password === "") return;
try {
setIsLoading(true);
// UserCredential 반환 -> 로그인한 사용자가 누군지 알려줌
await signInWithEmailAndPassword(auth, email, password);
navigate("/");
} catch (e) {
if (e instanceof FirebaseError) {
setError(e.message);
}
} finally {
setIsLoading(false);
}
};
return (
<Wrapper>
<Title>Log into 𝕏</Title>
<Form onSubmit={onSubmit}>
<Input
onChange={onChange}
name="email"
value={email}
placeholder="Email"
type="email"
required
/>
<Input
onChange={onChange}
name="password"
value={password}
placeholder="Password"
type="password"
required
/>
<Input type="submit" value={isLoading ? "Loading..." : "Log in"} />
</Form>
{error !== "" ? <Error>{error}</Error> : null}
</Wrapper>
);
}
❗
signInWithEmailAndPassword()
Firebase에서 제공하는 메서드로, 이메일과 비밀번호를 사용하여 사용자를 인증하는 데 사용된다. 이 메서드는 auth
객체와 사용자의 이메일과 비밀번호를 매개변수로 받아와서, 해당 정보로 사용자를 로그인시킨다.
이 메서드가 성공하면 UserCredential
라는 객체를 반환하는데, 이 객체를 통해 로그인한 사용자에 대한 정보를 얻을 수 있다.
추가로, 로그인 페이지에서 계정이 없는 경우에 회원가입 페이지로 이동할 수 있고, 회원가입 페이지에서 계정이 있는 경우에 로그인 페이지로 이동할 수 있는 Switcher
컴포넌트를 추가했다.
// create-account.tsx
// ...
export default function CreateAccount() {
// ...
return (
<Wrapper>
<Switcher>
Already have an account? <Link to="/login">Log in →</Link>
</Switcher>
</Wrapper>
);
}
// login.tsx
// ...
export default function Login() {
// ...
return (
<Wrapper>
// ...
<Switcher>
Don't have an account?{" "}
<Link to="/create-account">Create one →</Link>
</Switcher>
</Wrapper>
);
}
글이 너무 길어지는 것 같아서 이번 포스팅은 여기서 마무리를 지으려고 한다. 추가로 GitHub 로그인과 이메일로 회원가입 시 인증 메일을 보내는 기능까지 구현해 보고 추가로 포스팅을 이어나가려고 한다. 그럼 이번 포스팅은 여기서 끄-ㅌ!