Search

UI의 상태를 URL에 저장하기 #1 - 소개

생성일
2022/11/17 02:17
태그
Front-end
작성자
이 글은 유하에서 사용하는 Next.js 프레임워크를 중심으로 구성되었습니다.

요약

유하에서는 필터, 정렬, 페이지네이션 기능이 여러 곳에서 사용됩니다. 유튜버 검색 화면에서 필터, 정렬을 적용하는 것이 가장 대표적이며 백오피스에서도 많이 사용되는 기능들입니다.
현재 보는 페이지의 링크를 공유하면 똑같은 화면을 볼 수 있도록 하기 위해서, 그리고 뒤로가기를 통해 걸었던 필터나 정렬을 취소하기 위해서는 페이지의 상태를 URL에 저장해야 합니다. 그러나 상태를 URL에 저장하는 것은 단순히 useState를 사용하는 것보다 복잡하며, 관리해야 할 상태의 개수도 많습니다. 이러한 문제로 유하에 상태를 URL에 저장하는 것을 처음 구현된 코드는 매우 복잡하고 읽기 힘들었고, 수정하거나 재사용하기도 어려웠습니다.
이를 해결하기 위해 여러 차례의 리팩토링이 진행되었고, 3번째 시도 끝에 읽고 사용하기 쉬운 형태로 리팩토링할 수 있었습니다. 그 과정에서 있었던 경험과 그렇게 만들어진 npm 패키지들을 공유하고자 합니다.

UI의 상태를 URL에 저장해야 하는 이유

유하의 유튜버 검색 화면 (로그인해야 모든 정보를 볼 수 있습니다)
유하(youha.info)를 사용해 본 적 있나요? 유하에서는 내가 원하는 조건의 인플루언서를 검색할 수 있습니다.
유하를 어떻게 사용할 수 있는지 상상해봅시다. 유튜버 검색 페이지에서 검색어를 입력하거나 구독자수 범위, 카테고리 등으로 필터를 겁니다. 여기에 구독자수 높은 순, 혹은 광고 영상 호감도 높은 순으로 정렬도 가능합니다. 한 페이지에 표시되는 채널의 개수를 바꾸기도 하고, 페이지를 넘겨 좀 더 뒤쪽의 유튜버들을 확인해보기도 합니다. 그러다 궁금해진 채널을 클릭해 채널에 대한 더 자세한 정보가 나와 있는 상세 페이지로 이동하기도 하겠죠.
그러나 상세 페이지에서 뒤로 가기를 눌러 다시 검색 페이지로 돌아왔더니 설정해 두었던 검색어, 필터, 정렬, 보고 있던 페이지가 모두 초기화되어 버린다면 어떨까요? 사용하기 정말 불편할 것 같습니다. (이런 사이트들이 가끔 있죠..) 뒤로가기를 하는 상황뿐만 아니라 링크를 전달했을 때나, 즐겨찾기를 해두었을 때도 설정해두었던 내용들이 모두 유지되면 좋을 것 같습니다.
이것이 가능하기 위해서는 UI의 상태가 URL에 저장되어 URL에 따라 화면이 표시되어야 합니다.
SPA(single page application)가 나타나기 전, 전통적인 웹 사이트에서는 화면이 바뀌어야 할 때 마다 서버에 요청을 보내서 매번 새로 HTML을 받아 왔습니다. 따라서 페이지 안에서 화면을 어떻게 보여줄지에 대한 정보가 URL에 들어갈 수 밖에 없었죠. 그래서 동일한 링크만 있으면 누구든 같은 화면을 볼 수 있었습니다.
Google에서 검색어가 URL에 저장되는 모습
그런데 SPA가 등장하면서부터는 더 이상 URL에 정보를 저장할 필요가 없어졌습니다. 화면을 바꾸려고 서버에 요청을 보내지 않아도 되니까요(내부에서는 AJAX로 요청을 보내기는 하지만요). 하지만 이와 동시에 링크를 공유해서 다른 사용자와 경험을 나누기도 어려워졌습니다. 접근성이 좋다는 웹의 특성을 제대로 살리지 못한 것이라고 볼 수 있죠.

유하에서 UI의 상태를 URL에 저장하기

유하에서는 UI의 상태를 URL에 저장해야하는 경우가 많습니다. 앞서 보여드린 유튜버 검색 페이지에서는 필터 상태, 정렬 상태, 보고 있는 페이지 번호, 페이지당 표시 개수를 저장해야 합니다. 그 외에 광고영상 검색 페이지나 백오피스에서도 필터, 정렬이 정말 많이 사용됩니다. 이러한 기능들이 URL에 상태를 저장하도록 만들어야 했습니다.
그러나, 유하에 사용되는 Next.js 프레임워크에서는 상태를 URL에 저장하는 것이 useState 를 사용하는 것처럼 간단하지 않습니다. router.query객체에서 문자열을 읽고, router.push를 통해 URL을 변경해야 합니다. 이렇게 설명해서는 복잡하다는 것이 잘 느껴지지 않는데, 두 번째 글(UI의 상태를 URL에 저장하기 #2 - URL 상태 관리를 useState처럼 쉽게 만들기)에서 더 자세하게 설명했습니다.
또한, 일반적으로 상태 변경은 해당 컴포넌트 내에서 발생하는 경우가 많습니다. 버튼을 클릭하거나, 입력창에 입력했을 때 onClick, onChange에서 상태를 변경시키고, 변경된 값을 같은 컴포넌트에서 표시합니다. 그러나, URL은 외부에서 링크를 타고 들어오는 경우, 혹은 사이트 내부 링크를 통해 들어오는 경우와 같이 (유하에서는 네비게이션 바에서 카테고리를 선택해 유튜버 검색 화면으로 들어오는 링크가 있습니다.) 컴포넌트 외적인 요인으로 상태가 변경되기도 하여 단순히 useEffect로 맨 처음에 URL에 따라 초기화하는 것이 불가능합니다.
필터의 종류도 다양합니다. 보고 싶은 카테고리들을 여러 개 선택할 수 있는 필터, 구독자수 범위를 입력할 수 있는 필터, 남성, 여성 중 선택하거나 모두 선택 해제할 수 있는 필터 등 다양한 필터를 지원해야 합니다.
유하 유튜버 검색 화면의 필터

코드의 발전 과정

유하의 유튜버 검색 화면은 SearchContainer 컴포넌트 안에 SearchFilter, SearchTable 이 들어있는 구조입니다.
3차 리팩토링 이전의 코드의 경우 여기에 설명된 것 외에도 상당한 문제점들이 많이 있었고, 보기도 어려워 글에 첨부하지 않았습니다. 약 800라인의 매우 복잡하고 읽기 힘든 코드였다고만 이해해주세요.
맨 처음에는 필터의 상태를 SearchFilter에서 관리했습니다. useState로 저장한 객체 하나가 필터 관련 데이터들을 전부 가지고 있었습니다. 그리고 URL 변경 시마다 useEffect로 URL에서 상태를 가져오도록 만들었습니다.
그리고 SearchContainer에서는 API 호출에 사용할 수 있도록 가공된 필터의 상태를 useState를 이용해 별도로 갖고 있었고, 자식인 SearchFilter에 prop으로 setFilter 콜백을 전달해 자식이 데이터를 가공해 부모의 상태로 넣어주고 있었습니다.
SearchContainer는 가지고 있는 필터 상태를 SearchTable에 전달하고, SearchTable은 이를 이용해 API 요청을 하여 데이터를 가져와 보여줍니다.

첫 번째 리팩토링

첫 번째 리팩토링에서는 SearchFilteruseState 로 컴포넌트만의 상태를 가지는 대신 router.query에서 바로 읽어오는 식으로 글로벌 상태에 필터를 저장하도록 변경했습니다. 이를 통해 useStaterouter.query 가 상태를 각각 따로 가지던 것을 하나로 합쳐 단순화시켰고, useEffect를 통해 동기화할 필요가 없어져 간단하게 만들 수 있었습니다.
애초에 위처럼 코드를 만들지 않았던 이유는, router.query를 상태로 보지 않고, 상태를 가져와야 할 대상으로 인식하며 복잡하게 만들어졌던 것으로 보입니다. 이후 router.query 자체가 상태라는 것을 알고 이렇게 리팩토링이 가능했고요.

두 번째 리팩토링

두 번째 리팩토링에서는 SearchFilter가 필터 데이터를 가공해 SearchContainer의 상태로 넣어주는 것이 아니라, SearchFilter가 URL을 변경하면 SearchContainer는 자동으로 URL에서 상태를 가져와 알아서 가공하도록 만들었습니다. 이것으로 SearchContaineruseState도 없앴습니다.
또한 API 호출 책임을 SearchTable에서 SearchContainer로 옮겼습니다. SearchTable에 필터 조건을 넣으면 API 요청을 하는 것이 아니라SearchContainer에서 API 요청을 보내 데이터를 가져와 SearchTable에 넣어주면 SearchTable은 단순히 보여주기만 하도록 변경했습니다.

세 번째 리팩토링

마지막 세 번째 리팩토링에서는 컴포넌트 간의 결합을 끊고 컴포넌트들을 조합하는 형태로 만들었습니다.
검색창, 범위 입력기, 체크박스 등의 필터 UI를 종류별로 컴포넌트를 만들어 동작을 캡슐화하고 응집도를 높였습니다.
이 컴포넌트들은 prop으로 데이터나 콜백을 받지 않고, 독립적으로 URL에서 상태를 읽고 쓰며 동작합니다. Prop으로는 필터가 URL에 저장될 이름과 옵션 몇 가지만 받습니다. 이는 부모가 여러 필터의 상태를 관리하며 복잡해지는 문제를 해결해주고, 컴포넌트들을 쉽게 조합하어 전체 필터 패널을 구성할 수 있게 해줍니다.
또한, URL에서 필터 상태를 읽어와 API 요청을 보내는 부분은 UI와는 완전히 독립적으로 동작합니다. URL이라는 공통의 인터페이스만을 이용해 정보가 전달되며, UI의 존재를 알 수 없습니다. 이는 코드를 간단하게 해주며, 필터를 적용해 API 호출하는 관심사에만 집중할 수 있게 해줍니다.

리팩토링 이후

function SearchComponent() { // URL에서 필터 상태를 읽어오는 부분 const filters = useFilter([ ...filterDefFactory({ keyword: filterTypes.string.equal(), subscriberCount: filterTypes.integer.range({ excludeNull: true }), standardCommercialPrice: filterTypes.integer.range({ excludeNull: true }), averageCommercialViewCount: filterTypes.integer.range({ excludeNull: true }), priceVerified: filterTypes.boolean.equal(), isContactable: filterTypes.boolean.equal(), age: filterTypes.enum("10", "20", "40", "60").in(), MCN: filterTypes.nullable.string().in(), }), { queryTypes: { category: queryTypes.array(queryTypes.string) }, transform: (state) => { if (!state.category) return []; const categoryIdArray = getCategoryIdArrayFromLabelArray( categoryCatalog, state.category ); return categoryIdArray.length ? [["channelCategory", "=", categoryIdArray]] : []; }, }, { queryTypes: { gender: queryTypes.stringEnum(["male", "female"]) }, transform: (state) => { if (!state.gender) return []; return [ ["genderRatioInThread", "!=", null], ["genderRatioInThread", state.gender === "male" ? "<" : ">=", "0.5"], ]; }, }, ]); // URL에서 정렬, 페이지 상태를 읽어오는 부분 const [sort, setSort] = useSort(); const [{ limit, offset }] = usePagination(); // API 호출을 통해 데이터를 가져오는 부분 const data = useDataFromAPI({ filters, sort, limit, offset }); return ( <> <SearchFilter /> <SearchTable data={data} /> </> ); } function SearchFilter() { return ( <> <CollapsePanel name="카테고리"> <CheckboxFilter name="category" options={categories} /> </CollapsePanel> <CollapsePanel name="구독자수"> <NumberRangeFilter name="subscriberCount" subfix="명" min={0} /> </CollapsePanel> <CollapsePanel name="예상 광고 단가"> <NumberRangeFilter name="standardCommercialPrice" subfix="원" min={0} /> </CollapsePanel> <CollapsePanel name="광고 평균 조회수"> <NumberRangeFilter name="averageCommercialViewCount" subfix="회" min={0} /> </CollapsePanel> <CollapsePanel name="단가 인증"> <CheckboxFilter name="priceVerified" options={checkBoxLists.priceVerified} radioLike /> </CollapsePanel> <CollapsePanel name="연락 여부"> <CheckboxFilter name="isContactable" options={checkBoxLists.isContactable} radioLike /> </CollapsePanel> <CollapsePanel name="시청자 설별"> <CheckboxFilter name="gender" options={checkBoxLists.gender} radioLike /> </CollapsePanel> <CollapsePanel name="시청자 연령"> <CheckboxFilter name="age" options={checkBoxLists.age} clearOnSelectAll /> </CollapsePanel> <CollapsePanel name="MCN"> <McnSelectFilter /> </CollapsePanel> </> ); }
JavaScript
복사

이어지는 글

위 3번째 리팩토링을 진행하기 위해 URL에 상태를 쉽게 저장하는 방법이 필요했습니다. 이를 해결하는 과정에 대해 2, 3번째 글로 정리하였고, 해결하며 만들어진 코드를 오픈소스 npm 패키지로 만들었습니다.
마지막 4번째 글에서는 2, 3번째 글에서 만든 패키지를 이용해 3번째 리팩토링을 어떻게 진행했는지에 관해 설명합니다.
다음 글:
레포지토리 목록

참고자료

저자소개

이동엽은 Full stack 개발자로서 유하 서비스의 기틀을 마련하였고, YOUHA market이란 시험 서비스의 Lead engineer를 맡았으며, YOUHA Boost 프로토타입의 백엔드를 구축하는 등 주로 새로운 제품을 설계하고 개척하는 임무를 많이 맡았습니다. 새로운 기술에 대한 도전을 끊임없이 하면서 깊이 있는 이해를 가지려고 노력하고 있습니다.