안녕하세요 와디즈 FE 개발팀 개발자입니다.
본격적으로 글을 시작하기에 앞서 영상 하나를 보고 올게요.
어떠신가요? 아무런 느낌이 들지 않는다면 그게 맞아요!
위 영상은 app이 아닌 web에서의 영상인데요. 이번에 web에서 사용자의 와디즈 주요 탐색 동선에 SPA 기술을 적용해 사용자 경험을 개선했어요. 그러니 답답하거나, 불편하거나 등 어떠한 생각도 떠오르지 않고 자연스러운 것이 맞습니다. 이번 글에서는 그 개선 이야기를 풀어볼게요.
도입 배경
와디즈의 많은 페이지는 JSP를 이용한 SSR(Server Side Rendering)과 일부 영역에 React를 이용한 CSR(Client Side Rendering)이 혼재되어 있었어요. 그림으로 표현하면 아래와 같은 형태인데요.
레거시로 불리는 JSP 페이지에 추가/수정 요구사항이 빈번하게 있었어요. 그래서 JSP에 비해 상대적으로 유지 보수가 용이한 React로 화면 일부 영역을 변경하는 방법을 선택했어요.
다만, 이 방식은 다음과 같은 단점이 있었는데요.
- 한 페이지의 코드가 FE, BE repository에 분산되어 있어 코드 흐름 파악이 어려움
- JSP와 React의 커뮤니케이션이 필요한 경우, 개발이 어려움
- JSP, React에서 사용하는 모든 라이브러리가 로드되어 성능이 느림.
- React로 되어 있는 각 섹션이 별도 app으로 되어 있어, API를 각각 호출해 렌더링 타이밍이 다르게 적용되기 때문에 CLS(Cumulative Layout Shift)발생이 많음
유지보수가 어려운 코드의 가장 큰 단점은 개발자가 유지보수하기 싫어한다 예요. 😂
(개발자가 유지보수 하기를 싫어하면 기획자의 기획은 개발자의 ‘안 돼요…’ 라는 큰 난관에 봉착하게 됩니다.)
유지보수가 필요한 위 이슈를 해결하고자 JSP로 되어 있던 페이지를 순수 React App으로 변경하고 페이지 간 이동에 SPA를 적용했어요.
SPA 적용 과정
JSP + React로 되어 있는 페이지를 순수 React로 변경
변경이라고 했지만, JSP특성상 React에서 재사용 할 수 있는 코드는 거의 없어요. 네. 새로 개발했습니다.
CSR(Client Side Rendering)이 가능하도록 BE에서 REST API를 추가했어요. Java Controller에서 주입하고 있던 prop은 API를 통해 가져오도록 수정했고요. 신규 개발이지만 주요 컴포넌트는 디자인 시스템화되어 있어 단기간에 개발할 수 있었어요.
SPA 적용
새롭게 개발한 페이지를 React Router에 추가하고 페이지 이동을 a tag에서 <Link> 컴포넌트 혹은 history를 사용하도록 변경했어요.
JSP에서 include 하는 bundle(React App) 수정
각 페이지로 직진입 하더라도 페이지 전환 시 SPA로 동작하기 위해서는 각 페이지의 JSP에서 동일한 bundle을 include 해야 해요.
SPA 단점 극복하기
SPA를 적용해 페이지 전환 속도를 개선했다고 끝나는 것이 아니에요. SPA가 가지는 단점을 극복해야 했어요. SPA의 단점으로는 SEO(Search Engine Optimization), 느린 초기 로딩 속도, CLS가 있습니다.
단점 극복 1. SEO(Search Engine Optimization)
유명한 검색엔진들은 스크립트 실행이 가능해요. 그래서 이슈가 되는 것의 대부분은 메타 정보 제공이죠. 와디즈는 JSP를 이용해 메타 정보를 제공하고 있었어요. SPA를 적용하더라도 기존 JSP 파일을 제거하지 않고, include 하는 번들만 변경했어요. 따라서 SPA를 적용해도 메타 정보를 유지할 수 있게 되었습니다.
단점 극복 2. 느린 초기 로딩
여러 개의 app(번들)으로 나뉘어 있던 페이지를 하나의 app(번들)으로 병합하면 번들 크기가 커지는데요. SPA app에서는 화면 전환이 빠르다는 장점이 있지만, 초기 화면 로딩은 느리다는 단점이 있어요.
SPA app이라고해서 꼭 하나의 번들이어야 할 필요는 없어요. Webpack의 SplitChunk기능으로 번들을 분리하고, React의 lazy로 해당 번들을 로드하면 필요한 시점에 필요한 번들만 로딩할 수 있죠.
와디즈는 각 페이지와 용량이 큰 third party library를 별도 번들로 분리하고 lazy 로드하여 초기 로딩 속도를 개선했어요.
단점 극복 3. CLS
화면의 각 영역이 독립적으로 API를 호출하고 응답할 때, 개별로 렌더링 한다면 사용자는 화면의 콘텐츠에 예기치 않게 이동하는 LayoutShift를 경험해요. 이 현상은 화면에 필요한 데이터가 모두 준비되면 렌더링 하거나 skeleton을 적용하는 방법으로 해결할 수 있는데요. 우리는 다음 방법으로 LayoutShift를 최소화했어요.
- 페이지 표시에 필수 데이터가 모두 응답하기 이전에는 Spinner 노출
- 콘텐츠가 viewport에 진입 시 렌더링이 필요한 요소는 skeleton 적용
- image 등 로딩 이후에 콘텐츠 크기가 변경되는 요소는 고정 크기 적용
SPA 장점 활용하기
SPA의 장점 중 하나는 native app과 유사한 동작을 구현할 수도 있다는 것인데요. 우리가 선택한 방식을 소개할게요.
Prefetch
일반적인 SPA 페이지 전환은 다음과 같은 flow로 동작합니다.
페이지 전환 → API 호출 → 로딩바 노출 → API 응답 → 페이지 렌더링
이 flow는 페이지 전환에서 사용자에게 큰 의미가 없는 흰색 화면(로딩바)이 항상 노출되는 문제점이 있어요. Prefetch는 다음 flow로 동작하기 때문에 로딩바 노출 flow를 제거할 수 있어요.
카드 클릭 → API 호출 → API 응답 → 페이지 전환 & 렌더링
우리는 데이터 공유를 위해 ReactQuery의 staleTime option을 이용했어요.
History Modal
native app과 web에서의 모달(modal) 동작에는 큰 차이가 있는데요. 바로 모달을 닫는 방법이에요. native app에서는 모달을 back button 클릭으로 닫아요. 반면 web에서는 back button 클릭 시 이전 페이지로 돌아가는 이슈가 있어요. 이를 browser의 history API를 이용해, back button으로 닫히는 모달을 구현할 수 있어요.
모달이 오픈 시 history.push로 history를 누적하고, popState event에서 오픈된 모달을 닫는 로직을 추가하면 history back으로 닫히는 모달을 구현할 수 있습니다. 간단하게 설명했지만, 편하게 관리하려면 몇 가지 기술적인 부분이 추가로 필요해요.
이전 화면 유지
사용자 탐색 동선에서 이전 화면 유지는 매우 중요한 기능이에요. native app에서는 페이지 이동 시 신규로 생성하기 때문에 이전 화면 유지가 당연한 동작이에요. 하지만 web에서는 하나의 화면에서 다시 그리는 방식이기 때문에 이전 화면을 유지하기가 까다롭죠.
이전 화면으로 돌아왔을 때 데이터가 그대로 있다면 이전 화면을 유지할 수 있어요. 와디즈의 경우, ReactQuery로 서버 데이터를 관리하고 있고 ReactQuery의 staleTime을 적용하고 있는데요. 덕분에 특정 시간 동안 데이터를 유지해 화면이 전환되어도 데이터를 보존하고 있습니다.
주의 사항으로는 ReactRouter의 스크롤 복원 시점은 useLayoutEffect이기 때문에 해당 Hook호출 이전에 데이터가 복원되어 있어야 한다는 것이에요. 데이터 복원 시점이 늦다면 별도의 스크롤 유지 로직이 추가되어야 해요.
와디즈 app에 확대 적용
와디즈 app은 native(각 서비스 홈)와 webview(상세 페이지)가 모두 있는 hybrid app이에요. 기존에는 native 화면에서 상세 페이지 진입 시 매번 webview를 새로 생성하는 방식이었어요. SPA를 적용하려면 하나의 webview를 재사용하는 방식으로 변경해야 하기 때문에 몇 가지 기술적인 개발이 필요했어요. 아래는 그 개발 내용이에요.
webview를 재사용할 수 있도록 페이지 전환 함수(interface) 개발
app에서 페이지 전환을 요청할 interface가 필요했어요. SPA로 전환할 수 있는 페이지는 변경이 가능한데요, SPA 전환 여부를 return값으로 전달했어요.
페이지 전환 상태 protocol(event) 정의
app에서는 SPA 로드 시 성공/실패 확인 및 성능 측정을 위해 다음과 같은 event를 정의했어요.
컴포넌트 lifecycle이 정상 동작하기 위한 customHook 개발
web에서는 동일한 페이지를 반복으로 탐색하는 경우가 없는데요. 반면, app에서 webview를 재사용하는 경우에는 특정 페이지를 반복적으로 탐색하는 경우가 많아요. ReactRouter 특성상 path가 변경되지 않으면 컴포넌트를 재사용하기 때문에, 위 동선에서 페이지에 있는 각 컴포넌트의 초기화 코드(useEffect)가 불리지 않는 이슈가 있을 수 있어요.
app에서 interface를 통해 SPA 전환을 요청한 경우, 현 페이지를 지우고 새로 그리도록 customHook을 개발했습니다.
향후 계획
지금까지 SPA를 이용해 최적화를 진행한 내용을 찬찬히 살펴보았는데요. 나아가 SSR 방식에 비해 첫 화면 로딩이 느린 부분까지도 해결하고 싶은 마음이 있어요. NextJS를 도입하여 SSR + CSR로 개선할 계획입니다.
궁금한 내용이 남아 있나요? 👀