[Trouble Shooting] 스크롤 방향에 따른 반응형 헤더

FeRo 페로·2022년 8월 20일
0

10% 부족함

지난 벨로그 클론 코딩에서 스크롤이 내려갈 땐 헤더가 올라가고, 스크롤이 올라갈 땐 헤더가 올라오는 것을 구현했다. 나는 이걸 스크롤 방향에 따른 반응형 헤더라고 불렀다. 검색을 해서 찾아보고 한 것은 아니었다. 반응형 헤더라고 검색했지만 흔히들 생각하는 그 '반응형'에 대한 내용들만 나오고, 정작 내가 원하는 검색 결과를 찾기 어려웠기 때문이다. 다만 기반이 되는 throttling과 debounce에 대한 예시 코드들은 많이 찾아봤다. 그래서 흔히들 삽질이라고 부르는 것도 많이 해서 완성했다. 벨로그 클론코딩

그렇게 완성한 헤더지만 100%만족은 못했다. 왜냐하면 그때는 스크롤이 내려가고 올라가는 이벤트에 따라서 헤더 전체가 들어가고 나왔다. 즉 스크롤을 살짝 했을 때 헤더도 살짝 움직이는 섬세한 움직임은 구현하지 못했다. 그래서 이번 최종 프로젝트에서는 그 부분마저 구현해 보고 싶었고 이번에도 우연히 헤더를 맡게 되어서 도전해 보게 되었다.

누적된 값을 생각하자!

  // scroll 이벤트 핸들러
  const handleScroll = useCallback(() => {
    const { scrollY } = window;
    setValueY((prev) => {
      if (prev < scrollY) {
        console.log("내려감");
      } else if (prev > scrollY) {
        console.log("올라감");
      }
      timer.current = null;
      return scrollY;
    });
  }, [timer.current]);

  // throttling 핸들러
  const handleThrottle = useCallback(() => {
    if (!timer.current) {
      console.log("호출됨!");
      // 자연스러운 헤더 움직임을 위한 50ms로 지연시간을 지정했음.
      return (timer.current = setTimeout(handleScroll, 50));
    }
  }, []);

  // 스크롤 이벤트 바인딩
  useEffect(() => {
    window.addEventListener("scroll", handleThrottle);
    return () => window.removeEventListener("scroll", handleThrottle);
  }, []);

위 코드는 스크롤 이벤트를 throttling으로 제어하고 throttling을 통해서 스크롤에 따른 화면 변화 함수를 제어했다. 콘솔로 '올라감', '내려감'이 출력되는 부분에 헤더의 마진 값을 변화시키는 로직을 추가하면 완성이다. 이전에 벨로그 클론 코딩에서 전체적인 로직을 만들어 봐서 그런지 이 부분까지는 정말 금방 완성했다.

하지만 섬세한 헤더의 움직임을 구현하는 것부터는 쉽지 않았다. 처음에는 완성을 했다고 생각을 했는데 '누적값'을 생각하지 못했다.

스크롤의 방향이 바뀌면 그 순간부터는 누적값이 적용되어야 한다. 누적된 값 안에서 스크롤을 올리면 헤더가 내려가고, 스크롤을 내리면 헤더가 올라가야 한다. 그리고 특정 범위 이상을 움직이면 고정된 값을 가지고, 다시 방향 전환이 있으면 누적값이 적용되어야 한다.

이 로직을 적용시킨 코드가 아래와 같다.

// 헤더의 height는 50px
  // scroll 이벤트 핸들러
  const handleScroll = () => {
    const { scrollY } = window;
    setValueY((prev) => {
      // 스크롤이 올라갈 때
      if (prev - scrollY > 0) {
        // gapY.current가 0이면 더 더하지 말기
        if (gapY.current >= 0) {
          timer.current = null;
          return scrollY;
        } else {
          gapY.current += Math.abs(prev - scrollY);
          // 초과했으면 바꿔주기
          if (gapY.current > 0) {
            gapY.current = 0;
          }
          timer.current = null;
          return scrollY;
        }
      }
      // 스크롤이 내려갈 때
      else if (prev - scrollY < 0) {
        // gapY.current가 -50이면 더 빼지 말기
        if (gapY.current <= -50) {
          timer.current = null;
          return scrollY;
        } else {
          gapY.current -= Math.abs(prev - scrollY);
          // 초과했으면 바꿔주기
          if (gapY.current < -50) {
            gapY.current = -50;
          }
          timer.current = null;
          return scrollY;
        }
      }
    });
  };

// styled-components
const HeaderWrapper = styled.div`
  background-color: white;
  /* margin-top: -50px; */
  position: fixed;
  z-index: 999;
  margin-top: ${(props) => {
    console.log(props.gapY);
    return `${props.gapY}px`;
  }};
`;

생각지도 못한 에러

이제 완성했다! 그래서 자랑도 했다. 같은 팀인 종현님 거정님께도 자랑하고 스터디를 함께하고 있는 희정님께도 보여드렸다. 그런데 희정님께 보여드리는 과정에서 신나서(?) 스크롤을 미친 듯이 왔다 갔다 했는데 헤더가 어느 순간부터 끼였다. 정말 말 그대로 헤더가 끼어서 움직이질 않았다. 심지어 handleScroll 함수와 그 함수를 호출하는 throttling 로직까지 전부 작동되지 않았다.

원인을 찾아보기 시작했다. 일단 생각한 경우의 수를 곱씹어 보자면 다음과 같다.

  1. 메모리 누수 ::: throttling이 setTimeout으로 관리가 되기 때문에 많은 요청에 따라 콜 스택에서 메모리 누수가 발생함.
  2. 내가 만든 handleScroll 함수의 로직적인 문제

lodash

  useEffect(() => {
    window.addEventListener("scroll", _.throttle(handleScroll, 50));
    return () =>
      window.removeEventListener("scroll", _.throttle(handleScroll, 50));
  }, []);

첫 번째 문제인지를 확인해보기 위해서 lodash로 간단히 리팩토링을 해보았다. lodash는 최적화가 잘된 여러 메소드를 제공해준다. 메모리의 문제라면 더 좋은 로직을 제공해주는 lodash의 throttling 함수를 통해서 개선을 할 수 있다고 생각했기 때문이다. 하지만 결과는 똑같았다.

그냥 시원하게 없애버리다.

여기서 두 번째로 생각이 넘어갔다. '메모리 문제가 아닌가?.. 내 로직의 문젠가?'하는 생각이 들었다. 그래서 아무리 로직에 console.log를 찍어보고 확인을 해보려고 했지만 특정한 곳에서 걸린다는 증거를 찾아내지 못했다.

그래서 다시금 첫 번째로 돌아와서 다음과 같은 생각을 했다.

50ms로 setTimeout을 주면 안 주는 것과 엄청난 성능 차이가 발생할까?

이런 생각을 하고 벨로그의 헤더를 inspect 해보아도 딱히 throttling을 사용한다는 생각은 들지 않았다. 그래서 나도 그냥 throttling 로직을 빼보았다.

그 결과, 엄청 잘 됐다. 심지어 -50~0값을 벗어난 스크롤이 발생되면 더 이상은 리플로우가 안되다 보니까 성능적으로도 문제가 없어보였다. '뭐지?.. 정말 메모리 누수가 콜 스택에서 발생을 한 건가?..'하는 생각이 들었다. 다른 방법이 있을 수 있지만, 이렇게 해결이 되니까 기계적으로 scroll 이벤트에 throttling을 사용하는 것을 다시 생각해 보게 되었다.

Throttling에 대한 고민

짧은 시간에 다량의 이벤트가 발생하는 것은 성능을 저하시키고 이에 따라서 우리는 이벤트를 throttle 하고 debounce 해야 한다. 하기 전과 비교해서 유의미한 수준에서의 성능 차이가 나거나 이벤트가 백엔드에 과도한 요청을 발생시킨다면 하는 것이 무조건 좋다. 하지만 이번처럼 50ms로 setTimeout을 해서 call stack에 부담을 준다면 오히려 하지 않는 편이 더 좋다는 생각을 하게 되었다.

결론

코드는 결국 기계처럼 코드를 적는 것이 아닌, 상황에 맞춰서 잘 작성해야 하는 것이다. 이번 계기를 통해서 양질의 경험을 축척을 한 것 같다. 이런 삽질이라면 몇 번이고 더 할 수 있을 거 같다. 오히려 이런 건 삽질이 아니라 공부라고 부르는 것이 더 맞겠다.

참고자료
Browser Javascript Stack size limit

profile
주먹펴고 일어서서 코딩해

0개의 댓글