From d3f0fb216de219b53d6f5c5a8a9f112a08dee91b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=9D=BC=EC=A4=80?= Date: Sun, 31 May 2026 23:43:44 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=EA=B3=BC=20URL=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/components/SearchExperience.tsx | 155 ++++++++++++++++++- docs/development-log.md | 29 ++++ docs/search-design.md | 28 ++++ 3 files changed, 205 insertions(+), 7 deletions(-) diff --git a/apps/web/app/components/SearchExperience.tsx b/apps/web/app/components/SearchExperience.tsx index 231a9b0..cf057f1 100644 --- a/apps/web/app/components/SearchExperience.tsx +++ b/apps/web/app/components/SearchExperience.tsx @@ -108,6 +108,17 @@ type MatchReason = { snippets: string[]; }; +type SearchUrlState = { + query: string; + sort: SearchSort; + filters: SearchFilters; + page: number; +}; + +type RunSearchOptions = { + updateUrl?: boolean; +}; + const sortOptions: { value: SearchSort; label: string }[] = [ { value: "relevance", label: "관련도순" }, { value: "latest", label: "최신순" }, @@ -392,6 +403,94 @@ function saveFavoriteItems(items: FavoriteItem[]) { window.localStorage.setItem(favoritesStorageKey, JSON.stringify(items)); } +function defaultSortForQuery(value: string): SearchSort { + return value.trim() ? "relevance" : "latest"; +} + +function isSearchSort(value: string | null): value is SearchSort { + return value === "relevance" || value === "latest"; +} + +function uniqueParamValues(params: URLSearchParams, key: string): string[] { + return [...new Set(params.getAll(key).map((value) => value.trim()).filter(Boolean))]; +} + +function parsePage(value: string | null): number { + if (!value) { + return 1; + } + + const parsedValue = Number.parseInt(value, 10); + + return Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : 1; +} + +function searchUrlStateFromParams(params: URLSearchParams): SearchUrlState { + const query = params.get("q")?.trim() ?? ""; + const requestedSort = params.get("sort"); + const sort = + query && isSearchSort(requestedSort) ? requestedSort : defaultSortForQuery(query); + + return { + query, + sort, + page: parsePage(params.get("page")), + filters: { + sources: uniqueParamValues(params, "source"), + technologies: uniqueParamValues(params, "technology"), + problemKeywords: uniqueParamValues(params, "problem"), + contentTypes: uniqueParamValues(params, "content_type"), + }, + }; +} + +function currentSearchUrlState(): SearchUrlState { + return searchUrlStateFromParams(new URLSearchParams(window.location.search)); +} + +function searchUrlForState( + query: string, + sort: SearchSort, + filters: SearchFilters, + page: number, +): string { + const params = new URLSearchParams(); + const trimmedQuery = query.trim(); + const hasFilters = selectedFilterCount(filters) > 0; + + if (trimmedQuery) { + params.set("q", trimmedQuery); + } + + if (trimmedQuery || hasFilters || sort !== defaultSortForQuery(trimmedQuery)) { + params.set("sort", sort); + } + + if (page > 1) { + params.set("page", String(page)); + } + + appendFilterParams(params, filters); + + const queryString = params.toString(); + + return queryString ? `${window.location.pathname}?${queryString}` : window.location.pathname; +} + +function syncSearchUrl( + query: string, + sort: SearchSort, + filters: SearchFilters, + page: number, +) { + const nextUrl = searchUrlForState(query, sort, filters, page); + const currentUrl = `${window.location.pathname}${window.location.search}`; + + if (nextUrl !== currentUrl) { + window.history.pushState(null, "", nextUrl); + } +} + function isFavoriteItem(value: unknown): value is FavoriteItem { if (!value || typeof value !== "object") { return false; @@ -428,8 +527,30 @@ export function SearchExperience() { const committedQueryRef = useRef(null); useEffect(() => { - void runSearch("", "latest", emptyFilters, 1); + const initialState = currentSearchUrlState(); + + setFilters(initialState.filters); setFavoriteItems(loadFavoriteItems()); + + void runSearch( + initialState.query, + initialState.sort, + initialState.filters, + initialState.page, + { updateUrl: false }, + ); + + const handlePopState = () => { + const nextState = currentSearchUrlState(); + setFilters(nextState.filters); + void runSearch(nextState.query, nextState.sort, nextState.filters, nextState.page, { + updateUrl: false, + }); + }; + + window.addEventListener("popstate", handlePopState); + + return () => window.removeEventListener("popstate", handlePopState); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -489,16 +610,21 @@ export function SearchExperience() { nextSort: SearchSort = sortOrder, nextFilters: SearchFilters = filters, nextPage = 1, + options: RunSearchOptions = {}, ) { const trimmedQuery = nextQuery.trim(); const effectiveSort = trimmedQuery ? nextSort : "latest"; committedQueryRef.current = trimmedQuery; setQuery(nextQuery); setSortOrder(effectiveSort); + setFilters(nextFilters); setCurrentPage(nextPage); setIsSuggestOpen(false); setActiveSuggestionIndex(-1); setSuggestions([]); + if (options.updateUrl !== false) { + syncSearchUrl(trimmedQuery, effectiveSort, nextFilters, nextPage); + } setIsLoading(true); setErrorMessage(null); @@ -521,9 +647,11 @@ export function SearchExperience() { const data = (await response.json()) as SearchResponse; setResult(data); - if (nextFilters.sources.length === 0) { - setCompanyFacets(data.facets.sources); - } + setCompanyFacets((currentFacets) => + nextFilters.sources.length === 0 || currentFacets.length === 0 + ? data.facets.sources + : currentFacets, + ); } catch (error) { setResult(null); setErrorMessage(error instanceof Error ? error.message : "검색 중 오류가 발생했습니다."); @@ -584,10 +712,21 @@ export function SearchExperience() { }); } + function sortForSubmittedQuery(nextQuery: string): SearchSort { + const trimmedQuery = nextQuery.trim(); + + if (!trimmedQuery) { + return "latest"; + } + + return committedQueryRef.current === trimmedQuery ? sortOrder : "relevance"; + } + function handleSubmit(event: FormEvent) { event.preventDefault(); const activeSuggestion = suggestions[activeSuggestionIndex]; - void runSearch(activeSuggestion?.label ?? query, sortOrder, filters, 1); + const submittedQuery = activeSuggestion?.label ?? query; + void runSearch(submittedQuery, sortForSubmittedQuery(submittedQuery), filters, 1); } function handleInputKeyDown(event: KeyboardEvent) { @@ -640,7 +779,9 @@ export function SearchExperience() { void runSearch(suggestion.label, sortOrder, filters, 1)} + onSelect={(suggestion) => + void runSearch(suggestion.label, defaultSortForQuery(suggestion.label), filters, 1) + } onHover={setActiveSuggestionIndex} /> ) : null} @@ -656,7 +797,7 @@ export function SearchExperience() { className="tag" type="button" key={keyword} - onClick={() => void runSearch(keyword, sortOrder, filters, 1)} + onClick={() => void runSearch(keyword, defaultSortForQuery(keyword), filters, 1)} > {keyword} diff --git a/docs/development-log.md b/docs/development-log.md index 2b59614..ef54103 100644 --- a/docs/development-log.md +++ b/docs/development-log.md @@ -3665,3 +3665,32 @@ ndcg@10: 0.780 -> 0.786 ``` 요약 반영 후 검색 후보의 문제/해결/기술 맥락이 보강되면서 평가 지표가 일부 회복되었습니다. 다만 NHN Cloud 글 중 일부 title이 URL로 저장된 항목이 있어, 다음에는 NHN Cloud article title 추출 보정이 필요합니다. + +## 75. 검색 상태 URL query string 반영 + +검색어, 정렬, 페이지, 필터 상태를 URL query string과 동기화했습니다. + +반영 범위: + +```text +q: 검색어 +sort: relevance/latest +page: 현재 페이지 +source: 기업/sourceSlug 필터 +technology: 기술 필터 +problem: 문제 키워드 필터 +content_type: 콘텐츠 유형 필터 +``` + +URL에 검색 상태가 있으면 초기 로드 시 같은 검색을 복원하고, 브라우저 뒤로가기/앞으로가기도 URL 기준으로 검색 상태를 다시 적용합니다. + +검증: + +```text +web build: 성공 +API RAG 검색: /api/search?q=RAG&sort=relevance&page=1&page_size=3, total=35 +브라우저 직접 진입: /?q=RAG&sort=relevance -> RAG, 관련도순, 35개 결과 복원 +페이지 이동: 다음 -> /?q=RAG&sort=relevance&page=2 +뒤로가기: /?q=RAG&sort=relevance, 1페이지 복원 +추천 검색어 RAG 클릭: /?q=RAG&sort=relevance로 URL 갱신 +``` diff --git a/docs/search-design.md b/docs/search-design.md index c35f75e..437ff52 100644 --- a/docs/search-design.md +++ b/docs/search-design.md @@ -117,6 +117,34 @@ GET /api/search?q=&sort=latest 이 경우 `match_all` query를 사용하고 최신순으로 글을 반환합니다. 사용자가 검색어를 입력하기 전에도 최신 기업 기술 블로그 글을 둘러볼 수 있게 하기 위한 동작입니다. +프론트엔드는 빈 검색과 회사별 최신 글 탐색에서는 `latest`를 사용하고, 검색어 제출, +추천 검색어 선택, 자동완성 선택처럼 새 검색어로 검색을 시작하는 동작에서는 `relevance`를 +기본 정렬로 사용합니다. 사용자가 검색어가 있는 상태에서 정렬을 직접 바꾸면 해당 정렬을 +필터 변경과 페이지 이동에서 유지합니다. + +프론트엔드는 검색 상태를 URL query string에도 반영합니다. + +```text +q: 검색어 +sort: relevance 또는 latest +page: 현재 페이지 +source: sourceSlug 필터, 복수 지정 가능 +technology: technology 필터, 복수 지정 가능 +problem: problemKeywords 필터, 복수 지정 가능 +content_type: contentTypes 필터, 복수 지정 가능 +``` + +예시: + +```text +/?q=RAG&sort=relevance +/?q=Redis&sort=relevance&technology=Redis&problem=performance+optimization +/?sort=latest&source=woowa-tech-blog +``` + +URL에 검색 상태가 있으면 초기 로드 시 같은 검색을 복원하고, 브라우저 뒤로가기/앞으로가기도 +URL 기준으로 검색 상태를 다시 적용합니다. + 페이지네이션은 `page`, `page_size` query parameter로 받습니다. ```text