7. Query String으로 상태 관리: 라이브러리는 최후의 옵션

필터, 정렬, 페이지, 검색어처럼 화면의 모양을 바꾸는 값을 관리해야 할 때가 있다. 이런 상황에서 처음부터 상태 관리 라이브러리를 찾기 쉽지만, 막상 필요한 것은 복잡한 상태 관리가 아니라 값을 한 곳에 두고 다시 읽는 일인 경우도 많다.

그래서 먼저 이 값이 어디에서 의미를 가지는지 생각해 본다면, 컴포넌트 하나에서만 필요한 값이라면 local state로 충분하다. 같은 화면의 여러 컴포넌트가 공유해야 한다면 상위 컴포넌트로 올리는 방법이 먼저 보인다. 새로고침하거나 URL을 공유했을 때도 같은 화면을 보여줘야 하는 값이라면 query string을 고려할 수 있다.

URL에 남겨두면 좋은 상태

? 뒤에 붙는 query string은 현재 화면을 설명하는 데 사용할 수 있다.

/products?category=book&sort=price&page=2

이 URL을 통해 어떤 화면을 보고 있었는지 어느 정도 복원할 수 있다. 새로고침해도 조건이 남고, URL을 복사해서 같은 화면을 공유할 수도 있다. 브라우저의 히스토리와도 연결되기 때문에, 사용자가 이전 조건으로 돌아가는 경험을 만들기에도 좋다.

반대로 URL에 남길 필요가 없는 값도 있다. 아직 제출하지 않은 입력 초안이나 잠깐 열려 있는 모달처럼 현재 화면에서만 의미가 있는 값이다. 비밀번호나 토큰처럼 외부에 노출되면 곤란한 값도 query string에 넣을 수 없다. URL에 넣는 순간 그 값은 내부 구현이 아니라 사용자가 저장하고 공유할 수 있는 화면의 일부가 되기 때문이다.

분석 서비스를 예시로 살펴보자. 대시보드에서 어떤 사이트의 어떤 기간을 보고 있는지는 새로고침이나 공유 이후에도 유지되면 좋다. 그래서 현재 화면은 다음처럼 표현할 수 있다.

/dashboard/site_123?range=last_30_days

여기서 site_123은 route parameter이고, range=last_30_days는 query string이다. 기간을 바꾸면 화면의 데이터와 URL이 함께 바뀐다. 이를 통해, 분석 결과를 보고 있는 상태를 URL에서 다시 읽을 수 있다.

분석 결과에서 특정 국가나 브라우저를 필터링한다면 query string이 더 유용해진다.

/dashboard/site_123?range=last_30_days&country=KR&browser=Chrome

이제 이 URL은 단순히 대시보드라는 화면만 가리키는 것이 아니라, 어떤 사이트의 어느 기간을 어떤 조건으로 보고 있는지도 설명한다. 화면 안의 여러 컴포넌트가 각각 선택 상태를 가지고 URL을 따로 관리하는 대신, URL에 있는 값을 읽어 같은 화면을 구성할 수 있었다. 다만 값이 없거나 잘못된 경우에는 기본값을 정하고, URL에서 읽은 값의 형식도 확인해야 하는 검증이 필요하다.

Query String과 히스토리

query string을 사용하면 다음의 고민도 이어진다. URL이 바뀔 때마다 브라우저 히스토리에 새로운 항목을 남겨야 할까, 아니면 현재 항목을 바꿔야 할까?

push를 사용하면 새로운 히스토리 항목이 추가된다. 사용자가 하나의 작업을 끝냈고, 나중에 바로 이전 결과로 돌아가고 싶어 할 때 잘 맞는다. 검색을 제출하거나, 목록의 페이지를 이동하거나, 명확한 필터를 적용하는 순간이 여기에 가깝다.

/dashboard/site_123?range=today
        ↓ push
/dashboard/site_123?range=last_30_days

이런 변경은 사용자가 “다른 기간의 분석 결과를 보러 갔다”는 경험을 제공한다. 뒤로 가기를 눌렀을 때 이전 기간의 결과가 다시 나타나는 것도 자연스럽다.

반면, replace는 현재 히스토리 항목을 새 URL로 바꾼다. 검색어를 입력하는 중이거나, 슬라이더를 움직이는 중이거나, 여러 필터를 계속 조정하고 있을 때 중간 상태를 모두 히스토리에 남기고 싶지는 않을 수 있을 때 사용한다.

분석 대시보드에서도 기간 선택과 breakdown 필터를 바꿀 때마다 새로운 히스토리 항목을 추가할 필요가 있는지 고민했다. 기간을 today에서 last_30_days로 바꾸는 일은 명확한 선택처럼 보이지만, 이 화면에서는 필터를 여러 번 조정하며 데이터를 살펴보는 일이 더 많았다. 결국 매 변경을 push로 남기기보다는 replace로 현재 분석 조건을 갱신하는 쪽이 화면을 사용하는 감각에 가깝다.

/dashboard/site_123?range=last_30_days
        ↓ replace
/dashboard/site_123?range=today
        ↓ replace
/dashboard/site_123?range=all&country=KR

이렇게 하면 현재 URL은 항상 최신 분석 조건을 보여주지만, 조건을 하나씩 바꾼 과정이 모두 별도의 탐색 단계로 쌓이지는 않는다. 분석 화면을 둘러보는 과정과 새로운 결과를 확정해서 이동하는 과정은 조금 다르고 그 차이가 replace와 push를 나누는 기준이 된다.

검색 창처럼 입력과 제출이 나뉘는 경우에는 두 방식을 섞을 수도 있다. 입력 중에는 replace로 URL을 갱신하고, 사용자가 검색을 제출하는 시점에 push로 확정된 결과를 남기는 식이다. (URL을 바꾸는 빈도와 실제 데이터 요청의 빈도는 별개의 문제라서, 검색 요청은 별도로 debounce하거나 제출 시점에만 실행할 수도 있다.)

query string을 상태로 쓰면서는 URL의 일부만 바꿀 때 다른 조건을 지우지 않는 것도 신경 쓰게 된다. 예를 들어 range만 바꾸면서 이미 선택된 country=KR을 잃어버리면 사용자가 보고 있던 분석 조건이 달라진다. 그래서 변경할 값만 수정하고 기존 query parameter는 보존하는 방식이 필요하다.

결국 query string을 사용할 때 히스토리를 어떻게 남길 것인가에 대해서는 사용자가 그 변경을 하나의 완료된 탐색으로 느끼는지, 아니면 화면을 살펴보는 동안 계속 조정하는 과정으로 느끼는지에 따라 달라 진다.