개발새발 로그

ScrollSpy를 구현하면서 - 합성 컴포넌트 패턴, Render Props 패턴 본문

React

ScrollSpy를 구현하면서 - 합성 컴포넌트 패턴, Render Props 패턴

이즈흐 2024. 11. 1. 20:20

📖들어가며..

ScrollSpy를 구현해봐라! 했을 때 감이 전혀 안잡혔었다.

구현해본 경험이 없는 UI기도 했고, 각 요소의 위치 값이나 ref를 활용하는 방법을 생각해내지 못했다.

그래서 오늘은 ScrollSpy기능을 구현하면서 기록해보려고 한다.

 

 

스크롤 이벤트로 ScrollSpy 구현하기

먼저 스크롤 이벤트로 ScrollSpy를 구현해보려고 한다.

ScrollSpy의 구현 조건을 나열하면서 기록하려고 한다.

 

1. 어느 지점에서 ScrollSpy가 변하게 할 것인가?

나는 1번 요소의 1/2 지점에 도달했을 때 2번 요소가 강조되게 하려고 한다.

스크롤 값으로 설명하자면 하나의 Section이 100의 height를 가진다고 할 때 

0 ~ 50 까지가 Section 1이 강조되는 구간이고,

50 ~ 150 까지가 Section 2가 강조되는 구간이다.

 

 

2. 각 요소의 위치 값과 크기 그리고 현재 스크롤 값은 어떻게 갖고 올 것인가?

그럼 이제 중요한 각 요소의 현재 위치 값height 값, 스크롤 값은 어떻게 가져와야 할까?

이 부분에서 나는 막혔었다.

특히 각 요소의 현재 위치 값과 height값을 어떻게 가져올까에 고민이 많이 됐다.

 

이를 위해서는 getBoundingClientRect()를 이용하면 됐다.

먼저 리스트에 나열된 각 요소를 getElementById(d.id)로 접근해서

getBoundingClientRect()로 top값과 height값을 가져와 Ref에 저장했다.

 

2.1 offsetTop 속성을 사용하지 않는 이유?

offsetTop은 요소의 가장 가까운 Parent 요소로 부터의 상대 좌표라서 범용적으로 사용하려면 top을 사용해야한다.

 

2.2 창을 resize했을 때의 처리는?

현재 useEffect에서 위 계산을 처리하고, 빈배열을 넣어서 초기 렌더링에만 계산해서 ref에 값을 저장하도록 했다.

하지만 resize됐을 때는 top값과 height 값이 변경될 수 있기 때문에 아래와 같이 구성했다.

3. 스크롤 이벤트가 일어날 때

그럼 위 조건들을 기반으로 스크롤 이벤트가 일어날 때 어떻게 계산하는지 보자

먼저 나는 Viewport의 Top 값을 계산해서 도출하는 방식은 Context API와 useContext를 사용했다.

해당 훅에 대한 설명은 생략하고, 그저 현재 뷰포트의 top 값이 몇인지를 계산해서 저장하는 것이다.

그리고 해당 값이 바뀔 때마다 ScrollSpy의 Nav가 현재 어떤 요소인지 계산하도록 했다.

setCurrentItem()은 아래와 같이 각 요소의 top값 중 현재 top값에 포함되는 값을 찾고, 이를 Nav에 반영하는 것이다.

이 때 Nav 부분의 height를 빼줘서 더 정확한 계산이 되도록 해야한다.

우리는 하나의 요소의 1/2 지점까지의 범위를 기준으로 한다고 했기 때문에 위처럼 계산했다.

쉽게말하면 2번 요소1번 요소의 1/2 이후 지점 부터 ~ 3번 요소의 1/2 이전 지점 이다.

하지만 정확히 말하자면 2번 요소의 height에서 1/2한 이전, 이후 지점 부터인 것이다.

 

그리고 현재 어떤 요소의 위치인지 Nav를 보면 바로 알 수 있도록 scrollIntoView를 통해 Nav가 스크롤에 따라 계속해서 현재 요소를 가운데에 위치하도록 했다.

 

4. Nav를 클릭했을 때

Nav에서 보고싶은 요소를 클릭하면 이동하는 로직인데 단순히 해당 Item의 top값을 받아와서 

window.scrollTo로 이동하도록 했다.

scrollIntoView를 사용해도 되는데 behavior: "smooth" 로 설정했을 때 예상한 위치로 이동하지않고, 중간에 멈추는 버그로 인해 scrollTo를 사용했습니다.

 

5. Nav에 데이터 나열과 Contents에 데이터 나열

기존에는 정적 데이터인 data.ts를 그저 props로 내려주고 내부에서 알아서 처리하는 방법을 했었는데 

이는 범용성과 재사용성이 낮다고 판단합성 컴포넌트 패턴으로 변경했습니다.

 

ScrollSpy 내부에서는 아래와 같이 처리하고 있습니다.

Nav는 위에서 계산한 itemRef를 이용해서 index로 표현한 버튼을 나열하고 있고,

Contents 부분은 children을 활용했습니다.

 

 


 

위 구현 과정에서 볼 수 있는 문제점

문제 1. contextAPI 사용으로 인해 전체가 불필요하게 렌더링됩니다.

  - 스크롤 이벤트가 일어날 때마다 값이 변경되고 렌더링됩니다.

문제 2. Ul과 Li라는 합성 컴포넌트 사용을 강제해야합니다.

  - 합성 컴포넌트 패턴을 사용했지만 children 내부의 데이터를 안전하게 접근하기 위해 컴포넌트 순서와 사용이 강제적입니다.

문제 3. 여전히 네비게이션은 인덱스로만 가능한 문제

  - Nav를 표현하는 방식이 ScrollSpy 내부에서 처리되기 때문에 범용성과 재사용성이 낮습니다.

 


IntersectionObserver를 활용한 방법

문제 1. contextAPI 사용으로 인해 전체가 불필요하게 렌더링됩니다.

를 해결하기 위한 방법으로는 IntersectionObserver가 있습니다.

 

리스트 각 요소를 모두 감시해서 감지가 될 때마다 계산 후에 현재 요소를 Nav에서 강조하는 것입니다.

물론 스크롤이 빠르게 된다면 감지가 자주 일어나 스크롤 이벤트와 차이가 없을 수도 있지만 스크롤 이벤트보다 부담이 적을 것이라 판단했습니다.

 

 

기존에 스크롤 이벤트를 활용한 방법에서 단지 각 요소의 top값을 계산하는 방법을 바꾸면 됩니다.

저는 위와 같은 useIntersectionObserver 커스텀 훅을 만들어 사용했습니다.

간단하게 설명하면 entries 배열안에는 현재 감지된 데이터들이 담기게 되는 것입니다.

그리고 아래와 같이 useEffect 내부에서 각 감지된 요소의 top값을 도출하고, 현재 뷰포트의 top에 가장 근접한 요소를 강조하게 했습니다.

이때 entries에서 감지되었을 때 의 top 값을 사용하지 않고, 

관찰이 되어 entries의 값이 추가되거나 제거, 수정 됐을 때의 시점을 기준으로 다시 계산하도록 했습니다.

🛠️IntersectionObserver가 감시되는 시점과 요소의 Rect가 계산되는 시점

IntersectionObserver는 비동기적으로 동작하기 떄문에 관찰된 시점에 저장된 boundingClientRect 는 예측과는 다르게 됩니다.

Item1 (top: 50px)
Item2 (top: 150px)
Item3 (top: 250px)

위와 같은 상황일 때 스크롤을 내려서 IntersectionObserver로 관찰했을 때 아래와 같이 나올 가능성이 생깁니다.

Item1 : 저장된 top: 50px
Item2 : 저장된 top: 80px
Item3 : 저장된 top: 30px

 

그래서 새로운 요소가 관찰됐을 때 다시 모든 요소들의 위치를 새로 계산하는 로직을 추가했습니다.

 

 

결과적으로 Context API와 스크롤 이벤트를 사용하지 않아도 ScrollSpy를 구현할 수 있었습니다.

 


Title props와  Render Props 패턴을 활용한 방법

문제 3. 여전히 네비게이션은 인덱스로만 가능한 문제

를 해결하기 위해서 생각한 방법이 두가지였습니다.

 

먼저 첫 번째 방법은 Title Props라는 값을 줘서 아래와 같이 구성합니다.

저는 합성 컴포넌트 패턴을 사용하고 있기 때문에 합성 컴포넌트 중 하나에 props로 넘겨주게 했습니다.

그리고 위 데이터를 ScrollSpy 기능에서 접근해서 가져오도록 했습니다.

그러면 title props로 내려준 값을 ScrollSpy에서 접근이 가능해졌습니다.

이를 ref에 저장하고, 아래와 같이 map으로 뿌려줬습니다.

하지만 여전히 Nav 부분을 나열하기 위해서는 현재 나열되는 데이터의 id 값이나 연결되는 index값이 필요했고, 

위에서 구현하던 기존에 방식에서 단지 index로 출력되던 button이 title로 가능해진 것 밖에는 개선되는 점이 없었습니다.

리스트의 각 아이템의 정보를 담고있는 itemRef가 필요한 상태

사실 navRef에다가 모든 정보를 담아도 되긴하지만 

저는 이 Nav가 ScrollSpyComponent 내부에서만 나열돼야한다는 점이 불편했습니다.

 

그래서 이를 해결하기 위해 생각한 방법이 RenderProps 패턴이었습니다.

 

 Render Props 패턴

위 처럼 renderNav라는 값을 props로 받을 수 있도록 하고,

해당 값은 아래와 같이 출력하도록 했습니다.

defaultNavRender는 제외해도 되는 부분입니다.

그리고 ScrollSpy를 사용할 때는 아래와 같이 사용하면 됩니다.

그러면 확실히 ScrollSpy라는 기능을 사용할 때

Nav와 Contents 부분 모두를 사용자가 범용성있게 스타일링하거나 이벤트를 등록할 수 있게 됩니다.

 

단점이라면 위와 같은 틀을 강제해야한다는 점이 있습니다.

 

RenderProps로 내려주는 컴포넌트도 currentIndex, navRef,onNavClick이 모두 존재해야하고,

이를 등록하는 방식도 모두 위 코드와 같이 동일해야합니다.

 

하지만 이를 통해서 더욱 재사용성이 높아졌다는 점에서 저는 개선한 방식이 더 좋다고 판단하고 있습니다.

 

현재 제 코드에서 titleRef를 여전히 사용하고 있는데 이는 defaultNavRender 컴포넌트를 위해 넣게 되었습니다.

사실 제외해도 되는 부분이라 이를 제외하면 더욱 코드가 간결해집니다.

 

 

🚨RenderProps를사용할 때 주의할 점

1. 필수로 사용해야하는 파라미터들을 제외해도 에러가 나타나지 않습니다.

현재 RenderNavProps의 타입은 아래와 같이 만들었다.

보통은 위처럼 구성해서 사용하는 것이 대부분일 것이다.

근데 나는 ScrollSpy에서 위 3개의 파라미터를 모두 사용하게끔 강제해야한다.

근데 아래와 같이 customNavRender를 만들면 에러가 나타나지않고 컴파일 후에도 정상적으로 렌더링이된다.

다만 동작이 정상적으로 되지않는다 (navsRef로 각 아이템을 등록해야 함)

파라미터 중 navsRef를 제외한 모습, 하지만 타입 에러나 컴파일 에러 모두 나타나지 않는다.

이는 TypeScript의 구조적 타이핑(Structural Typing) 때문에 발생하는 현상이다.

TypeScript는 기본적으로 객체의 구조가 호환되면 할당을 허용한다.

즉, 더 적은 프로퍼티를 가진 객체도 필요한 프로퍼티만 있다면 허용된다.

 

해결방법 중 하나로 객체 비구조화 할당 대신 일반 함수 매개변수로 바꾸는 방법이 있다.

그럼 당연하게도 아래와 같이 에러가 발생할 것이다.

문제는 ScrollSpy 기능을 사용하는 개발자가 3개의 매개변수가 있음을 인지해야한다는 것이다.

 

나는 이 부분에서 어떤 방법이 더 나은 방법일까 고민됐다.

1. 객체로 타입을 구성하고 개발자가 해당 기능을 사용하기 쉽게한다.

2. 일반 함수 매개변수로 구성하고, 컴파일 단계에서 에러를 발견하도록 한다.

 

일단 나는 1번을 유지하기로 하고 아래와 같은 예외처리를 넣어줬다.

만약 사용해야할 매개변수를 사용하지 않으면 아래와 같이 브라우저에서 에러가 뜬다.

 

 

2. Render Props 내에서 생명 주기(Lifecycle) 함수를 사용할 수 없습니다.

이 부분은 꼭 기억해두자.

1. Render Props는 함수형 컴포넌트에서 사용되며, 생명 주기 함수는 클래스형 컴포넌트에서만 사용됩니다.
2. React Hooks를 통해 생명 주기와 상태 관리를 함수형 컴포넌트에서도 사용할 수 있지만, Render Props 패턴 자체는 이러한 생명 주기 관리가 필요하지 않도록 설계되었습니다.

 


마치며..

이렇게 ScrollSpy를 구현하면서 개선해보았습니다.

글을 작성하다보니 "어 불필요한 부분이 있네?" "이건 저렇게 개선하면 좋지않을까?" 한 부분이 계속해서 생겼습니다.

그러다보니 시간이 조금 지체되기도 했지만 이러한 기록이 나중에는 시간단축의 힘이 되지않을까 합니다.

 

합성 컴포넌트 패턴이나 RenderProps 패턴은 재사용성을 높이기 위해서 자주 사용할 것이라 생각됩니다.

해당 기능을 구현해보면서 배울 수 있게되어 좋았던 것 같습니다.

 

 

728x90
반응형
LIST