다크 모드
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, RouterLinkActive | URL에 매칭된 컴포넌트가 그려질 자리와 새로고침 없는 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>('');이렇게 메모리에만 상태를 두면 실무에서 곧바로 두 가지 불만이 접수됩니다:
- F5(새로고침) 시 상태 증발: 열심히
DEV카테고리로 필터링해 3페이지까지 찾아 들어갔는데, 실수로 새로고침을 누르는 순간 모든signal이 초기값(1페이지,'ALL')으로 리셋됩니다. - 동료에게 링크 공유 불가: 사내 메신저로 *"이 에러 관련 글들 좀 보세요"*라며 브라우저 주소(
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도 함께 알아두어야 합니다.
4.5.3 <router-outlet>과 RouterLink (app.component.ts)
최상위 레이아웃 컴포넌트인 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-linkCSS 클래스를 붙여 줍니다./경로는 모든 하위 경로(/write)의 접두사와 일치하므로[routerLinkActiveOptions]="{ exact: true }"를 주어 정확히/일 때만 활성화되게 합니다.
4.5.4 ActivatedRoute: 현재 URL 쿼리 파라미터 읽기
서비스나 컴포넌트에서 현재 URL의 쿼리스트링(?keyword=Angular&category=DEV&page=2)을 읽는 방법은 두 가지입니다:
- 초기 스냅샷 1회 읽기 (
route.snapshot.queryParamMap):typescriptconst initialKeyword = this.route.snapshot.queryParamMap.get('keyword') ?? ''; - [뒤로 가기 / 앞으로 가기]까지 실시간 감지하기 (
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>따라 해 보기:
http://localhost:4200/에서1. keyword=Angular 이동버튼을 누르면 주소가/?keyword=Angular로 바뀝니다.- 이어서
2. category=DEV 교체 (replaceUrl: true)버튼을 누르면 주소가/?keyword=Angular&category=DEV가 됩니다. - 이제 브라우저 [뒤로 가기] 버튼을 딱 한 번 눌러 보십시오!
/?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)를 읽고 숫자로 변환하는 파생 신호를 어떻게 작성하는지 직접 구현해 보십시오.
📝 미션 요구사항
- URL 쿼리
?page=3을 받는page = input<string | undefined>(undefined)와?category=NOTICE를 받는category = input<string | undefined>(undefined)를 선언하십시오. page()문자열을 안전한 양의 정수(없거나NaN이거나 1 미만이면 기본값1)로 변환하는pageNumber = computed(...)파생 신호를 작성하십시오.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줄
- 검색어나 페이지 번호를 컴포넌트 메모리에만 두면 새로고침 시 증발하고 링크 공유가 불가능하므로, URL 쿼리스트링(
?keyword=...&category=...)을 신뢰할 수 있는 상태 원천으로 삼아야 합니다. - Angular 22에서는
provideRouter(routes, withComponentInputBinding())로 라우터를 설정하며, 템플릿에서는<router-outlet>과routerLink로 새로고침 없는 화면 전환을 수행합니다. - 코드로 쿼리를 바꿀 때는
this.router.navigate([], { queryParams, queryParamsHandling: 'merge', replaceUrl: true })를 사용하여 기존 쿼리를 보존하면서 불필요한 히스토리 누적을 막습니다.
다음 단계: 이제 4장의 비대한 BoardAppComponent를 해체할 모든 무기가 준비되었습니다! 이어지는 Chapter 05에서 페이징(PaginationService), URL 동기화 검색 필터(PostFilterService), 폼 검증(FormValidationService) 3개의 @Injectable() 서비스를 도려내고, 0.1초 만에 도는 첫 Vitest 자동 단위 테스트를 작성해 봅시다!