URL 쿼리로 토글하는 인라인 전체화면: Next.js App Router + useRouter().replace() 패턴
'별도 /fullscreen 라우트 만들지 말고, 같은 페이지에서 ?panel=full 쿼리로 토글합시다.' PR 리뷰 코멘트 하나로 페이지 전체가 단순해졌다. URL을 상태로 삼는 패턴과 그 한계.
1. 문제 상황
1.1 "별도 라우트를 만들었더니 이상해요"
주간 리포트의 상세 뷰를 처음 만들 때, 분할 뷰에서 한 과제를 선택하면 우측 패널에 상세 정보가 뜨고, 확장 버튼을 누르면 전체 화면 모드로 전환되어 테이블 없이 상세만 크게 보이도록 설계했습니다.
초기 구현은 별도 라우트였습니다:
/reports/weekly → 테이블 + 분할 뷰
/reports/weekly/projects/[projectId] → 전체 화면 상세
페이지가 완전히 전환되니까 테이블 숨김, 네비게이션 바 재조정 등이 깨끗해 보였습니다. 하지만 리뷰에서 나온 코멘트:
같은 페이지에서 뷰 모드만 바뀌는 건데 라우트를 분리하면 주차 이동/닫기 동작이 복잡해지고 뒤로 가기 흐름도 어색해져요.
?panel=full쿼리로 토글하는 게 낫습니다.
맞는 지적이었습니다. 당시 코드는 네비게이션 상태가 분산되어 있었고, 주차 이동 시 "전체 화면 상태를 유지할지"를 일일이 처리해야 했습니다.
1.2 인라인 vs 별도 라우트 비교
| 항목 | 별도 라우트 | 인라인 (URL 쿼리) |
|---|---|---|
| 상태 공유 | 테이블 상태가 리셋됨 | 같은 페이지 → 상태 유지 |
| 네비게이션 | 주차 이동 시 라우트 변경 | URL 파라미터만 변경 |
| 뒤로가기 | "전체 화면 해제"가 뒤로가기 | 깨끗한 토글 |
| 구현 복잡도 | 두 페이지 모두 구현 | 하나의 뷰 컴포넌트 분기 |
| 파일 수 | 2개 page 파일 | 1개 |
초기 구현의 2개 page 파일 + 상태 동기화 로직 때문에 코드가 어지러워지고 있었습니다. 인라인 방식으로 바꾼 뒤 42줄 / 113줄 삭제, 핵심 뷰 컴포넌트만 리팩토링으로 충분했습니다.
2. 원인 분석
2.1 상태를 어디에 둘 것인가
React 애플리케이션에서 "뷰 상태"는 여러 곳에 둘 수 있습니다:
| 저장소 | 특징 | 공유 범위 |
|---|---|---|
| useState | 컴포넌트 로컬 | 현재 컴포넌트만 |
| Context | 트리 공유 | 공급자 하위 |
| Zustand/Redux | 전역 | 앱 전체 |
| URL query | 주소 표시줄 | 주소 복사/공유/뒤로가기 |
| URL path | 라우트 | 페이지 분리 |
"전체 화면 모드"는 어디에 속할까요?
- useState로 두면 주소 표시줄에 반영 안 됨 → 새로고침 시 리셋
- URL query로 두면 주소 복사/공유 시 상태 유지, 뒤로가기 작동
"특정 과제를 전체 화면으로 보는 중이다"라는 상태는 공유 가능해야 자연스럽습니다 — 링크로 다른 팀원에게 보낼 수 있어야 하니까요. 그래서 URL query가 정답입니다.
2.2 URL query의 3요소
이번 경우 URL에 담을 상태가 3개였습니다:
year— 주차의 연도week— 주차 번호project— 선택된 프로젝트 ID (null 가능)panel=full— 전체 화면 모드 여부
예시 URL:
/reports/weekly?year=2026&week=15 # 테이블만
/reports/weekly?year=2026&week=15&project=p1 # 분할 뷰
/reports/weekly?year=2026&week=15&project=p1&panel=full # 전체 화면
모든 상태가 URL에 담기므로, 주소 복사로 정확히 그 뷰를 팀원과 공유할 수 있습니다.
2.3 router.push vs router.replace
Next.js App Router에는 두 가지 네비게이션 메서드가 있습니다:
router.push(url)— 브라우저 히스토리에 새 entry를 추가router.replace(url)— 현재 entry를 덮어씀 (히스토리 추가 X)
뷰 모드 전환은 replace가 맞습니다. 이유:
push를 쓰면 "뒤로가기"가 토글마다 쌓여 이상한 히스토리가 만들어짐- 사용자가 "뒤로가기"를 눌렀을 때 이전 뷰가 아닌 이전 페이지로 가는 게 자연스러움
- 뷰 전환은 "페이지 이동"이 아니라 "같은 페이지의 상태 변경"이므로
// ✅ URL 업데이트에만 사용
router.replace(buildUrl(projectId, panelFull), { scroll: false });
scroll: false는 "URL만 바꾸고 스크롤 위치는 유지"하는 옵션입니다. 토글에선 거의 필수입니다 — 매번 맨 위로 스크롤되면 사용자 경험이 깨집니다.
3. 해결 방법
3.1 상태 두 곳: useState + URL
// src/components/reports/weekly-report-view.tsx
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter, usePathname } from "next/navigation";
export function WeeklyReportView({
year, week,
initialSelectedProjectId,
initialPanelFull,
}: {
year: number;
week: number;
initialSelectedProjectId: string | null;
initialPanelFull: boolean;
}) {
const router = useRouter();
const pathname = usePathname();
// 로컬 상태 — 즉각 반영, 렌더링 트리거
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
initialSelectedProjectId,
);
const [panelFull, setPanelFull] = useState(initialPanelFull);
// ... report data fetching ...
function buildUrl(projectId: string | null, full: boolean): string {
const params = new URLSearchParams();
params.set("year", String(year));
params.set("week", String(week));
if (projectId) params.set("project", projectId);
if (full) params.set("panel", "full");
return `${pathname}?${params.toString()}`;
}
function handleSelectEntry(projectId: string) {
// 같은 과제 재클릭 → 분할 뷰 닫기 (토글)
if (selectedProjectId === projectId && !panelFull) {
setSelectedProjectId(null);
router.replace(buildUrl(null, false), { scroll: false });
return;
}
setSelectedProjectId(projectId);
router.replace(buildUrl(projectId, panelFull), { scroll: false });
}
function handleClosePanel() {
setSelectedProjectId(null);
setPanelFull(false);
router.replace(buildUrl(null, false), { scroll: false });
}
function handleToggleFull() {
const next = !panelFull;
setPanelFull(next);
router.replace(buildUrl(selectedProjectId, next), { scroll: false });
}
// ... render based on selectedProjectId + panelFull ...
}
핵심 포인트:
useState로 로컬 상태 관리, URL 업데이트는router.replace로 보조- 양쪽을 같이 업데이트해야 일관성 유지 (
setPanelFull(true); router.replace(...)쌍) - 주차 이동 시 초기값 재주입:
initialPanelFull이 props로 오므로useEffect로 state 동기화
3.2 서버 컴포넌트에서 초기값 제공
Next.js App Router에서 URL query를 읽는 건 서버 컴포넌트에서 가장 깔끔합니다.
// src/app/(saas)/reports/weekly/page.tsx
export default async function WeeklyReportPage({
searchParams,
}: {
searchParams: Promise<{ year?: string; week?: string; project?: string; panel?: string }>;
}) {
const params = await searchParams;
const year = parseInt(params.year ?? "", 10) || defaultYear;
const week = parseInt(params.week ?? "", 10) || defaultWeek;
const initialSelectedProjectId = params.project ?? null;
const initialPanelFull = params.panel === "full";
return (
<WeeklyReportView
year={year}
week={week}
initialSelectedProjectId={initialSelectedProjectId}
initialPanelFull={initialPanelFull}
/>
);
}
왜 서버에서 읽나?
- 첫 렌더에 상태가 맞게 표시됨 (SSR hydration 일관성)
- 클라이언트에서
useSearchParams로 읽으면 초기 렌더에서 한 번null로 시작하고 이후 업데이트 → 플래시 유발
3.3 Props와 로컬 state 동기화
사용자가 주차를 이동(year=15 → year=16)하면 서버 컴포넌트가 새 initialPanelFull을 내려줍니다. 이때 로컬 state도 맞춰야 합니다:
// 주차 props가 바뀌면 URL 쿼리와 동기화
useEffect(() => {
setSelectedProjectId(initialSelectedProjectId);
setPanelFull(initialPanelFull);
}, [initialSelectedProjectId, initialPanelFull]);
이 useEffect 한 블록이 서버 ↔ 클라이언트 상태 일관성을 유지합니다. 생략하면 이전 주차의 panelFull 상태가 새 주차에 섞여 버그를 만듭니다.
4. 뷰 조건 분기
로컬 state 두 개로 4가지 뷰 모드가 파생됩니다:
const isFullMode = !!selectedEntry && panelFull; // 전체 화면
const isSplitMode = !!selectedEntry && !panelFull; // 분할 뷰
const isTableOnly = !selectedEntry; // 테이블만
// const noEntry = ... — 에러 또는 404 처리
렌더링:
{loading ? (
<TableSkeleton />
) : isFullMode && selectedEntry ? (
// 전체 화면: 테이블 숨기고 패널만 표시
<ReportEntryPanel
entry={selectedEntry}
panelFull
onClose={handleClosePanel}
onToggleFull={handleToggleFull}
/>
) : isSplitMode && selectedEntry ? (
// 분할 뷰: 좌측 sticky 테이블 + 우측 패널
<div className="grid grid-cols-[minmax(0,420px)_minmax(0,1fr)] gap-6 items-start">
<div className="min-w-0 sticky top-6 self-start max-h-[calc(100vh-160px)] overflow-auto">
<WeeklyReportTable ... compact />
</div>
<div className="min-w-0">
<ReportEntryPanel ... />
</div>
</div>
) : (
// 테이블 전용
<WeeklyReportTable ... />
)}
단일 컴포넌트 안에서 분기만으로 세 뷰를 처리합니다. 별도 라우트를 뒀을 때보다 훨씬 간단하고, 전환 시 상태가 섞이지 않습니다.
5. 핵심 개념 정리
5.1 URL을 상태 저장소로 보기
전통적인 React 사고방식에서는 "상태 = useState / useReducer / Context"입니다. URL은 "라우팅"이지 상태가 아니었습니다.
하지만 URL은 실은 매우 강력한 상태 저장소입니다:
- 새로고침 시에도 유지
- 브라우저 뒤로가기/앞으로가기로 네비게이션
- 링크로 공유 가능
- 북마크 가능
"이 상태를 URL에 넣을 가치가 있나?"를 물어보는 습관이 필요합니다. 답이 "네"라면 URL query를 써야 합니다.
5.2 어떤 상태를 URL에 둘지
| 예시 | URL에 둘까? |
|---|---|
| "어느 페이지에 있는가" | ✅ path로 |
| "어느 탭을 보고 있는가" | ✅ query로 |
| "필터/검색어" | ✅ query로 (공유 필수) |
| "모달이 열려있는가" | ⚠️ 상황에 따라 |
| "스크롤 위치" | ❌ 로컬 state |
| "임시 입력값" | ❌ 로컬 state |
| "호버 중인가" | ❌ 로컬 state |
판단 기준: 그 상태를 링크로 공유하고 싶은가?
5.3 URL + Local State 이중화의 이유
이번 구현처럼 useState와 router.replace를 둘 다 쓰는 이유:
useState: 렌더링 트리거 + 동기적 업데이트 (handleSelectEntry이후 즉시 반영)router.replace: URL 반영 (뒤로가기/새로고침 대비)
URL만 쓰면 매 업데이트마다 페이지가 재조립되어 성능이 나쁘고, useState만 쓰면 URL이 업데이트되지 않습니다. 두 가지를 함께 써야 둘 다의 이점을 얻습니다.
6. 베스트 프랙티스
6.1 체크리스트
- [ ] URL에 담을 상태와 로컬 state를 명확히 구분
- [ ] URL 업데이트는
router.replace(push 아님) - [ ]
scroll: false옵션으로 스크롤 점프 방지 - [ ] 서버 컴포넌트에서
searchParams로 초기값 읽기 (SSR 일관성) - [ ]
useEffect로 props → local state 동기화 - [ ]
buildUrl헬퍼로 URL 생성을 한 곳에 모음 (여러 handler가 참조) - [ ] 쿼리 키 이름을 상수화 (
PANEL_QUERY_KEY = "panel"등)
6.2 buildUrl 헬퍼 일관성
URL을 만드는 로직이 여러 handler에 흩어져 있으면 실수가 생깁니다. buildUrl(projectId, panelFull) 하나로 통일:
function buildUrl(projectId: string | null, full: boolean): string {
const params = new URLSearchParams();
params.set("year", String(year));
params.set("week", String(week));
if (projectId) params.set("project", projectId);
if (full) params.set("panel", "full");
return `${pathname}?${params.toString()}`;
}
장점:
- 모든 네비게이션이
buildUrl(next state)로 통일 year/week같은 기본 파라미터 누락 방지- 쿼리 키 이름 변경 시 한 곳만 수정
6.3 복잡도가 늘면 커스텀 훅으로
이번엔 뷰 컴포넌트 안에 handler 3개를 두는 게 충분했습니다. 하지만 handler가 5개 이상으로 늘거나 URL 파라미터가 추가되면 커스텀 훅이 나을 수 있습니다:
function useReportViewState(initial: {
projectId: string | null;
panelFull: boolean;
}) {
const [state, setState] = useState(initial);
const router = useRouter();
const pathname = usePathname();
const update = useCallback((next: Partial<typeof state>) => {
const merged = { ...state, ...next };
setState(merged);
const url = buildUrl(merged.projectId, merged.panelFull);
router.replace(url, { scroll: false });
}, [state, pathname, router]);
return { state, update };
}
호출부:
const { state, update } = useReportViewState({
projectId: initialSelectedProjectId,
panelFull: initialPanelFull,
});
function handleSelectEntry(projectId: string) {
update({ projectId });
}
로직이 한 곳에 모여 유지보수가 쉬워지지만, 작은 컴포넌트에서는 오히려 과한 추상화일 수 있습니다. 규모에 맞게 선택하세요.
7. FAQ
Q. useSearchParams로 클라이언트에서 읽어도 되지 않나요?
A. 가능합니다. 하지만:
- 서버 컴포넌트에서
searchParams를 props로 받는 것이 SSR/CSR 일관성이 더 좋음 useSearchParams는useRouter와 같이 써야 하고, Suspense 경계가 필요- Next.js 16의 공식 권장은 "서버에서 읽고 props로 전달"
규모가 작으면 어느 쪽이든 동작합니다.
Q. 쿼리 파라미터가 3개 이상 있으면 지저분하지 않나요?
A. URLSearchParams 객체로 관리하면 지저분함이 실제 값보다 적게 느껴집니다. 5~6개까지는 문제없고, 10개 이상은 파라미터 스키마 관리를 위해 라이브러리(nuqs, use-query-params 등) 도입을 고려할 수 있습니다.
Q. router.replace 대신 history.replaceState를 써도 되나요?
A. 기술적으로는 가능하지만 Next.js의 라우팅 시스템을 우회하게 되어 링크 전환, 데이터 fetching, loading state와 충돌할 수 있습니다. Next.js 안에서는 useRouter의 메서드를 쓰는 게 안전합니다.
Q. 뷰 전환 시 API 재호출이 되지 않나요?
A. router.replace는 같은 라우트의 URL만 바꾸므로 서버 컴포넌트가 재실행되지만, fetch에 캐시가 있다면 재사용됩니다. 또 이번 구현은 클라이언트 측 fetch(useEffect 안)를 사용하므로, year/week가 바뀔 때만 재호출됩니다. project나 panel이 바뀌어도 API는 재호출되지 않습니다.
Q. 모바일에서 전체 화면 모드는 어떻게 처리하나요?
A. panelFull 상태가 true이면 테이블을 숨기고 패널만 표시하는 건 동일합니다. 다만 모바일에서는 분할 뷰(isSplitMode)가 좁은 화면에 어울리지 않으므로, 별도 useMediaQuery로 "모바일에서는 split 대신 모바일 전체화면만" 같은 제약을 걸 수 있습니다. 이번 프로젝트는 내부 도구라 데스크톱 우선이었습니다.
8. 참고 자료
- Next.js - useRouter
- MDN - URLSearchParams
- Next.js - Searchparams
- nuqs - Type-safe query state management
9. 다음 단계
URL 상태가 일단 자리 잡으면, 다음은 타입 안전성입니다. searchParams는 항상 string | undefined라 parseInt / === "full" 같은 수동 파싱이 필요합니다. nuqs 같은 라이브러리는 타입 스키마로 이 과정을 단순화해주는데, 프로젝트 규모가 커지면 도입을 고려할 가치가 있습니다. 이 프로젝트는 아직 그 경계에 이르지 않았습니다.