타임라인은 여러 요소들을 시간의 흐름에 맞춰 보기에 적합하다. 이 포트폴리오에서 타임라인은 개발자로써의 일대기를 나열하는 데 사용되었다. 2년간의 일대기인 만큼 여러 포인트를 넣었는데,
가장 처음에는 단순히 매 스크롤 시도를 할 때마다 스크롤 문단들을 querySelector
를 통해 가져온 엘리먼트들로 때웠다. 당연히 문단들은 어디 가지도 않고 그 자리 그대로 있는 상수 배열이므로 이 방식은 불필요하며 나쁜 코드이다.
문단들을 왜 저장해야 하는가? 사용처를 근본적으로 추적해보자면
offsetTop
값년월일
값결국 이 2가지의 값만 쓰이는데 무거운 엘리먼트를 통째로 담을 필요는 하나도 없다. 고로 문단 아이템을 가져올 땐 아래 타입에 맞게만 가져오도록 바꾸었다.
앞서 말했듯이 어디 가지도 않고 그 자리 그대로 있는 상수 배열 이므로 상수 배열에 아이템 데이터를 초기화 값으로 담아두고 필요할 때 그 배열만 가져오면 된다.
따라서 위와 같이 클로저를 동원해 코드를 짰다.
잠시만요, 왜 굳이 배열을 lazy하게 초기화하죠?
일반적인 CSR 리액트나 바닐라 자바스크립트였다면, 즉 SSR이 아닌 환경이라면document
에 그대로 접근해도 별 탈이 없다. 하지만 이 포트폴리오가 사용하는 Next.js는 기본적으로 SSR이라document
나window
를 비롯한 다양한 클라이언트 측 객체들이 없는 서버 환경이다. 그러므로 초기화 시점에서 접근하면document is undefined
와 같은 에러를 만날 것이다.
이것 때문에getTimelineItems
함수는 입력에 의한 비동기 환경과 같이 렌더링 이후에서나 호출된다. 스크롤에만getTimelineItems
를 쓰는 타임라인과 달리 리모콘은 렌더링에getTimelineItems
를 쓰기 때문에 서버-클라이언트 간 렌더링 일치를 위해 어쩔 수 없이 클라이언트에서 렌더링되도록 dynamic import를 사용했다.
F5키를 누르면 생기는 문제다. 십중팔구 무조건 높은 확률로 CSR를 위한 dynamic import가 발목을 잡아
start와 end를 의미하는 top과 bottom 앵커만 배열에 담긴 것이다. 앞서 말했듯이 리모콘만 어쩔 수 없이 dynamic import되어야 하지 나머지들은 불필요하므로 모두 다 걷어준다.
역시나 불필요한 dynamic import를 다 걷어주니 정상적으로 작동한다.
정상
비정상
4번째 문단부터 뭔가가 비틀어지기 시작했다.
혹시나해서 GithubUserCard
만 크기 계산을 뮈해 미리 카드 배경만 렌더링시키도록 고쳤더니 맙소사, 14670 - 14258 이던 오차값에 14302라는 새로운 값이 등장했다.
핵심은 이게 정상값인 14670에 더 가까워졌다는 것이다. 만약 RepoCard도 이렇게 고친다면?
긍정적인 변화다. 하지만 아직 부족하다.
보았는가? 카드 배경만 렌더링시켜도 정확한 크기를 얻을 수 없는 것이다. 이를 해결하기 위해선 고정적인 최대 높이가 필요하다. 하지만 이건 정말 좋지 않은 방식이다. 확장성을 완전히 폐쇄시키기 때문에 미래에 참조 이미지라도 넣는 순간엔 모든게 다시 망가질 것이다. 다른 방식으로 접근할 필요가 있다.
애초에 처음 엘리먼트들을 저장하지 않고 재가공한 데이터들을 케싱한 이유가 무엇인가? 바로 매 인풋마다 엘리먼트들을 찾는 것 때문에 불필요한 중복 호출을 막기 위함이였다. 하지만 아래에서 말하겠지만 아이템을 가져올 스크롤 함수의 중복 호출은 이미 해결된 상태다. 이제 더이상 케싱으로 막을 필요가 없는 것이다.
게다가 굳이 이것이 아니더라도 갱신된 y 좌푯값들이 필요한게 핵심이기에 엘리먼트들은 케싱해주고 데이터들만 매번 다시 만들어내도 나쁘지 않은 성능을 뽐낼 것이다.
엘리먼트들은 케싱하고 데이터들만 매번 갱신하니 처음엔 dynamic import될 컴포넌트로 인해 y좌표들이 불완전했지만 컴포넌트들이 모두 불러와지고 나서 스크롤을 하자 금방 제자리로 돌아간 모습을 볼 수 있다.
심지어 불러와지는 중에도 y좌표들은 그때마다 갱신될테니 이제 시각적으로 스크롤이 맞지 않은 일은 없을 것이다.
서론에서 언급한 자동 스크롤
을 위해 각 인풋에 대응하는 이벤트 리스너와 헨들링을 모두 만들었다.
현재 렌더링에서 문제가 발생해 고치는 중인데 사이드 이펙트로 인해 SSR문제가 자꾸 터져서 CSR로 땜빵한 상태다. 인터페이스를 따로 만들까 생각도 했지만 높은 결합을 때기 위해 들일 노력에 비해 별로 안쓰일 것 같아서 그만뒀다.
이 컴포넌트에선 세 가지 역할을 하는데,
인데 방금 생각해보니 개선할 점들이 바로 보인다.
preventDefault
는 여전히 필요하다.tryScroll
를 자식 컴포넌트에 props로 넘기는데, 이건 처음엔 tryScroll
가 TimelineScroll
의 상탯값과 함수에 의존적이라 어쩔 수 없이 넘기던 것이였다. 이제 그 의존할 대상이 index.ts
로 넘어갔으므로 tryScroll
도 같이 넘어가는 동시에 props
로 넘겨받던 scroll
도 index.ts
의 tryScroll
로 바꿔써야 한다.자동 스크롤은 정확히 "스크롤 방향에 있는 가장 가까운 문단으로 스크롤" 하므로 가장 가까운 문단을 먼저 얻어야 한다.
.
가장 가까운 문단은 위 이미지의 현재 위치
와 문단 위치
간 간격이 가장 짧은 문단이므로 문단들을 간격 순으로 정렬해서 가장 짧은 값만 가져오면 가장 가까운 문단을 얻을 수 있다.
하지만 이러면 불필요한 순회가 발생한다
가장 짧은 문단을 얻었음에도 불구하고 정렬을 위해 불필요하게 도달하는 문단들이 존재한다. 정렬되지 않고 난잡하게 섞인 배열일 경우 가장 낮은 값을 얻기 위해선 정렬이 필수불가결하지만 문단 데이터는 이미 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
에서 스크롤 높이 - 창 높이
를 최댓값으로 정해두어 해결했다.
타임라인은 아직 완성되지 않았다.
완성될 때까지 글은 업데이트될 것이다.
현재 결과는 아래 배포된 사이트에서 볼 수 있다.
https://sharjects-76hzfot8a-sharlottes.vercel.app/timeline