Skip to content

Chapter 04.5. 라우터 맛보기: Angular Router로 URL 화면 전환과 쿼리 다루기 ​

"새로고침(F5)을 누르면 왜 3페이지에서 1페이지로 튕길까? 동료에게 메신저로 링크를 복사해 주면 왜 내가 보던 검색 결과가 보이지 않을까? URL을 신뢰할 수 있는 단일 상태 원천(Single Source of Truth)으로 삼자."


📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.

개념한 줄 요약설명 위치
provideRouter & withComponentInputBinding()앱에 라우트를 등록하고 URL 파라미터/쿼리를 컴포넌트 input() 신호로 자동 바인딩하는 설정4.5.2
<router-outlet>, RouterLink, RouterLinkActiveURL에 매칭된 컴포넌트가 그려질 자리와 새로고침 없는 SPA 네비게이션 지시어4.5.3
ActivatedRoute & queryParams현재 활성 URL의 경로·쿼리스트링(?keyword=Angular&page=2)을 반응형으로 읽는 서비스4.5.4
Router.navigate() & replaceUrl: true코드로 URL을 변경하되 브라우저 방문 기록(History)을 덮어쓰는 옵션4.5.5
queryParamsHandling: 'merge'기존 URL 쿼리 파라미터를 유지하면서 특정 키만 병합·교체하는 옵션4.5.5

TIP

🏁 이 장의 시작 파일
frontend/src/app/board-app/board-app.component.ts가 4장의 [코드 4-1]과 같은 상태에서 시작합니다. 이 장에서는 프로젝트에 이미 설정된 app.routes.ts, app.config.ts, app.component.ts의 전체 구조를 확인하고, BoardAppComponent에 URL 쿼리를 읽고 쓰는 실습을 수행합니다. 이 장에서 손대는 파일: frontend/src/app/app.routes.ts, frontend/src/app/app.config.ts, frontend/src/app/board-app/board-app.component.ts


4.5.1 왜 검색 조건과 페이지 번호를 URL에 담아야 하는가? ​

4장의 [코드 4-1]에서는 검색어와 현재 페이지를 컴포넌트 메모리의 signal()에만 보관했습니다:

typescript
currentPage = signal<number>(1);
selectedCategory = signal<string>('ALL');
searchKeyword = signal<string>('');

이렇게 메모리에만 상태를 두면 실무에서 곧바로 두 가지 불만이 접수됩니다:

  1. F5(새로고침) 시 상태 증발: 열심히 DEV 카테고리로 필터링해 3페이지까지 찾아 들어갔는데, 실수로 새로고침을 누르는 순간 모든 signal이 초기값(1페이지, 'ALL')으로 리셋됩니다.
  2. 동료에게 링크 공유 불가: 사내 메신저로 *"이 에러 관련 글들 좀 보세요"*라며 브라우저 주소(http://localhost:4200/)를 복사해 보내도, 동료 화면에는 검색어 없는 전체 목록만 뜹니다.

반면 검색 조건을 **http://localhost:4200/?category=DEV&keyword=Signals&page=1**처럼 URL 쿼리스트링에 동기화해 두면, 새로고침을 해도, 북마크에 저장해도, 동료에게 링크를 공유해도, 브라우저 [뒤로 가기]를 눌러도 완벽하게 그 화면 상태가 복원됩니다!


4.5.2 Angular 22 라우터 설정 해부 (app.routes.ts & app.config.ts) ​

모던 Angular 22는 과거의 RouterModule.forRoot(routes)를 쓰지 않고, app.config.ts에서 함수형 **provideRouter()**로 라우터를 등록합니다. 실습 프로젝트의 라우터 설정 파일 두 개를 열어 전체 코드를 확인해 봅시다.

typescript
// frontend/src/app/app.routes.ts
import { Routes } from '@angular/router';
import { BoardAppComponent } from './board-app/board-app.component';

export const routes: Routes = [
  {
    path: '',
    component: BoardAppComponent,
    title: '사내 기술 Q&A 게시판'
  },
  {
    path: 'write',
    component: BoardAppComponent,
    title: '새 게시글 작성'
    // 11장에서 여기에 함수형 인증 가드 canActivate: [authGuard]를 장착합니다!
  },
  {
    path: 'login',
    // 지연 로딩(Lazy Loading): /login에 처음 진입할 때만 번들을 내려받음 (11장 상세)
    loadComponent: () =>
      import('./views/login-view.component').then((m) => m.LoginViewComponent),
    title: '사내 포털 로그인'
  },
  {
    path: '**',
    redirectTo: ''
  }
];
typescript
// frontend/src/app/app.config.ts
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    // 1. Zone.js 없는 초고속 Signals 변경 감지 활성화
    provideZonelessChangeDetection(),

    // 2. 라우터 등록 + URL 파라미터/쿼리를 컴포넌트 input() 신호로 자동 주입!
    provideRouter(routes, withComponentInputBinding()),

    // 3. 표준 Fetch API 기반 HttpClient 활성화
    provideHttpClient(withFetch())
  ]
};

🆕 처음 보는 문법: withComponentInputBinding()의 혁신

provideRouter(routes, withComponentInputBinding())을 켜 두면, URL이 /?keyword=Angular&category=DEV일 때 컴포넌트 안에서 복잡한 라우트 서비스를 구독하지 않아도 keyword = input<string>('')만 선언하면 URL 쿼리 값이 자동으로 그 input() 신호에 들어옵니다!
단, 5장에서는 컴포넌트가 아니라 독립된 @Injectable() 서비스(PostFilterService) 내부에서 URL을 양방향으로 제어해야 하므로, 어디서든 쓸 수 있는 정석 도구인 ActivatedRoute와 Router도 함께 알아두어야 합니다.


최상위 레이아웃 컴포넌트인 frontend/src/app/app.component.ts는 상단 네비게이션 메뉴와 <router-outlet>을 제공합니다.

typescript
// frontend/src/app/app.component.ts
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet, RouterLink, RouterLinkActive],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <header class="app-header">
      <h1>🏢 사내 엔터프라이즈 기술 포털</h1>
      <nav class="app-nav">
        <a
          routerLink="/"
          routerLinkActive="active-link"
          [routerLinkActiveOptions]="{ exact: true }"
        >
          📋 Q&A 목록
        </a>
        <a routerLink="/write" routerLinkActive="active-link">
          ✏️ 글쓰기
        </a>
        <a routerLink="/login" routerLinkActive="active-link">
          🔐 로그인
        </a>
      </nav>
    </header>

    <main class="app-main">
      <!-- 🌟 현재 URL과 일치하는 라우트의 컴포넌트가 이 자리에 렌더링됩니다 -->
      <router-outlet />
    </main>
  `,
  styles: [`
    .app-header { display: flex; justify-content: space-between; align-items: center; padding: 14px 24px; background: #0f172a; color: #ffffff; }
    .app-header h1 { margin: 0; font-size: 18px; }
    .app-nav { display: flex; gap: 16px; }
    .app-nav a { color: #cbd5e1; text-decoration: none; font-size: 14px; font-weight: 500; }
    .app-nav a.active-link { color: #38bdf8; font-weight: 700; border-bottom: 2px solid #38bdf8; padding-bottom: 2px; }
    .app-main { max-width: 960px; margin: 24px auto; padding: 0 16px; }
  `]
})
export class AppComponent {}
  • 일반 <a href="/write">를 쓰면 안 되는 이유: href를 쓰면 브라우저가 서버에 새 HTML을 요청하며 전체 페이지를 새로고침해 버려 메모리의 모든 신호 상태가 날아갑니다. 반드시 routerLink="/write"를 써야 자바스크립트가 History API로 주소만 바꾸고 화면 부품을 부드럽게 교체합니다.
  • routerLinkActive="active-link": 현재 URL과 일치하는 메뉴 링크에 자동으로 active-link CSS 클래스를 붙여 줍니다. / 경로는 모든 하위 경로(/write)의 접두사와 일치하므로 [routerLinkActiveOptions]="{ exact: true }"를 주어 정확히 /일 때만 활성화되게 합니다.

4.5.4 ActivatedRoute: 현재 URL 쿼리 파라미터 읽기 ​

서비스나 컴포넌트에서 현재 URL의 쿼리스트링(?keyword=Angular&category=DEV&page=2)을 읽는 방법은 두 가지입니다:

  1. 초기 스냅샷 1회 읽기 (route.snapshot.queryParamMap):
    typescript
    const initialKeyword = this.route.snapshot.queryParamMap.get('keyword') ?? '';
  2. [뒤로 가기 / 앞으로 가기]까지 실시간 감지하기 (route.queryParamMap.subscribe): 같은 게시판 화면(/) 안에서 쿼리스트링만 바뀌면 Angular는 BoardAppComponent를 파괴하고 새로 만드는 대신 기존 인스턴스를 그대로 재사용합니다. 따라서 사용자가 브라우저 [뒤로 가기]를 눌렀을 때 URL 변화를 감지하려면 queryParamMap(또는 queryParams) 스트림을 구독해야 합니다.

IMPORTANT

URL에서 꺼낸 모든 쿼리 값은 항상 문자열(string | null)입니다!
주소창이 ?page=2일 때 queryParamMap.get('page')는 숫자 2가 아니라 문자열 '2'를 반환합니다. 만약 변환 없이 page + 1을 하면 3이 아니라 '21'이 되어 버립니다! 숫자로 쓸 때는 반드시 Number(param) || 1처럼 숫자로 변환하십시오.


4.5.5 Router.navigate()와 replaceUrl: true의 결정적 차이 ​

자바스크립트 코드에서 URL 쿼리스트링을 변경할 때는 this.router.navigate([], { ... })를 사용합니다.

typescript
// 1. 페이지 번호 이동: 히스토리에 새 항목을 쌓음 (뒤로 가기로 이전 페이지 복귀 가능)
this.router.navigate([], {
  relativeTo: this.route,
  queryParams: { page: newPage },
  queryParamsHandling: 'merge' // 기존 keyword, category 쿼리는 그대로 유지하고 page만 병합!
});

// 2. 검색 필터 변경: 현재 히스토리 항목을 덮어씀 (replaceUrl: true)
this.router.navigate([], {
  relativeTo: this.route,
  queryParams: {
    keyword: newKeyword || null, // null이나 undefined를 넣으면 URL에서 해당 파라미터 제거!
    category: newCategory === 'ALL' ? null : newCategory,
    page: 1
  },
  queryParamsHandling: 'merge',
  replaceUrl: true // 🌟 히스토리를 누적하지 않고 현재 기록을 교체!
});

📞 비유: 일반 통화 기록(기본 이동) vs 007 비밀 요원의 기록 덮어쓰기(replaceUrl: true)

  • 기본 이동 (replaceUrl: false): 전화를 걸 때마다 스마트폰 통화 목록에 차곡차곡 쌓입니다. [뒤로 가기]를 누르면 직전 기록이 나옵니다.
  • replaceUrl: true (비밀 통화): 방금 건 통화 기록을 지우고 현재 위치를 그 자리로 바꿔치기합니다.
    만약 검색 조건을 바꿀 때마다 히스토리를 쌓거나, 로그인 성공 후 원래 화면으로 보낼 때 replaceUrl: true를 안 쓰면 어떻게 될까요? 사용자가 게시판을 나가려고 [뒤로 가기]를 눌렀더니 아까 검색했던 10번의 키워드를 거꾸로 다 거슬러 올라가거나, 이미 로그인했는데 다시 로그인 창으로 되돌아가는 대참사가 벌어집니다!

4.5.6 [실습] BoardAppComponent에서 URL 쿼리 연동 실험하기 ​

5장에서 PostFilterService로 분리하기 전에, BoardAppComponent 하단에 작은 라우터 실험 패널(.router-lab)을 잠시 붙여 URL 쿼리가 어떻게 바뀌고 히스토리가 어떻게 동작하는지 눈으로 확인해 봅시다.

typescript
// frontend/src/app/board-app/board-app.component.ts 에 추가할 실험 코드 발췌
import { Router, ActivatedRoute, RouterLink } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';

// @Component.imports 에 RouterLink 추가:
// imports: [FormsModule, RouterLink],

export class BoardAppComponent implements OnInit {
  private readonly router = inject(Router);
  private readonly route = inject(ActivatedRoute);

  // 🌟 RxJS queryParams 스트림을 Angular Signal로 즉시 변환!
  currentQuery = toSignal(this.route.queryParams, { initialValue: {} });

  pushKeywordSample(): void {
    this.router.navigate([], {
      relativeTo: this.route,
      queryParams: { keyword: 'Angular' },
      queryParamsHandling: 'merge'
    });
  }

  replaceCategorySample(): void {
    this.router.navigate([], {
      relativeTo: this.route,
      queryParams: { category: 'DEV' },
      queryParamsHandling: 'merge',
      replaceUrl: true // 히스토리 덮어쓰기!
    });
  }
  // ... (나머지 코드는 4장 [코드 4-1]과 동일)
html
<!-- board-app.component.html 맨 아래(.pagination 아래)에 임시 추가 -->
<div class="router-lab" style="margin-top: 20px; padding: 12px; background: #f1f5f9; border-radius: 8px; font-size: 13px;">
  <strong>🧪 [4.5장 라우터 쿼리 실험실]</strong>
  <p>현재 URL Query: <code>{{ currentQuery() | json }}</code></p>
  <div style="display: flex; gap: 8px;">
    <button type="button" (click)="pushKeywordSample()">
      1. keyword=Angular 이동 (히스토리 쌓기)
    </button>
    <button type="button" (click)="replaceCategorySample()">
      2. category=DEV 교체 (replaceUrl: true)
    </button>
    <a [routerLink]="['/']" [queryParams]="{}">3. 쿼리 초기화 링크</a>
  </div>
</div>

따라 해 보기:

  1. http://localhost:4200/에서 1. keyword=Angular 이동 버튼을 누르면 주소가 /?keyword=Angular로 바뀝니다.
  2. 이어서 2. category=DEV 교체 (replaceUrl: true) 버튼을 누르면 주소가 /?keyword=Angular&category=DEV가 됩니다.
  3. 이제 브라우저 [뒤로 가기] 버튼을 딱 한 번 눌러 보십시오! /?keyword=Angular가 아니라 처음의 /로 한 번에 돌아갑니다. 2번 버튼이 replaceUrl: true로 keyword=Angular 기록을 덮어썼기 때문입니다!

되돌리기 안내: 실험이 끝났다면 방금 추가한 .router-lab 실험 코드를 지우고 4장의 [코드 4-1] 상태로 되돌려 두십시오. 곧바로 5장에서 이 라우터 기술을 품은 정식 PostFilterService를 작성합니다!

🔬 심화(건너뛰어도 됨): 운영 서버(Nginx / Spring Boot)에서 /write를 새로고침하면 404가 나는 이유

로컬 개발 서버(ng serve)는 브라우저가 /write를 요청해도 알아서 index.html을 내려줍니다. 하지만 빌드된 정적 파일을 Nginx나 Spring Boot에 배포한 뒤 사용자가 http://사내도메인/write에서 F5(새로고침)를 누르면, 웹 서버는 실제 디스크에서 write라는 파일이나 API를 찾다가 404 Not Found를 냅니다.
이를 막으려면 웹 서버에 **"정적 파일이나 /api가 아닌 모든 경로 요청은 index.html로 포워딩하라"**는 폴백(Fallback) 설정(Nginx의 try_files $uri $uri/ /index.html;)을 반드시 넣어주어야 합니다(부록 A에서 상세히 다룹니다).


4.5.7 [직접 해보기] 실습 미션 💪 ​

withComponentInputBinding()이 켜져 있을 때, 자식/페이지 컴포넌트가 ActivatedRoute 없이 단지 input() 신호만으로 URL 쿼리 파라미터(?page=3&category=NOTICE)를 읽고 숫자로 변환하는 파생 신호를 어떻게 작성하는지 직접 구현해 보십시오.

📝 미션 요구사항 ​

  1. URL 쿼리 ?page=3을 받는 page = input<string | undefined>(undefined)와 ?category=NOTICE를 받는 category = input<string | undefined>(undefined)를 선언하십시오.
  2. page() 문자열을 안전한 양의 정수(없거나 NaN이거나 1 미만이면 기본값 1)로 변환하는 pageNumber = computed(...) 파생 신호를 작성하십시오.
  3. category()가 없으면 기본값 'ALL'을 반환하는 activeCategory = computed(...) 파생 신호를 작성하십시오.
💡 미션 정답 코드 확인하기
typescript
import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';

@Component({
  selector: 'app-query-reader-demo',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>현재 페이지 번호(숫자): {{ pageNumber() }}페이지</p>
    <p>현재 카테고리: {{ activeCategory() }}</p>
  `
})
export class QueryReaderDemoComponent {
  // 🌟 withComponentInputBinding() 덕분에 ?page=3&category=NOTICE가 자동으로 input()에 주입됨!
  page = input<string | undefined>(undefined);
  category = input<string | undefined>(undefined);

  // 문자열 쿼리를 안전한 숫자 페이지 번호로 변환
  pageNumber = computed(() => {
    const parsed = Number(this.page());
    return Number.isInteger(parsed) && parsed >= 1 ? parsed : 1;
  });

  // 빈 값일 때 'ALL'로 폴백
  activeCategory = computed(() => this.category() || 'ALL');
}

4.5.8 이번 장 핵심 요약 및 다음 단계 🚀 ​

핵심 요약 3줄

  1. 검색어나 페이지 번호를 컴포넌트 메모리에만 두면 새로고침 시 증발하고 링크 공유가 불가능하므로, URL 쿼리스트링(?keyword=...&category=...)을 신뢰할 수 있는 상태 원천으로 삼아야 합니다.
  2. Angular 22에서는 provideRouter(routes, withComponentInputBinding())로 라우터를 설정하며, 템플릿에서는 <router-outlet>과 routerLink로 새로고침 없는 화면 전환을 수행합니다.
  3. 코드로 쿼리를 바꿀 때는 this.router.navigate([], { queryParams, queryParamsHandling: 'merge', replaceUrl: true })를 사용하여 기존 쿼리를 보존하면서 불필요한 히스토리 누적을 막습니다.

다음 단계: 이제 4장의 비대한 BoardAppComponent를 해체할 모든 무기가 준비되었습니다! 이어지는 Chapter 05에서 페이징(PaginationService), URL 동기화 검색 필터(PostFilterService), 폼 검증(FormValidationService) 3개의 @Injectable() 서비스를 도려내고, 0.1초 만에 도는 첫 Vitest 자동 단위 테스트를 작성해 봅시다!

Moon HomeLab Engineering Knowledge Hub