이전에 프로젝트 같이 했던 백엔드 팀원분께서 같이 프로젝트 하자고 연락이 왔다.
당연히 좋다고 했고 어제부터 시작했다.
기획을 듣고 디자인 먼저 피그마로 함. 레퍼런스 찾고 디자인하는데 한 다섯시간은 걸린듯.
geekseat 라는 사이트가 괜찮아보여서 거의 클론하다시피 했는데도 꽤 걸렸다.
백엔드로 시작한 분이라 코드 스타일이 신기하다. 자바스크립트를 자바처럼 쓰심.. 클래스를 잘쓰시더라.
common폴더가 하나 있음 좋을 것 같고 공통컴포넌트를 먼저 만들었다.
어떻게든 유지보수를 위해 공통컴포넌트를 뽑으려고 하는데,
그래서 피그마에도 공통 컴포넌트를 따로 만들어서 esset 페이지에 두어서 보기 좋게 했다.
아티스트 페이지 디자인
아티스트 카드가 무한스크롤로 나오고, 검색했을때 필터링되는 형식이다.
공통 컴포넌트로 gnb, search bar, title, artist card가 있다.
여기서 gnb는 나중에 작업하기로 하고 나머지만 일단 구현을 했다.
그래서 모르는 것도 많고 진행이 좀 느리다. styled-components를 보통 사용했는데 테일윈드를 사용하려니 더 느리다... 찾으면서 해야해서 😭
1. next13에서는 useState를 쓰려면 'use client' 문구를 최상단에 써야한다.
2. next13에서는 라우팅이 app/artist
일 경우, artist라는 페이지가 만들어진다.
app 폴더 내에서 페이지가 만들어진다. pages는 더이상 사용하지 않는 것 같다.
일단 formData로 submit된 정보를 받아온다.
formData로 받아올 때 주의할 점이, input name
이 있어야한다는 것이다.
id 있다고 name 안썼다가 null만 반환되는 대참사를... 겪지 마시길 흑흑
const SearchBar = ({
setKeyword,
}: {
setKeyword: React.Dispatch<React.SetStateAction<string | null>>;
}) => {
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const keyword = formData.get("search") as string;
setKeyword(keyword);
};
return (
<form onSubmit={handleSearch}>
<label
htmlFor="search"
>
Search
</label>
<input
type="search"
id="search"
name="search"
placeholder="어떤 아티스트의 공연을 찾고 있나요?"
required
/>
</form>
);
};
export default SearchBar;
"use client";
export interface ArtistListProps {
keyword: string | null;
}
export default function ArtistList({ keyword }: ArtistListProps) {
const [page, setPage] = useState(1);
const artistApi = new Artist();
const { data, error, isLoading } = useSWR("artists", () =>
artistApi.getArtist(100, 10)
);
const artists: ArtistData[] = data && data.data;
const filteredArtists = artists?.filter((artist) => {
const korKeyword = artist.korName.includes(keyword || "");
const enKeyword = artist.enName.includes(keyword || "");
return korKeyword || enKeyword;
});
return (
<section className=" mt-8">
{isLoading && <p>loading...</p>}
{error && <p>error!!!</p>}
<ul className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8">
{filteredArtists &&
filteredArtists.map((artist) => (
<li key={artist._id}>
<ArtistCard artist={artist} />
</li>
))}
</ul>
</section>
);
}
swr에서 무한스크롤 훅을 제공한다.
import useSWRInfinite from "swr/infinite"; //꼭 이렇게 import해야한다.
//import { useSWRInfinite } from 'swr' 이건 구버전
const InfiniteScroll = () => {
const getKey = (page: number, previousPageData: ArtistData[] | null) => {
if (previousPageData && !previousPageData.length) return null; //이전 페이지가 존재하지만 데이터 길이가 없다면(끝에 도달하면) 더 요청하지 않고 null 반환.(최적화)
if (page === 0) return `/api/artist?page=1&size=10`; // 첫 페이지 요청
return `/api/artist?page=${page + 1}&size=10`; // SWR 키 부분.
//다음 페이지 요청, 사이즈는 10으로 고정해서 스크롤 할 때마다 10명의 아티스트가 나오도록 함.
};
const fetcher = (url: string) => {
return axios.get(url).then((res) => res.data);
}; //이부분은 api가 이미 있어서 그걸 활용해야 할 듯. 그래서 고쳐야 할 것 같다...
const { data, setSize } = useSWRInfinite(
(page, previousPageData) => getKey(page, previousPageData),
fetcher
);
const artists: ArtistData[] = data ? [].concat(...data) : [];
useEffect(() => {
const handleScroll = () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
setSize((prevSize) => prevSize + 1); // 다음 페이지 데이터를 가져오기 위해 setSize 호출
}
}
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [setSize]);
return (
<section>
<Title>Artist 무한스크롤 가보자고</Title>
<ul className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8">
{artists?.map((artist) => (
<li key={artist._id}>
<ArtistCard artist={artist} />
</li>
))}
</ul>
</section>
);
};
export default InfiniteScroll;
키를 등록하고, fetcher함수에 키를 전달함으로서 렌더링한다. 하나의 페이지가 하나의 요청이라고 한다.
getKey
인덱스, 이전페이지 데이터를 받는다. 페이지의 key를 반환하는 함수이다.
fetcher
fetch하는 함수
option
여러가지가 있는데, 개중 쓸데있어보이는 게
parallel이지 않을까. 여러 페이지를 병렬적으로 동시에 불러온다고 한다. 구현해보니 뚝뚝 끊겨서... 써보면 좋을듯. 아래처럼 사용한다.const { data, setSize } = useSWRInfinite( (page, previousPageData) => getKey(page, previousPageData), fetcher, { parallel: true } );
size
가져올 페이지 및 반환될 페이지 수
setSize
가져와야 하는 페이지 수 설정하는 함수.
page(pageIndex)
page에 대해 처음에 useState를 사용해서 썼다가, 아무리 만져봐도 상태값을 활용하지 않는다는 것을 깨달았다. 보니까 useSWRInfinite 훅은 내부적으로 현재 페이지 인덱스를 관리하는 것 같다. 콘솔로 찍어봤을 때, 첫 페이지에서는 page가 인식되지 않다가 스크롤을 하니까 1씩 늘어나는 것을 볼 수 있었다. 내부적으로 useSWRInfinite가 페이지 인덱스를 증가시키고, 이전 페이지의 데이터를 활용하여 다음 페이지를 요청한다고 한다.
보아하니 Artist 페이지 뿐 아니라 다른곳(콘서트) 에서도 무한스크롤 기능이 필요해보였다.
그래서 추상화를 하기로 했다. 훅으로 만든 것.
import useSWRInfinite from "swr/infinite";
import axios from "axios";
import { useEffect } from "react";
interface InfiniteScrollParam<T> {
apiUrl: string;
}
const useInfiniteScroll = <T>({ apiUrl }: InfiniteScrollParam<T>) => {
const getKey = (page: number, previousPageData: any) => {
if (previousPageData && !previousPageData.length) return null;
if (page === 0) return `${apiUrl}?page=1&size=10`; // 첫 번째 페이지 요청
return `${apiUrl}?page=${page + 1}&size=10`; // 이전 페이지 데이터와 결합된 새로운 페이지 요청
};
const fetcher = async (url: string) => {
const res = await axios.get(url);
return res.data;
};
const { data, setSize } = useSWRInfinite<T>(
(page, previousPageData) => getKey(page, previousPageData),
fetcher,
{ parallel: true }
);
const renderedData = data ? ([] as T[]).concat(...data) : [];
useEffect(() => {
const handleScroll = () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
setSize((prevSize) => prevSize + 1); // 다음 페이지 데이터를 가져오기 위해 setSize 호출
}
};
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [setSize]);
return renderedData;
};
export default useInfiniteScroll;
import ArtistCard from "./ArtistCard";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { ArtistData } from "./ArtistList";
const InfiniteScroll = () => {
const artists = useInfiniteScroll<ArtistData>({ apiUrl: "/api/artist" });
return (
<ul className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8">
{artists?.map((artist) => (
<li key={artist._id}>
<ArtistCard artist={artist} />
</li>
))}
</ul>
);
};
export default InfiniteScroll;
제네릭으로 타입을 받고, api url을 매개변수로 받아서 fetch한다.
그래서 기존에 스크롤 이벤트를 감지하던 것에서 Observer api를 사용하는것으로 바꾸기로 했다.
몇시간을 뚝딱거려봤지만... 초기 렌더링 되었을때 target이 null로 찍히고, 아무리 스크롤을 해도 target이 바뀌지 않는 문제가 발생했다.
초기 렌더링 이후 변화가 있으면 무한스크롤 기능이 작동되는데, 이때도 문제가 직전 target까지 스크롤을 다시 올렸다가 내려야 다음 페이지가 나온다는 것이다.
지금 글을 적으면서 정리해보니까 문제가 뭔지 대략 알 것 같기도 하고.
일단은 낼 면접이 있어서 준비를 하고 담에 새로운 마음으로 다시 해야겠다. 골이 띵하다...ㅠㅠ
import useSWRInfinite from "swr/infinite";
import axios from "axios";
import { RefObject, useEffect } from "react";
interface InfiniteScrollParam<T> {
apiUrl: string;
target: RefObject<HTMLLIElement>;
}
interface ScrolledData<T> {
isLoading: boolean;
scrolledData: T[];
}
const useInfiniteScroll = <T>({
apiUrl,
target,
}: InfiniteScrollParam<T>): ScrolledData<T> => {
const getKey = (page: number, previousPageData: any) => {
if (previousPageData && !previousPageData.length) return null;
if (page === 0) return `${apiUrl}?page=1&size=10`;
return `${apiUrl}?page=${page + 1}&size=10`;
};
const fetcher = async (url: string) => {
const res = await axios.get(url);
return res.data;
};
const { data, isLoading, setSize } = useSWRInfinite<T>(
(page, previousPageData) => getKey(page, previousPageData),
fetcher,
{ parallel: true }
);
const scrolledData = data ? ([] as T[]).concat(...data) : [];
useEffect(() => {
let observer: IntersectionObserver | null = null;
let options = {
root: null,
rootMargin: "0px",
threshold: 0.5,
};
if (target.current) {
//target.current가 null로 나옴.ㅠㅠㅠㅠㅠㅠㅠ
observer = new IntersectionObserver((entries) => {
const targetEntry = entries[0];
if (targetEntry.isIntersecting) {
//타겟 요소와 루트 요소가 교차하면 다음페이지가 나온다.
setSize((prevSize) => prevSize + 1);
}
}, options);
observer.observe(target.current);
}
return () => {
if (observer) {
observer.disconnect();
}
};
}, [setSize, target]);
return { isLoading, scrolledData };
};
export default useInfiniteScroll;
import ArtistCard from "./ArtistCard";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { ArtistData } from "./ArtistList";
import { RefObject, useRef } from "react";
import Loading from "./common/Loading";
const ArtistInfiniteScroll = () => {
const target = useRef<HTMLLIElement | null>(null);
const { isLoading, scrolledData } = useInfiniteScroll<ArtistData>({
apiUrl: "/api/artist",
target: target as RefObject<HTMLLIElement>,
});
console.log(target, "넘겨줌: " + target.current?.outerText);
return (
<ul className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8">
{scrolledData?.map((artist, index) => (
<li
key={artist._id}
ref={index === scrolledData.length - 1 ? target : null}
>
<ArtistCard artist={artist} />
</li>
))}
</ul>
);
};
export default ArtistInfiniteScroll;