본문으로 건너뛰기

Next.js를 사용하며 고민한 것들

· 약 12분
lifeisegg

현 직장에서 Vue로 작성된 소스코드를 Next.js로 전환하는 작업을 주로 진행하고 있습니다. Next.js를 사용하며 이와 관련된 여러 문제들을 만나고, 이를 해결해 나가며 얻게된 경험들을 적어봅니다.

페이지 빌드 방식

첫번째로 전환하게 된 화면은 여느 커머스 서비스라면 흔하게 갖고 있는 상품에 대한 상세정보를 보여주는 화면이었습니다.

SSR? SSG?!

보통 Next.js를 사용하면 SSR을 사용하고 당연히 처음에는 저도 같은 방식을 사용하려 했으나, Next.js의 공식문서를 보며 SSG가 좀 더 권장되는 방법이고 대표적인 사용사례로 블로그 글이나 커머스 서비스의 상품페이지가 예시로 나오는 것을 보고 생각을 바꿔 SSG를 사용하기로 결정하였습니다.

Build Time에 받아올 정적인 데이터와 변경될 가능성이 있는 데이터를 분리하기를 원했으나, 여러 여건에 의해 이 부분은 진행하지 못하였고, 대신 모든 데이터를 브라우저에서 한번 더 받아와 화면을 새로 그려주는 방식을 채택하였습니다.

ISR!!

이러한 방식에 두가지 문제가 있었는데, 첫째는 한번 빌드된 페이지는 다음 배포때까지는 그대로 캐시되고 있어 데이터가 변경되면 이전값이 보였다가 브라우저의 요청이 완료되면 업데이트 되는 형태로 화면이 그려진다는 문제가 있었습니다.

이를 해결하기 위해 ISR이라는 방식을 도입하고자 했는데, 지정된 시간이 지나기 전까지는 캐시된 페이지를 보여주다가 시간이 지난뒤 유저의 요청이 들어오면 새로운 페이지를 빌드하는 방식으로 작동하게 됩니다.

아직까지 얼마의 시간이 적절한지, 혹은 On-Demand ISR 방식을 도입해야 하는지는 더 많은 고민이 필요하지만 단순히 SSG로 배포시에만 페이지를 빌드하는 것 보다 더 나은 유저 경험을 제공할 수 있게 되었습니다.

빌드시간...

두번째 문제는 빌드시간이었습니다. 커머스 서비스의 특성상 많은 상품이 존재하고 이를 모두 배포될 때 마다 빌드할 경우, 배포에 너무 오랜 시간이 걸려 원하는 기능을 원하는 시간에 내보내기 어렵다는 문제가 발생합니다.

fallback

getStaticPaths 함수를 통해 리턴되는 객체의 fallback이라는 값을 통해 미리 빌드된 페이지가 아니라면 어떻게 동작해야 할 지 정의해줄 수 있습니다.

  • false : 404페이지로 redirect처리를 합니다.
  • true : fallback ui를 보여준 뒤 페이지 빌드를 시작하고, 빌드가 완료되면 페이지를 보여줍니다.
  • 'blocking' : SSR과 같은 방식으로, 페이지 빌드가 완료될 때 까지 기다린 후 화면을 보여주게 됩니다.

이러한 기능을 통해 배포시에는 유저가 자주 보는 상품들만 미리 빌드하고, 상대적으로 유저의 방문이 적은 상품들은 요청이 들어왔을때 빌드하는 방식을 사용하게 되었습니다.

LocalStorage

Next.js가 서버사이드에서 렌더되는경우(SSG, SSR 모두 해당) window객체에 접근할 수 없고, 따라서 LocalStorageSessionStorage같은 브라우저 api에는 접근할 수 없는 경우가 생기게 됩니다.

이를 해결하기 위해 typeof window === 'undefined' 라는 구문을 추가하여 간단하게 해결하고자 하였으나 아래와 같은 코드는 server에서의 렌더트리와 client에서의 렌더트리가 달라 Hydration failed because the initial UI does not match what was rendered on the server.라는 에러를 반환하게 됩니다.

const Example = () => {
if(typeof window === 'undefined') {
return <div>Server</div>
}
return <div>{localStorage.getItem('example')}</div>
}

이를 해결하기 위해 localStorage를 활용하기 위한 방법에 대해 고민하게 되었고, 아래와 같은 코드를 작성하게 되었습니다.

LocalStorageBase.ts
export class LocalStorageBase<T> {
constructor(private readonly key: string, private readonly defaultValue?: T) {
this.key = key;
this.defaultValue = defaultValue;
}

protected get data(): T | null {
if (typeof window === 'undefined') return this.defaultValue;
const value = localStorage.getItem(this.key);
return value ? (JSON.parse(value) as T) : this.defaultValue;
}

get() {
return this.data;
}

getDefaultValue(): T | undefined {
return this.defaultValue;
}

set(data: T) {
localStorage.setItem(this.key, JSON.stringify(data));
}
}
useLocalStorage.ts
export const useLocalStorage = <T>(storage: LocalStorageBase<T>) => {
const [state, setState] = useState(storage.getDefaultValue());

const set = (data: T) => {
storage.set(data);
setState(data);
};

useEffect(() => {
setState(storage.get());
}, []);

return [state, set] as const;
};
const exampleStorage = new LocalStorageBase<boolean>('example', false);

const Example = () => {
const [state, setState] = useLocalStorage(exampleStorage);

return (
<div>
<div>
{state}
</div>
<button onClick={()=>setState(!state)}>toggle</button>
</div>
);
}

LocalStorageBase라는 클래스를 만들어 typeof window === 'undefined'라는 구문을 내부 메소드에서 처리하게 하였으며, useLocalStorage라는 커스텀 훅 내의 useState에서는 defaultValue를 초기값으로 설정하여 server-side에서 localStorage에 접근하는 것을 막았고, useEffect에서 localStorage에 접근하여 값을 가져오도록 처리하였습니다. 하나의 storage-key에 대해 localStorage.get 메소드가 여러번 호출되는 것을 막기 위해 값을 한번 가져온 뒤로는 state로 관리하도록 처리하였습니다.

client와 server를 구분하여 처리하고 있고 localStorage.get 메소드를 중복으로 호출하는 것을 방지하려고 하였지만 아직 몇가지 문제가 남아있는데요. 같은 스토리지에 대해 훅이 여러번 사용된다면 localStorage.get이 여러번 호출될 것이고 서로간의 데이터가 달라질 수 있다는 점입니다.

Reastorage

위에서 서술한 문제들을 해결한 Reastorage라는 라이브러리를 만들게 되었습니다. localStoragesessionStorage모두 사용이 가능하고, 추후에 커스텀 스토리지를 연결할 수 있는 기능을 제공할 계획이에요. 하나의 키에 대해 한번 이상 localStorage.get 메소드를 호출하지 않게 처리하였고, 한 군데에서 데이터가 업데이트 되면 다른 곳에서도 상태를 업데이트 할 수 있도록 처리하였습니다. 상태를 업데이트 할때 useState 처럼 data를 넣거나 이전값을 통해 연산하는 함수를 받을 수 있도록 개선하였습니다.

const reastorage = <T>(
key: string,
initialValue: T,
storage: "local" | "session" = "local"
): ReastorageInterface<T> => {
let data = initialValue;
let getInitial = false;
let listeners = new Set<VoidFunction>();

const get = () => {
if (!getInitial) {
getInitial = true;
const targetValue = window[`${storage}Storage`].getItem(key);
if (!targetValue) {
window[`${storage}Storage`].setItem(key, JSON.stringify(initialValue));
} else {
data = JSON.parse(targetValue);
}
}
return data;
};

const getInitialValue = () => initialValue;

const set = (dataOrUpdater: DataOrUpdaterFn<T>) => {
const value = isUpdaterFn(dataOrUpdater)
? dataOrUpdater(data)
: dataOrUpdater;

window[`${storage}Storage`].setItem(key, JSON.stringify(value));
data = value;
listeners.forEach((cb) => cb());
};
const reset = () => set(initialValue);

const subscribe = (listen: VoidFunction) => {
listeners.add(listen);
return () => {
listeners.delete(listen);
};
};

return {
get,
getInitialValue,
reset,
set,
subscribe,
};
};
import { useSyncExternalStore } from "use-sync-external-store/shim";
import { Reastorage } from "./Reastorage";

export const useReastorage = <T>(storage: Reastorage<T>) => {
const state = useSyncExternalStore(
storage.subscribe,
storage.get,
storage.getInitialValue
);
return [state, storage.set] as const;
};
const exampleStorage = reastorage<boolean>('example', false);

const Example = () => {
const [state, setState] = useReastorage(exampleStorage);

return (
<div>
<div>
{state}
</div>
<button onClick={()=>setState(!state)}>toggle</button>
</div>
);
}

dynamic import

Next.js에는 dynamic이라는 함수를 통해 dynamic import를 지원하고 있는데요. 어떤 컴포넌트를 dynamic import 해야할 지, 어떤 컴포넌트를 client-side에서만 불러와야 할 지 기준을 세우는데 몇가지 고민이 필요했습니다.

과도한 코드 스플리팅은 네트워크 요청을 증가하게 하여 오히려 성능에 악영향을 끼치게 될 것이고, 모든 코드를 한번에 받아오는 것은 큰 청크 파일을 한번에 받아오게 되어 네트워크 응답이 느려지게 됩니다.

고민의 결과로, 우선 Modal은 dynamic import를 사용해서 불러오게 처리하였습니다. 유저가 모달을 열기 전까지는 필요하지 않은 코드이고, 서버에서 미리 그려질 필요도 없다고 생각하여 ssr: false라는 옵션도 추가해서 사용중입니다.

또한 조건부 렌더링을 하는 코드들도 dynamic import를 통해 코드를 받아오게 하였습니다. 특정한 불리언 값에 따라 A혹은 B가 렌더되는 경우, A케이스에서는 B 컴포넌트의 코드가 필요하지 않기 떄문에 이에 대한 코드를 받아오지 않게 처리할 수 있었습니다.

마무리

소개하지 못한 부분들이 아직 남아 있고, 앞으로 더 고민해야 할 것 들도 많이 남아 있습니다. Next.js를 사용하며 좋은 기능들이 굉장히 많다고 생각했지만, nested routes에 대한 처리가 따로 지원되지 않고 있는 점은 아쉬웠습니다. 이 부분은 layout RFC로 해결이 가능할 것으로 보이는데, Remix에서는 이미 제공 되는 기능이기 떄문에 빠르게 대응이 되기를 기대해 봅니다.

긴 글 읽어주셔서 감사합니다 :)