Context 로 Anchoring 하기

드엔트론프·2024년 9월 8일
0
post-thumbnail

들어가며

디자인시스템을 통해 어드민 사이트의 전체적인 디자인을 바꾸는 일이 있었다.
디자이너의 UI/UX 를 고려한 디자인과 더불어 사용성을 높이는 작업이었는데, 디자인의 통일감은 맞춰졌으나 사용성에 어려움을 겪는 동작들이 하나 둘 생겼다. 그것들을 고치기 위한 여러 작업 중, 손이 많이 갔던 작업이 바로 이 글에 담기는 React.context를 활용한 Anchoring(앵커링)이다.
결론부터 말하자면, 제목에서 알 수 있듯 context를 활용하여 앵커링을 구현했다.

Anchoring

anchoring이라는 거창한 말로 작성했지만, 쉽게 말하자면 그 위치에 고정하는 것이다. 전체를 보여주는 리스트에서 상세 페이지로 갔다 다시 전체 페이지로 왔을 때 내가 이전에 머물렀던 위치로 스크롤이 가 있는 형태를 말한 것이다.

왜 어려웠을까?

크게 2가지 이유가 있다.

1. 렌더링

  • 전체 리스트를 그리는 컴포넌트에서 GET api를 통해 리스트를 받게 된다.
    매번 전체 리스트 컴포넌트로 가면 다시 GET api를 통해 리스트를 받고, 받은 데이터를 set해주니, 언제나 초기화됐다.

2. 무한스크롤

  • 리스트를 받아올 때, 50개의 리스트 정보를 먼저 받고, 스크롤이 div의 끝으로 갔을 때 다시 30개의 추가 목록을 불러오는 형태의 무한스크롤이 구현돼있다. 그러다보니 내가 상세페이로 갔다가 다시 돌아왔을 때, 어디까지 데이터를 받아왔는지도 확인 돼야 하는 일이었다.

단순하게 보자.

  • 내가 하고싶은 구현은 간단했다. (물론 구현은 어려웠다.🥲🥲)
    리스트 보여줘 - 스크롤 하다가 원하는 부분 클릭해 - 다시 뒤로 왔을 때 그 위치부터 보여줘

이 간단한..(?) 일을 어떻게 보여주면 될까?

Session Storage를 활용했던 Anchoring

이전에 context 없이 anchoring을 구현했었다.
컴포넌트 외부에 정의한 값은 다시 렌더링 되지 않는 것을 이용해서, 바깥에 미리 변수를 정해두고, Session Storage와 함께 사용하여 구현했었다.

똑같이 이 방법을 쓰면 됐는데, 왜 다른 방법을 찾았을까?
3가지의 이유가 있다.


  1. 컴포넌트 외부를 활용하는 건 상수로 쓰일 것들을 정의하는 것에 그치지 않고 데이터를 받아 저장하고 있다가 수정하는 방법들이 무언가 낯설고, 개인적으로 리액트스럽다고 느끼지 못했던 것 같다.

  2. 또, 이미 프로젝트에서 사용하고 있는 상태관리 라이브러리인 Redux가 있었지만 Redux를 API를 호출하는 방식으로만 사용하고 있어서 그 구조를 바꾸는 게 더 일이었다.

  3. 그렇다고 요즘 많이 쓰인다는 Zustand 를 사용해서 구현해봐? 하기에는 너무 과한 조치였다. 고작 앵커링 하나 하는데 상태관리 라이브러리를 하나 더 받아서 해야하나? (제대로 사용해본건 아니지만, 잠시 사용법을 익히려 공부해보며 정말 간단해서 살짝 놀라긴했다. 그냥쓸까 ? 생각함)

그래서 방법을 찾다가, Context를 사용하여 구현하는 방법으로 방향을 잡았다.

  1. 추가적인 라이브러리 설치도 아니고,
  2. 사용법이 Redux만큼 복잡하지도 않았고,
  3. 말그대로 '리액트'스러운 조치라 생각했기 때문이다.

React.Context

나는 Context를 사용해서 리스트의 정보를 갖고 있다가 조건에 따라 뿌려주고, 지워주는 형태로 구현했다.

Context는 간단히 말하자면, 내 맥락안에 속하면 내 정보 가질 수 있고 수정도 할 수 있어. 라고 생각한다. 그래서 내 맥락 속 깊게 들어가있는 컴토넌트에서 props drilling 과 같은 번거로움 없이 한 번에 정보를 줄 수도 있다.

글이란 것도 맥락이 참 중요한데.. 아무튼

그래서, 어떻게 구현했는가?
코드를 간단하게 적어보려한다.

//GlobalContext.jsx
import { createContext, useState } from "react";

export const GlobalComponentContext = createContext();

export const GlobalComponentProvider = ({ children }) => {
  const gState = {};

  // global
  [gState.ctxScrollPosition, gState.setCtxScrollPosition] = useState({});
  [gState.ctxTotalCount, gState.setCtxTotalCount] = useState({});

  // 유저 관리
  [gState.ctxUserSearchOption, gState.setCtxUserSearchOption] = useState({});
  [gState.ctxUserList, gState.setCtxUserList] = useState([]);
  
  return (
    <GlobalComponentContext.Provider value={gState}>
      {children}
    </GlobalComponentContext.Provider>
  );
};
  • 위는 컨텍스트를 생성하고 createContext, Provider

  • Provider에 보면 useState를 이용하여 값을 저장하려는 것을 알 수 있다.

//GlobalRouter.jsx
<GlobalComponentProvider>
        <Routes>
          <Route>{loginRouteList}</Route>

          <Route element={<PrivateRouter />}>
            <Route element={<GlobalLayout />}>{privateRouteList}</Route>
          </Route>
        </Routes>
</GlobalComponentProvider>
  • 위에 작성한 GlobalComponentProvider를 통해 컨텍스트의 값을 모든 내부 컴포넌트에 지정하는 작업을 해준 것이다. 이미 GlobalComponentProvider return 값에 value인 gstate가 ctxUserSearchOption, ctxUserList 등을 모두 담고 있는 커다란 객체가 된다.
  • 그 안에 children으로 Route 처리된 모든 컴포넌트가 담겨 있으니, 전역상태관리와 조금은 비슷하다고 해도 무방할 것 같다.
//ManageUser.jsx

  // context

  const {
    ctxUserList,
    setCtxUserList,
    ctxScrollPosition,
    setCtxScrollPosition,
    ctxUserSearchOption,
    setCtxUserSearchOption,
    ctxTotalCount,
    setCtxTotalCount,
  } = useContext(GlobalComponentContext);
  • 작성된 Context는 사용될 컴포넌트에서 useContext 훅을 통해 불러오게된다. useContext는 context를 읽고 구독할 수 있는 React Hook이다.
  • useContext안에 들어있는 GlobalComponentContext 는 앞서 만들었던 컨텍스트이다. 그 안에 미리 만들었었던 여러 값을 사용할 수 있다.
  const getLists = async (obj = filteringOption) => {
    setIsLoading(true);

    const data = await getSubscribersList(deleteUnusedKey(obj));

    setTotalUSer(data.total_count);
    setUserLists(data.member);

    setCtxUserSearchOption(obj);
    setCtxTotalCount({ ...ctxTotalCount, user: data.total_count });
    setCtxUserList(data.member);

    setStopFetchNew(false);
    setIsLoading(false);
  };
  • API를 호출하여 유저 목록을 받아오는 함수이다.
    중요하게 봐야할 곳은
    setCtxUserSearchOption(obj);
    setCtxTotalCount({ ...ctxTotalCount, user: data.total_count });
    setCtxUserList(data.member);

여기다. 기존 컨텍스트를 사용하지 않았을때는

    setTotalUSer(data.total_count);
    setUserLists(data.member);

두 개만 해줬다면, 이제는 위의 세 set을 사용하여 데이터를 저장해둔다.

  useEffect(() => {
    if (isEmptyObj(ctxUserSearchOption) || isEmpty(ctxUserList)) getLists();
  }, [filteringOption]);

  useEffect(() => {
    reloadContext();
  }, []);
  • useEffect가 필요하다. 이는 컨텍스트로 저장된 유저리스트나, 유저 나이, 성별등의 필터가 적용돼있지 않다면 리스트 API를 다시 호출하라는 것 한 개.
  • 그리고 컨텍스트를 다시 reload하는 함수이다.
  const reloadContext = () => {
    const {
      gender,
      participation_level,
      sort,
      min_age,
      max_age,
      min_uid,
      max_uid,
      user_name,
      subscriber_uid,
      nick_name,
    } = ctxUserSearchOption;

    setFilteringOption({
      gender: gender ?? -1,
      participation_level: participation_level ?? 0,
      sort: sort ?? "UID_DESC",
      min_age: min_age ?? null,
      max_age: max_age ?? null,
      min_uid: min_uid === max_uid ? null : min_uid,
      max_uid: min_uid === max_uid ? null : max_uid,
    });

    if (subscriber_uid) {
      setSearchUser(subscriber_uid ?? "");
      setIdStatus(SEARCH_STATUS.USER_UID);
    }
    if (user_name) {
      setSearchUser(user_name ?? "");
      setIdStatus(SEARCH_STATUS.USER_NAME);
    }
    if (nick_name) {
      setSearchUser(nick_name ?? "");
      setIdStatus(SEARCH_STATUS.USER_NICKNAME);
    }

    setUserLists(ctxUserList ?? []);
    setTotalUSer(ctxTotalCount.user ?? 0);
  };
  • reloadContext는 filteringOption에 들어있는, 필터를 설정해놓은 값들이 컨텍스트에 있다면 이를 다시 적용해주는 함수이다.
const onClickUserDetail = (event, user) => {
      event.preventDefault();
      navigate(`/user/detail?subscriber_uid=${user.subscriber_uid}`);
      setCtxScrollPosition({
        ...ctxScrollPosition,
        user: document.getElementById("user-list").scrollTop,
      });
    };
  • 해당 위치는 어떻게 기억하고 있을까? 다음처럼 클릭 이벤트가 일어났을 때 scrollTop을 저장해둔다.
 const userListRef = useCallback(
    (node) => {
      if (node !== null) {
        node.scrollTo(0, ctxScrollPosition.user);
      }
    },
    [ctxScrollPosition.user],
  );

return
 <div
   className="cp-scrollbar max-h-[70vh]"
   ref={userListRef}
   id="user-list">
  	{userLists?.map((user) => (
    <div
            ...
  • 그리고 userListRef가 사용된 부분을 보면, 이 함수는
    엘리먼트의 ref 속성에 할당해놨는데, 이 경우 userListRef는 해당 DOM 요소에 대한 참조를 관리하고, 그 요소가 렌더링될 때마다 스크롤 위치 설정을 하게 해준다 .

정리

설명이 조금 부족한 것은 추후에 시간이 되면 더 보완하여 작성해봐야겠다.
위에서 설명한,

리스트 보여줘 - 스크롤 하다가 원하는 부분 클릭해 - 다시 뒤로 왔을 때 그 위치부터 보여줘

는 컨텍스트를 쓰면서

1. 리스트 보여줘

  • 컨텍스트에 리스트 내용 저장해 같이, 리스트 필터 내용도 있으면 그것도 저장해

2. 스크롤 하다가 원하는 부분 클릭해

  • 클릭할 때 스크롤 저장하는 컨텍스트에 스크롤 위치 저장해둬.

3. 다시 뒤로 왔을 때 그 위치부터 보여줘

  • reload 하면서 어디 바뀐거 있음 수정해주고 - 데이터 있으니까 전체 div 데이터에 맞게 그려주는데 그때 위치 기억해둔거 있찌? 글로 가서 보여줘!

와 같은 순서로 진행될 수 있었다.

사용하며

완성하고 모~든 리스트 사용하는 곳에 해당 로직을 적용시켰다.
이 값을 언제는 초기화시켜주고 언제는 다시 업데이트하고 하는 걸 A 컴포넌트 뿐 아니라 B에서도 해줘야하고, C에서도 해줘야하는 상황이 있는 것들이 조금 헷갈렸던 것 같다.

그럼에도 다행히 원하는 형태로 구현이 잘 돼, 사용하는 내부 직원 모두 만족하는 모습을 보며 뿌듯함을 느낄 수 있었다.😌😌😌

profile
왜? 를 깊게 고민하고 해결하는 사람이 되고 싶은 개발자

0개의 댓글