diff --git a/src/components/organisms/SearchFilter.tsx b/src/components/organisms/SearchFilter.tsx
index af2b2f20..afa45af9 100644
--- a/src/components/organisms/SearchFilter.tsx
+++ b/src/components/organisms/SearchFilter.tsx
@@ -1,7 +1,7 @@
"use client";
import { Input } from "@/components/ui/input";
-import { Search } from "lucide-react";
+import { Search, XCircle } from "lucide-react";
import { ReactNode, useState, useEffect } from "react";
interface SearchFilterProps {
@@ -45,8 +45,17 @@ export function SearchFilter({
placeholder={isMobile ? "내용을 검색해주세요." : searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
- className="pl-10 text-[14px]"
+ className="pl-10 pr-8 text-[14px]"
/>
+ {searchValue && (
+
+ )}
{extraActions && (
diff --git a/src/components/templates/IntegratedListTemplateV2.tsx b/src/components/templates/IntegratedListTemplateV2.tsx
index 44a115c1..e6dbbe8e 100644
--- a/src/components/templates/IntegratedListTemplateV2.tsx
+++ b/src/components/templates/IntegratedListTemplateV2.tsx
@@ -1,7 +1,7 @@
"use client";
import { ReactNode, Fragment, useState, useEffect, useRef, useCallback, Children, isValidElement, cloneElement } from "react";
-import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown, Search } from "lucide-react";
+import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown, Search, XCircle } from "lucide-react";
import { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent } from "@/components/ui/tabs";
@@ -691,8 +691,17 @@ export function IntegratedListTemplateV2({
placeholder={searchPlaceholder}
value={searchValue || ''}
onChange={(e) => onSearchChange(e.target.value)}
- className="pl-9 w-full bg-gray-50 border-gray-200"
+ className="pl-9 pr-8 w-full bg-gray-50 border-gray-200"
/>
+ {searchValue && (
+
+ )}
)}
{/* 기존 extraActions (추가 버튼 등) */}
@@ -710,8 +719,17 @@ export function IntegratedListTemplateV2({
placeholder={searchPlaceholder}
value={searchValue || ''}
onChange={(e) => onSearchChange(e.target.value)}
- className="pl-9 w-full bg-gray-50 border-gray-200"
+ className="pl-9 pr-8 w-full bg-gray-50 border-gray-200"
/>
+ {searchValue && (
+
+ )}
)
)}
diff --git a/src/components/templates/UniversalListPage/index.tsx b/src/components/templates/UniversalListPage/index.tsx
index bd6ea562..854db2a0 100644
--- a/src/components/templates/UniversalListPage/index.tsx
+++ b/src/components/templates/UniversalListPage/index.tsx
@@ -15,6 +15,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useRouter, useParams } from 'next/navigation';
import { usePermission } from '@/hooks/usePermission';
import { useColumnSettings } from '@/hooks/useColumnSettings';
+import { useListSearchState } from '@/hooks/useListSearchState';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { toast } from 'sonner';
import { Download, Loader2 } from 'lucide-react';
@@ -48,14 +49,21 @@ export function UniversalListPage({
const locale = (params.locale as string) || 'ko';
const { canCreate: permCanCreate, canDelete: permCanDelete, canExport } = usePermission();
+ // ===== 검색 상태 보존 (sessionStorage + URL) =====
+ const {
+ getValue: getSearchStateValue,
+ setValue: setSearchStateValue,
+ } = useListSearchState({ debounceMs: 500 });
+ const restoredSearch = getSearchStateValue('search');
+
// ===== 상태 관리 =====
// 원본 데이터 (클라이언트 사이드 필터링용)
const [rawData, setRawData] = useState(initialData || []);
// UI 상태
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(!initialData);
- const [searchValue, setSearchValue] = useState(''); // UI 입력용 (즉시 반영)
- const [debouncedSearchValue, setDebouncedSearchValue] = useState(''); // API 호출용 (debounced)
+ const [searchValue, setSearchValue] = useState(restoredSearch); // UI 입력용 (즉시 반영, sessionStorage에서 복원)
+ const [debouncedSearchValue, setDebouncedSearchValue] = useState(restoredSearch); // API 호출용 (debounced)
const [selectedItems, setSelectedItems] = useState>(new Set());
// 탭이 없으면 IntegratedListTemplateV2의 기본값 'default'와 일치해야 함
const [activeTab, setActiveTab] = useState(
@@ -536,7 +544,7 @@ export function UniversalListPage({
// ===== 검색 핸들러 =====
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value); // UI 즉시 반영
- // 페이지 초기화와 외부 콜백은 debounce 후 실행됨 (아래 useEffect에서 처리)
+ setSearchStateValue('search', value); // sessionStorage + URL 동기화
}, []);
// 외부 콜백에 debounced 값 전달 (자체 loadData 관리하는 컴포넌트용)
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index af8c00f5..f2a21cb7 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -37,6 +37,14 @@ export type {
UseDetailPermissionsReturn,
} from './useDetailPermissions';
+// ===== 리스트 검색 상태 보존 =====
+export { useListSearchState } from './useListSearchState';
+export type {
+ SearchStateField,
+ UseListSearchStateOptions,
+ UseListSearchStateReturn,
+} from './useListSearchState';
+
// ===== 날짜 범위 / 목록 페이지 =====
export { useDateRange } from './useDateRange';
export type { DateRangePreset, UseDateRangeReturn } from './useDateRange';
diff --git a/src/hooks/useListSearchState.ts b/src/hooks/useListSearchState.ts
new file mode 100644
index 00000000..c4fa1fad
--- /dev/null
+++ b/src/hooks/useListSearchState.ts
@@ -0,0 +1,297 @@
+'use client';
+
+import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
+import { useRouter, useSearchParams, usePathname } from 'next/navigation';
+
+/**
+ * 검색 상태 필드 설정
+ */
+export interface SearchStateField {
+ /** URL 쿼리 파라미터 키 */
+ key: string;
+ /** 기본값 */
+ defaultValue: string;
+}
+
+export interface UseListSearchStateOptions {
+ /** 관리할 검색 상태 필드들 (기본: search, tab) */
+ fields?: SearchStateField[];
+ /** URL 업데이트 디바운스 ms (기본: 300) */
+ debounceMs?: number;
+}
+
+export interface UseListSearchStateReturn {
+ /** 검색 상태 값 조회 */
+ getValue: (key: string) => string;
+ /** 검색 상태 값 변경 (URL 자동 동기화) */
+ setValue: (key: string, value: string) => void;
+ /** 현재 페이지 번호 */
+ currentPage: number;
+ /** 페이지 변경 */
+ setPage: (page: number) => void;
+ /** 전체 상태 초기화 */
+ resetAll: () => void;
+ /** 현재 목록 URL (쿼리 포함) — 상세페이지에서 돌아올 때 사용 */
+ listUrl: string;
+}
+
+const DEFAULT_FIELDS: SearchStateField[] = [
+ { key: 'search', defaultValue: '' },
+ { key: 'tab', defaultValue: 'all' },
+];
+
+// ===== sessionStorage 헬퍼 =====
+function getStorageKey(pathname: string): string {
+ return `listSearch:${pathname}`;
+}
+
+function saveToStorage(pathname: string, state: Record) {
+ if (typeof window === 'undefined') return;
+ try {
+ sessionStorage.setItem(getStorageKey(pathname), JSON.stringify(state));
+ } catch {
+ // sessionStorage 용량 초과 등 무시
+ }
+}
+
+function loadFromStorage(pathname: string): Record | null {
+ if (typeof window === 'undefined') return null;
+ try {
+ const raw = sessionStorage.getItem(getStorageKey(pathname));
+ return raw ? JSON.parse(raw) : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * 리스트 페이지 검색 상태 보존 훅
+ *
+ * URL 쿼리파라미터 + sessionStorage 이중 저장으로
+ * 상세 페이지 이동 후 돌아왔을 때 검색 상태가 유지됩니다.
+ *
+ * 우선순위: URL 파라미터 > sessionStorage > 기본값
+ */
+export function useListSearchState(
+ options: UseListSearchStateOptions = {}
+): UseListSearchStateReturn {
+ const { fields = DEFAULT_FIELDS, debounceMs = 300 } = options;
+
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ // fields를 ref로 안정화
+ const fieldsRef = useRef(fields);
+ fieldsRef.current = fields;
+
+ // 초기값: URL 파라미터 > sessionStorage > 기본값
+ const getInitialValues = (): Record => {
+ const values: Record = {};
+ let hasUrlParams = false;
+
+ for (const field of fieldsRef.current) {
+ const urlValue = searchParams?.get(field.key);
+ if (urlValue !== null) {
+ values[field.key] = urlValue;
+ hasUrlParams = true;
+ }
+ }
+ const urlPage = searchParams?.get('page');
+ if (urlPage) hasUrlParams = true;
+
+ // URL에 파라미터가 있으면 URL 우선
+ if (hasUrlParams) {
+ for (const field of fieldsRef.current) {
+ if (values[field.key] === undefined) {
+ values[field.key] = field.defaultValue;
+ }
+ }
+ values._page = urlPage ?? '1';
+ return values;
+ }
+
+ // URL에 없으면 sessionStorage 복원 시도
+ const stored = loadFromStorage(pathname);
+ if (stored) {
+ return stored;
+ }
+
+ // 둘 다 없으면 기본값
+ for (const field of fieldsRef.current) {
+ values[field.key] = field.defaultValue;
+ }
+ values._page = '1';
+ return values;
+ };
+
+ // 로컬 상태
+ const [localState, setLocalState] = useState>(getInitialValues);
+
+ // ref로 현재 상태 추적
+ const stateRef = useRef(localState);
+ stateRef.current = localState;
+
+ // URL 업데이트 타이머 + 내부 업데이트 추적
+ const timerRef = useRef | null>(null);
+ const isInternalUpdate = useRef(false);
+
+ // URL → 로컬 상태 동기화 (브라우저 뒤로가기 대응)
+ useEffect(() => {
+ if (isInternalUpdate.current) {
+ isInternalUpdate.current = false;
+ return;
+ }
+ // URL에 파라미터가 없으면 sessionStorage에서 복원
+ let hasUrlParams = false;
+ for (const field of fieldsRef.current) {
+ if (searchParams?.get(field.key) !== null) {
+ hasUrlParams = true;
+ break;
+ }
+ }
+ if (searchParams?.get('page') !== null) hasUrlParams = true;
+
+ let newValues: Record;
+ if (hasUrlParams) {
+ newValues = {} as Record;
+ for (const field of fieldsRef.current) {
+ newValues[field.key] = searchParams?.get(field.key) ?? field.defaultValue;
+ }
+ newValues._page = searchParams?.get('page') ?? '1';
+ } else {
+ const stored = loadFromStorage(pathname);
+ if (stored) {
+ newValues = stored;
+ // sessionStorage에서 복원한 상태를 URL에도 반영
+ queueMicrotask(() => syncToUrlImmediate(stored));
+ } else {
+ return; // 기본값이면 변경 불필요
+ }
+ }
+
+ const current = stateRef.current;
+ const isDifferent = Object.keys(newValues).some(
+ (key) => newValues[key] !== current[key]
+ );
+ if (isDifferent) {
+ setLocalState(newValues);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [searchParams]);
+
+ // URL 즉시 업데이트 (디바운스 없이)
+ const syncToUrlImmediate = useCallback(
+ (state: Record) => {
+ isInternalUpdate.current = true;
+ const params = new URLSearchParams();
+ for (const field of fieldsRef.current) {
+ const value = state[field.key];
+ if (value && value !== field.defaultValue) {
+ params.set(field.key, value);
+ }
+ }
+ const page = state._page;
+ if (page && page !== '1') {
+ params.set('page', page);
+ }
+ const query = params.toString();
+ const url = query ? `${pathname}?${query}` : pathname;
+ router.replace(url, { scroll: false });
+ },
+ [pathname, router]
+ );
+
+ // 로컬 상태 → URL + sessionStorage 동기화 (디바운스)
+ const syncToUrl = useCallback(
+ (newState: Record) => {
+ // sessionStorage는 즉시 저장 (디바운스 없이)
+ saveToStorage(pathname, newState);
+
+ if (timerRef.current) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => {
+ syncToUrlImmediate(newState);
+ }, debounceMs);
+ },
+ [pathname, debounceMs, syncToUrlImmediate]
+ );
+
+ // 값 조회
+ const getValue = useCallback(
+ (key: string): string => {
+ return localState[key] ?? fieldsRef.current.find((f) => f.key === key)?.defaultValue ?? '';
+ },
+ [localState]
+ );
+
+ // 값 변경
+ const setValue = useCallback(
+ (key: string, value: string) => {
+ setLocalState((prev) => {
+ if (prev[key] === value) return prev;
+ const next = { ...prev, [key]: value };
+ if (key !== '_page') {
+ next._page = '1';
+ }
+ queueMicrotask(() => syncToUrl(next));
+ return next;
+ });
+ },
+ [syncToUrl]
+ );
+
+ // 페이지
+ const currentPage = parseInt(localState._page || '1', 10);
+ const setPage = useCallback(
+ (page: number) => {
+ setValue('_page', String(page));
+ },
+ [setValue]
+ );
+
+ // 초기화
+ const resetAll = useCallback(() => {
+ const defaults: Record = {};
+ for (const field of fieldsRef.current) {
+ defaults[field.key] = field.defaultValue;
+ }
+ defaults._page = '1';
+ setLocalState(defaults);
+ saveToStorage(pathname, defaults);
+ isInternalUpdate.current = true;
+ router.replace(pathname, { scroll: false });
+ }, [pathname, router]);
+
+ // 현재 목록 URL (쿼리 포함)
+ const listUrl = useMemo(() => {
+ const params = new URLSearchParams();
+ for (const field of fieldsRef.current) {
+ const value = localState[field.key];
+ if (value && value !== field.defaultValue) {
+ params.set(field.key, value);
+ }
+ }
+ const page = localState._page;
+ if (page && page !== '1') {
+ params.set('page', page);
+ }
+ const query = params.toString();
+ return query ? `${pathname}?${query}` : pathname;
+ }, [localState, pathname]);
+
+ // cleanup
+ useEffect(() => {
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current);
+ };
+ }, []);
+
+ return {
+ getValue,
+ setValue,
+ currentPage,
+ setPage,
+ resetAll,
+ listUrl,
+ };
+}