portfolio를 배포하고 난 후 carousel slide에서 찾은 오류를 해결해 보자
carousel slide를 처음 구현할때는 react-slick을 통해 구현했다.
carousel을 반응형으로 만들기 위해서 contents를 담는 browser component의 넓이를 가져와 Image에 적용했다.
interface CarouselProps {
images: string[];
w: number;
}
function CarouselSlide({images, w}: CarouselProps) {
const [size, setSize] = useState({w: 0, h: 0})
const settings = {
arrows: false,
dots: true,
infinite: false,
slidesToShow: 1,
slidesToScroll: 1,
variableWidth: true,
}
return (
<Slider {...settings}>
{images.map((img, i) => (
<div
key={img}
style={{
width: w/3,
height: w/4,
}}
>
<Image
src={img}
alt={'image' + i}
width={w/3}
height={w/4}
/>
</div>
))}
</Slider>
)
}
처음 테스트와 배포 후 까지 예상 외의 동작은 보이지 않아서 다른 사람의 피드백을 받아보았다.
browser를 처음 켰을때 이미지가 느리게 로딩 되는 부분이 좀 거슬린다.
next의 Image 태그는 이미지 최적화를 위해 생각보다 많은 지원을 하고 있다.
이 중에서 lazy Loading을 기본값으로 지원하는데, 이로 인해 이미지가 viewport에 나타날 때 까지 기다리느라 처음 한번 등장할때 없다가 생겼던 것이다.
이를 해결하기 위해 나는 priority 옵션을 통해 lazy loading를 사용하지 않는 방법을 선택했다.
<>
...
<Image
src={img}
alt={'image' + i}
width={w/3}
height={w/4}
priority
/>
</>
lazy loading를 손보고 난 후 부터 slide의 이미지가 두 줄로 나타나기 시작했다.
priority를 넣어줘서 나타나는 문제인가 싶어 다시 빼봤지만 계속해서 두 줄로 나타났다. 설상가상 이미 배포까지 했기에 빠른 해결이 필요했다.
slick setting 값 수정해보기 => 실패
rows: 1, slidesPerRow: 1 옵션값 지정
image와 부모 div의 크기 문제 => 가장 유력해보였다.
다시 코드를 보니 어거지로 구현된게 눈에 밟혔다. 우선은 한 줄로만 만들어놓고 다시 가다듬어야 했기에 browser 넓이를 그대로 넣기로 했다.
<>
...
<Image
// ...
width={w}
height={w/2}
priority
/>
</>
이제 두 줄로 나타나는 현상은 해결했지만 이제 slide의 width는 100%로 고정되었다.
browser가 최대화 되었을 때 이미지의 높이가 너무 커서 컨텐츠의 제목조차 안보이게 됐다..
일부러 스크롤을 내리고 찍은게 이정도..
이를 해결 해보기 위해서 다음과 같은 일련의 과정을 거쳤다.
boundary 방식을 기반으로 발전시키는 것이 구현 가능성이 높아 보였기에 이를 적용해 보았다.
function App() {
const carouselRef = useRef<HTMLDivElement>(null)
const images = ['/images/image01.png', ...]
return (
<div ref={carouselRef} className='max-w-[1000px] w-full h-max'>
<CarouselSlide boundary={carouselRef.current} images={images}/>
</div>
)
}
interface CarouselProps {
boundary: HTMLDivElement;
images: string[];
w: number;
}
function CarouselSlide({boundary, images, w}: CarouselProps) {
const [size, setSize] = useState({w: 500, h: 300})
const carouselRect = boundary?.getBoundingClientRect()
useEffect(()=>{
if(boundary) {
const {width, height} = carouselRect
setSize({
w: width,
h: height,
})
}
},[carouselRect?.width, carouselRect?.height, w])
const settings = {
arrows: false,
dots: true,
infinite: false,
slidesToShow: 1,
slidesToScroll: 1,
variableWidth: true,
rows: 1,
slidesPerRow: 1,
}
return (
<Slider {...settings}>
{images.map((img, i) => (
<div
key={img}
style={{
width: size.w,
height: size.h,
}}
>
<Image
src={img}
alt={'image' + i}
width={size.w}
height={size.h}
/>
</div>
))}
</Slider>
)
}
원하는 크기는 얻었지만 버튼을 통해 browser component의 크기가 변하면 최초 1회 다른 이미지의 옆부분 일부가 같이 나타나는 현상이 발생했다.
원인은 크게 두 가지로 예상됐다.
두 가지 원인들이 귀결하는 부분은 getBoundingClientRect() 였다. 이를 해결하기 위해서 useEffect와 ref에 대해 처음부터 찾아보기로 했다.
useEffect life cycle, deps
react에는 life cycle이란게 있다. 대강 설명하자면 컴포넌트가 화면에 등장하고 사라지는 일련의 과정을 뜻하는데 내가 알던 대로 라면 useEffect는 컴포넌트가 mount 된 이후(dom과 ref가 업데이트 된 이후) 동작하며, deps가 존재할 경우 의존성 요소가 변화한 경우 내부 콜백함수를 작동 시킨다.
내가 생각했던 위의 설명대로라면, ref를 통해 얻은 DOMRect값이 변화하면 변화된 값이 size에 업데이트 되어야 했다.
ref
ref는 react에서 지향하는 선언적인 코드를 위해 dom 요소에 접근할 수 있는 객체이다. useRef 값의 변화는 다른 hook과 달리 컴포넌트를 업데이트 시키지 않는다. 이는 DOMRect의 값도 마찬가지로 보인다. ref의 크기값이 변화해야 DOMRect의 값도 변하기 때문에 ref만 고려하면 될 사항이다.
이미지의 크기가 내가 원하는대로 설정되지 않는 이유는 이 때문이 확실하다. 하지만 나는 이러한 ref의 최신값을 가져오기 위해서 browser component의 w값을 deps에 추가해 보완했다.
이를 토대로 생각해보니 w와 ref값의 업데이트 타이밍이 어긋나서 이런 애매한 결과가 나오지 않았을까 라는 예감이 들었다.
react에서 제공하는 life cycle diagram을 확인해보니 예감이 확실해졌다.
https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/
뭔가 있어보이게 영어로 들고왔다
두 버튼을 각각 세 번 클릭해 state.w와 ref.w의 값을 0에서 부터 3까지 증가시킨다고 가정해 보자 우리가 생각하기에는 두 값은 동시에 바뀌어야 하지만 코드 내에서는 아니다.
밑에 있는 그림에서의 숫자들은 렌더링 된 값을 나타낸다.
ref의 변화는 렌더링을 일으키지 않기 때문에 다른 요인에 의해서 컴포넌트가 업데이트 될 때 변화된 값을 확인할 수가 있다. 마치 ref 값의 변화가 한 번 밀려서 나타나듯 보이게 된다.
carousel의 이미지도 이렇게 한 cycle이 밀린채로 변화되어서 넓이가 부족해 다른 이미지의 일부가 부족한 넓이 만큼 보이게 된 것이다.
위의 사실을 토대로 보았을 때 두가지의 방법을 고려해 볼만하다
1. rect를 부모에서 추출한 다음 props로 내려보내기
2. getBoundingclientRect()외의 다른 방식으로 변화되는 값을 추출
첫 번째 방법은 props의 변화가 리렌더링을 일으킨다는 부분에서 기인한다. 아마 다음과 같은 코드가 완성될 것이다.
function App() {
const carouselRef = useRef<HTMLDivElement>(null);
const carouselRect = carouselRef.current?.getBoundingClientRect();
return (
// ...
{carouselRect && ( //1
<CarouserlSlide
images={images}
boundary={{ // 2
width: carouselRect.width,
height: carouselRect.height,
}}
/>
)}
)
}
function CarouserlSlide({images, boundary}) {
// ...
const { width, height } = boundary;
useEffect(()=>{ // 3
setSize({
w: width,
h: height,
});
},[width, height])
return ...
}
예상되는 부분까지 시나리오를 짜보면 다음과 같다.
하지만 근본적인 문제는 해결이 되지 않았다.
최초 mount 시에 Ref는 갱신되지 않는다.
ㄴ 이는 browser component가 켜졌을 때 1번의 논리연산자로 인해 이미지가 나타나지 않는다는 것을 의미한다.
그렇다면 size state까지 부모 컴포넌트로 올리고 최소 값을 주면 되지 않을까?
ㄴ 이 방법을 통해 구현한다면 내가 원하는 결과물을 얻을 수 있다. 하지만 이렇게 할 경우 carousel size를 조절하는 로직과 carousel 컴포넌트가 분리되기 때문에 가독성이 떨어진다 판단하여 다른 방법을 찾았다.
두 번째 방법을 위해서 getRoundingClientRect()을 대체할 만한 다른 web API를 찾아야한다. 나는 이 과정에서 resizeObserver를 발견했다.
resizeObserver의 사용법은 다음과 같다.
//1. callback 함수를 인자로 가지는 resizeObserver 인스턴스를 생성한다.
const observer = new ResizeObserver(observerCallback)
//2. callback 함수를 통해 감시 대상의 위치, 크기 값을 통해 처리할 기능을 구현한다.
function observerCallback(entries, observer) {
// entries: 감시 대상들을 가지는 배열
// observer: resizeObserver 자체에 대한 참조
for (const entry of entries) {
const contentBox = entry.contentBoxSize // content의 크기 (객체 배열)
const borderBox = entry.borderBoxSize // (content + padding + border)의 크기 (객체 배열)
const contentRect = entry.contentRect // 위의 BoxSize보다 많은 정보를 가지는 객체
const target = entry.target // 감시 대상에 대한 참조
}
}
entry의 contentBoxSize와 borderBoxSize 쓰기모드 가로 기준 세로 길이의 blockSize와 가로 길이의 inlineSize두 값이 존재한다.
contentRect의 경우 구 버전부터 지원되어왔지만 브라우저 호완성에 대한 주의가 필요하고, 앞으로의 버전에서는 더 이상 사용되지 않을 가능성이 있다고 MDN에서 안내하고 있다.
사용법을 토대로 구현한 코드는 다음과 같다.
function CarouserlSlide({images, boundary}) {
// ...
const [size, setSize] = useState({w: 500, h: 300}) // 1
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
const { width, height } = entry.contentRect;
setSize({ // 3
w: width,
h: height,
});
});
useEffect(() => { // 2
if (boundary) {
observer.observe(boundary);
return () => {
observer.unobserve(boundary);
};
}
}, [boundary]);
return ...
}
구현된 코드에 대해서 간략하게 살펴보면 다음과 같은 순서로 작동된다.
mount 시 boundary는 null 이기 때문에 observer는 실행되지 않고, size 기본값으로 slide가 렌더링 된다.
두 번째 렌더링 시에 boundary의 값이 divElement가 되기때문에 useEffect의 observe(boundary)가 실행된다.
observer의 callback 함수로 인해 state가 변경되고 리렌더링이 실행되며, Image의 크기가 변경된다.
resizeObserver는 리렌더링과 독립적으로 동작하는 web API이기 때문에 w를 통해 반복적으로 useEffect를 작동시킬 필요가 없다. 또한 observer의 callback에서 setSize를 통해 리렌더링을 일으켜 observer를 1회만 실행만 시키면 되기 때문에 deps에서 w를 제거했다.
위에서 소개한 문제들 때문에 3일동안 계속해서 리팩토링을 진행했다. 이렇게 글로 정리하다보니 처음에 제대로 손을 봤더라면 못해서 1시간 안에 해결할 수 있지 않았을까란 생각과 함께 3일을 쓴게 조금 아까워 졌다..
새롭게 알게 된것도 생겼고 겸사겸사 browser 최대화에서 돌아갈때 최근 크기로 돌아가는 등의 기능도 추가를 했으니(여기를 건드려서 꼬였다..) 만족스럽기는 하다. 사전 설계를 좀 잘하도록 추가적인 공부를 할 필요가 있는거 같다.
다음 포스트에서는 carousel 라이브러리에서 직접 구현으로 넘어간 이유가 그 과정에 대해서 정리해보겠다.