가수면

라이브러리 오류 본문

일지

라이브러리 오류

니비앙 2023. 4. 21. 10:25

넷플릭스 클론코딩 도중 슬라이드를 똑같이 구현하는데 생각보다 많은 시간을 보내고 있다.

잦은 코드 변경이 있었는데, 그 과정에서 꽤 유익한 공부를 하게 되어 간만의 일지를 적는다.

문제 발생

opacity 조작과 관련해 슬라이드의 여러 기능이 얽혀 어려움을 겪고 있는데, 구현하려는 주요 기능은 다음과 같다.

A = '슬라이드 이동 화살표'
B = '슬라이드 페이지네이션 인디케이터'

1. 초기엔 A와 B가 보이지 말아야 한다.

2. 슬라이드에 마우스가 호버되면 A와 B가 나타나야 한다.

3. 슬라이드 아이템에 호버하고 0.5초 뒤 scale이 커지면 A와 B가 다시 사라져야 한다.

4. scale이 커지기 전까진 슬라이드 내에서 마우스를 움직여도 A와 B가 사라져선 안 된다.

5. 슬라이드 '이동 버튼'에 호버하면 A와 B가 나타난 상태에서 A가 커져야 한다.

6. 슬라이드의 양끝 아이템은 호버 시 scale이 커질 때 transform-origin이 적용되어야 한다.

어렵지 않아 보였다.

슬라이드와 슬라이드 아이템을 구별해야 해서(2 ~ 4번이 서로 맞물림) 이벤트 전파 부분이 조금 까다로웠을 뿐, 실제로 크게 어렵진 않았다.

 

나는 해당 기능을 framer motion라이브러리와 isHovered라는 상태값을 만들어 사용하는 방법으로 구현하였다.

 

근데 그렇게 구현하고 나니 엉뚱하게 6번에서 문제가 터져버렸다.

transform-origin이 두 번째 호버부터는 적용되지 않는 문제가 발생한 것이다.

 

한참의 삽질 끝에 알아낸 결과, 원인은 상태값 변경으로 인한 렌더링 때문이었다.

 

슬라이드 아이템에 마우스를 올리면 라이브러리에 의해 transform-origin을 설정한 방향으로 0.5초 뒤 scale이 커지지만, 거의 동시에 isHovered도 true로 바뀌며 렌더링이 발생하게 된다.

둘 다 0.5초대에서 벌어지는 일인데 transform-origin이 완료된 뒤 렌더링이 발생하면 다음부턴 transform-origin이 씹힌다.

실제로 scale이 커지는 속도를 늦춰보면 transform-origin 방향으로 scale이 커지는 도중 렌더링이 발생하면서 transform-origin이 초기화되어 원래 자리로 미끄러지는 것을 볼 수 있었다.

 

디버깅

먼저 나는라이브러리로 애니메이션을 조작하던 것을 isHovered 상태값 이용해 transform-origin과 scale을 키우는 로직으로 변경해보았다.

그러나 여전히 결과는 같았다.

 

상태값을 사용 못한다는 건 상당히 골치 아픈 문제였다.

리액트에서 상태값을 사용 못한다니?

이후 기능 구현에도 영향을 미친 너무도 큰 페널티였다. 상태값을 못 쓰니 추후 기능들도 비틀어가며 구현해야 했고, 결국 프로젝트의 전반적인 코드 퀄리티도 상당히 떨어지는 결과를 초래했다.

 

당시 나는 생각했다. 이건 리액트에 어울리지 않는 프로젝트라고.

이래서 프로젝트 별로 특징에 맞는 프레임워크와 라이브러리를 선택해서 사용하라는 거구나 확 와닿았다.

(후에 알았지만 사실 다른 이유 때문이었다.)

 

넷플릭스 공식 홈페이지는 대체 어떻게 구현한 건지 진짜 코드를 뜯어보고 싶을 지경이었다.

...근데 실제로 넷플릭스 공식 홈페이지에서도 비슷한 오류가 발생하고 있었다...

넷플릭스 공식 홈페이지의 모든 슬라이드 마지막 아이템에 저런 버그가 있다

 

어쨌든 판단한 문제는 결국 렌더링. 상태값을 사용을 못 하고나니 자연스럽게 떠오른 방법은 useRef였다.

 

1. DOM메소드로 테스트

로직을 바꿨다가 괜히 안되기라도 하면 작업 후 수정까지의 품이 너무 크고 번거로울 거라고 판단이 들었기에, 실제 DOM요소를 조작하지만 비슷한 방식으로 더 간단하게 기능을 구현할 수 있는 DOM메서드로 먼저 테스트해보기로 했다.

 

그리고 결과는 성공이었다.

transform-origin과 scale 모두 동작하고 기타 opacity 등 css도 정상적으로 작동하기 시작했다.

 

자주 사용될 기능이니만큼 사용하게 되면 리액트 특성상 퍼포먼스 저하 및 사이드 이펙트가 발생할 우려가 있었기에 나는 DOM메서드로 구현한 기능을 useRef로 대체하는 작업을 진행했다.

https://jhchoi1182.tistory.com/184

 

리액트에서 DOM API를 사용하면 안 되는 이유

리액트에서 DOM메서드를 사용하면 안 된다는 것은 쉽게 접할 수 있는 이야기다. 그렇다면 왜 리액트에서 직접 DOM을 조작하면 안 되는 것일까? 이것을 알기 위해선 먼저 리액트가 변경 사항을 어

jhchoi1182.tistory.com

 

2. 전역으로 관리되는 ref 만들기

DOM메서드 로직을 useRef로 수정한 뒤, 리팩토링을 진행하며 컴포넌트들이 나누어졌다.

 

그리고 그 과정에서 한 컴포넌트에서 관리되고 있던 ref가 형제 관계에 있는 요소들에게까지 가야하는 상황이 발생했기에 부모 컴포넌트에 ref를 설정해 props로 여기저기 뿌려주다보니 코드가 난잡해지기 시작했다.

 

난잡해진 코드를 보다가 문득 호기심이 생겼다.

ref도 전역 상태 관리 라이브러리로 관리가 가능한가?

 

궁금하면 해보는 성격이었기에 바로 돌입했다.

 

그리고 전역으로 관리하는 건 성공했다.

다만, 슬라이드 이동 버튼에 호버하면 A와 B의 opacity가 먹통이 되는 새로운 문제가 발생했다....(5번 기능)

3. css도 ref로 처리하기

// SlideMoveBtn.tsx

  &:hover .slider-hover {
    opacity: 1;
  }

ref 로직들을 주석 처리하면 위 코드가 제 기능을 하고 다시 살리면 먹히질 않는다.

도저히 이해할 수가 없는 문제...

 

당시 나는 슬라이드 아이템에 마우스를 올릴 땐 opacity 변경을 ref로(JavaScript로) 처리하고 있지만, 슬라이드 이동 버튼에 마우스를 올릴 땐 CSS에서 처리하고 있기 때문에 무언가 충돌이 일어난 것이 아닐까 생각했었다...

 

그렇다면 JavaScript로 조작할 것이냐 CSS로 조작할 것이냐를 통일하면 해결될 문제라 판단했다.

 

그리고 버튼의 :hover 속성을 ref를 사용해 조작하는 로직으로 변경하니 다시 제 기능하기 시작했다.

 

기능은 전부 다 정상 동작하고 모두가 행복한 결말이 되었지만 사실 잘 이해가 안 갔다.

javascript로 조작하는 거랑 css 조작하는 게 충돌이 난다고? 왜?

 

그리고 이때 나는 머릿속에 번개가 내려쳤다.

 

'아...이거 다시 상태값 쓴 거잖아!'

 

그렇다. 전역 상태 관리 라이브러리를 사용해 전역으로 관리되던 ref는 생각해보니 상.태.값이었다.

 

으으 그놈의 상태값 상태값!!!!!

그리고 추가로 궁금증이 생겼다.

 

그런데 이번엔 왜 잘 되는 걸까?

이전에 상태값을 쓸 땐 안 됐는데 이번엔 왜 되는 걸까?

(이때부터 느낌이 쎄했다... 나중에 생각해보니 이건 지금까지 벌어진 모든 일이 단순 상태값 때문에 발생한 문제가 아니라 라이브러리 오류 때문이라는 신호였다.)

4. 전역 css 변수 조작

 

굉장히 찝찝한데 더해 고작 css 하나 조작하는데 코드가 너무 난잡해진 것이 매우 불편했다.

그렇다고 ref를 전역 관리가 아닌 다시 제 기능되던 상태로(props로 여기저기 뿌려주고 props드릴링 시키는 로직) 롤백하자니 코드를 보고 있으면 매우 불편했다.

생각해 보면 애니메이션이랑 충돌되는 &:hover 하나 기능시키려고 ref로 전역 상태까지 가서 요소 태그에 덕지덕지 붙고 조건문에 뭐에...마음에 안 들었다.

 

이렇게까지 할 일인가? 그냥 호버될 때 opacity만 조작하면 되는데.

 

분명 더 좋은 방법이 있을 것이다. 그렇게 해서 사용하게 된 방법이 전역 css 조작이었다.

 

ref로 관리하던 A와 B의 opacity를 opacity 자체를 변수로 설정하는 로직으로 바꾸어 조작해 보기로 했다.

그렇게 되면 참조하고 있는 변수가 변경됨에 따라 A와 B의 opacity도 바뀔 것이다.

DOM API를 사용하지만 실제 돔 요소를 변경하는 것이 아닌 스타일을 변경하는 것이므로 실제DOM과 가상DOM 간의 불일치 문제가 생기진 않을 것이라는 판단이 들었다.

(하지만 이것 역시 css 속성을 수정함으로써 reflow, repaint 과정이 발생하는, 가상 dom을 이용하는 것이 아닌 실제 dom을 업데이트하는 방법이었다. 이 과정을 통해 브라우저 렌더링 과정에 대해 좀 더 깊게 공부할 수 있었다.)

:root {
  --slideHoverOpacity: 0
}

.slide-hover {
  opacity: var(--slideHoverOpacity);
}
export const useButtonOpacity = () => {
  let opacitySetTimeout: ReturnType<typeof setTimeout>;

  const setButtonOpacity = (opacity: number) => {
    document.documentElement.style.setProperty("--slideHoverOpacity", `${opacity}`);
  };

  const setButtonOpacityAfterDelay = (opacity: number) => {
    opacitySetTimeout = setTimeout(() => {
      setButtonOpacity(opacity);
    }, 500);
  };

  const setButtonOpacityAfterDelayInvalidation = () => {
    clearTimeout(opacitySetTimeout);
  };

  return { setButtonOpacity, setButtonOpacityAfterDelay, setButtonOpacityAfterDelayInvalidation };
};

위처럼 전역 css 변수를 설정해 주고, 훅을 생성한 뒤 import해서 사용했음에도 전체적으로 코드가 80줄 정도 줄어들었다.

가독성은 말할 것도 없다.

 

툴팁 기능까지 추가해 보았는데 넷플릭스 공식홈페이지와 다르게 scale, transform-origin, 슬라이드 이동, opacity 전부 잘 작동된다.

추가 디버깅

기능은 만들었으나 찜찜한 점들이 남아있었다.

상태값이 어떤 건 되고 어떤 건 안 되고, 정말 이게 리액트의 문제일까?

검색을 해봐도 관련 글을 어디에서도 찾을 수 없었다.

 

그러다가 문득 리액트가 아닌 다른 라이브러리의 문제일 수도 있겠다는 생각에 가닿았다. 

생각해보니 라이브러리들을 너무 찰떡같이 믿고 있었던 것 아닌가!

 

그렇게 나는 문제를 일으켰던 framer motion의 애니메이션 로직들을 전부 점검해보기 시작했다.

 

그렇게 결국 마우스를 올렸을 때 transform-origin이 두 번째 이후부터 적용되지 않던 문제에 대한 원인을 알아낼 수 있었다.

 

역시나 라이브러리 자체의 문제였는데, 조건은 다음과 같다.
1. layoutId가 있는 요소에 transform-origin속성이 부여되어 있을 경우
2. 마우스가 해당 요소를 벗어났을 때 상태값에 변화가 일어나는 경우

 

위 두 가지 조건이 동시에 충족되면 transform-origin이 두 번째 호버부터는 먹히지 않는다.

transform-origin 속성에 !important를 부여하면 작동은 되지만 마우스가 빠져나갈 때 애니메이션이 부자연스럽게 동작한다. 심지어 마우스가 요소의 경계선에 가 멈췄을 땐 상태값의 변화가 무한히 일어나 요소가 커졌다 작아졌다를 반복한다.)

 

깃허브 이슈에 적을까 싶어서 찾아가봤는데 역시나 나같은 피해자들이 꽤 있었던 것 아닌가..

 

그렇게 찾게된 해결책은 이 경우 transform-origin 대신 라이브러리에서 제공하는 originX, originY 속성을 인라인 스타일로 지정하면 너무도 허무하게 문제를 해결할 수 있었다.

 

방식은 아래와 같다

  const originXY = () => {
    switch (index) {
      case 1:
        return 0;
      case 6:
        return 1;
      default:
        return "initial";
    }
  };
  
        <Item
        layoutId={value + ""}
        variants={contentVariants}
        whileHover="hover"
        initial="normal"
        style={{ originX: originXY() }}
        onMouseEnter={opacityHandler}
        onClick={() => setIsModal((prev) => !prev)}
      />

 

 

유익한 경험이었다.

여러 방식으로 문제를 접근해보았고, 그 과정에서 새로운 지식들도 알게 되었다.

라이브러리를 맹신하지 말 것, 좁은 시야를 벗어나 더 다양한 각도에서 살펴봐야한다는 점도 배웠다.

프로젝트 특성에 맞게 의사결정을 하는 것이 중요하다는 것도 잊지말자.

 

 

 

'일지' 카테고리의 다른 글

넷플릭스 클론 프로젝트 최적화 일지  (0) 2023.05.20
레이아웃 세로 너비  (0) 2023.05.05
useNavigate에 대한 고찰 (?)  (0) 2023.02.23
실전 프로젝트 회고  (0) 2023.02.13
쓰곰그리곰 프로젝트 최적화 작업  (0) 2023.02.11
Comments