타임라인 만들기

Sharlotte ·2022년 12월 30일
0
post-thumbnail

타임라인

타임라인은 여러 요소들을 시간의 흐름에 맞춰 보기에 적합하다. 이 포트폴리오에서 타임라인은 개발자로써의 일대기를 나열하는 데 사용되었다. 2년간의 일대기인 만큼 여러 포인트를 넣었는데,

  • 각 사건 사이의 시간을 체감시키기 - 더미 날짜 텍스트가 많을수록 스크롤 시간이 길어져 사용자에게로 하여금 시간의 길고 짦음을 체감시키게 해준다.
  • 다양한 컴포넌트로 UX 개선
    • 사이드 리모콘 - 사건 시점을 5개 단위로 보게 하여 빠른 이동을 가능케 해준다.
    • 자동 스크롤 - w/s, 화살표 위/아래, 마우스 휠, 화면 스와이프 등 다양한 인풋으로 스크롤이 다음 문단으로 알아서 넘어가게 만들었다. 시간 체감과 편의성을 동시에 잡아주는 핵심 기술이다.
    • 레퍼런스 컴포넌트 - 깃허브 유저/레포지토리 카드, 이미지 등 다양한 참조 컴포넌트들로 각 문단의 설명 또한 소홀히 관리하지 않게 한다.
    • 커스텀 서식화된 문서 - 한 리액트 컴포넌트 파일에서 모든 사건 문단을 작성하고 관리하는건 매우 번거롭고 DX를 바닥까지 내려가게 만드므로 개별 파일을 만든다.

스크롤 문단을 저장하는 방식

가장 처음에는 단순히 매 스크롤 시도를 할 때마다 스크롤 문단들을 querySelector를 통해 가져온 엘리먼트들로 때웠다. 당연히 문단들은 어디 가지도 않고 그 자리 그대로 있는 상수 배열이므로 이 방식은 불필요하며 나쁜 코드이다.

애초에 엘리먼트들을 담을 필요가 없다

문단들을 왜 저장해야 하는가? 사용처를 근본적으로 추적해보자면

  • 스크롤을 위한 offsetTop
  • 리모콘 표시를 위한 년월일

결국 이 2가지의 값만 쓰이는데 무거운 엘리먼트를 통째로 담을 필요는 하나도 없다. 고로 문단 아이템을 가져올 땐 아래 타입에 맞게만 가져오도록 바꾸었다.

게다가 아이템들을 매번 가져올 필요도 없다

앞서 말했듯이 어디 가지도 않고 그 자리 그대로 있는 상수 배열 이므로 상수 배열에 아이템 데이터를 초기화 값으로 담아두고 필요할 때 그 배열만 가져오면 된다.

따라서 위와 같이 클로저를 동원해 코드를 짰다.

잠시만요, 왜 굳이 배열을 lazy하게 초기화하죠?
일반적인 CSR 리액트나 바닐라 자바스크립트였다면, 즉 SSR이 아닌 환경이라면 document에 그대로 접근해도 별 탈이 없다. 하지만 이 포트폴리오가 사용하는 Next.js는 기본적으로 SSR이라 documentwindow를 비롯한 다양한 클라이언트 측 객체들이 없는 서버 환경이다. 그러므로 초기화 시점에서 접근하면 document is undefined와 같은 에러를 만날 것이다.
이것 때문에 getTimelineItems 함수는 입력에 의한 비동기 환경과 같이 렌더링 이후에서나 호출된다. 스크롤에만 getTimelineItems를 쓰는 타임라인과 달리 리모콘은 렌더링에 getTimelineItems를 쓰기 때문에 서버-클라이언트 간 렌더링 일치를 위해 어쩔 수 없이 클라이언트에서 렌더링되도록 dynamic import를 사용했다.

문제: 약한 새로고침이 아이템을 제대로 안가져온다


F5키를 누르면 생기는 문제다. 십중팔구 무조건 높은 확률로 CSR를 위한 dynamic import가 발목을 잡아

start와 end를 의미하는 top과 bottom 앵커만 배열에 담긴 것이다. 앞서 말했듯이 리모콘만 어쩔 수 없이 dynamic import되어야 하지 나머지들은 불필요하므로 모두 다 걷어준다.

역시나 불필요한 dynamic import를 다 걷어주니 정상적으로 작동한다.

문제: 아이템들에 저장되는 y가 시각적으롭 보이는 실제 문단 y와 다르다.

정상

비정상

4번째 문단부터 뭔가가 비틀어지기 시작했다.

가설: 미쳐 렌더링되지 못한 참조 컴포넌트의 크기때문에 차이가 난 것이다.

혹시나해서 GithubUserCard만 크기 계산을 뮈해 미리 카드 배경만 렌더링시키도록 고쳤더니 맙소사, 14670 - 14258 이던 오차값에 14302라는 새로운 값이 등장했다.

핵심은 이게 정상값인 14670에 더 가까워졌다는 것이다. 만약 RepoCard도 이렇게 고친다면?


긍정적인 변화다. 하지만 아직 부족하다.

보았는가? 카드 배경만 렌더링시켜도 정확한 크기를 얻을 수 없는 것이다. 이를 해결하기 위해선 고정적인 최대 높이가 필요하다. 하지만 이건 정말 좋지 않은 방식이다. 확장성을 완전히 폐쇄시키기 때문에 미래에 참조 이미지라도 넣는 순간엔 모든게 다시 망가질 것이다. 다른 방식으로 접근할 필요가 있다.

타협: 아이템 가져올 때마다 갱신해도 괜찮지 않을까?

애초에 처음 엘리먼트들을 저장하지 않고 재가공한 데이터들을 케싱한 이유가 무엇인가? 바로 매 인풋마다 엘리먼트들을 찾는 것 때문에 불필요한 중복 호출을 막기 위함이였다. 하지만 아래에서 말하겠지만 아이템을 가져올 스크롤 함수의 중복 호출은 이미 해결된 상태다. 이제 더이상 케싱으로 막을 필요가 없는 것이다.
게다가 굳이 이것이 아니더라도 갱신된 y 좌푯값들이 필요한게 핵심이기에 엘리먼트들은 케싱해주고 데이터들만 매번 다시 만들어내도 나쁘지 않은 성능을 뽐낼 것이다.

엘리먼트들은 케싱하고 데이터들만 매번 갱신하니 처음엔 dynamic import될 컴포넌트로 인해 y좌표들이 불완전했지만 컴포넌트들이 모두 불러와지고 나서 스크롤을 하자 금방 제자리로 돌아간 모습을 볼 수 있다.
심지어 불러와지는 중에도 y좌표들은 그때마다 갱신될테니 이제 시각적으로 스크롤이 맞지 않은 일은 없을 것이다.

스크롤 입력을 처리하는 방식

스크롤 인풋 헨들링

서론에서 언급한 자동 스크롤을 위해 각 인풋에 대응하는 이벤트 리스너와 헨들링을 모두 만들었다.

현재 렌더링에서 문제가 발생해 고치는 중인데 사이드 이펙트로 인해 SSR문제가 자꾸 터져서 CSR로 땜빵한 상태다. 인터페이스를 따로 만들까 생각도 했지만 높은 결합을 때기 위해 들일 노력에 비해 별로 안쓰일 것 같아서 그만뒀다.

이 컴포넌트에선 세 가지 역할을 하는데,

  • 스크롤 이벤트로 리모콘 컴포넌트에 이벤트 전달(확산)
  • 휠과 키다운 이벤트로 스크롤 헨들링
  • 타임라인 리모콘과 본문 렌더링

인데 방금 생각해보니 개선할 점들이 바로 보인다.

  • 스크롤 이벤트는 리모콘 컴포넌트 위에서 처리할 필요가 하나도 없다. 불필요한 ref 가 생길 뿐이다. 물론 브라우저에 따른 스크롤 간섭을 막기 위해 preventDefault는 여전히 필요하다.
  • tryScroll를 자식 컴포넌트에 props로 넘기는데, 이건 처음엔 tryScrollTimelineScroll의 상탯값과 함수에 의존적이라 어쩔 수 없이 넘기던 것이였다. 이제 그 의존할 대상이 index.ts로 넘어갔으므로 tryScroll도 같이 넘어가는 동시에 props로 넘겨받던 scrollindex.tstryScroll로 바꿔써야 한다.

스크롤 구현

가장 가까운 문단 얻기

자동 스크롤은 정확히 "스크롤 방향에 있는 가장 가까운 문단으로 스크롤" 하므로 가장 가까운 문단을 먼저 얻어야 한다.
.

가장 가까운 문단은 위 이미지의 현재 위치문단 위치 간 간격이 가장 짧은 문단이므로 문단들을 간격 순으로 정렬해서 가장 짧은 값만 가져오면 가장 가까운 문단을 얻을 수 있다.

하지만 이러면 불필요한 순회가 발생한다
가장 짧은 문단을 얻었음에도 불구하고 정렬을 위해 불필요하게 도달하는 문단들이 존재한다. 정렬되지 않고 난잡하게 섞인 배열일 경우 가장 낮은 값을 얻기 위해선 정렬이 필수불가결하지만 문단 데이터는 이미 0을 기준으로 한 거리 정렬이 이미 되어있다! 이 말은 for문으로 순회했을 때, 현재 위치에서 차잇값이 양수인(스크롤 방향에 있는) 문단 중 가장 처음 도달하는게 가장 가까운 문단이라는 말이 된다.

차잇값이 양수인 문단들은 스크롤 방향을 마주보고 있는 문단들을 의미한다. 다시 위 이미지를 보자면
아래로 스크롤할 땐 파란 영역의 문단 위치가 작은 것들을 거른다.
위로 스크롤할 땐 빨간 영역의 문단 위치가 큰 것들을 거른다. 또한 문단 순서를 거슬러 올라가는 것이므로 자연스래 문단 순회는 반대로 되어야 한다. 그러지 않을 경우 가장 먼 문단을 얻을 것이다..

방향에 문단이 더이상 없다면?
가령 가장 위에서 위로 스크롤하거나 가장 아래에서 아래로 스크롤할 경우엔 문단이 더이상 없으므로 현재 위치를 반환해야 할 것이다.

이제 이를 구현한 코드를 보자.

기본적으론 가장 처음 아이템부터 가져오지만 위로 스크롤할 땐 가장 마지막 아이템부터(문단 순서가 반대로 되어야 하니) 가져오게 만들었다. 위로 스크롤할 땐 문단 위치가 최초로 작은 것을, 아래로 스크롤할 땐 최초로 큰 것이 가장 가까운 문단이므로 그것을 반환한다.
만약 문단이 더이상 없다면 현재 위치를 반환하는 새 아이템으로 땜빵해서 반환한다.

중복 스크롤 요청 무시하기

만약 스크롤 도중에 다시 스크롤이 실행된다면 어떻게 될까? 정답은 중간에 끊기고 다시 시작된다. 이는 사용자 경험을 손상시키므로 스크롤이 도중에 끊기지 않도록 스크롤 중복 실행을 막아야 한다.
처음엔 단순히 특정 주기 내 중복 호출을 무시하는 debounce를 구현해서 사용했지만 이내 스크롤 시간이 불규칙적이란걸 깨닫고 스크롤이 끝나는 시점을 알아야 해결이 된다는 것을 알아냈다.
이를 위해선 스크롤이 끝날 때를 알아야 하는데, 이때 두가지 방법이 있다.

  • 스크롤의 속도와 거리를 계산해 시간을 얻어 debounce의 특정 주기를 동적으로 가지기
  • 스크롤을 비동기 객체인 Promise로 만들어 스크롤이 끝날 때를 논리적으로 정확히 알게 하기

첫번째 방법은 debounce의 주기를 동적으로 만들어야 하는 구조적 개선과 스크롤의 속도를 알아내야 하는 난이도가 큰 부담이라 두번째 방법을 선택했다.

비동기 스크롤

사실 이에 대한 필요성은 나만 갖고 있는게 아니였다.
https://stackoverflow.com/a/53247994
스택 오버플로우에는 이에 대한 질문과 답변이 이미 마련되어있었지만, 무려 2년 전의 답변으로 그 코드엔 deprecated된 속성이 많이 있었다. 그래서 현재 버전에 맞게 바꾸고 코드 개선과 타입스크립트 적용을 거쳐 아래와 같이 만들었다.

type smoothScrollOptionsType = {
  offset?: number;
  timeout?: number;
  target?: Element;
};

type smoothScrollType = (
  from?: number,
  options?: smoothScrollOptionsType
) => Promise<void>;

/**
 * smooth scroll which returns **Promise** object
 *
 * @param from - top value of element starts scrolling
 * @param options - scroll options
 * @property `offset` - y position offset
 * @property `timeout` - scroll timeout for rejecting promise
 * @property `target` - the element to scroll
 * @see https://stackoverflow.com/a/53247994
 * */
const smoothScroll: smoothScrollType = (
  from = 0,
  { offset = 0, timeout = 10000, target = window } = {}
) => {
  const targetPosition = from + offset;
  target.scrollTo({
    top: targetPosition,
    behavior: "smooth",
  });

  return new Promise((resolve, reject) => {
    if (
      (target instanceof Window ? target.scrollY : target.scrollTop) ===
      targetPosition
    ) {
      resolve();
      return;
    }

    const failed = setTimeout(() => {
      reject();
      target.removeEventListener("scroll", scrollHandler);
    }, timeout);

    const scrollHandler = () => {
      if (
        (target instanceof Window ? target.scrollY : target.scrollTop) !==
        targetPosition
      )
        return;
      target.removeEventListener("scroll", scrollHandler);
      clearTimeout(failed);
      resolve();
    };
    target.addEventListener("scroll", scrollHandler);
  });
};

export default smoothScroll;

단점은 가로 스크롤에 대한 고려를 일절 하지 않은 점이라 나중에 리팩토링을 할 필요가 있다.

이제 제대로 중복 스크롤 방지 구현하기


비동기 스크롤이 있으니 그 이후는 별달리 어려운 부분이 없었다. 굳이 클로저를 쓰지 않아도 되는데 갑자기 스코프 오염을 막고 싶어져서 써버렸다.

코드 클리닝

이제 어느정도 스크롤 구색을 갖췄으니 먼저 불필요한 부분들을 걷어내어 클린 코드를 만들고 리팩토링으로 최적화를 해야 한다.

마지막 문단 스크롤이 완료되지 않는다


window.scrollTop가 62426까지만 도달하고 62915까진 도달하지 않는데, 원인은
단순히 scrollHeight가 62426이기 때문에 스크롤 최대 높이를 넘은 62915까지 스크롤할 수 없기 때문이다. 즉, 이미 스크롤 끝에 도달해버렸으니 더이상 내릴 수 없다.

scrollWindow에서 스크롤 높이 - 창 높이를 최댓값으로 정해두어 해결했다.

Work In Progress

타임라인은 아직 완성되지 않았다.
완성될 때까지 글은 업데이트될 것이다.
현재 결과는 아래 배포된 사이트에서 볼 수 있다.
https://sharjects-76hzfot8a-sharlottes.vercel.app/timeline

profile
샤르르르

0개의 댓글