구글 소셜 로그인이 제공되는 포토 다이어리 형식의 프로젝트로 이미지와 함께 게시글 작성이 가능하며 댓글, 좋아요, 북마크, 팔로잉/팔로우로 회원간 소통이 가능한 웹 서비스
기술 스택으로는 Typescript, Next.js, SWR, Tailwind CSS, Sanity, Vercel로 구성했다.
이유는 vercel에서 만든 넥스트, swr을 같이 적용시켜보고 싶었고, 리액트 쿼리와 swr중 고민하다가 최신 버전인 swr은 많은 부분 리액트쿼리와 별만 차이점이 없어서 vercel에서 만든 swr를 사용했다.
Sanity는 비전공자로서 백엔드 지식이 없는 나에게 유튜브 강의와 공식문서를 참고하면서 만들기에 좋은 시작일 것 같아 선택하였다. 그리고 Sanity 공식문서에 친절하게 다 나와있어 부족하지만 강의 + 구글링으로 시작해볼 수 있었다.
// next-social-app
📦src
┣ 📂app
┃ ┣ 📂api
┃ ┣ 📂auth
┃ ┣ 📂newpost
┃ ┣ 📂search
┃ ┣ 📂user
┃ ┣ layout.tsx
┃ ┣ page.tsx
┣ 📂components
┣ 📂context
┣ 📂hooks
┣ 📂models
┣ 📂service
┣ 📂type
┗ 📂util
→ 기능상으로 연관이 있는 파일들끼리 폴더 별로 정리하였고, 폴더 구조만으로도 파일 내 코드가 어떤 역할을 하는지 알 수 있도록 하였다. 또한 파일명도 명시적으로 표현해보려고 하였다.
→ 검색창을 구현할때 사용자가 입력하는 키워드 하나하나 네트워크 요청이 가는 이슈가 발생하였고 이는 성능적으로 좋지 않다고 생각하였다.
키워드가 변경될때마다 swr이 불필요한 네트워크 요청을 하기 때문에 성능 개선이 필요하고, 사용자 네트워크 용량을 잡아먹는 문제점이 있다. 백엔드에서 sanity 데이터 베이스에 접근하여 데이터를 읽어오기 때문에 서버 과부하가 걸릴 수 있는 문제점이 있다.
→ Debounce 커스텀 훅을 생성하여 해결하였다. 부트캠프 2차 팀프로젝트에서 무한스크롤을 최적화 하기 위해 사용해봤던 디바운스를 적용해 보았다. 디바운스 하면 같이 따라오는 **Throttle**도 함께 더 공부할 수 있었다. **Debounce vs Throttle** 이벤트가 빈번히 발생하는 키워드 검색같은 나의 경우에는 Debounce를 사용하는 것이 더 적합하여 Debounce를 사용하였다.
→ **Debounce(디바운싱)** 사용자가 인풋에 지속적으로 무언가를 입력하다가 멈추면 그때 네트워크 요청을 한번 한다. 키워드를 삭제할때에도 동작을 멈추면 다시 요청하는 방식 → 사용자의 이벤트가 끝나면 그때 네트워크가 요청된다.
→ 사용자가 키워드를 오랫동안 타입하는 상황에서는 디바운스를 적용하는게 적합 → 내부적으로 cache된 값을 사용하기 때문에 중복 네트워크 요청을 하지 않음
import { useState, useEffect } from "react";
export default function useDebounce(value: string, delay: number = 500) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debounced;
}
→ useEffect를 사용하여 (전달된 밸류로 초기값을 설정) value가 변경 될때마다 디바운스 상태값을 업데이트 해주는게 아니라 우리가 원하는 만큼의 딜레이를 줘서 타임아웃을 지정한다. 마지막 단계에 디바운스 상태값이 업데이트 되면서 swr이 네트워크 요청을 보낸다.
→ 서버에서 static 하게 만들어진 페이지와 클라이언트에서 hydration 뒤에 렌더링되는 ui가 매칭되지 않아 생기는 문제였다. 정말 이문제는 next.js를 처음 사용하면서 수도없이 겪었던 문제이다. 저번에 블로그를 만들었들 때 처음 맞딱뜨려 고생했던 경험이있는데, 역시나 이번에도 발생하였다.
import dynamic from "next/dynamic";
const ClipLoader = dynamic(
() => import("react-spinners").then(lib => lib.ClipLoader),
{
ssr: false,
}
);
interface Props {
color?: string;
}
export default function LoadingSpinner({ color = "black" }: Props) {
return <ClipLoader color={color} />;
}
→ dynamic import를 사용하여 CilpLoader 컴포넌트를 로드하는 방식으로 선택하였다. 이 접근 방식은 컴포넌트를 클라이언트 코드가 실행될때 까지 로드를 지연시켜 서버 측에 렌더링된 콘텐츠와 충돌을 방지한다. 컴포넌트가 클라이언트 측에서 로드되어 실행되므로 클라이언트 측 렌더링 경험이 원활하게 이루어지고 서버 측 렌더링과 충돌이 발생하지 않게 된다.
→ ssr: false 옵션 설정으로 위 컴포넌트에 대한 서버 측 렌더링을 시도하지 않도록 한다.
→ default props를 설정하여 color 프롭의 기본 값을 black으로 설정하면 나중에 color가 제공되지 않을 경우에도 컴포넌트가 유효한 기본값을 가지게 된다.
→ 좋아요 토글에서 likes put 요청 시 너무 많은 과정을 통해 업데이트가 되어 UI 반영이 느린 문제가 생겼다.
→ 예전에 리액트 쿼리를 잠깐 공부했을 때 Optimistic Updates(낙관적 업데이트) 라는 개념을 잠깐 스치듯 봤던 기억이있는데, SWR에서도 Bound Mutate로 optimistic ui updates를 통해 개선 할 수 있어 이 기회에 적용시켜 보았다.
→ Optimistic Updates(낙관적 업데이트)란 네트워크 요청을 보내기 전에 UI를 업데이트하는 기능이다. 예를 들어, 사용자가 좋아요를 눌렀을때, UI에서 즉시 하트 아이콘과 좋아요 수가 증가 하도록 하는 것이다.
// hooks > post.ts
async function updateLike(id: string, like: boolean) {
return fetch("/api/likes", {
method: "PUT",
body: JSON.stringify({ id, like }),
}).then(res => res.json());
}
export default function usePosts() {
const {
data: posts,
isLoading,
error,
mutate,
} = useSWR<SimplePost[]>("/api/post");
const setLike = (post: SimplePost, username: string, like: boolean) => {
const newPost = {
...post,
likes: like
? [...post.likes, username]
: post.likes.filter(item => item !== username),
};
const newPosts = posts?.map(p => (p.id === post.id ? newPost : p));
return mutate(updateLike(post.id, like), {
optimisticData: newPosts,
populateCache: false,
revalidate: false,
rollbackOnError: true,
});
};
return { posts, isLoading, error, setLike };
}
→ optimisticDate 에 우리가 즉각적으로 ui에 업데이트 할 데이터를 제공해주면 바로 로컬(ui)상에 먼저 업데이트를 해두고 백그라운드 상에서 다시 네트워크 요청해서 데이터를 받아오는 것이다. 위 코드의 return 부분에서 반응이 올때까지 기다리지 않고, 미리 전달 optimisticDate를 사용해주기 위해 populateCache를 false로 설정한다. 그리고 업데이트가 완성이 되면 다시한번 포스트에 대한 전체 정보를 받아오는데, 이미 우리는 어떤 것이 변경되어야 할지 알고있기 때문에 백엔드에서 데이터를 받아올 필요가 없으므로 revalidate를 false로 설정해두었다.
→ likes가 변경된다고 해서 새로운 요청을 다시 전달하지 않기때문에, 빠른 ui구현과 네트워크 절약까지 완료할 수 있는 장점이 있다. 또한, likes 과정에서 네크워크 오류가 생긴다면 롤백 될 수 있도록 rollbackOnError를 true로 설정 해준다.
→ 변경전 데이터를 저장해두고 만약 오류가 발생했을 때는 저장해둔 변경 전 데이터로 롤백해주는 것이다.
Optimistic Updates(낙관적 업데이트) 후 반응 속도
화면 기록 2023-09-01 오후 10.38.31.mov
→ 팔로우 버튼(client) 을 누르면, 이미 정적으로 생성된 ssr 페이지(팔로워/팔로잉 숫자) 가 업데이트가 되지 않는 문제점 → 페이지 리프레시 후 1로 증가된다.
→ following page를 ssr로 처리하는 것이 아니라 csr로 바꾸어서 처리한다. 전체를 업데이트 하는 것이 아니라 버튼 부분만 업데이트를 시켜 불필요한 네트워크 요청을 줄이고, 성능을 향상시킬 수 있다.
const handleFollow = async () => {
setIsFetching(true);
await toggleFollow(user.id, !following);
setIsFetching(false);
startTransition(() => {
router.refresh();
});
};
→ 로딩 중일 때 버튼을 비활성화 시켜 성능을 향상시킨다.
→ userprofile 페이지에서 좋아요한 포스트에 좋아요 토글이 눌리지 않는 문제가 발생했다. 홈에서 받아오는 데이터와 유저 페이지에서 받아오는 데이터의 키값이 달라서 문제였다.
→ React Context로 해결 console.log를 찍어보니, 홈은 api/likes 이지만 유저 페이지 안의 좋아요는 키값이 달랐다. likes 버튼이 있는 Actionbar 컴포넌트에서는 /api/posts 로 받아오고 UserProfile에서는 /api/users/username/posts 로 받아오기 때문에 좋아요 처리가 않되는 문제였는데, 이러한 점들은 props drilling으로 해결 가능하지만 코드가 너무 복잡해지고 전달해주는 컴포넌트마다 Props를 전달해야하기 때문에 비효율적이라고 판단해 React Context로 문제를 해결하였다.
→ React Context는 데이터를 props를 통해 하위 컴포넌트로 전달하지 않고도 컴포넌트 트리 전체에서 상태 및 설정 값을 공유할 수 있고 전역 상태 관리에 잘 활용되는 기능이다.
참고문서 - **Passing Data Deeply with Context**
import { createContext, useContext } from "react";
interface CacheKeysValue {
postsKey: string;
}
export const CacheKeysContext = createContext<CacheKeysValue>({
postsKey: "/api/post",
});
export const useCacheKeys = () => useContext(CacheKeysContext);
→ useCacheKeys 정의 : 데이터를 Context 전달해주는 방법은 context라는 폴더에 만들어 useCacheKeys를 정의해준다. 그 다음 UserPosts 컴포넌트에서 context provider로 컴포넌트를 감싸준다.
// UserPosts.tsx
<CacheKeysContext.Provider
value={{ postsKey: `/api/users/${username}/${query}` }}
>
<PostGrid />
</CacheKeysContext.Provider>
→ 이전에는 PostGrid 컴포넌트에 username 과 query를 전달해주었는데, 이제는 username 과 query는 단순히 key를 만들기 위해 존재했기 때문에 props을 따로 전달해 줄 필요가 없다.
export default function usePosts() {
const cacheKeys = useCacheKeys();
const {
data: posts,
isLoading,
error,
mutate,
} = useSWR<SimplePost[]>(cacheKeys.postsKey);
→ hooks/posts.ts 에서 api/post 키를 쓰는 것이 아니라 context에서 정의해둔 useCacheKey()를 가져와서 cacheKeys.postKey를 사용하도록 만들어둔다. context를 사용하면 일일히 Props를 전달하지 않고도 필요한 곳에서 활용할 수 있다.
프로필과 내용입력 가능한 폼으로 구성
처음으로 이미지 업데이트를 구현해보면서 새로운 기능들을 많이 찾아보고 공부할 수 있어 재밌었던 경험이었다.
// NewPost.tsx
const textRef = useRef<HTMLTextAreaElement>(null);
포스트 이미지 밑의 form 태그 부분에 텍스트를 입력할때마다 리렌더링 현상이 일어날 수 있으므로, textRef를 만들어 실제 노드 참조값과 연결시켜 구성하였다.
textarea → 온체인지가 발생할때 마다 내부 상태가 변경되므로, 리렌더링이 발생 → 이미지 깜빡임 현상을 방지하기 위해 ref를 전달해준다.
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!file) return;
setLoading(true);
const formData = new FormData();
formData.append("file", file);
formData.append("text", textRef.current?.value ?? "");
// textRef에 연결된 current 노더가 있다면 그안에 있는 value를 사용한다.
<textarea
className='outline-none mt-2 text-sm p-6 border border-gray-200 resize-none rounded-3xl'
name='text'
id='input-text'
required
rows={8}
placeholder={"What's on your mind?"}
ref={textRef}
/> // ref → textref와 노드 실제 참조값을 연결한다.
HTMLInputElement
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
const files = e.target?.files;
if (files && files[0]) {
setFile(files[0]);
}
};
<label
className={`w-full h-96 flex flex-col items-center justify-center rounded-3xl ${
!file && "border border-gray-200"
}`}
htmlFor='input-upload'
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{dragging && (
<div className='absolute inset-0 z-10 bg-gray-500/20 pointer-events-none' />
)}
{!file && (
<div className='flex flex-col items-center pointer-events-none'>
<p className='text-sm text-gray-300'>
Drag and Drop your image here or click
</p>
</div>
)}
{file && (
<div className='relative w-full aspect-square'>
<Image
className='object-cover rounded-3xl'
src={URL.createObjectURL(file)}
alt='local file'
fill
sizes='650px'
/>
</div>
)}
</label>
위의 코드처럼 label 태그안에 onDragEnter={handleDrag} / onDragLeave={handleDrag} / onDragOver={handleDragOver} / onDrop={handleDrop} 네개의 함수 → 이미지를 드래그 & 드롭 할때 처리해주는 함수들을 만들어 주었다.
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
const files = e.target?.files;
if (files && files[0]) {
setFile(files[0]);
}
};
const handleDrag = (e: DragEvent) => {
if (e.type === "dragenter") {
setDragging(true);
} else if (e.type === "dragleave") {
setDragging(false);
}
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
setDragging(false);
const files = e.dataTransfer?.files;
if (files && files[0]) {
setFile(files[0]);
}
};
e.preventDefault()
→ **handleDrop**
함수 안에서도 e.preventDefault()
를 호출하여 기본 드롭 동작을 중지한다.
브라우저 내부에서 자동적으로 해당 파일을 브라우저 페이지에서 열려고 하기 때문에 그 기본적인 행동을 취소해준다.
setDragging(false)
→ **setDragging**
을 false
로 지정하고, 드래깅을 할때 그안에 파일이 있는지 한번 더 확인해주어, 드롭핑할때 사용자가 이미지 파일을 드롭핑 했다면 그걸 useState 상태로 저장해준다. 사용자가 이미지를 드래깅하는 도중에는 drag and drop 텍스트를 보여주고, drop한 순간 이미지로 대체되게 해준다.
→ 또 이번에 이미지를 넣을때 뒤에 오버레이 효과를 주었는데 그냥 이미지를 드래그 하니까 계속 깜빡거리는 이슈가 발생해 찾아보니 pointer-events-none을 사용하면 마우스 드래그 오버를 할때 깜빡거리는 현상이 사라진다.
우선 비전공자로서 프론트엔드 개발 공부를 시작한 뒤로 나는 지속적으로 백엔드 개발 지식에 대한 필요성을 느껴왔었다. 부트캠프에서 팀 프로젝트를 진행하며 협업 할 때에도 커뮤니케이션의 중요성을 느꼈고, 스스로가 많이 부족하다고 느꼈었다. 더불어 늘 프론트엔드 뿐만 아니라 백엔드까지 개발하여 혼자서 하나의 온전한 서비스를 만들 수 있기를 바랬고, 다양한 분야를 배워보고 싶다는 궁금증도 많았다. 그래서 아무래도 Sanity와 같은 미들웨어를 사용하면 초심자도 쉽게 이해하고 따라할 수 있어 선택하게 되었다. 강의와 공식문서를 보면서 쿼리 짜는 법과 데이터 관리하는 법을 공부하면서 많이 좌절하기도 했지만 그래도 많이 발전할 수 있는 프로젝트 과정이었다.
프로젝트를 진행하면서 공통적으로 사용하는 컴포넌트를 따로 분리하고, 최대한 하나의 컴포넌트에 많은 기능을 포함하지 않게 노력하였다. SWR의 사용으로 유저의 행동에 따라 잦은 API 호출이 예상되는 부분에서 불필요한 통신을 최소화는 것을 목표로 개발하였고 아직은 많이 부족하지만 SSR에 대해 많이 경험하고 배울 수 있는 과정이었다. 비즈니스 로직과 재사용 가능한 컴포넌트를 분리함으로써 유지 보수성을 높일 수 있는 코드에 관해 학습 할 수 있었고 전역관리 또한 이전에 Props drilling로 해결했던 부분을 Context를 활용해 전역 상태관리를 하고 더 나은 프로젝트 구조를 고민할 수 있었다.
처음으로 서버까지 관리하는 프로젝트를 하다보니 막 배운 지식으로 시작하기에는 인스타그램 형식의 게시판 프로젝트가 적합하다고 생각했다. 단순한 형식이지만 서버 통신과정에서 크고 작은 에러 상황들을 많이 겪기도 하면서 문제 해결에 대한 고민을 할 수 있어서 좋은 경험이었다.