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 이중화의 이유

이번 구현처럼 useStaterouter.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 일관성이 더 좋음
  • useSearchParamsuseRouter와 같이 써야 하고, 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가 바뀔 때만 재호출됩니다. projectpanel이 바뀌어도 API는 재호출되지 않습니다.

Q. 모바일에서 전체 화면 모드는 어떻게 처리하나요?

A. panelFull 상태가 true이면 테이블을 숨기고 패널만 표시하는 건 동일합니다. 다만 모바일에서는 분할 뷰(isSplitMode)가 좁은 화면에 어울리지 않으므로, 별도 useMediaQuery로 "모바일에서는 split 대신 모바일 전체화면만" 같은 제약을 걸 수 있습니다. 이번 프로젝트는 내부 도구라 데스크톱 우선이었습니다.


8. 참고 자료


9. 다음 단계

URL 상태가 일단 자리 잡으면, 다음은 타입 안전성입니다. searchParams는 항상 string | undefinedparseInt / === "full" 같은 수동 파싱이 필요합니다. nuqs 같은 라이브러리는 타입 스키마로 이 과정을 단순화해주는데, 프로젝트 규모가 커지면 도입을 고려할 가치가 있습니다. 이 프로젝트는 아직 그 경계에 이르지 않았습니다.