티스토리 뷰

 

 

 

 

내가 썼지만 무슨 말인지 잘 모르겠는 제목..!

말 그대로 특정 이미지를 클릭하면 클릭한 이미지를 바로 크게 보여주고, 위나 아래로 스크롤하면 이전 이미지와 다음 이미지를 계속해서 볼 수 있는 쪼끄맣지만 소중한 기능이다..!

 

 

이런 느낌

 

구현 결과

 

 

구현하게된 계기

지난 주 일요일, 정말 부랴부랴 실전 프로젝트의 프로덕션 배포를 했다.

런칭이 계획보다 훨씬 늦어지는 바람에 피드백을 적게 받으면 어떡하지 라는 걱정을 했었는데, 다행히 정말 많은 분들이 새로운 시선으로 자세히 봐주신 덕분에 UX 개선에 도움이 되는 피드백을 많이 받을 수 있었다!

그 중 짤방 페이지에 대한 피드백도 있었는데, 목록에서 어떤 이미지를 보려고 클릭해서 디테일 페이지로 이동한 후 또 다른 이미지를 보려면 다시 뒤로 가기를 눌러야 하는 과정이 불편하다는 피드백이 세 건이나 있었다. 디테일 페이지를 정말 디테일 페이지까지로만 생각해서 클릭한 이미지 한 개만 보여주도록 했는데, 피드백을 받은 후 다시 짤방 페이지를 이용해 보니 불편하다는 피드백에 백 번 공감이 갔다.

 

다른 이미지를 보려면 계속해서 왔다 갔다를 반복해야 한다..UX 최악..

 

가로 스크롤? 세로 스크롤?

짧게 고민했던 부분이다.

우리는 모두 페이스북이나 인스타그램에 익숙해져 있기 때문에 가로로 페이지네이션을 하면 마치 한 유저가 여러 개의 이미지를 올린 것처럼 보여질 수도 있을 것 같다는 생각이 들었다. 그래서 세로 스크롤을 적용하고, 마찬가지로 Intersection Observer API를 이용하여 무한 스크롤도 적용하기로 했다!

 

 


 

 

내가 생각한 구현 방법은 단순한 로직인데, useRef로 ref를 여러개 만들어서 페이지가 이동되자마자 유저가 클릭한 이미지의 ref로 스크롤을 이동시키는 거다. 구현하는 과정에서 두 가지 문제가 발생했는데, 1) ref를 어떻게 여러 개 생성해서 리스트로 만들지와 2) 맵핑하는 컴포넌트에 ref가 prop으로 넘겨지지 않는 문제였다. 지금 생각해보니 두 문제가 서로 관련이 있었던 것 같다.

 

 

1. useRef( )로 ref 여러개 생성해서 리스트로 만들기

 

ref를 생성해야 하는 이유는 스크롤 이동 이벤트를 발생시키기 위해서 DOM 노드에 접근해야 하기 때문이다.

ref를 로드해오는 이미지의 수만큼 생성해서 리스트로 만들어야 하는 이유는 이미지 리스트 중 유저가 클릭한 이미지의 인덱스를 찾아서 ref 리스트의 인덱스 번째 DOM 노드로 스크롤을 이동시켜야 하기 때문!

 

 

첫 번째 시도 - 실패 🙅🏻‍♀️

 

'useRef 리스트로 만드는 법' 이라고 검색하면 쉽게 찾아볼 수 있는 방법이다.

아래의 코드와 같이 useRef()의 초기값에 배열을 선언하고, current에도 빈 배열을 할당한 후 map이 돌 때마다 ref.current(배열)에 각각의 ref를 push 하는 방식이다. 이 방법을 적용하면 ref들을 담고 있는 리스트는 생성이 되지만, ref는 실제 DOM을 가르키고 있지 않고 초기 값인 null만 할당되어 있는 상태로 확인된다. 실제 DOM을 참조하지 못하는 이유를 추측해보면, ref를 prop으로 넘겨주려는 대상이 함수형 컴포넌트이기 때문이라는 생각이 들었다. 

 

OneDetailImageCard 컴포넌트의 코드를 합칠 수도 있었지만 굳이 분리해 놓은 컴포넌트를 다시 합칠 필요가 있을까 싶어 다른 방법을 모색해 보기로 했다!

 

// ImageDetailList.js

const imageRefs = useRef([])
imageRefs.current = []

const addToRefs = (el) => {
  imageRefs.current.push(el)
}

// return 부분
<Container>
  <InfinityScroll callNext={getImageList} paging={{ next: imageData.has_next }}>
    {imageData.image_list.map((image, i) => {
      return <OneDetailImageCard ref={addToRefs()} key={`image-detail-${image.boardId}`} image={image} />
    })}
  </InfinityScroll>
</Container>

 

ref가 정상적으로 DOM을 참조하고 있다면 26번째 라인처럼 current: div.~~ 로 출력되어야 한다.

 

 

두 번째 시도 - 성공! 🙆🏻‍♀️

 

두 번째 방법은 useRef 안에서 이미지 리스트와 map 메소드, createRef를 이용하여 ref들을 생성하는 것이다.

자식 컴포넌트에는 ref={imageRefs.current[i]} 의 형태로 넘겨준다.

결과적으로 성공했다!

 

// ImageDetailList.js

const imageRefs = useRef(imageData.image_list.map(() => createRef()))

//return
<Container>
  <InfinityScroll type="white" callNext={getImageList} paging={{ next: imageData.has_next }}>
    {imageData.image_list.map((image, i) => {
      return <OneDetailImageCard ref={imageRefs.current[i]} key={`image-detail-${image.boardId}`} image={image} />
    })}
  </InfinityScroll>
</Container>

 

 

 

2. 함수형 컴포넌트에 prop으로 ref 넘겨주기 - forwardRef()



위와 같이 만드는 과정에서 자식 컴포넌트에 ref가 prop으로 넘겨지지 않는 문제가 발생했다. 초기값인 null도, DOM 노드도 아닌 undefined를 참조하고 있었는데, 이는 기본적으로 리액트에서는 함수형 컴포넌트에 ref를 prop으로 넘길 수 없도록 하고 있기 때문이었다! 찾아보니 함수형 컴포넌트도 ref를 받을 수 있도록 리액트에서 forwardRef()라는 훅을 제공하고 있어 이를 적용하고 나서야 비로소 위와 같이 제대로 자식 컴포넌트의 DOM을 참조할 수 있게 되었다.

 

forwardRef() 훅을 적용하는 방법은 간단하다.

 

1) 아래와 같이 함수형 컴포넌트를 forwardRef()로 감싼 후, 두 번째 파라미터로 ref를 받는다.

2) 그리고 참조하려는 최상위 DOM 노드에 ref={ref} 를 작성해주면 함수형 컴포넌트에도 ref를 prop으로 넘겨줄 수 있게 된다!

 

// OneDetailImageCard.js

const OneDetailImageCard = forwardRef((props, ref) => {
  const { image } = props
  
  // ...

  return (
    <>
      <Container ref={ref}>
      {* ... *}
      </Container>
    </>
  )
})

 

 

3. 유저가 클릭한 이미지 정보 가져오기 - 리덕스 사용

 

유저가 ImageList 페이지에서 이미지를 클릭하면 이미지의 유니크한 boardId가 리덕스에 저장되도록 디스패치하고,

ImageDetailList 페이지에서 useSeletor로 boardId를 불러온 후 해당 boardId의 이미지가 이미지 리스트에서 몇 번째 인덱스에 위치하고 있는지 찾도록 작성했다.

 

// image 리덕스 모듈

// 액션 타입
const GET_CLICKED_BOARDID = 'GET_CLICKED_BOARDID'

// 액션 생성 함수
const getClickedBoardId = createAction(GET_CLICKED_BOARDID, (clickedBoardId) => ({ clickedBoardId }))

// 초기값
const initialState = {
  clickedBoardId: null,
}

// 리듀서
[GET_CLICKED_BOARDID]: (state, action) =>
  produce(state, (draft) => {
    draft.clickedBoardId = action.payload.clickedBoardId
  }),

 

// ImageDetailList.js

const imageData = useSelector((state) => state.image)
const clickedBoardId = imageData.clickedBoardId
const index = imageData.image_list.findIndex((image) => image.boardId === clickedBoardId)

 

 

4. 페이지 이동 후 클릭한 이미지 바로 보여주기 - useEffect, scrollIntoView()

 

인덱스도 찾았으니 이제 이미지를 클릭하고 페이지를 이동했을 때 클릭한 이미지를 바로 보여주기만 하면 된다!

바로 보여지는 것처럼 보이지만 사실은 scrollIntoView() 이벤트로 클릭한 이미지 위치로 스크롤을 이동시킨 것이다.

페이지가 렌더링되자마자 이벤트가 발생해야 하기 때문에 useEffect도 함께 사용하고, 렌더링될 때 한 번만 발생하면 되기 때문에 의존성 배열에는 아무 값도 넣지 않는다.

 

boardId로 index를 찾은 이미지 리스트와 ref들을 리스트로 만들 때 map() 에 사용한 이미지 리스트는 동일한 데이터이기 때문에 image_list[index] 와 imageRefs.current[index].current는 동일한 데이터(이미지)를 가지고 있게 된다.

 

scrollIntoView()에는 다른 스크롤 이벤트들과 마찬가지로 움직임을 부드럽게 하는 등의 속성을 설정할 수 있는데, behavior : smooth 속성을 주면 오히려 스크롤이 이동하는 게 눈에 보이기 때문에 이미지가 바로 보여진다는 느낌을 저해하길래 기본인 auto로 두었다.

// ImageDetailList.js

useEffect(() => {
  imageRefs.current[index]?.current.scrollIntoView()
}, [])

 

scrollIntoView에 behavior: 'smooth' 속성을 주면 아래와 같이 위부터 참조한 DOM 위치까지 스크롤이 이동하는 모션이 모두 노출된다.

 

 

여기까지 하면 페이지가 이동되자마자 클릭한 이미지를 바로 보여주고, 스크롤로 이전, 다음 이미지까지 볼 수 있게 된다!


전체 코드

// ImageDetailList.js
// ref를 받는 자식 컴포넌트가 함수형 컴포넌트라면 forwardRef() 처리하는 거 잊지 말자!

const ImageDetailList = (props) => {

  const imageData = useSelector((state) => state.image)
  const clickedBoardId = imageData.clickedBoardId
  const index = imageData.image_list.findIndex((image) => image.boardId === clickedBoardId)
  const imageRefs = useRef(imageData.image_list.map(() => createRef()))

  useEffect(() => {
    imageRefs.current[index]?.current.scrollIntoView()
  }, [])

  return (
    {* ... *}
    <InfinityScroll type="white" callNext={getImageList} paging={{ next: imageData.has_next }}>
      {imageData.image_list.map((image, i) => {
        return <OneDetailImageCard ref={imageRefs.current[i]} key={`image-detail-${image.boardId}`} image={image} />
      })}
    </InfinityScroll>
    {* ... *}
  )
}

단순한 기능이지만 최종 발표가 얼마 안 남은 시점이라 시간이 얼마 없었는데 피드백을 꼭 반영하고 싶어서 고군분투하면서도 즐겁게 했던 기억이 새록새록 난다!

 

 

 

링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
Total
Today
Yesterday