Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 148 additions & 7 deletions apps/web/app/components/SearchExperience.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: "최신순" },
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -428,8 +527,30 @@ export function SearchExperience() {
const committedQueryRef = useRef<string | null>(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
}, []);

Expand Down Expand Up @@ -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);
Expand All @@ -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 : "검색 중 오류가 발생했습니다.");
Expand Down Expand Up @@ -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<HTMLFormElement>) {
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<HTMLInputElement>) {
Expand Down Expand Up @@ -640,7 +779,9 @@ export function SearchExperience() {
<SuggestionList
suggestions={suggestions}
activeIndex={activeSuggestionIndex}
onSelect={(suggestion) => void runSearch(suggestion.label, sortOrder, filters, 1)}
onSelect={(suggestion) =>
void runSearch(suggestion.label, defaultSortForQuery(suggestion.label), filters, 1)
}
onHover={setActiveSuggestionIndex}
/>
) : null}
Expand All @@ -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}
</button>
Expand Down
29 changes: 29 additions & 0 deletions docs/development-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 갱신
```
28 changes: 28 additions & 0 deletions docs/search-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down