[React] 수동 가로 슬라이드 만들기

eonisal·2023년 11월 23일
3

🎈 서론

React + Express 로 개인 프로젝트를 진행하는 도중, 캐러셀 형태의 가로 슬라이드 영역을 만들 일이 있었다. 보통 캐러셀처럼 자동으로 내용이 돌아가며 로테이션 되는건 아니고 이전/다음 버튼을 사용자가 누르면 캐러셀 영역의 내용물이 왼쪽이나 오른쪽으로 스르륵 넘어가는 수동 가로 슬라이드 컴포넌트이다.

예를 들면 딱 인프런 사이트에 있는 이런 요소이다.

가로방향 슬라이드 기능 자체는 구현이 어렵진 않았는데, 내가 원하는건 화면의 너비가 달라져도 비율이 유지되는 반응형 슬라이드였다.

내가 구현하려 하는건 캐러셀 내의 내용이 한번 이동할때 딱 캐러셀 영역의 width 만큼 이동하는 형태이다. 즉 캐러셀 내에 컴포넌트가 5개가 있으면 이전/다음 버튼을 누를때마다 이 컴포넌트 5개에 해당하는 width 만큼을 이동해야 하는 것이다.

다음 버튼을 누르면 기존에 캐러셀 영역에 보이던 5개의 컴포넌트의 다음 순서인 컴포넌트 5개가 모두 보이도록 컴포넌트 5개만큼 오른쪽으로 이동하고, 이전 버튼을 누르면 컴포넌트 5개만큼 왼쪽으로 이동하는 형식이었다.

그런데 나는 이 캐러셀 영역을 min-width를 주고 width는 % 단위로 지정해두었다. 그래서 브라우저의 너비에 따라서 컴포넌트 5개에 해당하는 width, 즉 캐러셀 영역의 width도 변하기 때문에 사용자 화면의 크기에 따라 한번에 이동하는 양이 다 달라야 했다.

따라서 사용자의 화면 크기에 따른 반응형 캐러셀이 되기 위해선 한번에 이동시켜야하는 width의 값이 브라우저의 너비에 맞춰서 변해야 했기 때문에 이를 구현하는게 조금 까다로웠다.

그래서 이를 구현한 코드와 조금 더 효율적으로 개선한 코드를 정리하며 이 경험을 기록해보고자 한다 🙂

⛺️ 기본 전략

가로 슬라이드를 구현하는건 여러 방법이 있을 수 있겠지만 일단 내가 생각한 방법은 이렇다.

사용자가 화면상에서 보는 슬라이드 영역이 위 그림의 Carousel 영역이라고 하면, 사용자의 이전/다음 입력에 따라 왼쪽, 오른쪽으로 슬라이딩되는 내용물이 ImgWrapper 영역이다.

보여줘야할 컨텐트들을 일렬로 쭉 나열하고 이를 ImgWrapper 라는 영역으로 감싸준 뒤, 이 ImgWrapper를 Carousel 영역의 하위 요소로 넣는다. 그리고 Carousel 영역의 overflow를 hidden으로 지정하면 ImgWrapper 내의 이미지들은 Carousel 영역 내에 위치한 이미지들만 보이게 된다.

이 상태에서 ImgWrapper를 이동시켜주면 사용자는 Carousel 영역만 보이기 때문에 Carousel 영역 내에서 이미지들이 가로방향으로 이동하는것처럼 보이게 된다.

곧이어 소개하겠지만 내가 처음으로 구현한 로직은 ImgWrapper의 left를 조절하여 ImgWrapper 자체를 직접 움직이는 방식이었고, 리팩토링한 두번째 로직은 Carousel의 overflow를 auto로 하여 가로방향의 스크롤이 생기게하고 이 스크롤의 위치를 조정하여 Carousel 내에서 ImgWrapper를 움직이는 방식이었다.

아무튼 이 두 방식 모두 기본 골자는 화면상에 보이는 영역을 지정해두고, 이 영역 하위에 모든 컨텐트들을 다 나열한 영역을 배치한 뒤 이 영역을 움직이는 방식이라고 보면 되겠다.

🎯 ImgWrapper의 left 속성을 조절하는 방식

ImgWrapper의 left를 조절해서 ImgWrapper를 움직이는 방식이다. 사용자의 입력에 따라 ImgWrapper의 left 속성의 값을 증가시키거나 감소시키면 된다.

만약 이전/다음 입력 한번당 이미지 한개만큼 이동한다고 하면, 다음 입력이 들어오면 left의 값을 이미지의 width 만큼 증가시키면 되고 이전 입력이 들어오면 width 만큼 감소시키면 된다.

나는 클릭 한번당 컴포넌트 5개씩 이동하길 원했기 때문에 컴포넌트 5개의 width, 즉 캐러셀 영역의 width 만큼을 증감시켰다.

하지만 이부분이 문제였다. 앞서 말했듯이 캐러셀의 width가 브라우저의 너비에 따라서 변하는 상황. ImgWrapper의 이동거리를 "<캐러셀의 width>px" 라고 명확히 지정할 수가 없는 것이었다..

ImgWrapper의 이동거리가 그냥 고정값인 캐러셀의 width가 아닌 브라우저의 너비에맞게 변하는 캐러셀의 width가 되어야 하는 상황이 되어버렸다.

이를 구현하는 좋은 방법이 있는진 모르겠지만 일단 내가 생각한 방법은 window 객체의 innerWidth를 이용하는 것이었다.

어차피 캐러셀의 width가 최상위 div의 60%로 정해져있고, 이 최상위 div는 곧 브라우저의 크기이니 캐러셀의 width는 window.innerWidth의 60%인 셈이다. 따라서 캐러셀의 이동거리를 0.6 * window.innerWidth 로 지정해주면 브라우저 너비에 따라서 자동으로 이동거리가 적절하게 조정되지 않을까? 하는 생각이었다.
(하지만 왜인지 0.6을 곱하면 실제 캐러셀 컴포넌트의 width와 약간의 차이가 있었다. 그래서 가장 값이 비슷하게 나오는 배율을 최대한 찾아보니 0.594792 정도이길래 0.6이 아닌 0.594792를 곱했다. 근데 이마저도 화면 너비에따라 오차가 점점 더 커져서 완전히 똑같진 않음...)

이를 구현한 로직은 다음과 같다.

const [ postInfo, setPostInfo ] = useState<PostObject[][]>([]);
const [ leftOffSet, setLeftOffSet ] = useState(0);
const [ windowWidth, setWindowWidth ] = useState(0.594792 * window.innerWidth);

useEffect(() => {
  const getReviewsInfo = async () => {
    const response = await axios.get("http://localhost:3001/review/?page=1&perPage=20");
    // 한번에 컴포넌트 5개씩 이동하니 편의상 5개를 한묶음으로 총 4묶음이 있는 2차원 배열로 생성
    let postsWrapper: PostObject[][] = [];
    let posts: PostObject[] = [];
    response.data.thumbnailInfo.map((info: PostObject) => {
      posts.push(info);
      if (posts.length === 5) {
        postsWrapper.push(posts);
        posts = [];
      };
    });
    setPostInfo(postsWrapper);
  };

  getReviewsInfo();
}, []);

useEffect(() => {
  const handleResize = () => {
    setWindowWidth(0.594792 * window.innerWidth);
  };

  // 브라우저의 너비에 변할때마다 바로바로 windowWidth state의 값이 반영되도록 setWindowWidth를 window 객체의 resize 이벤트에 등록
  window.addEventListener('resize', handleResize);

  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

// 현재 캐러셀의 min-width가 800px 이므로 windowWidth가 800 이하면 채택 X
const componentWidth = Math.max(800, windowWidth);

const handlePrevClick = () => {
  if (leftOffSet >= 0) return;
  setLeftOffSet((prevOffset) => prevOffset + componentWidth);
};

const handleNextClick = () => {
  if (leftOffSet <= -(postInfo.length - 1) * componentWidth) return;
  setLeftOffSet((prevOffset) => prevOffset - componentWidth);
};
  
return (
  <LeftShiftButton className="" direction="left" state={leftOffSet >= 0 ? "disable" : "enable"} onClick={handlePrevClick} />
  <Carousel>
    <ImgWrapper multi={postInfo.length} leftOffSet={leftOffSet}>
      {postInfo.map((postWrapper, outerIdx) => (
        <PostWrapper key={outerIdx}>
          {postWrapper.map((post, innerIdx) => (
            <Link to={`/posts/detail/${post.reviewId}`} key={innerIdx} style={{width: '20%'}}>
              <Post className="" url={post.productImage} name={post.productName} grade={post.grade} />
            </Link>
          ))}
        </PostWrapper>
      ))}
    </ImgWrapper>
  </Carousel>
  <RightShiftButton className="" direction="right" state={leftOffSet <= -(postInfo.length - 1) * componentWidth ? "disable" : "enable"} onClick={handleNextClick} />
)


// 스타일
const Carousel = styled.div`
  overflow: hidden;
  width: 100%;
`

const ImgWrapper = styled.div<{ multi: number, leftOffSet: number }>`
  display: flex;
  width: ${props => props.multi * 100}%;
  position: relative;
  left: ${props => props.leftOffSet}px;
  transition: .35s;
`

const PostWrapper = styled.div`
  display: flex;
  width: 100%;
`

이렇게 해서 힘들게 구현을 했으나.. 뭔가 별로였다. 일단 사소한 문제점이 두가지 있었다.

  1. 캐러셀의 width로 준 최상위 div의 60%와, window.innerWidth의 60% 사이에 약간의 오차가 있음.
  2. 브라우저 첫 렌더링 시 캐러셀 내의 컴포넌트들이 한 점에서부터 주-욱 커져서 완전한 모습이 되는 듯한(?) 현상이 발생함.

1번은 브라우저의 너비에 따라 이동거리를 다르게 하려는 과정에서 생기는 현상이고 2번은 이동거리가 고정값인 상황에서도 그냥 이런 방식으로 하는 경우에 발생하는 현상이다.

1번은 코드 위에서 소개한 사항인데, 0.594792로 최대한 비슷하게 맞췄지만 내 맥북 화면 기준으로 맞춘거라서 브라우저가 이보다 더 크거나 작아질수록 오차도 다시 점점 커져서 한번 이동할때마다 컴포넌트들의 위치가 아주 조금씩 밀리거나 당겨지게 된다. 물론 아주 조금이고 최대 이동 횟수도 4번이라 티는 거의 안나서 상관없긴 하지만 거슬린다.

2번은 말로 설명하니 참 이상한데 페이지에 처음 들어가면 캐러셀 내에 컴포넌트 5개가 배치되어있는 모습이 바로 딱 나오는게 아니라 컴포넌트가 없었다가 0.3초 정도의 애니메이션으로 크기가 커져서 본래의 모습으로 배치가 되는 현상이 발생했다;; 왜그런지는 도저히 모르겠음

둘 다 사용하는데 지장은 딱히 없는 현상들이지만 UI 적으로 좀 거슬린다. 그리고 브라우저 크기가 변할때마다 그걸 감지해서 windowWidth 변수를 조정하는것 자체도 뭔가 딱히 깔끔한 방식은 아닌 거 같은 느낌이 들어서 개운하지 않았다. 코드를 좀 더 효율적이고 깔끔하게 고치고 싶었다.

그래서 뭔가 다른 방법이 있나 고민하다가, 말귀를 잘 못알아먹는 내 사수 GPT에게 내 상황과 코드를 열심히 정리해서 물어보았더니 window 객체의 scrollTo 메서드를 이용하여 캐러셀 영역에서 스크롤의 위치를 이동시키는 아이디어를 슬쩍 제안했다.

물론 답으로 준 코드는 좀 개판이었지만, 그거에 영감을 받고 캐러셀에서 ImgWrapper의 스크롤을 만들고 얘를 컨트롤하는 방식으로 하면 이동거리를 브라우저 너비에 따라 맞추는 짓거리를 안해도 알아서 비율이 맞춰지겠네! 라는 생각이 들어서 한번 구현해보았다.

♟️ Carousel의 스크롤을 제어하는 방식

Carousel 영역의 overflow를 auto로 하여 가로방향의 스크롤이 생기게하고 이 스크롤의 위치를 조정하여 ImgWrapper를 움직이는 방식이다.

const [ postInfo, setPostInfo ] = useState<PostObject[]>([]);
const [ scrollPosition, setScrollPosition ] = useState(0);
const carouselRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
  const getReviewsInfo = async () => {
    const response = await axios.get("http://localhost:3001/review/?page=1&perPage=20");
    setPostInfo(response.data.thumbnailInfo);
  };

  getReviewsInfo();
}, []);

// 캐러셀의 스크롤 위치를 지정하는 동작을 scrollPosition state가 변할때마다 
// 바로바로 실행되게 하기 위해 useEffect로 분리
useEffect(() => {
  if (carouselRef.current) {
    carouselRef.current.scrollTo({
      left: scrollPosition,
      behavior: "smooth"
    });
  }
}, [scrollPosition]); 

const handlePrevClick = () => {
  if (scrollPosition === 0) return;
  if (carouselRef.current) {
    // 현재 스크롤 위치에서 Carousel 컴포넌트의 길이만큼 감소
    const newScrollPosition = scrollPosition - carouselRef.current.clientWidth;
    setScrollPosition(newScrollPosition);
  };
};

const handleNextClick = () => {
  if (carouselRef.current) {
    if (scrollPosition >= carouselRef.current.clientWidth * 3) return;
    // 현재 스크롤 위치에서 Carousel 컴포넌트의 길이만큼 증가
    const newScrollPosition = scrollPosition + carouselRef.current.clientWidth;
    setScrollPosition(newScrollPosition);
  };
};

return (
  <LeftShiftButton className="" direction="left" state={scrollPosition === 0 ? "disable" : "enable"} onClick={handlePrevClick} />
  <Carousel ref={carouselRef}>
    {postInfo.map((post, index) => (
      <Link to={`/posts/detail/${post.reviewId}`} key={index} style={{ flex: "0 0 auto", width: "20%" }}>
        <Post className="" url={post.productImage} name={post.productName} grade={post.grade} />
      </Link>
    ))}
  </Carousel>
  <RightShiftButton 
    className="" 
    direction="right" 
    state={carouselRef.current ? scrollPosition >= carouselRef.current.scrollWidth - carouselRef.current.clientWidth ? "disable" : "enable" : "disable"}
    onClick={handleNextClick} 
  />
)

// 스타일
const Carousel = styled.div`
  display: flex;
  overflow: auto;
  width: 100%;
  scrollbar-width: thin;
  scrollbar-color: transparent transparent;
  &::-webkit-scrollbar {
    width: 6px;
  }
  &::-webkit-scrollbar-thumb {
    background-color: transparent;
  }
`

Carousel 컴포넌트에 ImgWrapper의 길이로 인해 생긴 가로 스크롤의 위치를 제어하고 scrollTo의 behavior 옵션을 통해 ImgWrapper에 transition을 주고 ImgWrapper를 직접 움직이는것처럼 애니메이션 효과를 주어 가로 슬라이드를 구현했다.

스크롤은 화면에서 안보이도록 스타일을 지정해준다.

이 방식으로 하니 브라우저의 너비에 따른 변화를 신경쓰지 않아도 되고 ImgWrapper의 이동도 캐러셀의 width만큼 스크롤을 이동시켜주기만 하면 되니까 이전의 방식보다 더 효율적이고 코드도 깔끔해진 것 같아서 조금 더 나은 로직이 된 거 같았다.

하지만 이 방식도 문제점이 하나 있는데, 화면 렌더링 후 브라우저 크기가 변하면 그 이후부터는 스크롤의 이동과 캐러셀 내에 컴포넌트들이 나오는 부분에 이상이 생긴다는 것이다.

화면이 렌더링 되고 중간에 브라우저의 크기가 변하지 않으면 화면의 너비가 얼마든 상관없이 정상적으로 동작이 된다. 하지만 최초 렌더링 후 화면의 너비가 변하면 슬라이드가 좀 어긋나진다. 새로고침을 하면 그 화면 크기에 맞게 다시 정상적으로 돌아온다.

아마 scrollWidth 와 carouselRef.current.clientWidth(캐러셀 영역의 width) 가 화면 크기가 변해도 바로 바뀌지 않아서 생기는 문제같은데 자세히는 잘 모르겠다.. 그렇다고 이들을 화면 너비를 감지해서 화면 너비에 따라 바로바로 값을 계산해서 갱신시키자니 이전 방식이랑 다를게 없어보이고...

보통은 실제 사용시 중간에 브라우저 크기를 변화시킬 일은 거의 없긴 하겠지만, 만약 변화시키는 경우에는 UI 적으로는 꽤 치명적일 수 있는 문제같다. 사용자에게 "홈 화면에서는 만약 중간에 브라우저 크기를 바꾸면 새로고침을 해야 가로 슬라이드가 정상적으로 작동합니다..ㅋ" 라고 대놓고 알려주기도 뭐하고.. 난감하다

😭 결국 최적의 답은 찾지 못한채..

결국 이 문제를 해결하지 못하고 시간 관계상 일단 넘어가야 할 거 같아서 이 두가지 방법중 하나를 택해야 할 거 같다. 좀 더 고민해보고 찾아보면 해결할 수 있을지 모르겠지만, 개인프로젝트라 가뜩이나 시간도 오래걸리는데 겨우 이 기능 하나에 계속 매달려있을 수도 없으니.. 그래도 구현은 했으니까 일단은 넘어가기로 함

요약하자면 첫번째 방식은 화면 크기의 변화에는 바로바로 적응하지만 아주 약간의 오차가 있고 최초 렌더링시에 보이는 이상한 애니메이션 현상이 있다. 그리고 화면 크기가 변할때마다 캐러셀의 width 값이 항상 재조정되면서 리렌더링 되기 때문에 효율성이 약간 의심되고 코드도 두번째 방식보다는 조금 난잡하다.

두번째 방식은 화면의 크기가 변하지만 않으면 화면 크기가 얼마든 정확하게 슬라이딩이 구현되고 프론트 단에서의 로직도 첫번째 방식보다는 비교적 단순해서 코드가 더 깔끔하고 효율적인 거 같다. 하지만 중간에 화면 크기가 변하는 경우에는 캐러셀 내의 UI가 어긋나서 새로고침을 해야 화면 크기에 맞춰 다시 적응하는 문제점이 있다.

둘 다 장단점이 있고 사실 성능, 효율성의 차이도 어떻게 보면 거기서 거기인거 같기도 하고 해서.. 무엇이 그나마 더 나은 방법일지는 사실 모르겠다. 하지만 일반적인 상황에서 사용하는 경우를 생각해봤을때는 두번째 방식이 그래도 더 나은 거 같아서 일단 두번째 방식을 택했다.

나중에 시간 나면 문제점을 해결하거나 다른 방식을 생각해보거나 해야할듯.

아무튼 원하는 최상의 결과는 얻지 못했지만 그래도 구현은 했으니.. 일단은 만족하고 넘어가야겠다. 이 수동 가로 슬라이드가 처음 예시로 소개한 인프런 사이트 말고도 알고보면 꽤 여러곳에서 자주 쓰이는 방식같은데, 두가지 방식으로 직접 구현해보며 삽질을 해보니 완벽히 해결은 못했지만 그래도 많은 공부가 되었다. ⛹️‍♂️

profile
언제까지_이렇게_살아야돼_

2개의 댓글

comment-user-thumbnail
2023년 12월 3일

문제 해결을 위해 끊임없이 생각하는 모습이 정말 멋집니다!

1개의 답글

관련 채용 정보