이전에 만들었던 올라프를 활용하여 나만의 소원을 적는 메모앱을 만들면 어떨까? 하는 마음으로 리액트와 파이어베이스를 사용하여 만들어본 토이 프로젝트이다.
이 프로젝트에서는 리액트와 파이어 베이스를 사용하였다.
리액트는 워낙 편리한 라이브러리이기도 하고, 리액트를 활용한 토이프로젝트를 진행해보고 싶었기에 사용하였다.
파이어 베이스는 파이어 베이스의 DB를 이용해 데이터를 저장하고 불러오는 기능을 구현할 수 있다. 백엔드를 구축할 수 없는 토이 프로젝트에서 사용하기에 찰떡이라 사용하였다.
나는 로그인, 로그아웃, 소원작성, 소원삭제 기능이 있는 소원앱을 만들 계획이다.
터미널에 아래 코드를 입력하여 리액트 프로젝트 시작!
npx create-react-app
그리고 파이어베이스에 앱을 등록하고
firebase를 설치!
https://firebase.google.com/ <-파이어베이스 주소
npm install firebase
src 폴더에 firebase-> config.js 라는 파일을 생성해서 파이어베이스에서 제공한 SDK설정 script 코드를 내 파일에 붙여 놓는다.
import { initializeApp } from "firebase/app";
const firebaseConfig = {
apiKey: process.env.REACT_APP_API_KEY,
authDomain: process.env.REACT_APP_AUTH_DOMAIN,
projectId: process.env.REACT_APP_PROJECT_ID,
storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID,
appId: process.env.REACT_APP_APP_ID,
};
// firebase 초기화
const app = initializeApp(firebaseConfig);
// firestore 초기화 -> 훗날 데이터 저장을 위해
const appFireStore = getFirestore(app);
export default app;
process.env.REACT_APP_API_KEY
이건 뭐냐면
인증키는 공개되면 안되는 아이들이기 때문에(사용자가 누군지 알려주는 정보) 환경변수로 만들어 준 것 이렇게 .env 파일을 따로 만들어서 보관 해주면 된다.
그리고 이 .env 파일은 .gitignore 파일에 넣어줌!
자 이제,
index.js 파일에 import를 해줘야 한다.
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import app from './fBase.js'
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
나는 로그인페이지, 회원가입페이지, 메인페이지, 소원작성페이지 총 4가지 페이지를 만들어 줬기 때문에 라우트를 4개로 처리했다.
라우트 사용 위해 react-router-dom
설치, Routes 는 V6 사용
npm install react-router-dom
<BrowserRouter>
<Nav />
<Routes>
<Route path="/" element={<Home />}></Route>
<Route path="/login" element={<Login />}></Route>
<Route path="/signup" element={<Signup />}></Route>
<Route path="/wish" element={<Wish />}></Route>
</Routes>
</BrowserRouter>
파이어베이스에서 어떤 인증 서비스를 사용할지 고를 수가 있는데 나는 이메일, 비밀번호 인증을 사용하여 로그인 하는 방식을 선택하였다.
Authentication에서 제공하는 여러 기능을 이용하기 위해 config.js 파일에 관련 코드를 추가한다.
import { getAuth } from "firebase/auth";
// 인증 초기화
const appAuth = getAuth();
export { appFireStore, appAuth }
로그인, 회원가입 폼을 작성하고 useLogin, useSignup hooks를 만들었다.
Custom hook을 따로 만들어준 이유는 다른 컴포넌트에서도 재사용이 가능하도록 해주기 위함이다.
파이어베이스에서는 로그인, 회원가입, 로그아웃을 위한 함수를 제공한다.
-로그인
signInWithEmailAndPassword
import { getAuth, signInWithEmailAndPassword } from "firebase/auth";
const auth = getAuth();
signInWithEmailAndPassword(auth, email, password)
.then((userCredential) => {
// Signed in
const user = userCredential.user;
// ...
})
.catch((error) => {
const errorCode = error.code;
const errorMessage = error.message;
});
-회원가입
createUserWithEmailAndPassword
import { getAuth, createUserWithEmailAndPassword } from "firebase/auth";
const auth = getAuth();
createUserWithEmailAndPassword(auth, email, password)
.then((userCredential) => {
// Signed in
const user = userCredential.user;
// ...
})
.catch((error) => {
const errorCode = error.code;
const errorMessage = error.message;
// ..
});
-로그아웃
signOut
import { getAuth, signOut } from "firebase/auth";
const auth = getAuth();
signOut(auth).then(() => {
// Sign-out successful.
}).catch((error) => {
// An error happened.
});
이 함수들을 이용하여 커스텀 훅을 만들었다.
import { useState } from 'react'
import { appAuth } from '../firebase/config'
import { createUserWithEmailAndPassword, updateProfile } from 'firebase/auth'
//파이어베이스에서 제공하는 함수 import 해주기
export const useSignup = () => {
const [error, setError] = useState(null); // 에러 정보
const [isPending, setIsPending] = useState(false); // 서버와 통신중인 상태
// email, password, displayName 세가지 매개변수를 가짐
const signup = (email, password, displayName) => {
setError(null); // 아직 에러가 없으니 null
setIsPending(true); // 통신중이므로 true
createUserWithEmailAndPassword(appAuth, email, password) //파이어베이스 제공함수
.then((userCredential) => {
// Signed in
const user = userCredential.user;
console.log(user);
if (!user) {
throw new Error('회원가입에 실패했습니다.');
}
// 회원가입이 완료되면 유저 정보에 닉네임을 업데이트
updateProfile(appAuth.currentUser, { displayName })
.then(() => {
setError(null);
setIsPending(false);
}).catch((err) => {
setError(err.message);
setIsPending(false)
console.log(err.message);
});
})
.catch((err) => {
setError(err.message);
setIsPending(false);
console.log(err.message);
});
}
return { error, isPending, signup }
}
처음에 nickName 으로 변수명을 설정해주었다가 오류가 나고 잘 작동이 되지 않았다. 알고보니 displayName은 파이어베이스에서 유저 정보에 저장 할 수 있는 속성중 하나라서 다른 변수명을 사용하면 안된다고 한다 ..
import { useState } from "react";
import { useSignup } from "../../hooks/useSignup";
import styles from "./Signup.module.css";
function Signup() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState("");
// 만들어준 커스텀 훅을 이용하기 위해 작성
const { error, isPending, signup } = useSignup();
//회원가입 폼을 제출하면 signup 훅이 작동!
const handleSubmit = (e) => {
e.preventDefault();
signup(email, password, displayName);
};
return (
<form className={styles.signup_form} onSubmit={handleSubmit}>
{/* 생략 */}
{!isPending && (
<button type="submit" className={styles.btn}>
START
</button>
)}
{isPending && <strong className={styles.loding_text}>Loading...</strong>}
{error && <strong className={styles.error_text}>{error}</strong>}
</form>
);
}
export default Signup;
만들어준 signup 커스텀 훅을 적용해주었다. 그리고 pending, error 상태일때 보여줄 화면도 삼항연산자를 이용하여 작성해주었다.
회원가입을 해보자 !
가입 후 파이어베이스 콘솔에서 확인해보니 가입한 유저정보가 들어와 있음을 볼 수 있다 !
파이어베이스는 회원가입을 하면 저절로 로그인까지 이뤄진다.
이제 로그인이 된 상태일 때와 아닐 때의 화면을 다르게 만들어 주고 싶은데 ?
=> 유저 정보를 Context 로 관리해주자 !
Context 는 리액트 컴포넌트 트리 안에서 데이터를 전역으로 사용할 수 있도록 해준다. 그래서 트리단계마다 props를 넘겨줄 필요 없이 어디에서나 꺼내 쓸 수 있게끔 Context 로 관리해줄 것 !
src 폴더에 context 폴더를 생성하고 인증과 관련된 정보를 담을 AuthContext.jsx 파일을 만들어서 관리
import { useReducer, createContext, useEffect } from "react";
import { onAuthStateChanged } from "firebase/auth";
import { appAuth } from "../firebase/config";
//context 객체 생성
const AuthContext = createContext();
//리듀서 함수로 유저 정보 관리 - 객체화 데이터는 리듀서 이용
const authReducer = (state, action) => {
switch (action.type) {
case "login":
return { ...state, user: action.payload }; //기존의 유저정보에 새로운 유저 상태병합
case "logout":
return { ...state, user: null };
case "isAuthReady":
return { ...state, user: action.payload, isAuthReady: true };
default:
return state;
}
};
//context 를 구독할 컴포넌트의 묶음 범위를 설정
const AuthContextProvider = ({ children }) => {
// 유저정보 관리
//useReducer 매개변수 : 리듀더 함수, 관리할 유저정보의 초기화
const [state, dispatch] = useReducer(authReducer, {
user: null,
isAuthReady: false,
});
useEffect(() => {
const unsubscribe = onAuthStateChanged(appAuth, (user) => {
dispatch({ type: "isAuthReady", payload: user });
});
return unsubscribe;
}, []);
//유저 정보를 value 값으로 불러옴
return <AuthContext.Provider value={{ ...state, dispatch }}>{children}</AuthContext.Provider>;
};
export { AuthContext, AuthContextProvider };
useReducer
useState의 대체 함수이다. 보통 숫자형이나 문자열 같은 간단한 형태의 데이터는 useState를 이용하지만 객체와 같이 복잡한 형태의 데이터를 다룰 때 Reducer을 많이 사용한다!
형태:
const [관리할 값, dispatch 함수] = useReducer(리듀서 함수, 관리할 값의 초기화)
dispatch 함수: 리듀서 함수를 호출하는 역할
형태:
dispatch({ type: 'login', payload: user })
전달하는 인자: action
action에는 type과 전달할 데이터인 payload 가 있다.
그리고 이걸
index.js 에서 를 Context 로 감싸 하위에 있는 컴포넌트가 컨텍스트 정보에 접근할 수 있도록 작성해줌
import { AuthContextProvider } from './context/AuthContext';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<AuthContextProvider>
<App />
</AuthContextProvider>
</React.StrictMode>
);
이 다음 단계로,
인증 context를 쉽게 사용할 수 있도록 훅을 만들어 주었다.
import { useContext } from "react";
import { AuthContext } from "../context/AuthContext";
export const useAuthContext = () => {
const context = useContext(AuthContext);
return context; //state(유저정보) 와 dispatch 함수가 들어있음
};
useAuthContext를 통해 업데이트하면 이제 유저정보를 어디서든 사용할 수가 있다.
그렇게 해주기 위해서는 useSignup.jsx 훅에 아래 코드를 추가해준다.
// 유저정보를 전역에서 활용할 수 있도록 dispatch 함수를 통해 회원 정보의 상태 업데이트
const { dispatch } = useAuthContext();
…
…
// 프로필을 업데이트하는 함수에 dispatch 함수를 실행하여 만들었던 authReducer 함수를 호출
updateProfile(appAuth.currentUser, { displayName })
.then(() => {
// action으로 전달될 인자
dispatch({ type: 'login', payload: user }); //유저 업데이트
setError(null);user
setIsPending(false);
}).catch((err) => {
setError(err.message);
setIsPending(false)
console.log(err.message);
});
정말 복잡하고 어려운 과정이었다...
이제 제대로 작동이 되는지 확인해보자
AuthContext 에 콘솔을 찍어서 회원가입 진행을 해보니
짠 유저 정보가 잘 들어와 있음을 볼 수 있다.
로그인 기능도 회원가입과 비슷하게 구현해주면 됨!
import { useState } from "react";
import { appAuth } from "../firebase/config";
import { signInWithEmailAndPassword } from "firebase/auth";
import { useAuthContext } from "./useAuthContext";
export const useLogin = () => {
const [error, setError] = useState(null); //에러 정보를 저장한다.
const [isPending, setIsPending] = useState(false); //서버와 통신중인 상태를 저장한다.
const { dispatch } = useAuthContext();
const login = (email, password) => {
setError(null); // 아직 에러가 없기 때무에 null로 설정
setIsPending(true); //서버와 통신중이기 때문에 true로 설정
signInWithEmailAndPassword(appAuth, email, password)
.then((userCredential) => {
// Loged in
const user = userCredential.user;
dispatch({ type: "login", payload: user });
setError(null);
setIsPending(false);
if (!user) {
throw new Error("회원가입에 실패했습니다.");
}
})
.catch((err) => {
setError(err.message);
setIsPending(false);
});
};
return { error, isPending, login };
};
useLogin 커스텀훅을 만들어주고
Login.jsx 에서 이걸 사용할 수 있도록 코드를 추가해주면 된당.
다 작성하면 아까 회원가입 해줬었던
olaf@gmail.com 계정으로 로그인이 가능해진다!
이메일 혹은 비밀번호가 틀렸을 경우의 에러처리까지 해주면 완료!
개인적으로 개발하면서 가장 신기하고 재미있는 부분이 유저에 따라 다른 화면을 보여주는 것 이다. 로그인 했을때와 안했을 때의 상태를 다르게 구현해보자 !
우선 UI 구성은 이러하다.
이를 구현하기 위해서는
만들어준
import { useAuthContext } from '../hooks/useAuthContext';
const { user } = useAuthContext();
을 임포트해서 user을 받아온다.
{!user && (
<>
<li>
<Link to="/login">LOGIN</Link>
</li>
<li>
<Link to="/signup">SIGNUP</Link>
</li>
</>
)}
{user && (
<>
<li>
<Link to="/" onClick={logout}>
Logout
</Link>
</li>
</>
)}
그리고 !user && , user && 은 !user 이 true 일 때.
즉, 전자는 유저가 없는게 맞을 때, 후자는 유저가 있는게 맞을 때 나오는 요소가 된다!
Firebase 홈페이지에서 database 페이지로 이동 후 테스트 모드로 데이터베이스를 하나 만들어 준다.
그리고 코드로 돌아와서 firestore 전용 훅을 만든다.
isPending, Error + document 와 sucess 까지 조금 많은 애들을 관리해줘야 하기 때문에
useReducer로 작성해줄것!
//관리할 애들 객체 생성, 초기화
const initState = {
document: null, //파이어스토어에 document의 생성을 요청하면 생성한 document를 반환
isPending: false, //통신중인지 아닌지 상태
error: null,
success: false //요청에 대한 응답의 성공 유무
}
// 전달 받는 action에 따른 state 업데이트를 위한 함수
const storeReducer = (state, action) => {
switch (action.type) {
default:
return state
}
}
파이어 스토어에는 도큐먼트와 컬렉션이 있다.
문서(document)는 데이터를 객체 형식으로 저장하는데 이 저장 공간을 말하고,
컬렉션은 여러 문서 저장하는 문서의 컨테이너를 말한다. (컬렉션은 반드시 필요)
colRef : 컬렉션의 참조 요구
firestore에서 collection method 불러오기
collection의 인자: (config에서 파베초기화 했던 appFireStore, 컬렉션의 이름)
컬렉션을 만들지 않았어도 이걸 사용하면 파이어베이스에서 자동으로 컬렉션을 생성해준다!
// 저장할 컬렉션을 인자로 저장
export const useFirestore = (transaction) => {
//[관리할 데이터 이름, 디스페치]
const [response, dispatch] = useReducer(storeReducer, initState);
const colRef = collection(appFireStore, transaction);
//컬렉션에 문서 추가
const addDocument = async (doc) => {
dispatch({ type: "isPending" });
//시간순으로 정렬위해 timestamp를 불러와서 createdTime을 만들어준다.
try {
const createdTime = timeStamp.fromDate(new Date());
const docRef = await addDoc(colRef, { ...doc, createdTime });
dispatch({ type: "addDoc", payload: docRef });
} catch (error) {
dispatch({ type: "error", payload: error.message });
}
};
return { addDocument, response };
};
디스패치에 넣어준 타입을 적어준다.
const storeReducer = (state, action) => {
switch (action.type) {
case "isPending":
return {
isPending: true,
document: null,
success: false,
error: null,
};
case "addDoc":
return {
isPending: false,
document: action.payload,
success: true,
error: null,
};
case "error":
return {
isPending: false,
document: null,
success: false,
error: action.payload,
};
default:
return state;
}
};
소원 작성 form 을 submit 하면 store에 전달되게끔 해보자.
function WishForm({ uid }) {
//userid를 props로 받기 -> Wish.jsx에 useAuthContext로 받아오기
const { addDocument, response } = useFirestore("wish");
//useFirestore에서 함수들을 받아오고
//addDocument에 ({ uid, title, text }) 로 받아오기 !
const handleSubmit = (e) => {
e.preventDefault();
addDocument({ uid, title, text });
};
그럼 소원을 작성하고 확인을 해보면!
뭔가가 잘 작동한 듯한 콘솔이 찍혀있다!
파이어베이스에 들어가서 스토어를 확인해보자
굳 !!!!!
원했던 uid , title, text, createdTime 까지 잘 저장되어 있음을 볼 수 있다.
위에서 통신이 잘 된 것을 확인할 수 있다!
통신이 잘 되면 인풋값을 초기화 해주자~
//통신 완료되면 인풋 값 초기화
useEffect(() => {
if (response.success) {
setTitle("");
setText("");
}
}, [response.success]);
input 에 value 값을 넣어주는 것 잊지말자 !
<input
className={styles.wish_tit_text}
id="title"
type="text"
maxLength="50"
required
onChange={handleData}
value={title}
/>
같은 방법으로 text input 로 작성.
firestore에서 제공하는 onSnapshot
함수를 사용할 것이다.
onSnapshot
은 가장 최신의 컬렉션의 내용을 반환시켜준다.
그리고 이걸 snapshot
인자로 받아온다!
collection에 변화가 생길때마다 실행하도록 useEffect 함수로 불러온다.
때문에 항상 최신의 컬랙션 상태를 반환 받을 수 있다.
import { collection, onSnapshot} from "firebase/firestore";
function useCollection(transaction) {
useEffect(() => {
onSnapshot(collection(appFireStore, transaction),
(snapshot) => {
//snapshot 에 doc이 배열 형태로 저장되어 있음 .
let result = [];
snapshot.docs.forEach((doc) => {
result.push({ ...doc.data(), id: doc.id });
});
//각각의 도큐먼트의 문서를 가져오기 위해 데이터 메서드를 실행(전개구문으로 데이터 함수의 반환값을 객체에 나열)
//id도 추가
})
})
}
여기까지 했으면 state를 통해 컬렉션의 도큐먼트, 에러상태를 관리해주자 .
근데 여기서
onSnapshot 함수를 실행시킨다는 것 = 파이어스토어와 통신하게 되는 것 = 통신 채널을 열어둔 상태로 두게 되는 것이다.
데이터를 또 수신하기 위해 대기상태가 되는 것인데,
이걸 중단시켜주기 위해 스냅샷은 자동으로 unsubscribe 클린업 함수를 반환해준다.
function useCollection(transaction, myQuery) {
const [documents, setDocuments] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const unsubscribe = onSnapshot(
myQuery ? q : collection(appFireStore, transaction),
(snapshot) => {
//snapshot 에 doc이 배열 형태로 저장되어 있음 .
let result = [];
snapshot.docs.forEach((doc) => {
result.push({ ...doc.data(), id: doc.id });
});
setDocuments(result);
setError(null);
},
(error) => {
setError(error.message);
}
);
return unsubscribe; //clean-up 함수
}, [collection]);
return { documents, error };
}
export default useCollection;
useEffect 훅의 return 값에 함수를 반환하면 clean-up 함수가 된다.
외부에서 데이터를 구독하는 경우 clean-up 함수는 useEffect훅을 사용하는 컴포넌트가 마운트 해제될때 실행되어 구독을 종료한다.
이렇게 만들어준 useCollection에서 데이터를 받아오면 WishList에 뿌려줘야 한다.
key = {item.id}
title 은 {item.title}
text 는 {item.text} 로 데이터를 뿌려줌!
import { useFirestore } from "../../hooks/useFirestore";
function WishList({ wishes }) {
const { deleteDocument } = useFirestore("wish");
return (
<>
{wishes.map((item) => {
return (
<li key={item.id} className={styles.wishlist_item}>
<strong className={styles.wishlist_title}>{item.title}</strong>
<div className={styles.wrapper_text_btn}>
<p className={styles.wishlist_text}>{item.text}</p>
</div>
</li>
);
})}
</>
);
}
export default WishList;
여기까지 다 해주면 리스트가 불러와 지는데,
로그인 한 사용자 뿐만 아니라 다른 유저가 작성한 내용까지 다 불러와지는 문제가 생김,,
이걸 해결하기 위해서는 파이어스토어 쿼리가 필요!
특정한 데이터를 불러오는 DB의 명령어
도큐먼트 중에서도 로그인 한 사용자의 아이디와 일치하는 데이터만 불러올 수 있도록 해준다!
문서의 uid 와 사용자의 uid를 비교해보면 된다.
useCollection 에 보낼 인자에 쿼리를 추가한다. (문서의 uid와 사용자의 uid를 비교하는 쿼리문)
//Wish.jsx
const { documents, error } = useCollection("wish", ["uid", "==", user.uid]);
그리고 위에서 만들어준 useCollection.jsx 에서 코드에 쿼리 관련 내용을 추가해주면 된다!
컨텐츠의 순서가 뒤죽박죽인건 파이어베이스에서 제공하는 orderBy 정렬 함수를 사용한다.
orderBy("createdTime", "desc")
desc 는 내림차순을 반환하도록 해준다.
import { collection, onSnapshot, orderBy, query, where } from "firebase/firestore";
function useCollection(transaction, myQuery) { //쿼리 추가
const [documents, setDocuments] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let q;
if (myQuery) { //쿼리가 존재한다면
q = query( //쿼리라는 파이어베이스 함수 실행
collection(appFireStore, transaction), //컬렉션 실행
where(...myQuery), //배열로 받는 마이쿼리
orderBy("createdTime", "desc")
//앞에서 해준 createdTime과 "desc" -> 내림차순으로 정렬
);
}
const unsubscribe = onSnapshot(
myQuery ? q : collection(appFireStore, transaction),
(snapshot) => {
//마이쿼리가 참이면 q 반환
let result = [];
snapshot.docs.forEach((doc) => {
result.push({ ...doc.data(), id: doc.id });
});
setDocuments(result);
setError(null);
},
(error) => {
setError(error.message);
}
);
return unsubscribe;
}, [collection]);
return { documents, error };
}
export default useCollection;
적용을 하고 리액트 화면으로 돌아오면 아래와 같은 경고 문구가 보여진다..
쿼리문을 두 개 이상 사용할 경우 나타나는 것으로 사용한 쿼리문이 작동할 수 있도록 index를 생성해야 한다. 이거는 그냥 이 링크로 이동해서 색인을 생성하면 해결!
//컬렉션에서 문서를 제거
//id 는 삭제할 도큐먼트의 아이디
const deleteDocument = async (id) => {
dispatch({ type: "isPending" });
try {
const docRef = await deleteDoc(doc(colRef, id));
dispatch({ type: "deleteDoc", payload: docRef });
} catch (e) {
dispatch({ type: "error", payload: e.message });
}
};
그리고 디스페치에 deleteDoc을 실행하게 해줬으니 case 에 타입 추가해주기!
case "deleteDoc":
return {
isPending: false,
document: action.payload,
success: true,
error: null,
};
그리고 이 함수를 실행하게 해주기 위해서
버튼을 만들고 이벤트를 주자 !
<button
className={styles.delete_btn}
type="button"
onClick={() => {
deleteDocument(item.id);
}}
>
🫥
</button>
이렇게까지 하면 삭제도 완 성 - !
프로젝트도 완 성 - !
긴 여정이었다 ...
*이 프로젝트는 인프런 제코베 파이어베이스 수업을 듣고 만든 프로젝트입니다.
와!! 올라프 너무 귀여운데요?!
게다가 css에 인증과 DB까지 알찬 구성이네요! 좋은 프로젝트 후기 잘 보고 갑니다 멋지네요 +_+