이번 Zustand 최적화를 통해 어떤 부분에서 병목이 일어나는지 직접 확인하고 개선하는 과정을 거치면서 '잘 작동하는 것'과 '최적화된 것'의 차이를 명확히 깨달았습니다. 눈에 보이지 않던 성능 병목을 찾아 해결하며, useShallow로 불필요한 리렌더링을 줄이고, getState()의 올바른 사용법을 배웠습니다. 또한, requestIdleCallback과 Immer 미들웨어 도입으로 사용자 경험뿐만 아니라 코드 품질까지 향상시킬 수 있었습니다. 이 경험으로 성능 최적화는 단순히 버그를 잡는 것을 넘어, 개발자의 책임감과 깊이 있는 이해가 필요한 중요한 과정임을 느꼈습니다.
조직 기반 유저 매칭 서비스를 개발하고 있는데, devtools의 Performance 탭을 살펴보던 중 채팅 페이지에서 불필요한 리렌더링이 발생하고 있는 것을 확인했습니다. 현재 상황을 분석해보았을 때 성능 병목을 일으키고 있는 Chat 관련 컴포넌트 2개, Modal 관련 컴포넌트 5개 등 Zustand를 구독하고 있는 주요한 컴포넌트가 모두 채팅 페이지에서 발생하는 것으로 확인되었고, 여러 방법으로 성능 최적화를 진행해보려고 합니다.
문제 요인 분석하기
처음에 어떤 페이지에서 어떠한 요소를 개선해야 할 지 파악하기 위해 Zustand 스토어 현황에 대해 살펴보았습니다.
1. Zustand 스토어 현황
📁 Chat 관련 (2개)
- useNavNewMessageStore - 단순 boolean (hasNewMessage)
- useNewAlarmStore - 단순 boolean (hasNewAlarm)
📁 Modal 관련 (5개)
- useConfirmModalStore - 확인 모달 상태 (복잡한 구조)
- useWaitingModalStore - 매칭 대기 모달 (persist 사용)
- useMatchingResponseStore - 매칭 응답 모달 + localStorage 직접 조작
- useNewMessageStore - 새 메시지 토스트 상태
- useChannelRoomStore - 채팅방 관계 타입 상태
📁 기타 (3개)
- useSSEStore - SSE 연결 상태
- useMatchingConfirmedStore - 매칭 확정 상태
- useTuningStore - 튜닝 관련 상태
2. 주요 문제점 및 최적화 포인트 분석하기
1. 비효율적 구독 및 불필요한 리렌더링
여러 개의 state를 개별적으로 구독하여 불필요한 렌더링이 발생하고 있었으며, 컴포넌트 내에서 전체 스토어를 구독하는 경우가 많았습니다. useShallow를 활용한 최적화가 필요했습니다.
2. React 패턴 위반 및 메모리 누수 위험
getState() 메서드를 React 컴포넌트의 렌더링 로직이나 상태 변화에 반응해야 하는 곳에서 직접 호출하는 안티패턴이 존재했습니다. 이는 React의 반응형 시스템을 우회하여 예측 불가능한 동작과 메모리 누수 위험을 초래했습니다.
3. 메인 스레드 블로킹
useMatchingResponseStore에서 localStorage를 동기적으로 직접 조작하여 Total Blocking Time (TBT) 지표에 부정적인 영향을 주고 있었습니다.
4. 불변성 관리 비효율
useChannelRoomStore와 같이 객체 상태를 업데이트할 때 스프레드 연산자를 사용하여 매번 새로운 객체를 생성함으로써 메모리 할당 비용과 렌더링 성능 저하를 야기했습니다.
대략적으로 위와 같은 문제가 있는 것으로 파악했고, 순서대로 개선해보려고 합니다.
개선사항 1: useShallow로 실제 값이 변경된 경우만 리렌더링하기
이해를 위해 useShallow에 대해 짧게 설명하자면 Zustand에서 제공하는 selector 최적화 훅으로, 기본적으로 Zustand에서 useStore 훅을 사용할 때는 리턴 값이 얕은 비교(shallow comparison) 없이 참조가 바뀌면 무조건 리렌더가 발생합니다.
import { useStore } from 'zustand' const Component = () => { const { a, b } = useStore((state) => ({ a: state.a, b: state.b, })) // ... }
위 코드에서 state.a만 바뀌더라도 b도 함께 구조 분해 할당되고, 새로운 객체가 리턴되니까 컴포넌트는 불필요하게 리렌더링되는 문제가 발생합니다.
이 해결책으로 useStore(selector, shallow)로 zustand가 제공하는 비교함수인 shallow를 사용해 리턴된 객체의 얕은 비교를 할 수 있는데, 이 과정을 더 편하게 만든 훅인 useShallow를 사용하면 shallow를 직접 import 할 필요 없이, selector에 적용한 결과를 자동으로 얕은 비교할 수 있습니다.import { useShallow } from 'zustand/react/shallow' import { useStore } from './store' const Component = () => { const { a, b } = useStore( useShallow((state) => ({ a: state.a, b: state.b, })) ) }→ useShallow는 단순히 selector 함수의 리턴값을 shallow equal 비교해주는 wrapper라고 이해할 수 있습니다.
전체 스토어를 구독하게 되면 스토어에 담고 있는 모든 state가 변경될 때마다 구독 중인 컴포넌트가 불필요하게 렌더링되고 있었습니다. 따라서 전체 구독이 아니라 필요한 state만 구독해서 사용해야합니다. 이 때 zustand에서 제공하는 shallow 함수를 사용하면 state 값을 비교한 후 변경되었을 때만 렌더링을 진행합니다.
현재 채팅 페이지의 최적화 포인트인 useWaitingStore에서 여러 개의 selector를 개별적으로 사용하고 있어 불필요한 리렌더링이 발생하고 있어 useShallow를 사용해 개선해보았습니다.
기존 코드
const isWaitingModalVisible = useWaitingModalStore((state) => state.shouldShowModal);
const isMatchingResponseModalVisible = useMatchingResponseStore((state) => state.isModalOpen);
위 코드처럼 여러 개의 상태를 개별적으로 선택하면, 두 개의 독립적인 구독이 생성됩니다. useWaitingModalStore의 상태가 변경되면 첫 번째 줄 때문에 컴포넌트가 리렌더링되고, useMatchingResponseStore의 상태가 변경되면 두 번째 줄 때문에 다시 리렌더링됩니다. 하나의 상태만 변경되었음에도 두 개의 구독이 모두 다시 평가된다면 비효율적인 코드가 될 수 있습니다.
수정한 코드
import { useShallow } from 'zustand/react/shallow';
// Zustand 최적화: shallow comparison으로 불필요한 리렌더링 방지
const { isWaitingModalVisible } = useWaitingModalStore(
useShallow((state) => ({
isWaitingModalVisible: state.shouldShowModal,
})),
);
const { isMatchingResponseModalVisible } = useMatchingResponseStore(
useShallow((state) => ({
isMatchingResponseModalVisible: state.isModalOpen,
})),
);
위 문제를 해결하기 위해 기존처럼 개별 상태를 선택하는 대신, 필요한 모든 상태를 하나의 객체로 묶어 반환하도록 수정했습니다. 또 useShallow를 적용해 Zustand에게 '이 객체 내부의 속성이 변경되었을 때만 리렌더링해줘'라고 지시할 수 있습니다.
또 useShallow는 객체 자체의 메모리 주소가 아닌, 객체 내부의 isWaitingModalVisible과 같은 속성 값만 비교합니다. 이전에 shouldShowModal 값이 true였고 지금도 true라면, 객체는 새로 생성되었더라도 useShallow가 동일하다고 판단해 리렌더링을 막습니다.
깃허브 코드 확인하기
refactor: shallow comparison으로 불필요한 리렌더링 방지 · 100-hours-a-week/2-hertz-fe@0842cce
const { data, isLoading, isError, error, fetchNextPage, hasNextPage } =
github.com
개선사항 2: ClientLayoutContent.tsx의 안티패턴 해결하기
Zustand는 React 컴포넌트 외부에서 상태를 읽을 수 있는 getState() 메서드를 제공합니다. 하지만 이 편리한 기능도 잘못 사용하면 React의 핵심적인 원칙을 위반하고 애플리케이션의 성능을 저하시킬 수 있습니다.
문제점: 컴포넌트 내부에서 getState() 직접 호출하기
기존 ClientLayoutContent 컴포넌트는 useEffect 훅 내부에서 여러 Zustand 스토어의 getState() 메서드를 직접 호출하여 상태를 조작하고 있었습니다.
// 안티패턴: getState() 직접 호출로 React 상태 관리 패턴 위반
const confirmModalStore = useConfirmModalStore.getState();
const matchingResponseStore = useMatchingResponseStore.getState();
const waitingModalStore = useWaitingModalStore.getState();
// useEffect에서 직접 조작
useEffect(() => {
confirmModalStore.temporarilyHideModal(channelRoomId);
waitingModalStore.restoreModal(channelRoomId);
}, [channelRoomId]);
잘 작동하는 것처럼 보이지만, 다음과 같은 문제가 있습니다.
1. React의 반응형 시스템 우회
getState()는 컴포넌트가 스토어의 상태 변화를 구독(subscribe)하지 못하게 합니다. 이는 스토어의 상태가 변해도 컴포넌트가 자동으로 리렌더링되지 않음을 의미하며, React의 핵심적인 '상태 변화에 따른 UI 업데이트' 패턴을 무시하는 행위입니다.
2. 예측 불가능성
컴포넌트가 상태를 제어하는 것처럼 보이지만, 실제로는 외부에서 상태를 몰래 가져와 조작하는 형태가 되어 디버깅을 어렵게 만듭니다.
3. 메모리 누수 위험
getState() 자체는 메모리 누수를 유발하지 않지만, 이와 같은 패턴이 수동 구독(store.subscribe(...))과 함께 사용될 경우, 언구독(unsubscribe)을 놓쳐 메모리 누수로 이어질 가능성이 높습니다. React 훅은 이러한 구독 / 언구독 과정을 자동으로 처리해줍니다.
해결책: 올바른 React 훅 패턴 적용하기
따라서 getState() 호출을 제거하고, 대신 Zustand가 권장하는 useStore 훅과 useShallow를 결합하여 상태 조작 함수를 직접 구독하는 방식으로 코드를 개선했습니다.
// React hook 패턴 + useShallow로 최적화
const { temporarilyHideModal: hideConfirmModal, restoreModal: restoreWaitingModal } = useConfirmModalStore(
useShallow((state) => ({
temporarilyHideModal: state.temporarilyHideModal,
restoreModal: state.restoreModal,
})),
);
const { restoreModal: restoreMatchingModal } = useMatchingResponseStore(
useShallow((state) => ({
restoreModal: state.restoreModal,
})),
);
// useEffect에서 함수형 접근으로 상태 조작
useEffect(() => {
hideConfirmModal(channelRoomId);
restoreWaitingModal(channelRoomId);
// restoreMatchingModal도 필요 시 사용
}, [channelRoomId, hideConfirmModal, restoreWaitingModal]);
먼저 getState() 대신 useStore 훅을 사용해 React의 라이프사이클에 맞춰 언제든지 최신 상태와 함수에 접근할 수 있도록 구독하는 방식을 변경했습니다. 또 useShallow를 적용해, 스토어의 다른 상태가 아닌 우리가 구독한 함수들이 변경되었을 때만 컴포넌트가 리렌더링되도록 수정했습니다. 불필요한 스토어 전체 구독을 제거하고 필요한 함수들만 선택적으로 구독해 리렌더링 오버헤드를 최소화했습니다.
왜 getState()는 안티패턴일까요?
zustand는 React 컴포넌트의 경계를 넘어 어디서든 상태에 접근할 수 있는 getState()를 제공합니다. React 컴포넌트 내부에서 상태를 읽거나 조작할 때 사용하면 반응형 시스템을 우회하게 되는데, 다음과 같은 문제가 발생할 수 있습니다.
1. React의 생명주기 제어 상실
getState()는 상태 변화를 구독(subscribe)하지 않습니다. 컴포넌트가 렌더링될 때 한 번만 현재 상태를 읽어올 뿐, 이후 상태가 변경되더라도 컴포넌트는 그 사실을 알지 못해 UI가 업데이트되지 않습니다. 이는 곧 상태와 UI 간의 불일치로 이어지며, 디버깅을 매우 어렵게 만듭니다. 반면 React 훅(hook)을 사용하면, 상태가 변경될 때마다 자동으로 컴포넌트를 리렌더링하여 UI를 최신 상태로 유지해줍니다.
// ❌ getState()는 상태 변화를 구독하지 않음
const Component = () => {
const store = useMyStore.getState();
// store.value가 변해도 컴포넌트가 리렌더링되지 않음
return <div>{store.value}</div>;
};
// ✅ hook은 자동으로 구독/언구독
const Component = () => {
const value = useMyStore(state => state.value);
// value가 변하면 자동으로 리렌더링
return <div>{value}</div>;
};
2. 메모리 누수 위험
React 훅은 컴포넌트가 마운트될 때 구독을 시작하고, 언마운트될 때 자동으로 정리(cleanup)합니다. 하지만 getState()와 함께 수동으로 store.subscribe()를 사용하면, 개발자가 직접 언구독(unsubscribe) 로직을 구현해야 합니다. 만약 이를 잊어버리면 컴포넌트가 이미 사라진 후에도 구독이 남아있어 메모리 누수가 발생할 수 있습니다.
// ❌ 수동으로 구독하면 언구독을 깜빡할 수 있음
useEffect(() => {
const unsubscribe = useMyStore.subscribe((state) => {
...
});
// unsubscribe를 깜빡하면 메모리 누수 발생
}, []);
// ✅ React hook은 자동으로 정리
const value = useMyStore(state => state.value);
3. 테스트하기 어려움
getState()를 직접 호출하는 코드는 테스트 시 스토어의 상태를 모킹(mocking)하거나 제어하기가 까다롭습니다. 반면 훅을 사용하면 테스트 라이브러리(@testing-library/react)를 통해 컴포넌트를 렌더링하고 상태 변화를 쉽게 시뮬레이션할 수 있어 테스트 용이성이 크게 향상됩니다.
// ❌ getState()를 직접 호출하면 모킹하기 어려움
const handleClick = () => {
const store = useMyStore.getState();
store.increment();
};
// ✅ hook을 사용하면 의존성 주입이 쉬움
const increment = useMyStore(state => state.increment);
const handleClick = () => increment();
그럼에도 getState()를 사용해야 하는 이유
그렇다면 getState()는 아예 사용하지 말아야 할까요? 그렇지 않습니다. getState()는 React 컴포넌트의 렌더링 맥락(context) 외부에서 일회성으로 스냅샷(snapshot)을 가져와야 할 때 매우 적합한 도구입니다.
1. 이벤트 핸들러 및 비동기 함수
onClick 같은 이벤트 핸들러나 handleSubmit 같은 비동기 함수 내부에서 현재 시점의 상태가 필요할 때 유용합니다. 컴포넌트가 리렌더링될 필요 없이, 단순히 최신 상태 값만 가져와서 로직에 활용할 수 있습니다.
const handleSubmit = async () => {
const currentUser = useAuthStore.getState().user; // 렌더링과 무관하게 현재 사용자 정보만 가져옴
await api.submit(currentUser.id);
};
2. React 컴포넌트 외부의 유틸리티
API 클라이언트, SSE(Server-Sent Events) 핸들러처럼 React 컴포넌트의 생명주기와 독립적으로 동작하는 모듈에서 현재의 인증 토큰이나 사용자 정보를 가져와야 할 때는 구독 자체가 필요 없기 때문에 getState()를 사용하는 것이 가장 효율적입니다.
export const apiClient = {
async makeRequest() {
const token = useAuthStore.getState().token; // 컴포넌트가 아니므로 구독이 필요 없음
return fetch('/api', { headers: { Authorization: token } });
}
};
getState()와 React Hook 구독 중 상황에 맞는 선택이 중요합니다.
- React 컴포넌트 내부: hook 사용 (리렌더링 필요)
- React 외부/이벤트 핸들러: getState() 사용 (일회성 조회)
프로젝트를 진행하면서 컴포넌트의 렌더링에 영향을 주는 상태는 useShallow와 같은 React 훅으로 구독하여 최적화했습니다. 반면, React 컴포넌트의 생명주기와 무관하게 외부에서 동작하는 SSE 핸들러에서는 getState()를 그대로 두어 불필요한 구독을 제거하고 메모리 사용량을 최소화했습니다.
결론적으로, getState()는 컴포넌트 내부에서 지속적으로 상태를 추적하며 리렌더링이 필요한 곳에서는 안티패턴이지만, 외부 유틸리티나 이벤트 핸들러에서 일회성으로 상태를 읽는 경우에는 가장 효율적인 사용법이 됩니다. 이 두 가지를 명확히 구분하는 것이 Zustand를 올바르게 활용하는 핵심 방법입니다.
깃허브 코드 확인하기
refactor: ClientLayoutContent에서 Zustand 최적화 적용 · 100-hours-a-week/2-hertz-fe@1705c06
- getState() 직접 호출을 React hook 패턴으로 변경 - useShallow로 불필요한 리렌더링 방지 - SSE 핸들러의 useMemo 의존성 문제 해결
github.com
개선사항 3: LocalStorage 동기 접근 문제 해결하기
로컬스토리지에 동기적으로 접근하는 작업은 메인 스레드를 차단해 사용자 인터페이스(UI)를 멈추게 만들 수 있습니다. TBT 증가와 사용자 경험 저하에 영향을 주는 요인이기 때문에 수정해보았습니다.
기존 코드
LocalStorage에 직접 접근한다면 메인 스레드 블로킹을 발생할 수 있습니다.
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
수정한 코드
requestIdleCallback을 사용해 비동기 처리를 진행해보았습니다. 브라우저가 유휴 상태일 때 작업을 실행하도록 요청할 수 있어 메인 스레드를 블로킹하지 않고 작업을 처리해 UI의 끊김을 방지하였습니다.
requestIdleCallback(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
}, { timeout: 100 });
개선사항 4: Immer 미들웨어를 활용한 불변성 관리
setRelationType이 호출될 때마다 전체 relationTypeMap 객체를 새로 생성하고 있어 불필요한 리렌더링을 유발하고 있었습니다. 따라서 useChannelRoomStore에서 immer 미들웨어를 도입하여 불변성 관리를 최적화하도록 수정해보았습니다.
Zustand에서 스프레드 연산자를 사용하는 방식은 새 객체를 생성하여 메모리 할당을 증가시키지만, Immer 미들웨어를 사용하면 더 효율적으로 불변성을 관리할 수 있습니다.
기존 코드
setRelationType에서 스프레드 연산자로 전체 객체를 복사하고 있었습니다.
const updated = { ...get().hasRespondedMap, [channelRoomId]: value };
set({ hasRespondedMap: updated });
수정한 코드
Immer는 set 함수 내에서 상태를 직접 변경(mutate)하는 것처럼 코드를 작성해도 내부적으로 불변성을 유지해 주어 코드를 간결하게 만들었습니다.
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
const useMyStore = create(
immer((set) => ({
hasRespondedMap: {},
setHasResponded: (channelRoomId, value) => {
set((state) => {
state.hasRespondedMap[channelRoomId] = value;
});
},
}))
);
객체 스프레드 연산을 제거하면 메모리 사용량을 감소시킬 수 있고, 더 효율적인 불변성 관리로 리렌더링을 최적화 할 수 있습니다. 특히 relationTypeMap이 커질수록 성능 개선 효과가 더 커진다는 장점이 있습니다.
깃허브 코드 확인하기
refactor: Zustand 성능 최적화 및 React 패턴 개선 · 100-hours-a-week/2-hertz-fe@3d52586
- useWaitingModalStore: 4개 개별 구독 → 1개 통합 구독 - useChannelRoomStore: getState() 제거, React hook 패턴 적용 - ClientLayoutContent: SSE 핸들러 useMemo 의존성 최적화 - shallow comparison으로 리렌더링 감소
github.com
최적화 전후 렌더링 성능 비교하기




최적화 전
- UserPreferences: 1.6ms
- TurboPackInfo: 1.6ms
- Router: 2.7s
- ChannelIndividualPage: 0.9ms
- BottomNavigationBar: 0.7ms
- Router: 0.7ms
최적화 전에는 ChannelIndividualPage와 BottomNavigationBar 등 여러 컴포넌트가 개별적으로 렌더링되면서 총 렌더링 시간이 길게 측정되었습니다. 특히, ChannelIndividualPage와 BottomNavigationBar의 경우, 각각 0.9ms와 0.7ms의 렌더링 시간이 소요되었는데, 이는 각 컴포넌트가 Zustand 스토어의 상태를 개별적으로 구독하며 불필요한 리렌더링이 발생하고 있었기 때문입니다.
최적화 후
- UserPreferences: 2.4ms
- TurboPackInfo: 2.5ms
- Router: 2.2s
- ComponentStyles: 1.5ms
- NextMark: 1.3ms
최적화 후 이미지에서는 이전과 달리 특정 컴포넌트들이 개별적으로 렌더링되는 모습이 보이지 않습니다. 대신 전체 렌더링의 총 시간은 이전과 비슷하거나 약간 늘어났지만, 중요한 점은 개별 컴포넌트들의 렌더링 시간이 크게 감소했다는 것입니다. 특히, ChannelIndividualPage와 BottomNavigationBar와 같은 주요 컴포넌트들이 렌더링 목록 상단에서 사라졌습니다. 이는 다음과 같은 방법을 성공적으로 적용해 개선된 것으로 파악됩니다.
- useShallow를 통한 불필요한 리렌더링 방지: 여러 개의 상태를 개별적으로 구독하는 대신 useShallow를 사용하여 하나의 객체로 묶어 구독함으로써, 상태가 실제로 변경된 경우에만 컴포넌트가 리렌더링되도록 했습니다. 이로 인해 ChannelIndividualPage와 BottomNavigationBar와 같은 컴포넌트들이 상태 변화에 덜 민감하게 반응하게 되면서 렌더링 횟수가 줄어들었습니다.
- getState()를 이용한 구독 해제: React 컴포넌트 렌더링 외부에서 상태가 필요한 경우, 불필요한 구독을 유발하는 useStore 훅 대신 getState()를 사용했습니다. 이로 인해 컴포넌트 렌더링 목록에서 여러 스토어 관련 컴포넌트들이 사라지며, 전반적인 렌더링 성능이 향상되었습니다.
결론적으로 최적화 전에는 여러 컴포넌트가 개별적으로 자주 리렌더링되어 성능 저하가 발생했지만, 최적화 후에는 Zustand의 구독 방식을 개선하여 필요한 경우에만 렌더링이 일어나도록 함으로써 개별 컴포넌트의 렌더링 오버헤드를 크게 줄였습니다. 이는 겉으로 드러나는 총 시간 변화보다 더 의미 있는 개선이며, 사용자 인터랙션이 많은 페이지에서 매끄러운 경험을 제공하는 데 기여할 것으로 생각됩니다.
회고
이번 Zustand 최적화를 진행하면서 가장 크게 느낀 점은 "직접 병목 현상을 확인하고 개선하는 과정"이 얼마나 중요한지 깨달았다는 것입니다. DevTools의 Performance 탭으로 눈에 보이지 않던 렌더링 오버헤드, 숨어있는 getState() 안티패턴, 그리고 메인 스레드를 막고 있던 동기 작업을 시각적으로 확인할 수 있어 어떤 부분을 개선해야 할 지 빠르게 파악할 수 있었습니다.
최적화 전에는 여러 컴포넌트가 제각각 렌더링되면서 낭비되는 자원이 명확히 드러났고, useShallow를 적용해 이 비효율을 제거했을 때, 렌더링 목록이 깔끔하게 정리되는 것을 직접 눈으로 확인했습니다. requestIdleCallback을 사용해 localStorage 접근 방식을 바꿨을 때도, Performance 탭의 긴 블로킹 시간이 사라지는 것을 보며 단순히 코드를 수정하는 것을 넘어, 성능 지표가 개선되는 것을 확인하는 과정 자체가 큰 보람으로 다가왔습니다.
이번 경험을 통해 저는 '잘 작동하는 코드'와 '최적화된 코드'는 다르다는 것을 확실히 이해하게 되었습니다. 앞으로도 코드를 짤 때 단순히 기능 구현에만 만족하지 않고, 성능과 사용자 경험이라는 더 큰 목표를 염두에 두고 개발해야겠다는 다짐을 했습니다. 긴 글 읽어주셔서 감사합니다 :)
'👩🏻💻 Develop > Performance Optimization' 카테고리의 다른 글
| [Next.js] 성능 최적화를 위한 데이터 캐싱하기 (0) | 2025.10.13 |
|---|---|
| [Next.js] SSR 성능 최적화: Core Web Vitals 지표 개선하기 (1) | 2025.09.20 |
| [Next.js] Next.js 이미지 최적화로 성능 60% 개선하기 (0) | 2025.09.19 |
| [Next.js] Dynamic Import로 번들 사이즈 25% 최적화하기 (0) | 2025.09.18 |
| [Next.js] 메모이제이션 및 리렌더링을 활용한 TBT 43% 개선기 (3) | 2025.09.01 |