본문 바로가기

업무

Prefetching을 활용한 웹뷰 서비스 설계 및 회고

이번 포스트는 Next.js로 SPA 웹뷰 서비스를 설계해본 경험을 정리하는 포스트이다.

 

설계한 서비스 플로우

 

 

설계한 웹뷰 서비스는 위 예시처럼 페이지를 순차적으로 이동한다. 많은 웹뷰 서비스가 이런 형태로 설계되는데 매우 단순해보이지만 높은 사용자 경험을 주기 위해선 고려할 것이 많다. 특히, 뒤로가기가 단순하지 않을 경우 설계가 복잡해진다. 위 플로우를 보면 페이지 4에서 뒤로가기를 하면 페이지 2를 가야하고, 페이지 3에서는 페이지 1로 가야 한다. 뒤로가기도 경우의 수가 있다. 뒤로가기 버튼을 클릭하여 클릭 이벤트를 통해 넘어가는 경우도 있고 스마트폰 화면에서 스위핑하여 브라우저 이벤트가 발생하여 뒤로가는 경우도 있다.

 

또한, 웹뷰에서 처리가 불가능하고 네이티브 앱에서 처리가 필요한 경우도 고민해야 한다. 앱<->웹 전환을 고려하는 것이 필요하다. 그럴 때 앱 개발자와 웹뷰 스택 관리를 어떻게 할 것인지, 웹뷰를 제어할 인터페이스는 어떻게 할 것인지 미리 협의하고 결정해야 한다.

 

 

기존 서비스 설계 방식

 

import Page1 from './Page1';
import Page2 from './Page2';
import Page3 from './Page3';
import Page4 from './Page4';
export default function Router() {

  const router = useRouter();
  const { progress } = router.query;

  const Page = {
    'page1': Page1,
    'page2': Page2,
    'page3': Page3,
    'page4': Page4,
  }[progress] || EmptyPage;

  return (<Page />);
}

 

기존에는 SPA 느낌이 중요한 서비스의 경우 전형적인 리액트의 CSR 방식으로 구현을 하였다.

/pages/Router?progress=page1를 입력하면 Router 페이지에서 미리 Page1, Page2, Page3, Page4 컴포넌트를 import 한 뒤에 object로 가지고 있고 요청값(progress)에 따라 각 컴포넌트를 return 하여 렌더링하는 것이다.

 

 

기존 방식의 장점

  • 구조를 이해하기가 매우 쉽다.
    • 다른 사람이 작성한 코드여서 처음 보는 코드임에도 코드가 어떻게 동작할지 한 눈에 보였다.
  • 신뢰도가 높다.
    • 나도 이 방식을 많이 사용해봤고 실제로 많이 쓰이는 방식이라 개발 기간을 단축시킬 수 있고 버그 발생이 적다.
  • UX가 좋다.
    • 첫 페이지 로드 후에는 SPA 방식이라 페이지 전환이 없어서 높은 UX를 보여주기에 고객 만족도가 높을 것으로 예상된다.

 

 

기존 방식의 단점

  • 첫 페이지 로드 시간이 길다.
    • 첫 페이지 진입 시에 모든 컴포넌트를 import 하므로 첫 페이지의 TTI(Time to interaction)가 매우 높을 것이다. 
      • 리액트와 Next.js에서 제공하는 lazy loading을 이용하면 줄일 수 있다. 하지만 그렇게 되면 페이지 이동 시마다 번들을 다운로드 받아야 하기에 각 페이지의 TTI가 증가하게 된다. SPA의 장점을 잃게 된다.
  • 뒤로가기 처리 어려움
    • 페이지가 하나이기 때문에 뒤로가기를 구현하려면 브라우저 자체 뒤로가기 이벤트를 가로채서 구현해야 하기 때문에 이 부분에서 버그가 많이 발생하고 자연스러운 UX 장점을 잃게 될 수 있다. 많은 웹뷰 서비스가 SPA를 구현할 때 뒤로가기 처리가 부족하여 UX가 엉성하게 되는 경우가 많다.
  • Next.js의 다른 페이지와 조합이 어렵다.
    • SPA를 벗어나서 경로 라우팅 방식의 다른 페이지와 확장하게 될 때는 컴포넌트가 아니기 때문에 예외로 처리해야 할 것들이 많아진다. 그렇게 되면 코드가 복잡해지게 되어 가독성을 잃을 수 있다.

기존 방식은 단점에 비해 장점이 아주 강력하기 때문에 충분히 구현하고자 하는 웹뷰 서비스를 구현할 수 있었지만

Next.js의 라우팅 기능이 위 기존 방식의 장점을 대부분 가져가면서 단점도 개선할 수 있을 것으로 생각되어 Next.js를 활용한 설계를 생각하게 되었다.

 

 

Next.js의 특징

 

next.js는 파일 경로 라우팅을 사용하는데 이 방식의 장점은 코드 스플리팅이 자동으로 지원되는 것이다. 각 경로마다 리소스를 생성하기 때문에 리액트와 다르게 자동 코드 스플리팅이 가능하다.

 

next/router 소개

 

ssr은 csr에 비해 빠르게 화면에 표시할 수 있지만 문제는 여러 개로 구성된 페이지이다 보니 화면이 전환되는 것이 사용자에게 느껴지는 문제가 있다. next.js는 이러한 SPA 느낌을 구현하기 위해 csr 방식으로 라우팅 되는 router를 만들었다.

next/router는 크게 전역 객체인 Router와 useRouter로 구성되며 useRouter가 context api 기반의 hook이기 때문에 일반적으로 함수형 컴포넌트에서 많이 사용된다.

 

페이지를 이동하는 router.push(url)를 하면 어떤 일이 일어날 것 같은가?

html을 받아올까? 처음엔 그렇게 생각했지만 아니었다.

router가 csr 방식을 지원하기 위해 나왔다는 것을 생각해보면 html을 다시 줄 리가 없다.

바로 .js 번들 파일을 주게 된다. 정말로 csr 방식으로 이동되게 되는 것이다.

router.push 내부 코드도 history.pushState를 하도록 되어있다.

 

Prefetch

 

next/router의 장점 중 하나이자 이번 설계의 키 포인트인 Prefetch는 앞서 말한 번들 파일을 미리 가져온다. 가져와서 브라우저의 메모리 캐시에 두고 다음 번에 그 페이지에 요청하게 되면 요청하지 않고 캐시를 쓰는 방식이다. 디스크 캐시도 아니어서 아주 빠르다.

또 prefetch는 router의 메서드이기 때문에 호출이 매우 자유로워서 활용하기가 좋다.

 

 

Prefetching을 활용한 방식

 

 

컨트롤러 페이지를 따로 두게 되는데

이 페이지의 역할은 모든 페이지간 라우팅 제어, 인증 로직, 공통 처리 로직, 다음 페이지 프리페칭 이다.

hook으로 만들 수도 있었지만 따로 페이지로 둔 이유는 페이지들의 실제 url을 숨기고 싶었기 때문이다.

실제 url이 노출되면 앱과 강하게 결합되고 페이지들 간에 결합도도 높아지게 된다. 

 

추가적으로, 요즘 next.js는 이런 경우를 위해 미들웨어 기능을 12.2 버전부터 소개하고 있는데 이번 설계에선 적용을 철회했다. 왜냐하면 현재 swc 대신 바벨을 사용중인데 미들웨어 기능 도입에 문제가 다소 있기 때문이다. 그리고 stable 기능이지만 아직 너무 최신 기능이다. 관심이 있다면 최근에 관련 포스트를 올린 적이 있으니 참고해보길 바란다. 미들웨어를 쓰면 컨트롤러 페이지 역할을 대체할 수 있다. 물론 따로 페이지를 두는 것이 미들웨어 보다 훨씬 처리 범위가 커서 각각 장단점이 있다.

 

 

 

페이지 1에서 페이지 2로 이동하게 되는 경우

페이지 1에선 router.push로 컨트롤러에 페이지 2를 요청하게 된다.

그럼 컨트롤러는 router.replace로 마치 처음부터 페이지 2에 요청했던 것처럼 인증, 공통처리 로직 등을 수행한 후 페이지 2로 안내한다.

replace를 쓴 이유는 history 스택을 관리하기 위해서다. push를 두 번쓰게 되면 스택이 두 번씩 쌓이게 되어 뒤로가기가 이상하게 동작한다.

 

 

 

 

컨트롤러는 prefetch도 담당한다. 페이지 1에서 2를 요청한 경우 페이지 2를 가져온 후 페이지 3를 prefetch 하도록 자동 요청한다. 서비스 플로우가 순차적이기 때문에 가능하다.

 

수도코드로 보면 대략 다음과 같다.

 

import { useRouter } from 'next/router';

function ContractController() {
  const router = useRouter();

  const { progress } = router.query;

  useEffect(() => {
     const autoPrefetch = () => {
        const urls = Array.from(routes.entries());
        const step = urls.findIndex(([queryKey]) => queryKey === progress);

        if (step !== -1 && step < urls.length - 1) {
          const nextPathname = urls[step + 1][1];
          router.prefetch(nextPathname);
        }
    }
    if (
      typeof progress === 'string' &&
      routes.has(progress as Progress)
    ) {
      router
        .replace(
          {
            pathname: routes.get(progress as Progress),
            query: {
              ...router.query,
            },
          },
          router.asPath
        )
        .then(autoPrefetch);
    }
  }, [progress, router]);

  useEffect(() => {
    // 공통 처리 로직
  }, []);

  return <></>;
}