Skip to content

React 컴포넌트에 애니메이션을 적용해보자 🏃🏻💨

n-ryu edited this page Dec 18, 2022 · 27 revisions

현재까지의 상황

다이어그램 뷰 구현 상황

  • 사용자가 추가한 Todo들의 선후관계를 플로우차트 형태로 보여주는 다이어그램 뷰를 구 현했다.
  • 다이어그램 뷰의 X축은 전체적인 우선순위 순으로 정렬되고, Y축은 Todo간 선후관계의 위계를 나타낸다. (선후관계가 있다면, 나중에 해야하는 Todo의 Y 위치가 더 크다)
  • 토글 버튼을 통해서 다이어그램에 이미 완료된 Todo도 함께 표시할지, 아직 하지 않은 Todo들만 표시할지 결정할 수 있다.
  • 앞으로 다이어그램 뷰 내에서도 Todo를 생성, 수정하고, 선후관계도 즉석에서 추가, 제거가 가능한 기능을 추가할 예정이다.

🤔하지만 뭔가 부족한걸...?

  • 다이어그램 뷰에 표시되는 Todo의 목록이 바뀔 때 어떤 변화가 일어나는 것인지 알기가 어렵다.
  • 지금은 이미 완료된 Todo 표기를 바꿀 때만 이런 트랜지션이 있지만, 다이어그램 뷰 내에서 추가, 삭제, 편집이 가능하다면 어떤 변화가 일어난 것인지 사용자에게 보여주어야 사용자가 자신의 입력 제대로 반영된 것인지 판단하기 쉽다.
  • 다이어그램을 구성하는 Todo Block과 선후관계 vertex들이 다이어그램에 추가되고 삭제될 때 애니메이션 있으면 사용자가 이해하기도 쉽고 미려한 UX를 제공할 수 있을 것이다!

다이어그램에 애니메이션을 넣어보자

일단 CSS transition 옵션부터 넣어보자

  • TodoBlock 컴포넌트와 TodoVertex 컴포넌트의 스타일에 transition: transform 1s를 추가했다.
  • 기본적으로 리액트에서 Array.map을 통해 생성된 컴포넌트들은 key값을 통해서 동일 컴포넌트인지 판별이 되므로, 표시 데이터 변화 전과 후 모두에 존재하는 컴포넌트들은 그 위치가 자연스럽게 트랜지션 애니메이션이 적용되는 것을 확인할 수 있다.
  • 하지만 기존에 없었던 컴포넌트가 생기거나 있었던 컴포넌트가 없어지는 경우, 그냥 추적할 객체 자체가 없었거나 사라지므로 애니메이션 없이 갑자기 Todo Block이나 Vertex가 사라지는 것을 확인할 수 있다.

생성되고 삭제되는 컴포넌트를 어떻게 추적할 수 있을까?

  • 현재 데이터 저장 방식의 문제

    State Structure without Animation

    • 기존에는 TodoList API에서 받아온 데이터를 다이어그램을 그리기 위한 데이터로 변환하고, 그 데이터를 직접 <Diagram> 컴포넌트에 상태로 보관했다.
    • 그리고 상태로 저장된 데이터를 .map()으로 JSX 컴포넌트로 변환해서 여러 다이어그램 요소들을 그려주게 된다.
    • 하지만 이렇게 데이터를 바로 상태로 보관을 하면, 새로운 요소가 추가되거나 삭제되는 것을 <Diagram> 컴포넌트는 전혀 모르므로, 생성이나 제거 등의 변화를 추적할 수 없다!
  • 변화를 고려한 데이터 저장 방식

    State Structure considering Animation

    • 요소의 추가, 삭제를 추적하려면 데이터를 바로 상태로 보관하는 것이 아니라, 어떤 데이터가 새로 생겼고, 없어졌는지를 판별하고, 판별 결과와 정보들을 묶고 그 결과의 합집합을 통째로 상태로 저장해 두어야 한다.
    • 이렇게 저장을 한 뒤, 새로 생겨나는 요소들은 생성 트랜지션(애니메이션)을, 없어지는 요소들은 제거 트랜지션(애니메이션)을 적용시켜주면 생성, 제거 애니메이션을 구현할 수 있다.
    • 변경 이전과 이후 데이터의 합집합을 변화상태(생성, 삭제, 불변)와 함께 보관하면 요소들의 변화 추적이 가능하므로, 애니메이션을 넣을 수 있을 것이다!

실제로 요소 상태 추적 로직을 구현해보자

  • 일단 기존 상태와 새로운 상태를 받아서 요소들의 변화를 추적해보자

      const mountData = [...incomingData].filter(([key]) => !currentData.has(key));
      const idleData = [...incomingData].filter(([key]) => currentData.has(key));
      const unmountData = [...currentData].filter(([key]) => !incomingData.has(key));
    • HashMap 구조로 인자로 입력되는 신규 데이터와 기존 데이터를 비교해서 생성되는 요소들, 유지되는 요소들, 제거되는 요소들을 우선 분류해보았다.
  • 새로운 변화가 있어도 이전의 변화도 알고는 있어야 한다

    • 위의 데이터 관리 로직 그림에서는 새로운 변화 (기존 데이터와 신규 데이터의 차이)만을 저장하한다. 하지만 트랜지션이 1초가 걸린다고 한다면 그 사이에 변화가 두 번, 세 번, 심지어 만 번도 일어날 수 있다는 문제가 있다!
    • 따라서 이전에 일어났던 변화도 기억을 해 두어야한다. 이전의 변화 상태값을 그대로 두고 사용하고, 신규 변화만을 이전 변화 상태값에 업데이트 하는 방식으로 구현하면 이전의 변화가 지워지지않고 유지되고, 같은 요소에 대해 변화 상태가 달라지면 이 역시도 갱신되게 된다.
      const resultTransitionData = new Map([...currentTransitionData]);
      idleData.forEach(([key, value]) => {
        const target = resultTransitionData.get(key);
        if (target === undefined) return;
        resultTransitionData.set(key, { ...target, props: value });
      });
    • 위의 코드처럼, 기존의 데이터를 우선 변화 상태를 얕은 복사를 하고, (idle=불변인 요소들에 대해서는 기존 값을 그대로 복사해주도록 했다.)
  • 언젠가 변화 상태가 스스로 변해야 한다

    • 요소가 mount=생성 변화상태이거나, unmount=제거 변화상태라면 입력 데이터의 변화가 없어도 언젠가 트랜지션이 완료되면 스스로 상태를 idle=불변으로 업데이트 하거나, 스스로를 전체 상태리스트에서 제거해야한다.
    • mount 요소의 경우, 일단 mount 상태를 적용해서 초기 style 값을 주고, setTimeout으로 직후에 바로 idle 상태를 적용해서 트랜지션이 일어나게 했다. (transition-duration이 style에 정의도어 있으므로, 생성 직후에 초기값을 주입하고, 바로 변화 목표값을 주입하면 duration대로 변화가 일어난다.)
        mountData.forEach(([key, value]) => {
          setTimeout(() => {
            setTransitionData((prev) => {
              const newState = new Map([...prev]);
              newState.set(key, { aniState: 'idle', props: value });
              return newState;
            });
          }, 0);
          resultTransitionData.set(key, { aniState: 'mount', props: value });
        });
    • unmount=제거 요소의 경우, unmount 상태를 적용해서 제거되는 목표 style을 적용시켜서 idle style에서 unmount style로 트랜지션이 발생하도록 했다. unmount 요소의 경우 트랜지션이 끝난 이후에는 전체 데이터 리스트에서 제거되어야 하므로, 트랜지션 시간 이후에 스스로 전체 목록에서 제거되도록 setTimeout을 설정해 주었다.
        unmountData.forEach(([key, value]) => {
          setTimeout(() => {
            setTransitionData((prev) => {
              const newState = new Map([...prev]);
              newState.delete(key);
              return newState;
            });
        }, duration);
        resultTransitionData.set(key, { aniState: 'unmount', props: value });
  • 변화 중인 요소가 또 변화한다면, 기존 timeout을 제거해야 한다

    • 만약 unmount 중인 데이터가 다시 입력되어서 mount가 된다면, 그냥 상태를 mount로 만드는 것으로는 불충분하다.

      앞선 unmount 업데이트 과정에서 일정 시간 후에 스스로를 전체 데이터 리스트에서 제거하도록 setTimeout을 설정해 두었으므로, mount가 된 이후에 갑자기 요소가 사라져버리게 된다!

    • 따라서 setTimeout을 설정할 때에, 해당 timeout을 데이터와 함께 저장해 두었다가, 다른 setTimeout을 신규 등록해야 하는 차례가 오면 clearTimeout으로 이전에 등록했던 timeout을 제거해주도록 코드를 추가했다.
        mountData.forEach(([key, value]) => {
          const target = resultTransitionData.get(key);
          if (target !== undefined) clearTimeout(target.timeout);
          const timeout = setTimeout(() => {
            setTransitionData((prev) => {
              const newState = new Map([...prev]);
              newState.set(key, { aniState: 'idle', props: value });
              return newState;
            });
          }, 0);
          resultTransitionData.set(key, { aniState: 'mount', props: value, timeout });
        });
        unmountData.forEach(([key, value]) => {
          const target = resultTransitionData.get(key);
          if (target !== undefined) clearTimeout(target.timeout);
          const timeout = setTimeout(() => {
            setTransitionData((prev) => {
              const newState = new Map([...prev]);
              newState.delete(key);
              return newState;
            });
        }, duration);
        resultTransitionData.set(key, { aniState: 'unmount', props: value, timeout });

🤗 구현한 결과를 한번 다이어그램 뷰에 적용해보자

  • 결과물을 다이어그램 뷰에 적용시켜 보았다. 생성, 제거 트랜지션은 단순하게 opacity값만을 조정했다.
  • 의도한 대로, Todo Block과 Vertex가 생성되고 추가할 때에 뿅 나타나는 것이 아니라 투명도가 변하면서 서서히 나타나고 서서히 없어지게 구현이 잘 되었다!

개선 또 개선!

  • Vertex가 생성 추가될 때, 연관된 Todo를 위치를 추적하도록 하자

    • 위의 영상을 잘 보면, Todo Vertex가 생성되거나 제거될 때에 연결된 Todo Block의 위치는 따라가지 않는 것을 확인할 수 있다.
    • 이는 Vertex의 시작점과 도착점이 당시의 Data(Transition Data가 아닌 순수 Data)를 기준으로만 계산되기 때문으로, 이 변화를 적용시켜 주려면, 한단계의 작업이 더 필요하다.
      • Vertex가 새로 생성되었을 때, mount 상태의 초기 시작점/도착점 위치는 이전 상태 Todo Block을 참조해서 계산되어야 한다. (Vertex가 새로 생겼으므로 스스로의 이전 위치는 존재하지 않는다.)
      • Vertex가 제거 될 때, unmount 상태의 초기 시작점/도착점 위치는 이후 상태 Todo Block을 참조해서 계산되어야 한다. (Vertex가 제거될 것이므로 스스로의 이후 위치 정보는 존재하지 않는다.)
    • Vertex가 자신만의 정보뿐만 아니라 Todo Block의 정보까지 참조하도록 코드를 수정하니, Vertex가 생성, 삭제 시에도 자신과 연관된 Todo Block을 잘 쫓아다니는 것을 확인할 수 있다!
    • 이후 다이어그램 뷰의 편집기능을 추가했을 때도, 별도의 코드 수정 없이 편집기능과도 잘 연동되어 트랜지션이 일어나는 것을 확인했다.
  • 가끔씩 애니메이션이 스킵되는 문제가 있다?

    • 아래의 첫번째 영상을 보면, 가끔씩 새 Todo Vertex가 생성 될 때에 애니메이션이 제대로 적용되지 않고 뿅하고 요소가 생성되는 것을 확인할 수 있다.

      • Vertex의 연결은 물론, opacity 또한 처음부터 1로 뚜렷하게 나타는 것으로 보아 아예 mount 변화상태가 적용되지 않고, 즉시 idle로 덮어씌워지는 것으로 보인다.
      • 팀원들과 함께 분석해본 결과, 렌더링 시간이 오래걸려서 상태 업데이트를 하는 setTimeout(,0)이 실제 렌더링이 일어나기 전에 호출된 것으로 보인다. 즉, 리액트가 mount 상태를 적용된 결과를 렌더링하는 도중에 eventLoop이 한바퀴 돌았고, 화면 표시 전에 setTimeout으로 인해 상태가 다시 idle로 덮어씌워졌다는 것이다
    • 이를 해결하기 위해서는 여러가지 접근법이 제안되었다.

      • onTransitionEnd 이벤트 이용하기 (unmount의 경우)
        • 컴포넌트에 직접 onTransitionEvent의 이벤트 핸들러를 달아서 다음 상태를 적용시켜주는 방법. onTransitionEvent가 끝난 트랜지션의 종류 또한 event 인자에 넣어서 넘겨주기 때문에 가장 정확한 방법일 듯 하다.
        • 게다가 변화 상태가 업데이트 될 때에 onTransitionEvent도 바꿔 달아주므로 앞서 있었던 clearTimeout과 같은 불편한 과정이 필요하지 않다.
        • 또한, transition 시간을 style 한군데에서만 조절할 수 있다는 것도 커다란 장점이다.
      • useEffect 이용하기 (mount의 경우)
        • 생성 이전에 값이 덮어씌워지므로, 생성 하자마자 값을 바꿔주려면 useEffect가 가장 적절해보인다.
        • 하지만 이런 방식의 사용이 가능한지, 리액트 렌더링 과정에서 useEffect의 호출시점이 실제 paint이후인지 명확하지 않아 추가적인 공부와 실험이 필요하다.
      • setTimeout의 duration 더 주기
        • 가장 무식하지만 수정이 가장 없기에 일단 채용한 방식
        • mount상태 이후에 idle상태로 업데이트 하는 일에 최소한의 대기시간을 주면 그 간격 이내에만 렌더링이 된다면 정상적으로 트랜지션이 일어난다.
        • 또, 반대로 말하면 정적 상태를 계산하는 렌더링 시간이 너무 오래걸리면 애니메이션을 스킵하므로, 성능이 안좋다면 애니메이션을 스킵하게 되어, 오히려 유저 경험상 좋을 수도 있다는 생각이 들었다.
        • 결과적으로는 이 방법을 채택해서 약 50ms정도의 최소 지연시간을 주었고, 아래의 두번째 영상처럼 트랜지션이 스킵되지 않게 되었다.

💊 비타500

📌 프로젝트

🐾 개발 일지

🥑 그룹활동

🌴 멘토링
🥕 데일리 스크럼
🍒 데일리 개인 회고
🐥 주간 회고
👯 발표 자료
Clone this wiki locally