지금 하고 있는 사이드 프로젝트는 웹뷰 기반으로, 웹과 앱 모두에서 제공되는 서비스를 만들고 있다.
웹뷰는 처음이지만, 앱 개발자 분과 메시지 규격도 맞춰가며 나름 재미있게 협업 중이다.

게시글 작성 과정은 제목 -> 이미지 첨부 -> 내용 -> 타입, 카테고리 선택 -> 완료 순서로 구성되어 있다.
아직 개발 중이지만, 전반적인 기능은 원하는 대로 잘 동작하고 큰 문제 없이 구현되고 있었다.
하지만 개발하면서 한 가지 아쉬운 점이 있었는데, 앱처럼 화면이 스무스하지 않는 점이었다.
앱처럼 화면이 슬라이딩 되면서 넘어가지 않는 이유는 리액트의 기본 라우팅 방식이 페이지 컴포넌트를 언마운트하고 새로 마운트하기 때문이다.
A 페이지에서 B 페이지로 이동한다고 하면 A 페이지를 언마운트 하고 B 페이지를 마운트 하기 때문에 전환 시에 애니메이션을 넣을 수 없다.
그렇다는 말은 이전 페이지를 갖고 있으면 애니메이션 효과를 넣을 수 있다!
이를 해결하기 위해 고민하다가 당근의 Stackflow를 발견했다.
오픈 소스를 파보고 싶었지만, 오픈 소스를 읽는 건 왜이리 힘든지...
일단 구현이 우선이니 깊게 파보는 건 나중에 하고 간단하게 써보고 대략적인 아이디어만 가져오기로 했다.
Stackflow는 말 그대로 스택에 페이지 컴포넌트에 해당하는 Activity를 쌓아서 관리한다.
스택에 A 페이지 위에 B 페이지를 올리기 때문에 A를 유지한 채로 B를 보여줄 수 있기 때문에, 슬라이딩 효과를 넣거나 이전 페이지를 복원할 수도 있다.
그러면 Stackflow를 쓰면 해결이 될까?
Stackflow는 리액트 라우터를 대체하는 라이브러리라서 기존 라우터와 병행해서 사용하긴 어렵다.
이미 프로젝트에 많은 부분(라우터 가드, 프로바이더 등)에서 리액트 라우터를 사용하고 있고, 개인 프로젝트가 아니기 때문에 독단적으로 바꾸기는 어려웠다.
전체 라우터를 바꾸는 게 아니라, 게시글 작성처럼 하나의 프로세스 안에서만 Stackflow 구조를 흉내내면 되지 않을까?
게시글 작성 페이지에서 스택을 관리하고 각 단계를 Activity처럼 사용하면 되지 않을까?

그림으로 표현해보면 위와 같다.
페이지 컴포넌트에서 스택을 관리하고, 스택의 가장 윗 부분의 컴포넌트를 사용자에게 보여준다.
(물론 공식 문서나, 소스를 자세하게 뜯어본 건 아니라서 틀릴 수도 있다. 아님 말구 ㅋ)
어떻게 구현해야 할까?
일단 냅다 훅으로 만들고 보자.
import { useState } from 'react';
type Activity = {
  key: string;
  element: React.ReactNode;
};
export const useStack = () => {
  const [stack, setStack] = useState<Activity[]>([]);
  const push = (activity: Activity) => {
    setStack((prev) => [...prev, activity]);
  };
  const pop = () => {
    setStack((prev) => prev.slice(0, -1));
  };
  const init = (activities: Activity[]) => {
    setStack(activities);
  };
  const clear = () => {
    setStack([]);
  };
  return { stack, push, pop, clear, init };
};
훅으로 만든 다음 적용하려고 보니, 문제가 될 만한 점이 생각났다.
각 페이지에서 다음 또는 이전을 눌러서 이동할 때 push, pop 메서드를 사용해야 한다.
페이지 컴포넌트와 Activity는 한 뎁스 차이라서 props로 넘겨줘도 괜찮을 것 같지만, 만약 Activity가 추가된다면 매번 props에 push와 pop을 받을 수 있게 처리해야 한다.
props 두 개 쯤은 상관없을 수도 있지만, 스스로 생각해낸게 기특해서 이 악물고 ContextAPI를 사용했다.
ContextAPI를 써도 어차피 페이지에서 매번 호출해야 되긴 하지만 구조가 훨씬 깔끔해진다.
import { createContext, useContext } from 'react';
import { Activity } from '@/types';
export interface StackStateContextType {
  stack: Activity[];
}
export interface StackActionContextType {
  push: (activity: Activity) => void;
  pop: () => void;
  clear: () => void;
  init: (activities: Activity[]) => void;
}
export const StackStateContext = createContext<StackStateContextType | undefined>(undefined);
export const StackActionContext = createContext<StackActionContextType | undefined>(undefined);
export const useStackStateContext = () => {
  const state = useContext(StackStateContext);
  if (state === undefined) {
    throw new Error('useStackStateContext must be used within an StackStateContextProvider');
  }
  return state;
};
export const useStackActionContext = () => {
  const actions = useContext(StackActionContext);
  if (actions === undefined) {
    throw new Error('useStackActionContext must be used within an StackActionContextProvider');
  }
  return actions;
};
import { StackActionContext, StackStateContext } from '@/hooks';
import { Activity } from '@/types';
import { useMemo, useState } from 'react';
import { Outlet } from 'react-router-dom';
export const StackContextProvider = () => {
  const [stack, setStack] = useState<Activity[]>([]);
  const push = (activity: Activity) => {
    setStack((prev) => [...prev, activity]);
  };
  const pop = () => {
    setStack((prev) => prev.slice(0, -1));
  };
  const init = (activities: Activity[]) => {
    setStack(activities);
  };
  const clear = () => {
    setStack([]);
  };
  const stateValue = useMemo(() => ({ stack }), [stack]);
  const actionValue = useMemo(() => ({ push, pop, clear, init }), [push, pop, clear, init]);
  return (
    <StackStateContext.Provider value={stateValue}>
      <StackActionContext.Provider value={actionValue}>
        <Outlet />
      </StackActionContext.Provider>
    </StackStateContext.Provider>
  );
};
이제 각 페이지들을 스택에서 관리해보자.
먼저 스택에 내용이 바뀔 때, 즉 페이지가 전환될 때 애니메이션을 보여줄 수 있는 컴포넌트를 먼저 작성했다.
아마 Stackflow에서 AppScreen이 이 역할을 하는 컴포넌트가 아닐까 싶다.
import { useStackStateContext } from '@/hooks';
export const StackRenderer = () => {
  const { stack } = useStackStateContext();
  return (
    <div className="w-full min-h-screen relative overflow-hidden">
      {stack.map((activity, i) => (
        <div
          key={activity.key}
          className="absolute top-0 left-0 w-full h-full transition-transform duration-300"
          style={{
            transform: `translateX(${(i - stack.length + 1) * 100}%)`,
            zIndex: i,
          }}
        >
          {activity.element}
        </div>
      ))}
    </div>
  );
};
스택에 여러 페이지가 쌓여 있을 때 어떻게 마지막 페이지를 사용자에게 보여줄 수 있을까?
여기서 핵심은 (i - stack.length + 1) * 100 이 부분이다.
이 수식이 스택의 가장 마지막 요소를 translateX(0%)로 만들어, 화면의 중심에 오도록 한다.
만약 스택에 페이지가 하나만 있다면, 보여줘야 할 페이지 인덱스는 0이고, (0 - 1 + 1) * 100이 되어 0이 된다.
스택에 두 페이지가 있고, 두 번째 페이지를 보여줘야 된다면?
(1 - 2 + 1) * 100으로 translateX(0%)가 된다.
스택의 모든 페이지를 렌더링하기 때문에, 이전 페이지를 갖고 있고, 현재 보여질 페이지를 보여주면서 이전 페이지를 슬라이딩하게 넘길 수 있다.
추가로 'Provider에 한 번에 작성하면 되지 않을까?'라는 고민도 했었는데, Provider는 스택의 상태를 제공해주는 역할이고 Renderer는 애니메이션 효과를 주는 역할로, 서로의 역할이 다르다고 생각해서 분리했다(이렇게 생각한 나 자신 조금 뿌듯쓰).
const CommunityPostPage = () => {
  const { push, pop, init } = useStackActionContext();
  const { setFile } = useCommunityFormActionContext();
  const handleAlbumDataFromRN = (event: MessageEvent) => {
    // RN에서 앨범 데이터를 받아 처리하는 함수
  };
  const handleWebFileSelection = () => {
    // 웹에서 파일 선택을 처리하는 함수
  };
  const handleImageSelection = () => {
    // RN과 웹 환경에 따라 적절한 이미지 선택 방식을 처리하는 함수
  };
  useEffect(() => {
    const getActivity = (key: string): Activity => {
      switch (key) {
        case 'write':
          return {
            key: 'write',
            element: <Write onNext={() => push(getActivity('title'))} />,
          };
        case 'title':
          return {
            key: 'title',
            element: (
              <Title
                onNext={() => {
                  handleImageSelection();
                  push(getActivity('description'));
                }}
                onExit={() => pop()}
              />
            ),
          };
        case 'description':
          return {
            key: 'description',
            element: <Description onNext={() => push(getActivity('preview'))} onExit={() => pop()} />,
          };
        case 'preview':
          return {
            key: 'preview',
            element: <Preview onNext={() => push(getActivity('complete'))} onExit={() => pop()} />,
          };
        case 'complete':
          return {
            key: 'complete',
            element: <Complete />,
          };
        default:
          throw new Error(`Unknown activity key: ${key}`);
      }
    };
    init([getActivity('write')]);
    const handlePopState = () => {
      pop();
    };
    window.addEventListener('popstate', handlePopState);
    window.addEventListener('message', handleAlbumDataFromRN);
    window.history.replaceState({ step: 0 }, '', window.location.href);
    return () => {
      window.removeEventListener('message', handleAlbumDataFromRN);
      window.removeEventListener('popstate', handlePopState);
    };
  }, []);
  return <StackRenderer />;
};
export default CommunityPostPage;
페이지 컴포넌트가 렌더링 되면 스택에 첫 번째 페이지에 해당하는 컴포넌트를 스택의 초기값으로 넣어준다.
그리고 각 페이지 별로 다음 화면에 넘어갈 때 getActivity("다음 페이지 key값")을 호출하여 페이지를 이동한다.

적용하면 위와 같이 페이지 전환 시에 애니메이션이 발생한다.
하지만 문제가 있다.
다음 화면으로 넘어갈 때, 이전 화면은 좌측으로 넘어가는 애니메이션이 발생하지만 그 위에 현재 페이지가 바로 나와 애니메이션이 무색해진다.
쉽게 설명하면, 이전 페이지는 슬라이딩 애니메이션이 들어가지만, 다음 페이지는 애니메이션 없이 떨어진다.
이 문제를 해결하려면 다음 페이지에 애니메이션 효과를 넣어줘야 한다.
다시 translateX를 결정하는 수식을 보면서 생각해보면, 이전 페이지는 0% -> -100%가 되면서 슬라이딩 효과가 발생한다.
하지만 다음 페이지는 0%로 고정이 되기 때문에 슬라이딩 효과없이 떨어지는 것이다.
그러면 마지막 페이지, 즉 보여지고 싶은 페이지를 100% -> 0%로 되게끔 변경해주면 될까?
그럴경우 push에는 대응할 수 있을 것 같은데, pop에는 대응할 수 없다.
사용자가 뒤로 가기를 눌러서 pop이 실행될 경우, 스택에서는 현재 페이지가 없어진다.
그러면 전 페이지를 보여줘야 하는데, 이게 스택의 마지막이므로 오른쪽에서 등장하게 된다.
뒤로 가기를 눌렀는데, 페이지가 오른쪽에서 등장하면 좀 당황스러울 것 같지 않나?
뭐 머리로만 생각한 거라서 실제 구현해보면 조금 다를 수도 있다.
아무튼 pop이 대응 안 된다고 생각했는데, 그러면 어떻게 할 수 있을까?
export type TransitionState = 'current' | 'animating';
export type TransitionDirection = 'left' | 'right';
export interface Activity {
  key: string;
  element: React.ReactNode;
  transition: TransitionState;
  direction: TransitionDirection;
}
먼저 Activity가 상태를 갖도록 설정했다.
저렇게 타입을 선언하기까지 많은 우여곡절이 있었지만... 결론만 말하면 저렇다.
TransitionState는 사용자에게 보여지는 페이지를 current로, 움직이거나 움직인 페이지를 animating으로 정했다.
음... TMI로 우여곡절을 좀 설명하면, push될 때, pop될 때 상태를 따로 두다가 굳이 분리해야 되나 싶어서 하나로 합쳤다.
하지만 이미 움직인 상태와 움직이고 있는 상태가 모두 animating이라는 상태로 관리되는 게 이상하다고 생각했다.
animating이라고 하면 움직이고 있는 상태라고 생각이 들기 때문이다.
exited라는 상태를 둬서 구분을 해볼까 했지만, 움직인 이후에 상태를 바꿔줘야 하는 로직을 추가해야 하기 때문에 오히려 로직이 복잡해질 것 같아서 제외했다.
const push = (activity: Omit<Activity, 'transition' | 'direction'>) => {
  const newActivity: Activity = {
    ...activity,
    transition: 'animating',
    direction: 'right',
  };
  setStack((prev) => [...prev, newActivity]);
  requestAnimationFrame(() => {
    setStack((prev) =>
             prev.map((item, i) =>
                      i === prev.length - 1
                      ? { ...item, transition: 'current' }
                      : item.transition === 'current'
                      ? { ...item, transition: 'animating', direction: 'left' }
                      : item
                     )
            );
  });
};
const pop = () => {
  setStack((prev) => {
    const newStack = [...prev];
    const current = newStack[newStack.length - 1];
    const prevPage = newStack[newStack.length - 2];
    if (current) {
      current.transition = 'animating';
      current.direction = 'right';
    }
    if (prevPage) {
      prevPage.transition = 'animating';
      prevPage.direction = 'left';
    }
    return newStack;
  });
  requestAnimationFrame(() => {
    setStack((prev) =>
             prev.map((item, i) =>
                      i === prev.length - 2 && item.transition === 'animating' ? { ...item, transition: 'current' } : item
                     )
            );
  });
  setTimeout(() => {
    setStack((prev) => prev.slice(0, -1));
  }, 300);
};
const init = (activities: Omit<Activity, 'transition' | 'direction'>[]) => {
  if (activities.length === 0) {
    setStack([]);
    return;
  }
  const newStack = activities.map((activity, i) => {
    const isLast = i === activities.length - 1;
    return {
      ...activity,
      transition: isLast ? ('current' as TransitionState) : ('animating' as TransitionState),
      direction: isLast ? ('right' as TransitionDirection) : ('left' as TransitionDirection),
    };
  });
  setStack(newStack);
};
Provider 쪽의 코드도 바꿔줬다.
중요하게 볼 점은 push, pop이다.
공통적으로 requestAnimationFrame을 사용한다.
사용하는 이유는
setStack((prev) => [...prev, newActivity]);
setStack((prev) =>
         prev.map((item, i) =>
                  i === prev.length - 1
                  ? { ...item, transition: 'current' }
                  : item.transition === 'current'
                  ? { ...item, transition: 'animating', direction: 'left' }
                  : item
                 ));
만약 requestAnimationFrame 없이 위와 같이 사용된다면 리액트의 Batching으로 마지막 상태만 화면에 반영된다.
상태가 동적으로 변함에 따라 애니메이션이 구현되어야 하는데, 상태가 바뀌지 않아서 원하는 대로 동작하지 않게 된다.
requestAnimationFrame은 브라우저가 다음 프레임을 그리기 직전에 콜백을 실행하기 때문에, 애니메이션 시작 시점을 명확하게 분리할 수 있다.
추가로 pop에서는 나간 페이지에 애니메이션을 보여준 뒤 스택에서 없애기 위해 setTimeout을 활용했다.
import { useStackStateContext } from '@/hooks';
import clsx from 'clsx';
export const StackRenderer = () => {
  const { stack } = useStackStateContext();
  return (
    <div className="w-full min-h-screen relative overflow-hidden">
      {stack.map((activity, i) => {
        const { transition, direction } = activity;
        const translateClass =
          transition === 'animating'
            ? direction === 'right'
              ? 'translate-x-full'
              : '-translate-x-full'
            : 'translate-x-0';
        return (
          <div
            key={activity.key}
            className={clsx(
              'absolute top-0 left-0 w-full h-full',
              'transition-transform duration-300 ease-in-out',
              translateClass,
              transition === 'animating' ? 'pointer-events-none' : 'pointer-events-auto',
              `z-[${i}]`
            )}
          >
            {activity.element}
          </div>
        );
      })}
    </div>
  );
};
Renderer에서 현재 상태에 따른 translateX 값을 동적으로 넣어 애니메이션 효과가 보이도록 했다.

적용 결과를 보면 원하는 대로 앱처럼 넘어가는 웹 화면이 구현됐다.

추가적으로 해결해야 하는 문제들이 있다.
새로고침 시에 현재 페이지를 유지하게 한다거나(로컬 스토리지에 저장하면 될듯?!), 뒤로 가기에 반응 한다거나(push, pop 시에 popstate를 넣어주면 될듯?!) 등.
그리고 현재는 getAcitivity 함수 내부에 스택에 보여질 페이지들이 명시적으로 적혀있지만, 훅으로 빼서 사용하는 쪽에서 추가하도록 개선할 수도 있을 것 같다.

이 내용들을 전에 담고 결로 회고를 쓰려고 했지만, 길어지니 힘도 빠지고 주제에 벗어나는 것 같아서 그건 그냥 내가 따로 해보기로 했다.
아무튼 기존 라이브러리에서 아이디어를 얻어서 생각나는 대로 구현해보니, 구현 과정에서 생각과 다른 점들도 많고, 미처 고려하지 못한 상황들도 나왔지만 구현하는 과정이 재밌었다.
저도 겪은 고민들이라 많이 공감하면거 읽었어요! stackflow도 잘 만들어졌지만 react router를 대체하고 웹에서도 제공되는 페이지들이라 도입하기엔 많은 고민이 따르더라고요.. 직접 하나하나 구현하신 게 대단하시네요! 써주신 글 참고해서 저도 해봐야겠네요 글 잘 읽었습니다 :)
문제를 해결하기위해 아이디어를 찾고 결국엔 해결하는 과정까지...글이 쭉쭉 잘 읽혔어요.
모바일 페이지가 앱과 같은 자연스러움이 더욱 UX를 좋게 한다는걸 다시금 깨닿고갑니다 !
작은 부분까지 신경써서 이걸 반영하신게 너무 멋지네요! stackflow를 보면서 아이디어를 얻고 이를 바탕으로 프로젝트 환경에 맞는 해결법을 찾아나가신 과정이 흥미로웠습니다 👍🏻
새로고침이나 뒤로가기 처리도 고민하고 계신데, 이런 세세한 UX 고려가 좋은 서비스를 만들수있는데 .. 매번 바쁘다는 핑계로 대충하고 넘어갔던 제 자신을 반성합니다. 감사합니다 잘 읽었습니다 :)
웹 뷰로 구현할 때 항상 자연스러운 애니메이션을 구현하는게 어려운 작업인 것 같습니다ㅜ 특히 RN이 아니라 React로 개발하면서 직접 low level부터 구현해나가는 과정이 인상 깊네요 bb
기승전결로 나눠주셔서 이해하기 너무 좋았습니다.
라우터 구현하신 것도 잘 추상화 하신거 같아요