이전 글 : 사용자 경험 개선하기
시작하며
이전 글에 이어서 새롭게 생긴 UX에 대한 나의 관점과 프로젝트에 이를 적용하며 개선한 점들에 대해 이야기하고자 한다.
성능 최적화
최고의 성능이 곧 최고의 UX다.
내가 자주 사용하는 토스 증권과 당근 웹사이트를 분석해보며 ‘왜 나는 이 사이트에 자주 들어가게 되지?’ 곰곰히 생각해보았다. 이 두 사이트의 공통점은 ‘빠르다는 것’이었다. 속도가 정말 빠르다. 덕분에 사이트에 들어가는 데에 거리낌이 없다. 사이트를 들어가기 전에 ‘들어갈까 말까’고민을 하는 것보다 들어가서 고민하는게 더 빠르기 때문이다.
이 서비스들을 사용해보며 애니메이션, 토스트 메시지, 로딩보다 가장 먼저 고려해야할 것이 성능이라는 것을 깨달았다. 그래서 이번에는 성능 개선을 하는데에 중점적으로 고민했다.
1) Static Serve
관련 커밋
https://github.com/guesung/Web-Memo/pull/130/commits/b142f3445a08928e7a8ce55b4c277149e892698d
Next.js에서는 기본적으로 SSG를 제공한다. 하지만 페이지 내 cookies, headers 등에 접근하는 Dynamic API를 사용하면 SSR로 자동으로 바뀌게 된다.
웹 메모 서비스에는 소개 페이지와 업데이트 소식 페이지가 있는데 이 둘을 기존 SSR에서 SSG로 바꾸며 큰 성능 개선을 이룰 수 있었다. 그 결과 introduce 페이지 기준 LCP 2.9s에서 0.6s까지 단축할 수 있었고 성능 부하 테스트에서는 TPS(Transactions Per Second) 가 4배 증가(46 → 179), MTT(Mean Test Time)는 4배 감소(609 → 179), 에러 발생율은 7배 감소(3.9% → 0.5%) 할 수 있었다.
SSR에서 SSG로 바꾼 방법은 다음과 같다.
- middleware에서 cookies에 접근해 인증을 체크하던 로직을 AuthProvider라는 컴포넌트 내에서 체크하도록 수정한다. 이 컴포넌트는 클라이언트 단에서 동적으로 동작하기에 빌드 타임에 영향을 끼치지 않는다.
- headers 인증이 필요한 컴포넌트를 지연 로딩을 하여 클라이언트 단에서 렌더링하도록 수정
이에 대한 구체적인 내용은 다음 글에서 따로 소개할 예정이다.
2) 렌더링 최적화
관련 커밋
https://github.com/guesung/Web-Memo/pull/129/commits/b7e43587975b7539d96504da248b64c233f64c7e
https://github.com/guesung/Web-Memo/pull/129/commits/a6fd75153da08b775fb63ce5f92afdc0fccb14ae
리액트에서 렌더링 최적화의 대표적인 예시로는 메모이제이션이 있다. React.memo로 컴포넌트를, useCallback으로 함수를, useMemo로 값을 메모이제이션할 수 있다. props가 동일함에도 부모 컴포넌트의 리렌더링에 의해 자식 컴포넌트에서 리렌더링이 자주 일어난다면 React.memo로 감싸고, 함수 혹은 값의 불필요한 재선언이 자주 일어난다면 useCallback 혹은 useMemo로 감싸면 된다.
이 ‘자주’가 상당히 상대적인 용어이기는 하다. 하지만 이 렌더링 최적화에는 트레이드 오프가 충분히 존재하기에 개발자 모드를 켜고 ‘이 컴포넌트 불필요하게 리렌더링이 자주 일어나는데?’싶으면 그 때 최적화를 수행해도 충분하다고 생각한다.
두 번째 방법으로는 상태를 부모가 아니라 자식에서 관리하는 것이다. 리액트에서는 기본적으로 부모 컴포넌트가 리렌더링이 일어나면 자식 컴포넌트는 리렌더링이 일어난다. 물론 위에서 이야기한 것처럼 메모이제이션을 활용하여 이 문제를 어느정도 해결할 수 있다.
하지만 이러한 메모이제이션도 결국 추가적인 메모리를 사용하게 되므로 정말 필요한 경우에만 사용하는 것이 좋다. 특히 렌더링이 자주 발생하지 않는 컴포넌트라면 메모이제이션이 오히려 성능에 악영향을 미칠 수 있다. 또한 메모이제이션으로 컴포넌트와 props를 꽁꽁 감싸는 것보다 더 간단한 방법은 상태를 처음부터 자식 컴포넌트에서 관리하는 것이다. 부모 컴포넌트의 상태가 변경되어도 자식 컴포넌트가 자신의 상태만 관리한다면 다른 자식 컴포넌트의 리렌더링에 영향을 끼치지 않기 때문이다.
사실 이 둘은 트레이드오프다. 메모이제이션은 '리렌더링을 막는 대신 메모리와 비교 비용을 쓰는' 방식이고, 상태를 자식으로 내리는 건 '애초에 리렌더링 범위를 좁혀서 감쌀 일 자체를 줄이는' 방식이다. 나는 후자를 먼저 시도하고, 그걸로 안 되는 곳에만 메모이제이션을 얹는 편이 낫다고 느꼈다. 감싸는 코드가 늘어날수록 읽기도 어려워지니까.
근데 요즘은 이 고민 자체가 조금씩 옛날 이야기가 되어가고 있다. React 19와 함께 나온 React Compiler는 어떤 값과 컴포넌트를 메모이제이션할지 빌드 타임에 자동으로 판단해준다. 즉 내가 손으로 useMemo, useCallback, React.memo를 붙이던 일을 컴파일러가 대신 해주는 것이다. 그럼 수동 메모이제이션은 이제 필요 없을까? 아직은 아니라고 본다. 컴파일러를 아직 도입하지 않은 프로젝트도 많고, 컴파일러가 판단하지 못하는 경우도 있기 때문이다. 그래서 지금은 '컴파일러가 깔려 있으면 맡기고, 아니면 위 원칙대로 손으로 챙긴다' 정도가 현실적인 기준인 것 같다.
아래는 위 내용을 바탕으로 렌더링을 최적화하기 이전 영상이다.
렌더링 최적화 이전에는 각 컴포넌트가 메모이제이션되어있지 않기때문에 스크롤을 할 때마다 부모 컴포넌트가 리렌더링되고 자식 컴포넌트들 또한 모두 리렌더링이 일어난다.
또한 각 아이템에 hover시 상태 관리를 부모에서 하고 있기 때문에 부모 컴포넌트가 리렌더링되고 자식 컴포넌트들 모두 리렌더링이 된다.
렌더링 최적화 이후에는 스크롤 시 새롭게 렌더링되는 컴포넌트 외에는 리렌더링이 일어나지 않는다. 각 아이템의 컴포넌트, props로 넘기는 함수와 객체에 메모이제이션을 했기때문이다.
또한 아이템 hover시 해당 컴포넌트만 리렌더링이 된다. 이는 hover에 대한 상태 관리를 각 컴포넌트 내부에서 하고 있기 때문이다.
3) Next.js
Next.js에서는 기본적으로 여러가지 최적화 기능을 제공한다. 그 대표적인 방법들에 대해 간단하게 살펴보자.
- Image :
- Webp, Avif같은 현대 이미지 포맷으로 자동으로 변환하여 제공한다.
- 이미지가 로드되는 동안 레이아웃 시프트을 자동으로 방지한다.
- 레이지 로딩을 자동으로 제공하고 blur placeholder를 선택적으로 제공한다.
- 사용자 기기에 맞게 사이즈를 조절하여 제공한다.
- link : : 해당 페이지의 prefetching을 수행한다.
- scripts :
- afterInteractive : 하이드레이션 이후에 로드한다.
- lazyOnload : 브라우저가 활발히 상호작용하지 않을 때 실행한다.
이 외에도 lazy loading을 위한 dynamic imports, 분석 도구(Web Vital 측정), 정적 자원을 캐싱하는 등 다양한 최적화를 위한 기능을 제공한다.
UX 최적화
1) 자주 사용하는 페이지에서는 애니메이션을 최소화하자
사용자 경험 개선하기 에서 각 메모 아이템이 등장할 때 부드러운 애니메이션을 추가한 이야기를 했다.
그런데 ‘이 애니메이션이 정말 사용자에게 유익할까?’ 에 대해 고민을 해보았을 때 잘 모르겠는 것이다. 사용자가 궁금한 건 메모이지 메모가 얼마나 자연스럽게 나타나느냐가 아니다. 다른 웹사이트는 어떻게 하는지 살펴보았을 때 토스 증권과 당근에서는 모두 초기 로딩 시에 애니메이션을 넣지 않고 있었다. 애니메이션을 없애고 서버에서 최대한 렌더링하는 데에 더 중점을 맞추었다.
왜 이렇게 했을까 고민을 했을 때, 랜딩 페이지나 소개 페이지같은 경우에는 사용자가 자주 사용하기보다는 이 서비스에 대해 첫인상을 남기는 것이 중요하기에 애니메이션이 큰 효과가 있다. 하지만 메모 목록과 같이 실제 서비스의 핵심 기능을 담은 페이지에서는 애니메이션보다는 빠른 렌더링이 더 중요하다고 판단했다.
애니메이션을 제거한 이후의 영상이다.
사람마다 다르게 생각할 수 있지만 애니메이션이 없는 이 버전이 오히려 더 깔끔하고 사용성이 좋아 보인다. 필요한 정보를 즉시 확인할 수 있기 때문이다. 특히 메모 목록과 같이 자주 사용되는 페이지에서는 불필요한 애니메이션보다 즉각적인 정보 전달이 더 중요하다고 느껴졌다.
2) 로딩 스패너보다는 스켈레톤을 지향하자
토스 증권 웹사이트는 모든 컴포넌트에 스켈레톤를 대체하여 가장 먼저 보여진다. 인터넷이 느린 경우 아래와 같이 스켈레톤이 하나씩 실제 콘텐츠로 교체되는 것을 볼 수 있다. 이는 사용자에게 컨텐츠가 로드되고 있다는 것을 시각적으로 알려주는 동시에 레이아웃 시프트를 방지하는 효과가 있다.
아래는 웹 메모에 스켈레톤을 적용하기 전의 영상이다.
기존에는 메모 아이템이 흐름 끊기듯이 나타나는 느낌이었다면
아래는 스켈레톤 적용 후 이미지이다.
스켈레톤을 먼저 보여줌으로써 ‘이 자리에 메모아이템이 등장할거에요’라며 사용자에게 부드럽게 알려주는 듯한 느낌이 생겼다.
또한 스켈레톤이 실제 컨텐츠로 자연스럽게 전환되는 과정은 사용자에게 앱이 반응하고 있다는 느낌을 준다. 이는 개인적으로 단순히 로딩 스피너를 보여주는 것보다 훨씬 더 세련된 방식이라 생각한다. 느린 환경에서도 덜 답답하게 기다리게 해준다.
3) 로딩을 구역별로 보여준다
관련 커밋
https://github.com/guesung/Web-Memo/pull/127/commits/5d2b664fa9a33bfde5d280ce0d1be6d232226b22
Suspense는 내부 컴포넌트에서 throw한 Promise가 pending상태일 동안 fallback ui를 보여주는 컴포넌트이다. 만약 Suspense로 컴포넌트를 통째로 감쌌을 때, 컴포넌트 내에서 하나 이상의 pending상태인 Promise를 throw한다면 이 컴포넌트는 전체가 로딩 화면을 보여주게 된다.
Suspense를 전체로 감쌌을 때의 영상이다.
위 예시에서 ‘메모’ 글씨와 외부 링크 아이콘은 로딩 스패너를 보여줄 필요가 없다. 왜냐하면 ‘메모’글씨와 아이콘은 항상 같은 값과 이미지이기 때문이다. 로딩 스패너를 보여주어야할 것은 페이지의 제목을 받아오는 ‘새 탭’ 부분이다. 그래서 기존 Suspense로 MemoHeader를 통째로 감싸던 것에서 페이지 제목인 TabTitle 부분만 감싸도록 수정했다.
- 기존 코드
<Suspense fallback={<Loading />}>
<MemoHeader />
</Suspense>
- 수정 후 코드
<MemoHeader />
// ..
function MemoHeader(){
return
<>
<span className="whitespace-nowrap font-bold">{I18n.get('memo')}</span>
<Suspense fallback={<ExternalLinkIcon size={16} />}>
<MemoLink />
</Suspense>
<Suspense fallback={<Skeleton className="ml-auto h-full w-32" />}>
<TabTitle />
</Suspense>
</>
}
‘메모’ 글씨는 처음부터 보여주고 메모의 url이 나타나기 전까지는 url 이동이 불가능한 링크 아이콘을 fallback ui로 보여준다.
Suspense를 각 구역별로 필요에 따라 나눈 이후의 영상이다.
4) API 요청을 최소화한다
관련 커밋
https://github.com/guesung/Web-Memo/pull/128/commits/a84953daa7cb23f4e051b5e9c689eda47f9ceb77
API 요청을 줄이면 성능과 사용자 경험에도 간접적으로 도움이 된다. 불필요한 요청은 서버 부하를 키우고, 클라이언트 리소스와 사용자의 데이터 사용량까지 잡아먹는다. 그래서 적절한 캐싱 전략이 필요하다.
API 요청에 대한 정보는 네트워크 창을 열어 각 버튼들을 눌러보며 데이터 패칭 타이밍이 적절한지 체크할 수 있다.
불필요한 API 요청이 일어날 때의 영상이다.
모달 창이 열릴 때마다 데이터를 패칭해오는 것을 볼 수 있다. 이는 모달이 열리며 SearchParams값이 변경된다. react query는 url이 변경되어 stale상태의 데이터를 자동으로 리패칭한다. 그렇기에 모달이 열릴 때마다 데이터 패칭을 다시 하는 것이다.
이를 해결하기 위해 staleTime과 gcTime을 각각 5분과 30분으로 설정하였다. (cacheTime은 TanStack Query v5에서 gcTime으로 이름이 바뀌었다. v4 이하를 쓴다면 cacheTime을 그대로 쓰면 된다.)
const { data } = useQuery({
queryKey: ['key'],
queryFn: fetchData,
staleTime: 1000 * 60 * 5, // 5분
gcTime: 1000 * 60 * 30, // 30분 (v4 이하는 cacheTime)
});
불필요한 API 요청을 없앤 후의 영상이다.
RSC파일 이외의 데이터 요청은 없는 것을 확인할 수 있다.
맺으며
이번 글에서는 성능 최적화와 UX 개선에 대해 다루었다. 성능 최적화를 위해 Static Serve, 렌더링 최적화, Next.js의 최적화 기능 등을 적용했고 UX 개선을 위해 애니메이션 최소화, 스켈레톤 UI 도입, 구역별 로딩 처리, API 요청 최적화 등을 구현했다.
결국 최적화는 사용자를 안 기다리게 하려는 거다. 애니메이션이 화려하고 UI가 세련되었더라도 사용자가 기다려야 한다면 그것은 좋은 UX가 아니다. 토스나 당근과 같은 서비스들이 성공할 수 있었던 이유 중 하나는 바로 이 '기다림 없는 경험'을 제공했기 때문이라고 생각한다.
특히 서비스의 핵심 기능에서는 화려한 애니메이션보다는 빠른 응답이 멋진 로딩 스피너보다는 즉각적인 피드백이 더 중요하다. 이건 성능 수치보다 사용자가 체감하는 속도의 문제다.
앞으로도 수치보다 체감 속도를 먼저 보려 한다. 최고의 UX는 사용자가 인터페이스를 의식하지 않고 자연스럽게 쓰게 하는 것이니까.