이번 주말엔 영화앱을 완성하자.
영화앱 완성에 필요한 내용들
먼저 큰 기능부터 작성하자.
커뮤니티 페이지부터 만들자.
먼저 실시간으로 구현 후, 요청 횟수를 추후에 줄이는 방향으로 진행해야 겠다.
먼저 커뮤니티 페이지에 사용되는 카드 디자인은 Bootstrap 4 Card With Background Image : Codepen이 사용되었다.
page/board.tsx 이다.
크게 Header와 CardGrid로 나뉘어 있다.
그리고 새로운 글 생성을 감지하는 creating 스테이트가 있다.
한편, 글을 생성하기 위해서는 유저 정보가 있어야 하는데, onAuthStateChanged를 이용해서 store를 업데이트 시켜주기로 했다.
(사실 store에서 유저 정보를 불러올 때, 유저 정보가 없으면 fetchProfile을 실행시키고자 했는데,
이러한 작업을 리덕스에서는 side effect로 분류한다.
side effect 작업을 수행하기 위해서는 리덕스 사가를 도입해야 한다.
리덕스 사가를 모르는 나는 어쩔 수 없이 onAuthStateChanged로 파이어베이스 유저 정보와 store, 둘 다 옵저빙하고 있다.)
const board = () => {
const dispatch = useDispatch();
const [creating, setCreating] = useState(false);
const { uid } = useTypedSelector((state) => {
return state.authSlice.userProfile;
}, shallowEqual);
useEffect(() => {
authService.onAuthStateChanged((user) => {
if (user) {
if (uid !== user.uid) {
fetchProfile(user.uid).then((res) => {
dispatch(setUserProfile(res));
});
}
}
});
}, [uid]);
return (
<div>
<BoardHeader creating={creating} setCreating={setCreating} uid={uid} />
<CardGrid creating={creating} setCreating={setCreating} />
</div>
);
};
export default board;
BoardHeader는 매우 간단하다.
단순히 새 글을 작성하는 버튼이 있다.
해당 버튼을 누르면 '생성 모드'로 들어가게 된다.
유저 정보가 없는 경우 생성이 불가능한데, 단순히 store에 uid가 없는 경우 error를 띄우는 것으로 대체했다.
const BoardHeader = ({ creating, setCreating, uid }) => {
return (
<StyledBoardHeader className="container">
<div className="row">
<div className="col text-center mb-5">
<h1>영화 일기</h1>
<div
className={`lead eddting-text ${creating && "invisible"}`}
onClick={() => {
if (!uid) return toastError("로그인이 필요합니다.");
setCreating(true);
}}
>
새로운 글을 작성해보세요
</div>
</div>
</div>
</StyledBoardHeader>
);
};
export default BoardHeader;
const StyledBoardHeader = styled.div`
.eddting-text {
max-width: 300px;
margin-left: auto;
margin-right: auto;
cursor: pointer;
color: orange;
font-size: small;
font-weight: 500;
&:hover {
transform: scale(1.05);
}
}
`;
const CardGrid = ({ creating, setCreating }) => {
return (
<div className="container">
<div className="row">
{creating ? <CardCreate setCreating={setCreating} /> : <></>}
<Card />
<Card />
<Card />
<Card />
<Card />
<Card />
<Card />
</div>
</div>
);
};
export default CardGrid;
Card Grid 역시 단순하다. Card 목록을 불러와 그리드 시스템으로 나열한다.
이때 create 모드로 전환되면 목록의 가장 앞에 새로운 카드를 추가한다.
카드 디자인은 앞서 언급했듯 Bootstrap 4 Card With Background Image : Codepen이 사용되었다.
여기에 좋아요를 표시할 likes라는 새로운 디자인이 추가되었고, 큰 디자인 수정은 없이 진행했다.
작성자 정보에 작성자의 팔로워수를 표시할까 했는데,
작성자의 팔로워수를 게시글 정보 DB에까지 저장하는 것이 부담스러워 제외하였고, 대신 작성일을 표시하기도 한다.
const Card = () => {
return (
<StyledCard className="col-sm-12 col-md-6 col-lg-4 col-xl-3 mb-4">
<div
className="card text-white card-has-bg click-col"
style={{
backgroundImage: `url("https://image.tmdb.org/t/p/w780/${post.backdrop_path}")`,
}}
>
<img
className="card-img d-none"
src={`https://image.tmdb.org/t/p/w780/${post.backdrop_path}`}
alt={post.title}
/>
<div className="card-img-overlay d-flex flex-column">
<CardBody post={post} likesCount={post.likes.length} />
<div className="card-footer likes">
<small className="">{post.likes.length}개의 좋아요</small>
</div>
<CardFooter
author={post.author}
published_date={post.published_date}
/>
</div>
</div>
</StyledCard>
);
};
아래와 같이 더미 데이터를 작성하면
const post = {
title: "다크나이트",
body: "으라차챠차",
backdrop_path: "nMKdUUepR0i5zn0y1T4CsSB5chy.jpg",
published_date: "2021-02-18",
author: {
uid: "1234",
nickname: "배트맨좋아",
image: "1.jpg",
},
likes: ["1234", "45678"],
};
이런 모습이 된다.
Card 컴포넌트를 최대한 활용하고 싶었지만,
Card 컴포넌트 안에 CRUD 중 RUD가 다 들어갈 예정이라 Create 기능은 CardCreate라는 컴포넌트로 분리하였다.
추가로, Create와 Update는 카드의 title과 body를 수정한다는 점에서 같다. 따라서 CardBodyEdditing이라는 새로운 컴포넌트도 만들었다.
components/board/card/CardCreate.tsx
return (
<StyledCard className="col-sm-12 col-md-6 col-lg-4 col-xl-3 mb-4">
<div
className="card text-white card-has-bg click-col"
style={{
backgroundImage: `url(${imageURL})`,
}}
>
<img
className="card-img d-none"
src={`${imageURL}`}
alt={movie?.title}
/>
<div className="card-img-overlay d-flex flex-column">
<CardBodyEdditing
input={input}
setInput={setInput}
movie={movie}
setMovie={setMovie}
></CardBodyEdditing>
<div className="card-footer likes ">
<small
className={`create-button ${canCreate ? "" : "invisible"}`}
onClick={onCreate}
>
작성하기
</small>
</div>
</div>
</div>
</StyledCard>
);
CardCreate에 사용되는 상태들은 아래와 같다.
/*
input : 작성글의 내용 input이다
movie : 작성글의 영화 내용이다.
initalMovie: movie의 초기 상태를 분리했다.
imageURL: 배경 이미지이다. movie정보가 있을 때 movie.backdrop_path로 업데이트 된다.
canCreate: input과 movie가 있을 때 true로 업데이트 된다. true일 때 작성하기 버튼이 활성화된다.
uid, nickname, image: 유저 정보이다.
*/
const initalMovie = {
backdrop_path: "",
title: "",
id: "",
};
const [input, setInput] = useState("");
const [movie, setMovie] = useState(initalMovie);
const [imageURL, setImageURL] = useState("/noResult.jpg");
const [canCreate, setCanCreate] = useState(false);
const { uid, nickname, image } = useTypedSelector((state) => {
return state.authSlice.userProfile;
}, shallowEqual);
const resetState = () => {
setInput("");
setMovie(initalMovie);
setImageURL("/noResult.jpg");
setCanCreate(false);
setCreating(false);
};
useEffect(() => {
if (movie.title && input) {
setCanCreate(true);
} else {
setCanCreate(false);
}
}, [movie, input]);
useEffect(() => {
if (movie.backdrop_path) {
setImageURL(`https://image.tmdb.org/t/p/w780/${movie.backdrop_path}`);
} else {
setImageURL("/noResult.jpg");
}
}, [movie]);
작성하기를 누르면 발생할 onCreate 함수를 선언하기 전에
두 가지 타입을 만들었다.
export interface Author {
uid: string;
nickname: string;
image: string;
}
export interface Article {
title: string;
body: string;
backdrop_path: string;
published_date: number;
author: Author;
likes: Array<string>;
}
Author는 현재 유저의 프로필 정보를 담는다. (해당 타입을 선언하면서 기존 ProfileType은 Author에서 extends 되도록 했다.)
Article는 영화 정보의 일부와 카드의 내용, 작성자 정보(Author타입)를 저장한다. 그리고 좋아요를 누른 사용자들의 정보를 uid로 저장하기 위해 비정규화한 likes라는 배열을 가진다.
const resetState = () => {
setInput("");
setMovie(initalMovie);
setImageURL("/noResult.jpg");
setCanCreate(false);
setCreating(false);
};
const onCreate = () => {
const article: Article = {
title: movie.title,
body: input,
backdrop_path: movie.backdrop_path,
published_date: Date.now(),
author: {
image,
uid,
nickname,
},
likes: [],
};
if (movie.title && uid && input)
createArticle(article).then(() => {
toastSuccess("작성되었습니다.");
resetState();
});
};
onCreate함수는 state들을 참조하여 Article타입 객체를 만든다.
해당 객체를 createArticle()라는 service 함수에 넣어 실행한다.
글이 작성되면 참조했던 state들을 초기상태로 돌려놓고 글쓰기 모드를 종료하게 된다.
Firestore에 글을 쓰기 위해 'articles'라는 새로운 콜렉션을 만들었다.
createArticle()함수는 매우 간단한 형태이다.
export const createArticle = async (article: Article) => {
return dbService.collection("articles").add(article);
};
article 객체만 정확하게 받으면 바로 글이 작성될 것이다.
먼저 게시글을 작성시간 기준으로 정렬한다.
export const fetchAticles = () => {
return dbService.collection("articles").orderBy("published_date", "desc");
};
그리고 .onSnapshot을 이용하여 해당 게시글을 실시간으로 반영하기로 한다.
const [articles, setArticles] = useState([]);
useEffect(() => {
fetchAticles().onSnapshot((snapshot) => {
const newArticles = snapshot.docs.map((document) => {
return {
id: document.id,
...document.data(),
};
});
setArticles(newArticles);
});
}, []);
완성된 게시글을 map으로 순회하여 card를 렌더링하면 된다.
return (
<div className="container">
<div className="row">
{creating ? <CardCreate setCreating={setCreating} /> : <></>}
{articles.map((article) => {
console.log(article);
return <Card article={article} />;
})}
</div>
</div>
);
성능상으로는 실시간 데이터 베이스로 요청 횟수도 늘어나고, next.js에서 static한 렌더링도 불가능한 대환장 파티지만, 가장 중요한건 일단 '잘 작동한다'는 점이다.
그런데...
글 생성을 하면 일시적으로 레이아웃 쉬프트가 일어난다.
그 이유는
위 두가지의 로직이 서로 겹칠 때 레이아웃 쉬프트가 일어나는 것이다.
성능 문제는 참아도 레이아웃 쉬프트는 못참는다.
위 문제를 해결하기 위해서는 데이터베이스가 변경되는 시점과 state를 바꾸는 시점을 일치시켜야 하는데, onSnapshot이 다른 유저가 접근하여 DB를 수정할 때에도 발생하는 이벤트이기 때문에 동기화 시키는 것이 쉽지 않다.
결국 실시간 데이터 베이스를 걷어내고 get()함수로 바꾼다.
article을 관리할 store를 만든다.
initialState: {
articles: [] as Array<Article>,
},
reducers: {
setArticles(state, action: PayloadAction<Array<Article>>) {
state.articles = action.payload;
},
},
card Grid의 state를 리덕스와 연결한다.
fetchArticles 함수를 아래와 같이 get()와 data()를 이용하여 바꾼다. 배열을 반환하기에 .then으로 받아 쓰기도 편하다.
export const fetchAticles = () => {
return dbService
.collection("articles")
.orderBy("published_date", "desc")
.get()
.then((Snapshot) => {
const Articles = Snapshot.docs.map((doc) => {
const documentId = doc.id;
const documentData = doc.data();
return { documentId, ...documentData } as Article;
});
return Articles;
});
};
useEffect(() => {
fetchAticles().then((articles) => {
dispatch(setArticles(articles));
});
}, []);
이렇게 받아오면 된다.
이제 카드를 생성했을 때,
fetchArticles => {setArticles(articles), setCreatating(false)} => 렌더링
과 같이 setArticles(articles)와 setCreatating(false)가 동시에 반영되어 렌더링하도록 하면 된다.
그런데......dispatch도 실시간으로 반영이 안되어 매우매우매우매우 짧지만 텀이 있었다.
생각보다 까다로운 작업이었다.
그래서 redux도 빼고,
jsx가 상태를 직접 참조하기 보다 이미 상태가 다 반영된 상태를 참조하도록 바꾸는 작업을 했다.
if (movie.title && uid && input)
createArticle(article).then(() => {
toastSuccess("게시글이 작성되었습니다.");
setUpdated(false);
setCreating(false);
});
게시글 생성시 바로 fetch하는 것이 아니라, setUpdated(false)로 card grid의 상태를 바꾼다.
useEffect(() => {
if (creating) {
//(2)
setGrid([
<CardCreate setCreating={setCreating} setUpdated={setUpdated} />,
...cardList,
]);
} else {
//(3)
if (updated) {
setGrid([...cardList]);
} else {
//(1)
fetchAticles().then((articles) => {
setArticles(articles);
});
}
}
}, [creating]);
(1) 최초로 접속했을 때에는 creating도 false, update도 false인 상태이므로 fetchAticles를 실행하여 게시글을 불러온다.
(2) 이후 creating모드가 되면 grid의 가장 앞에 CardCreate 컴포넌트를 삽입시켜 렌더링한다.
(3) creating이 false인데 이미 게시글이 있는 상황이면 grid에서 CardCreate만 제외하여 렌더링 한다.
useEffect(() => {
const cardList = articles.map((article) => {
return <Card article={article} key={article.documentId} />;
});
setCardlist(cardList);
setGrid(cardList);
setUpdated(true);
}, [articles]);
articles가 새로운 배열이면, 그것에 맞게 새로운 카드 리스트들을 만들고 setUpdated를 true로 만들어 준다.
Updated라는 상태를 따로 만든 이유는, create 모드를 취소했을 때, 불필요하게 서버에 요청이가는 것을 막기 위함이다.
두 개의 상태가 서로 맞물리며 상당히 복잡한 상태관리였으나,
delete를 할 때에도 사용될 패턴이라 결과적으로 보람은 있었다.
위를 적용한 결과,
(fetchAticles를 처리하는 지연시간이 약간 발생하지만,) CardCreate 컴포넌트가 새로 생성된 Card 컴포넌트로 대체되며 렌더링 된다.
앞서 살펴본 cardCreate 중 아래와 같이 props drilling이 일어나는 부분이 있다.
cardCreate 컴포넌트
const [input, setInput] = useState("");
<CardBodyEdditing
input={input}
setInput={setInput}
movie={movie}
setMovie={setMovie}
></CardBodyEdditing>
CardBodyEdditing
<StyledTextArea
value={input}
onChange={(e) => {
setInput(e.target.value);
}}
></StyledTextArea>
이렇게 되면 textArea에서 글을 작성할 때마다 cardCreate 컴포넌트 전체가 리렌더링된다.
cardCreate 전체를 리렌더링 하지 않고도 textArea의 value를 부모로 불러와야 한다.
useReducer 를 사용하여 상태 업데이트 로직 분리하기-벨로퍼트 를 참고하여 최적화 할 수 있다.
input과 useReducer를 객체 안에 넣는 것이다. 이렇게 하면 부모 요소는 객체의 불변성으로 인해 리렌더링 되지 않지만, 객체의 프로퍼티를 조회하는 자식 요소들은 프로퍼티 값이 변함을 탐지해서 리렌더링된다.
하지만 이번엔 기존에 만들어두었던 customhook인 useDebounce로 렌더링 성능만 최적화 시키기로 했다.
먼저 아래와 같이 CardTextArea라는 컴포넌트를 분리시켰다.
CardBodyEdditing
<CardTextArea searching={searching} setInput={setInput} />
CardTextArea안은 이렇게 생겼다.
1. textArea의 입력과 연관된 body 스테이트
2. body 스테이트를 debounce하여 결과값을 받을 debouncedBody
3. debouncedBody의 변화를 감지하여 setInput을 실행시킬 useEffect
CardTextArea
const CardTextArea = ({ searching, setInput }) => {
const [body, setBody] = useState("");
const debouncedBody = useDebounce(body, 200);
useEffect(() => {
setInput(debouncedBody);
}, [debouncedBody]);
return (
<StyledTextArea
value={body}
onChange={(e) => {
setBody(e.target.value);
}}
></StyledTextArea>
);
};
이렇게 input태그나 textarea와 같이 상태를 자주 변화시키는 태그들은 분리시키는 것이 좋다. (혹은 앞서 언급한대로 useReducer를 이용하는 것이 좋다.)