검색어 + 페이지 번호 + 정렬 조건을 하나의 URL 상태로 만들기
목록 → 상세 → 뒤로가기까지 검색 상태를 유지하고 복원하는 실무형 구현
이번 3편에서 만들 것
이번 3편에서는 검색어, 페이지 번호, 정렬 조건을 하나의 URL 상태로 관리하고, 목록 → 상세 → 뒤로가기 과정에서 사용자가 보던 목록 상태를 다시 구성하는 방법을 다룹니다.
URL 하나만으로 현재 목록의 검색어, 페이지, 정렬 상태를 표현합니다.
1. 왜 목록 상태를 URL에 넣어야 할까?
목록 화면의 상태를 Vue 메모리 안에서만 관리하면 새로고침이나 화면 이동 과정에서 현재 상태를 다시 구성하기 위한 별도의 처리가 필요합니다.
검색어와 페이지 번호, 정렬 조건처럼 동일한 목록을 다시 재현해야 하는 값을 URL에 표현하면 URL 자체가 현재 목록 상태를 설명할 수 있습니다.
| 상태 | URL 예시 | 역할 |
|---|---|---|
| 검색어 | search=vue | 검색 조건 |
| 페이지 | page=3 | 현재 조회 위치 |
| 정렬 | sort=latest | 목록 정렬 기준 |
2. URL에서 목록 상태 읽기
브라우저의 URLSearchParams를 사용하면 Query String을 직접 문자열로 분리하지 않고 읽을 수 있습니다.
const params = new URLSearchParams(window.location.search);
const search = params.get('search') ?? '';
const page = Number(params.get('page')) || 1;
const sort = params.get('sort') ?? 'latest';
예를 들어 주소가 다음과 같다면:
/Board?search=vue&page=3&sort=latest
search는 vue, page는 3, sort는 latest로 읽습니다.
3. URL 상태를 Vue의 반응형 상태로 초기화
import { ref } from 'vue';
const params = new URLSearchParams(window.location.search);
const search = ref(params.get('search') ?? '');
const page = ref(Number(params.get('page')) || 1);
const sort = ref(params.get('sort') ?? 'latest');
이제 Vue 내부에서는 search, page, sort를 현재 목록 상태로 사용할 수 있습니다.
4. URL 생성 책임을 하나의 함수로 모으기
function buildListUrl() {
const params = new URLSearchParams();
if (search.value) {
params.set('search', search.value);
}
params.set('page', String(page.value));
params.set('sort', sort.value);
return `/Board?${params.toString()}`;
}
검색, 정렬, 페이지 버튼에서 URL을 각각 문자열로 조립하지 않고 하나의 함수에서 관리합니다.
5. URL을 브라우저 History에 반영하기
history.pushState()를 사용하면 현재 문서를 새로 로드하지 않고 URL과 History entry를 변경할 수 있습니다.
function updateUrl() {
const url = buildListUrl();
history.pushState(
{},
'',
url
);
}
6. 검색어가 변경되면 페이지를 1페이지로 초기화
async function changeSearch() {
page.value = 1;
updateUrl();
await loadPosts();
}
검색 조건이 바뀌면 새로운 검색 결과의 첫 페이지부터 조회하도록 구성합니다.
7. 정렬 조건 변경
async function changeSort(value) {
sort.value = value;
page.value = 1;
updateUrl();
await loadPosts();
}
8. 페이지 이동
async function changePage(value) {
page.value = value;
updateUrl();
await loadPosts();
}
페이지 이동 역시 URL에 기록되므로 현재 페이지를 URL만으로 재구성할 수 있습니다.
9. 브라우저 뒤로가기는 popstate로 처리한다
중요한 부분입니다. pushState()가 Vue의 ref 값을 자동으로 복원해 주는 것은 아닙니다. 브라우저가 History entry를 변경할 때 발생하는 popstate를 감지하고 URL을 다시 읽어 Vue 상태를 복원해야 합니다.
window.addEventListener('popstate', () => {
restoreStateFromUrl();
loadPosts();
});
10. URL → Vue 상태 복원 함수를 만든다
function restoreStateFromUrl() {
const params = new URLSearchParams(window.location.search);
search.value = params.get('search') ?? '';
page.value = Number(params.get('page')) || 1;
sort.value = params.get('sort') ?? 'latest';
}
초기 진입과 뒤로가기 모두 동일한 복원 함수를 사용할 수 있습니다.
11. 목록 → 상세 이동
현재 목록 상태를 상세 페이지에 전달할 필요가 있다면 현재 목록 URL을 returnUrl로 전달할 수 있습니다.
function openDetail(id) {
const returnUrl = buildListUrl();
const detailUrl =
`/Board/Detail/${id}?returnUrl=${encodeURIComponent(returnUrl)}`;
window.location.href = detailUrl;
}
예를 들어 현재 목록이 /Board?search=vue&page=3&sort=latest라면 이 값이 인코딩되어 상세 URL의 returnUrl에 들어갑니다.
12. ASP.NET Core MVC에서 returnUrl 받기
public IActionResult Detail(int id, string? returnUrl)
{
var model = GetBoardDetail(id);
ViewBag.ReturnUrl = returnUrl;
return View(model);
}
returnUrl은 외부에서 전달되는 입력값이므로 Redirect에 사용하기 전에 검증해야 합니다.
13. returnUrl은 Local URL인지 검증한다
ASP.NET Core MVC에서는 Url.IsLocalUrl()을 사용하여 로컬 URL인지 확인할 수 있습니다.
private IActionResult RedirectToLocal(string? returnUrl)
{
if (!string.IsNullOrWhiteSpace(returnUrl)
&& Url.IsLocalUrl(returnUrl))
{
return Redirect(returnUrl);
}
return RedirectToAction(nameof(Index));
}
외부에서 전달된 returnUrl을 검증 없이 Redirect()에 전달하지 않습니다.
14. 브라우저 뒤로가기와 목록 버튼의 차이
History entry가 이전 상태로 이동하면 popstate에서 현재 URL을 다시 읽고 Vue 상태와 목록 데이터를 복원합니다.
전달받은 returnUrl을 검증한 후 해당 목록 URL로 이동합니다.
15. Vue 전체 핵심 코드
다음 코드는 URL 읽기, Vue 상태 복원, URL 생성, History 변경, 뒤로가기 복원까지 연결한 하나의 예제입니다.
import { ref, onMounted, onBeforeUnmount } from 'vue';
const search = ref('');
const page = ref(1);
const sort = ref('latest');
const posts = ref([]);
const totalCount = ref(0);
function restoreStateFromUrl() {
const params = new URLSearchParams(window.location.search);
search.value = params.get('search') ?? '';
page.value = Number(params.get('page')) || 1;
sort.value = params.get('sort') ?? 'latest';
}
function buildListUrl() {
const params = new URLSearchParams();
if (search.value) {
params.set('search', search.value);
}
params.set('page', String(page.value));
params.set('sort', sort.value);
return `/Board?${params.toString()}`;
}
async function loadPosts() {
const params = new URLSearchParams();
if (search.value) {
params.set('search', search.value);
}
params.set('page', String(page.value));
params.set('sort', sort.value);
const response = await fetch(
`/api/board?${params.toString()}`
);
if (!response.ok) {
throw new Error('게시글 조회에 실패했습니다.');
}
const data = await response.json();
posts.value = data.items;
totalCount.value = data.totalCount;
}
function updateUrl() {
history.pushState(
{},
'',
buildListUrl()
);
}
async function changeSearch() {
page.value = 1;
updateUrl();
await loadPosts();
}
async function changeSort(value) {
sort.value = value;
page.value = 1;
updateUrl();
await loadPosts();
}
async function changePage(value) {
page.value = value;
updateUrl();
await loadPosts();
}
function openDetail(id) {
const returnUrl = buildListUrl();
const detailUrl =
`/Board/Detail/${id}?returnUrl=${encodeURIComponent(returnUrl)}`;
window.location.href = detailUrl;
}
async function handlePopState() {
restoreStateFromUrl();
await loadPosts();
}
onMounted(async () => {
restoreStateFromUrl();
window.addEventListener(
'popstate',
handlePopState
);
await loadPosts();
});
onBeforeUnmount(() => {
window.removeEventListener(
'popstate',
handlePopState
);
});
16. Vue Template 연결 예제
<input
v-model="search"
type="search"
placeholder="검색어를 입력하세요"
>
<button
type="button"
@click="changeSearch"
>
검색
</button>
<select
:value="sort"
@change="changeSort($event.target.value)"
>
<option value="latest">최신순</option>
<option value="title">제목순</option>
</select>
<button
v-for="item in pages"
:key="item"
type="button"
@click="changePage(item)"
>
{{ item }}
</button>
<a
href="#"
@click.prevent="openDetail(post.id)"
>
{{ post.title }}
</a>
17. 실제 사용자 흐름
18. URL 상태와 API 요청을 동일하게 유지한다
URL과 API 요청이 서로 다른 값을 사용하면 주소와 실제 화면이 다른 상태를 가리킬 수 있습니다. 따라서 동일한 search, page, sort 값을 사용해야 합니다.
const params = new URLSearchParams();
if (search.value) {
params.set('search', search.value);
}
params.set('page', String(page.value));
params.set('sort', sort.value);
const response = await fetch(
`/api/board?${params.toString()}`
);
19. ASP.NET Core API 요청 모델
public sealed class BoardListRequest
{
public string? Search { get; init; }
public int Page { get; init; } = 1;
public string Sort { get; init; } = "latest";
}
[HttpGet]
public IActionResult List([FromQuery] BoardListRequest request)
{
// request.Search
// request.Page
// request.Sort
return Ok();
}
20. 실무에서 확장하는 방법
검색어, 페이지, 정렬뿐 아니라 카테고리나 날짜 범위 같은 조건도 같은 원칙으로 확장할 수 있습니다.
/Board?search=vue&page=3&sort=latest&category=notice&from=2026-09-01&to=2026-09-16
다만 URL에는 목록을 재현하는 데 필요한 상태를 중심으로 넣고, 민감한 정보나 대량의 임시 데이터를 넣는 방식은 피하는 것이 좋습니다.
21. pushState와 replaceState를 구분하기
pushState()는 새로운 History entry를 추가합니다. 반대로 현재 History entry만 변경하고 별도의 뒤로가기 단계를 만들지 않으려면 replaceState()를 사용할 수 있습니다.
history.replaceState(
{},
'',
buildListUrl()
);
검색어 입력 한 글자마다 pushState()를 호출하면 History가 지나치게 많이 쌓일 수 있으므로 검색 버튼이나 debounce 등 실제 UX에 맞는 방식을 선택해야 합니다.
22. 최종 구조
URL → URLSearchParams → Vue 상태 → API 요청 → 목록 렌더링
뒤로가기 → popstate → URL 다시 읽기 → Vue 상태 복원 → API 재조회
23. 최종 체크리스트
☑ 검색어가 URL에 저장되는가?
☑ 페이지 번호가 URL에 저장되는가?
☑ 정렬 조건이 URL에 저장되는가?
☑ URL에서 Vue 상태를 초기화하는가?
☑ URL 생성 로직이 하나의 함수로 관리되는가?
☑ 검색 조건 변경 시 페이지를 1로 초기화하는가?
☑ pushState로 목록 History를 관리하는가?
☑ popstate에서 URL을 다시 읽는가?
☑ popstate 이후 목록 데이터를 다시 조회하는가?
☑ 상세 페이지의 returnUrl을 검증하는가?
☑ API 요청도 동일한 검색·페이지·정렬 상태를 사용하는가?
24. 마무리
이번 편의 핵심은 검색어, 페이지 번호, 정렬 조건을 URL로 정의하고 이를 Vue 상태와 API 요청에 동일하게 사용하는 것입니다.
목록 변경 → Vue 상태 → URL
뒤로가기 → popstate → URL → Vue 상태 → 목록 재조회
이 구조를 기반으로 다음 단계에서는 카테고리, 날짜 범위, 상태 필터까지 URL 상태에 포함하고 ASP.NET Core API와 Dapper를 통해 SQL Server의 검색·페이징·정렬까지 연결할 수 있습니다.
공식 참고 자료
- Vue.js 공식 문서 — Reactivity API / ref()
- MDN Web Docs — URLSearchParams
- MDN Web Docs — History API / pushState()
- MDN Web Docs — popstate
- Microsoft Learn — ASP.NET Core MVC
- Microsoft Learn — ASP.NET Core Open Redirect 방지
※ 본문의 코드는 URL 상태 관리 패턴을 설명하기 위한 예제입니다. 특정 프로젝트에서 직접 실행한 실측 결과가 아니며, 실제 API·Controller·DB 구조는 프로젝트 환경에 맞게 연결해야 합니다.
※ 작성 기준일: 2026년 9월 16일
#ASP.NETCore #ASP.NETCoreMVC #CSharp #Vue3 #JavaScript #URLState #URLSearchParams #HistoryAPI #popstate #pushState #페이징 #검색 #정렬 #웹개발 #Blogger
댓글
댓글 쓰기