회고 목적: 시간을 들인 과제를 회고하면서, 개선점을 찾아보기
과제에 대한 세부 내용은 생략되었습니다.
서버 상태 관리를 위한 라이브러리(tanstack query)를 사용하는 게 좋을까? 사용하지 않는 것으로 함
이전 회사에서 사용했던 방법은 react-query의 infiniteQuery를 사용하는 것이었다.
클라이언트 사이드에서 데이터를 가져오는 방식은 첫 페이지를 렌더링할 때 값이 없는 상태가 되었다. 이 문제는 페이지 컴포넌트를 서버에서 렌더링할 때, 서버사이드에서 prefetch를 수행하고, hydrate 시 그 데이터를 기본 값으로 사용하는 방식을 통해 해결할 수 있었다.
이전 회사는 NextJS 12 버전을 사용하고 있었고, 이번 과제는 NextJS 14 (app router)였기에 동일한 방식을 적용할 수는 없었다.
우선, react-query를 사용해야 하는지를 생각해보았다. 현재는 tanstack-query 라이브러리인 해당 라이브러리는 서버 상태를 관리하는 데 유용한 라이브러리이다.
유저의 데이터만 다루면 되었기에, 서버 상태를 관리하기 위한 라이브러리까지 추가하진 않아도 될 것이라고 생각했다. 또한, 과제를 진행하는 입장에서는 라이브러리를 사용하면, 안좋게 보진 않을까? 싶기도 했기 때문에…
서버 상태 관리는 라이브러리를 사용하지 않고, 직접 구현하는 것으로 했다.
로컬 상태 vs 전역 상태
<Page>
<Filter />
<DataSection />
</Page>
컴포넌트 구조를 위와 같이 설계했기에, Filter와 DataSection 모두 사용하는 데이터는 각 컴포넌트 상위에서 관리해야 했다.
Page 컴포넌트는 서버 컴포넌트로 구현하려고 했기 때문에(클라이언트 컴포넌트로 변경하면, app router의 서버 컴포넌트 이점을 아무것도 살리지 못할 것이라고 생각했음) 로컬 상태가 아닌 전역 상태로 관리하고자 했다.
전역 상태를 관리하기 위한 방법
서버 상태를 관리하기 위해 라이브러리를 설치하지 않았는데, 전역 상태를 관리하기 위해 라이브러리를 추가하는 것은 이상했기에 전역 상태도 직접 구현하고자 했다.
nextjs에서는 서버 컴포넌트와 클라이언트 컴포넌트를 조합하여, 효율적으로 렌더링하기 위해 composition pattern을 소개하고 있고, ContextAPI의 Provider를 활용하는 방식 역시 동일한 패턴이기에 ContextAPI를 활용하여 데이터를 관리하는 것으로 결정했다.
무한 스크롤 구현을 위해 필요한 로직은 Context 내부에 담아, 관심사를 분리하고자 했다.
// DataContext.tsx
'use client';
type DataContextType = {
data: Data[];
setData: (data: Data[]) => void;
loadMoreData: () => void; // 다음 데이터를 가져오기
isFetching: boolean; // 데이터 로딩 중
hasMore: boolean; // 추가로 가져올 데이터가 있는지 여부
}
const DataContext = createContext<DataContextType | null>(null);
const DataContextProvider = ({
initialData, // 렌더링시 필요한 초기 값
children, // compositionPattern
}) => {
const [data, setData] = useState([initialData]);
const [offset, setOffset] = useState(10);
const [isFetching, setIsFetching] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadMoreData = async () => {
if (isFetching || !hasMore) return;
setIsFetching(true);
const newData = await getData(offset, 10);
setData(prev => [...prev, ...newData]);
setOffset(prev => prev + 10);
setIsFetching(false);
setHasMore(newData.length === 10);
};
return (
<DataContext.Provider
value={{
data,
setData,
loadMoreData,
isFetching,
hasMore,
}}
>
{children}
</DataContext.Provider>
)
};
// useDataContext.ts
const useDataContext = () => {
const contextValue = useContext(DataContext);
if (!contextValue) {
throw new Error('useDataContext는 DataContextProvider 내부에서 사용되어야 합니다.');
}
return contextValue;
}
초기 데이터 문제는 서버 컴포넌트가 서버에서 렌더링될 때 가져온 후, Provider로 값을 넘겨주는 것으로 해결할 수 있었다.
// page.tsx
export default async function Home() {
const data = await getData(0, 10);
return (
<DataContextProvider initialData={data}>
<Filter />
<DataSection />
</DataContextProvider>
)
}
데이터를 사용하여, 대시보드에 렌더링
// DataSection.tsx
const DataSection = () => {
const { data, loadMoreData, isFetching, hasMore } = useDataContext();
// ...
return (
<>
{data.map...}
{isFetching && <LoadingSkeleton />}
{hasMore && <div ref={loader}>...</div>}
</>
)
}
스크롤을 내려, 대시보드 최하단까지 스크롤이 되었을 때 추가로 데이터를 가져오는 로직을 구현하기
Intersection Observer API를 활용하여, 최하단까지 스크롤이 되었음을 확인하고 이때 다음 데이터를 불러오도록 함
// DataSection.tsx
const DataSection = () => {
// ...
const loader = useRef<HTMLLIElement>(null);
useEffect(() => {
const intersectionObserverOptions: IntersectionObserverInit = {
root: null,
rootMargin: "0px",
threshold: 1.0,
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadMoreData();
}
});
}, intersectionObserverOptions);
if (loader.current) {
observer.observe(loader.current);
}
return () => {
observer.disconnect();
};
}, [loadMoreData]);
return (
<>
{data.map...}
{isFetching && <LoadingSkeleton />}
{hasMore && <div ref={loader}>...</div>}
</>
)
};
버튼을 눌렀을 때, 해당 유저의 아이디를 클립보드에 복사하기 위해 Navigator.clipboard.writeText
메서드를 활용했다.
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
alert('복사되었습니다.');
} catch (e) {
alert('복사에 실패했습니다.');
}
}
json-server로 API 요청을 보내, 데이터를 가져올 때 해당 데이터에 대한 검증을 위한 데이터 검증 레이어를 추가하기 위해서.
// getData.ts
const dataSchema = z.object({
id: z.number(),
name: z.string(),
// ...
});
const getData = async (offset: number, limit: number) => {
const res = await fetch(...);
// ...
const data = await res.json();
try {
return dataSchema.array().parse(data);
} catch (e) {
console.error('screening data 구조가 올바르지 않습니다.', e);
return [];
}
}
날짜 및 시간 포맷팅
대체 가능한 라이브러리로는 date-fn, moment 등이 있음. 그 중 dayjs를 선택한 이유는 사이즈가 가장 작고, 높은 다운로드 수 & 스타 수.
Intersection Observer API 관련 로직을 DataSection.tsx
에 두었는데, 이는 훅이나 컴포넌트로 분리시키는 것이 더 좋았을 것 같다.
// useIntersectionObserver.ts
type Props = {
callback: IntersectionObserverCallback;
options?: IntersectionObserverInit;
};
const DEFAULT_OPTIONS: IntersectionObserverInit = {
root: null,
rootMargin: "0px",
threshold: 0,
};
export const useIntersectionObserver = ({
options = DEFAULT_OPTIONS,
callback,
}: Props) => {
const observer = useRef<IntersectionObserver | null>(null);
useEffect(() => {
const handleIntersect = (
entries: IntersectionObserverEntry[],
observer: IntersectionObserver
) => {
if (callback) {
callback(entries, observer);
}
};
observer.current = new IntersectionObserver(handleIntersect, options);
return () => {
if (observer.current) {
observer.current.disconnect();
}
};
}, [callback, options]);
const observe = (element: Element) => {
if (observer.current && element) {
observer.current.observe(element);
}
};
const unobserve = (element: Element) => {
if (observer.current && element) {
observer.current.unobserve(element);
}
};
return {
observe,
unobserve,
};
};
Context API가 아닌 다른 상태관리 라이브러리를 사용하는 것이 더 나을 수 있을 것 같다.
해당 회사의 채용 공고를 보면, 'Zustand, Recoil, Redux 등 상태 관리 개발 경험이 있으신 분'이 명시되어 있는데 단순한 상태를 관리한다는 이유로 Context API를 사용하는 것은 채용 공고에 명시되어 있는 상태 관리 방식을 어필할 수 없게 되는 부분도 있기 때문.