다크 모드
Chapter 01.5. Angular 기본기 실습: Signal 쿼리, 양방향 model(), 함수형 입출력
"글을 등록한 뒤 제목 입력창으로 포커스는 어떻게 자동으로 되돌릴까? 선택한 행만 색칠하거나 삭제 버튼의 이벤트 버블링을 막으려면 어떻게 해야 할까? 그리고 자식 컴포넌트와 데이터를 주고받을 때 과거의
@Input(),@Output()대신 최신input(),output(),model()을 어떻게 쓸까?"
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
viewChild() & ElementRef | 템플릿의 #이름표 DOM 요소를 Signal 형태로 안전하게 참조하는 쿼리 API | 1.5.1 |
afterNextRender() | 다음번 DOM 렌더링 주기가 완료된 직후 안전하게 DOM을 조작(포커스·스크롤)하는 훅 | 1.5.1 ~ 1.5.2 |
[class.클래스명], [style.속성] | 조건에 따라 CSS 클래스와 인라인 스타일을 선언적으로 바인딩하는 문법 | 1.5.3 |
$event.stopPropagation() | 자식 요소 클릭 시 부모 요소로 이벤트가 버블링(전파)되는 것을 차단 | 1.5.4 |
Signal Forms & model() | 폼 입력 상태를 신호로 다루고 부모-자식 간 양방향([( )]) 신호를 직결하는 API | 1.5.5 |
input() & output() | @Input(), @Output() 데코레이터를 대체하는 함수형 컴포넌트 입출력 | 1.5.6 |
TIP
🏁 이 장의 시작 파일
1장을 마친 frontend/src/app/board-app/board-app.component.ts와 board-app.component.html 상태에서 시작합니다. 이 장에서 손대는 파일: frontend/src/app/board-app/board-app.component.ts, frontend/src/app/board-app/board-app.component.html, 그리고 자식 컴포넌트 맛보기로 만드는 frontend/src/app/components/board-toolbar.component.ts
1.5.1 viewChild() Signal 쿼리: $('#title').focus()는 Angular에서 어떻게 하나?
jQuery에서는 언제든 $('#input-title').focus()로 DOM을 직접 찾아 포커스를 주었습니다. Angular에서는 document.querySelector로 전역 DOM을 뒤지는 대신, 템플릿 요소에 이름표(#titleInput)를 달아 두고 컴포넌트에서 viewChild() 신호로 받아 씁니다.
요구사항: 게시판 화면이 처음 열릴 때 제목 입력창에 커서가 자동으로 깜빡여야 하고, 새 글을 등록한 직후에도 다시 제목 입력창으로 커서가 돌아와야 한다.
🏷️ 비유: 옷걸이에 건 이름표(
viewChild)
방 안의 수많은 옷 중에서 내 코트를 찾을 때 매번 집안 모든 옷장을 뒤지는 대신(document.querySelector), 코트에 빨간 이름표(#titleInput)를 달아두고viewChild신호 파이프로 즉시 꺼내 쓰는 것과 같습니다.
[Step 1] 템플릿 <input>에 #titleInput 이름표 달기
html
<input
#titleInput
[ngModel]="newTitle()"
(ngModelChange)="newTitle.set($event)"
name="title"
type="text"
placeholder="제목"
required
/>[Step 2] viewChild()와 afterNextRender()로 포커스 제어하기
typescript
import {
Component,
signal,
viewChild,
ElementRef,
afterNextRender
} from '@angular/core';
export class BoardAppComponent {
// 🌟 최신 Signal 기반 DOM 쿼리 (HTML의 #titleInput과 자동 연결!)
titleInput = viewChild<ElementRef<HTMLInputElement>>('titleInput');
tableBody = viewChild<ElementRef<HTMLTableSectionElement>>('tableBody');
constructor() {
// 🌟 화면이 브라우저 DOM에 완전히 그려진 직후 최초 1회 실행!
afterNextRender(() => {
this.titleInput()?.nativeElement.focus();
});
}
}🆕 처음 보는 문법: viewChild(), ElementRef, afterNextRender()
viewChild<ElementRef<HTMLInputElement>>('titleInput'): 과거의@ViewChild('titleInput')데코레이터를 대체하는 Signal 쿼리 함수입니다. 반환값 자체가Signal<ElementRef<HTMLInputElement> | undefined>이므로 읽을 때는this.titleInput()처럼 괄호를 붙여 호출합니다. 요소가 반드시 존재함을 보장할 때는viewChild.required(...)를 쓸 수도 있습니다.ElementRef.nativeElement: Angular가 감싼 래퍼(ElementRef) 안에서 실제 브라우저 표준 DOM 객체(HTMLInputElement)를 꺼내는 속성입니다.afterNextRender(() => { ... }): 컴포넌트의 생성자(constructor)가 실행될 때는 아직 HTML DOM이 화면에 그려지기 전입니다.afterNextRender를 등록해 두면 이번 렌더링 사이클이 끝나고 브라우저 DOM이 완성된 직후 딱 한 번 안전하게 콜백을 실행해 줍니다(SSR 서버 사이드 렌더링 환경에서도 브라우저에서만 실행되므로document is not defined에러가 나지 않습니다).
1.5.2 상태 변경 직후의 DOM 타이밍과 스크롤 이동
요구사항: 새 글을 등록하면 목록 맨 위에 추가된 새 행으로 부드럽게 스크롤(scrollIntoView)하고, 제목 입력창으로 포커스를 되돌린다.
Angular Signals는 this.posts.update(...)를 호출하는 순간 변경 사항을 모아 두었다가 렌더링 주기에 한꺼번에 DOM을 갱신합니다. 따라서 상태를 바꾸자마자 바로 다음 줄에서 <tr>을 찾으면 아직 이전 DOM일 수 있습니다. 이때 afterNextRender(또는 Injector와 함께 호출)나 setTimeout(..., 0) 대신 Angular의 렌더링 완료 타이밍을 활용합니다.
📦 비유: 택배 기사님과 엘리베이터 (
afterNextRender)
배열에 글을 하나 넣었다고 해서 Angular가 그 줄에서 즉시 브라우저 DOM을 고치지 않습니다. 택배 기사님이 1층에서 상자 하나 실을 때마다 엘리베이터를 10층까지 오르락내리락하지 않고, 1층에 도착한 물건을 카트에 다 모아 실은 뒤 한 번에 10층으로 올리는 것과 같습니다. DOM이 10층에 도착한 직후에 포커스와 스크롤을 실행해야 정확히 동작합니다!
1.5.3 [class]와 [style] 바인딩: addClass() 대신 상태로 스타일 제어하기
요구사항: ① 클릭한 행은 파란색 배경(.row-selected)으로 강조 ② 방금 등록한 글은 노란색으로 반짝임(.row-new) ③ 제목 앞에 카테고리별 색상 배지(.category-dev, .category-notice, .category-free) 표시.
jQuery라면 $tr.addClass('row-selected').siblings().removeClass('row-selected')처럼 DOM 클래스를 수동으로 뗐다 붙였다 했을 것입니다. Angular에서는 선택된 게시글 ID 신호(selectedId) 하나만 두고 템플릿에서 조건식으로 연결합니다.
typescript
// board-app.component.ts에 상태 추가
selectedId = signal<number | null>(null);
lastCreatedId = signal<number | null>(null);html
<!-- board-app.component.html의 <tbody> 내부 -->
<tbody #tableBody>
@for (post of posts(); track post.id; let idx = $index) {
<tr
class="data-row"
[class.row-selected]="post.id === selectedId()"
[class.row-new]="post.id === lastCreatedId()"
(click)="selectedId.set(post.id)"
>
<td>{{ idx + 1 }}</td>
<td>
<span [class]="'category-badge category-' + post.category.toLowerCase()">
{{ post.category }}
</span>
<span class="post-title">{{ post.title }}</span>
</td>
<td>{{ post.author }}</td>
<td>{{ post.createdAt }}</td>
<td>
<button type="button" class="btn-delete" (click)="onDeleteClick(post.id, $event)">
삭제
</button>
</td>
</tr>
}
</tbody>🆕 처음 보는 문법: [class.클래스명]="조건"과 @for의 컨텍스트 변수($index)
[class.row-selected]="post.id === selectedId()": 우측 조건식이true이면row-selected클래스를 붙이고,false이면 자동으로 제거합니다! 다른 행을 클릭해selectedId가 바뀌면 이전 행의 클래스는 저절로 사라집니다.let idx = $index:@for블록이 기본 제공하는 암묵적 변수입니다.$index(0부터 시작하는 순번),$first(첫 행 여부),$last(마지막 행 여부),$even/$odd(짝수/홀수 행),$count(전체 개수)를 별도 계산 없이 바로 꺼내 쓸 수 있습니다.
1.5.4 이벤트 전파 막기 ($event.stopPropagation())
위 코드에서 행(<tr>) 전체에 (click)="selectedId.set(post.id)"가 걸려 있습니다. 만약 삭제 버튼에서 그냥 (click)="handleDelete(post.id)"만 호출하면, 삭제 버튼을 눌렀을 때 클릭 이벤트가 부모인 <tr>까지 **버블링(전파)**되어 삭제하려던 행이 파랗게 선택되는 부작용이 일어납니다.
이를 막기 위해 템플릿의 특수 변수 $event(실제 DOM MouseEvent 객체)를 넘겨 stopPropagation()을 호출합니다:
typescript
onDeleteClick(id: number, event: MouseEvent): void {
event.stopPropagation(); // 🌟 부모 <tr>로의 클릭 이벤트 전파 차단!
this.handleDelete(id);
}1.5.5 Signal Forms와 model() 양방향 시그널 바인딩
Angular 22에서는 폼 입력을 다루는 방식이 크게 진화했습니다.
- 컴포넌트 내부 폼 상태: 라디오 버튼, 체크박스, 텍스트 입력을 Signal 상태와 직결하거나, 스키마 기반의 Signal Forms 패턴으로 묶어 다룹니다.
- 부모 ↔ 자식 간 양방향 바인딩 (
model()): 과거에는 자식이@Input() pageSize: number와@Output() pageSizeChange = new EventEmitter<number>()두 개를 쌍으로 만들어야만 부모에서[(pageSize)]양방향 바인딩을 쓸 수 있었습니다. 이제는 자식 컴포넌트에pageSize = model<number>(20)단 한 줄만 선언하면 끝납니다!
1.5.6 최신 컴포넌트 통신(input(), output(), model())과 전체 통합 실습 코드
이제 목록 상단 툴바(총 건수 배지 + 페이지 크기 선택 드롭다운 + 새로고침 버튼)를 별도의 자식 컴포넌트 BoardToolbarComponent로 만들어 input(), output(), model() 3총사를 한 번에 실습해 보겠습니다.
[코드 1.5-1] 자식 컴포넌트: frontend/src/app/components/board-toolbar.component.ts
typescript
// frontend/src/app/components/board-toolbar.component.ts
import { Component, ChangeDetectionStrategy, input, output, model } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-board-toolbar',
standalone: true,
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="list-toolbar">
<div class="toolbar-left">
<span class="badge">총 {{ totalCount() }}건</span>
@if (selectedId() !== null) {
<span class="selected-info">선택된 글 ID: #{{ selectedId() }}</span>
}
</div>
<div class="toolbar-right">
<!-- 🌟 model() 양방향 신호는 ngModel과도 즉시 연결되며, 값이 바뀌면 부모의 신호도 자동으로 함께 바뀐다! -->
<select
[ngModel]="pageSize()"
(ngModelChange)="onSizeChange($event)"
class="size-select"
>
<option [ngValue]="5">5개씩 보기</option>
<option [ngValue]="10">10개씩 보기</option>
<option [ngValue]="20">20개씩 보기</option>
</select>
<button type="button" class="btn-refresh" (click)="refresh.emit()">
🔄 새로고침
</button>
</div>
</div>
`,
styles: [`
.list-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding: 8px 12px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; }
.toolbar-left, .toolbar-right { display: flex; gap: 10px; align-items: center; }
.badge { background: #ecfdf5; color: #10b981; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.selected-info { font-size: 13px; color: #2563eb; font-weight: 600; }
.size-select { padding: 4px 8px; border-radius: 4px; border: 1px solid #cbd5e1; }
.btn-refresh { background: #ffffff; border: 1px solid #cbd5e1; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; }
`]
})
export class BoardToolbarComponent {
// 1. input.required<T>(): 부모가 반드시 넘겨주어야 하는 읽기 전용 Signal 입력
totalCount = input.required<number>();
// 2. input<T>(기본값): 선택적 읽기 전용 Signal 입력
selectedId = input<number | null>(null);
// 3. model<T>(기본값): 부모와 [(pageSize)]="pageSize"로 양방향 직결되는 쓰기 가능 Signal!
pageSize = model<number>(20);
// 4. output<T>(): 부모에게 사건 발생을 알리는 이벤트 발신기
refresh = output<void>();
onSizeChange(newSize: number): void {
this.pageSize.set(Number(newSize)); // 🌟 부모의 pageSize 신호가 자동으로 업데이트됨!
this.refresh.emit(); // 목록 재조회 요청 발신
}
}🆕 처음 보는 문법: input(), output(), model() 3총사 비교
| 함수 | 방향 | 자식 내부에서의 역할 | 부모 템플릿 바인딩 문법 |
|---|---|---|---|
input() / input.required() | 부모 → 자식 (단방향) | 읽기 전용 Signal<T> (.set 불가) | [totalCount]="posts().length" |
output() | 자식 → 부모 (이벤트) | .emit(값)으로 부모에 알림 | (refresh)="loadPosts()" |
model() | 부모 ↔ 자식 (양방향) | 읽기와 .set()/.update() 모두 가능한 ModelSignal<T> | [(pageSize)]="pageSize" |
[코드 1.5-2] 부모 컴포넌트 전체 완성본: board-app.component.ts & .html
1.5장의 모든 기능(viewChild 자동 포커스, 카테고리 라디오 선택, 작성자 유지 체크박스, 행 선택 및 새 글 하이라이트, 이벤트 전파 차단, BoardToolbarComponent 연동)을 통합한 전체 코드입니다.
typescript
// frontend/src/app/board-app/board-app.component.ts
import {
Component,
ChangeDetectionStrategy,
signal,
inject,
OnInit,
viewChild,
ElementRef,
afterNextRender,
Injector
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { BoardToolbarComponent } from '../components/board-toolbar.component';
export interface Post {
id: number;
title: string;
author: string;
content: string;
category: string;
createdAt: string;
likeCount?: number;
}
interface PageResponse {
items: Post[];
total: number;
}
@Component({
selector: 'app-board-app',
standalone: true,
imports: [FormsModule, BoardToolbarComponent],
templateUrl: './board-app.component.html',
styleUrl: './board-app.component.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BoardAppComponent implements OnInit {
private readonly http = inject(HttpClient);
private readonly injector = inject(Injector);
// 1. Signal DOM 쿼리 (#titleInput, #tableBody 참조)
titleInput = viewChild<ElementRef<HTMLInputElement>>('titleInput');
tableBody = viewChild<ElementRef<HTMLTableSectionElement>>('tableBody');
// 2. 카테고리 옵션 상수
readonly categoryOptions = [
{ value: 'DEV', label: '개발' },
{ value: 'NOTICE', label: '공지' },
{ value: 'FREE', label: '자유' }
] as const;
// 3. 목록 및 UI 상태 신호
posts = signal<Post[]>([]);
isLoading = signal<boolean>(false);
isSubmitting = signal<boolean>(false);
pageSize = signal<number>(20);
selectedId = signal<number | null>(null);
lastCreatedId = signal<number | null>(null);
keepAuthor = signal<boolean>(false);
// 4. 등록 폼 신호
newTitle = signal<string>('');
newAuthor = signal<string>('');
newContent = signal<string>('');
newCategory = signal<string>('DEV');
constructor() {
// 최초 마운트 및 렌더링 직후 제목 입력창 자동 포커스
afterNextRender(() => {
this.titleInput()?.nativeElement.focus();
});
}
ngOnInit(): void {
this.loadPosts();
}
loadPosts(): void {
this.isLoading.set(true);
this.http
.get<PageResponse | Post[]>('/api/posts', {
params: { size: this.pageSize() }
})
.subscribe({
next: (res) => {
const list = Array.isArray(res) ? res : (res.items ?? []);
this.posts.set(list);
this.isLoading.set(false);
},
error: () => {
alert('게시글 목록을 불러오지 못했습니다.');
this.isLoading.set(false);
}
});
}
handleSubmit(): void {
const title = this.newTitle().trim();
const author = this.newAuthor().trim();
const content = this.newContent().trim();
if (!title || !author || !content) {
alert('제목, 작성자, 내용을 모두 입력해 주세요.');
return;
}
this.isSubmitting.set(true);
this.http
.post<Post>('/api/posts', {
title,
author,
content,
category: this.newCategory()
})
.subscribe({
next: (createdPost) => {
this.posts.update((prev) => [createdPost, ...prev]);
this.lastCreatedId.set(createdPost.id);
// 폼 초기화 (keepAuthor 체크 시 작성자 유지!)
this.newTitle.set('');
this.newContent.set('');
if (!this.keepAuthor()) {
this.newAuthor.set('');
}
this.isSubmitting.set(false);
// 🌟 새 행이 DOM에 반영된 직후 스크롤 이동 및 제목창 재포커스
afterNextRender(
() => {
this.tableBody()
?.nativeElement.querySelector('tr')
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
this.titleInput()?.nativeElement.focus();
},
{ injector: this.injector }
);
},
error: () => {
alert('게시글 등록에 실패했습니다.');
this.isSubmitting.set(false);
}
});
}
onDeleteClick(id: number, event: MouseEvent): void {
event.stopPropagation(); // 행 선택 클릭 이벤트 전파 차단
this.handleDelete(id);
}
handleDelete(id: number): void {
if (!confirm('정말 삭제하시겠습니까?')) return;
this.http.delete<void>(`/api/posts/${id}`).subscribe({
next: () => {
this.posts.update((prev) => prev.filter((p) => p.id !== id));
if (this.selectedId() === id) {
this.selectedId.set(null);
}
},
error: () => {
alert('게시글 삭제에 실패했습니다.');
}
});
}
}html
<!-- frontend/src/app/board-app/board-app.component.html -->
<div class="board-card">
<div class="card-header">
<h2>✅ [Angular 22 마이그레이션] 사내 기술 Q&A 게시판</h2>
<span class="badge">Angular 22 + Signals</span>
</div>
<!-- 1. 등록 폼 -->
<form class="form-container" (ngSubmit)="handleSubmit()">
<div class="input-row">
<input
#titleInput
[ngModel]="newTitle()"
(ngModelChange)="newTitle.set($event)"
name="title"
type="text"
placeholder="제목"
required
/>
<input
[ngModel]="newAuthor()"
(ngModelChange)="newAuthor.set($event)"
name="author"
type="text"
placeholder="작성자"
class="author-input"
required
/>
<button type="submit" [disabled]="isSubmitting()" class="btn-submit">
{{ isSubmitting() ? '등록 중...' : '게시글 등록' }}
</button>
</div>
<div class="option-row">
@for (opt of categoryOptions; track opt.value) {
<label>
<input
type="radio"
name="category"
[value]="opt.value"
[ngModel]="newCategory()"
(ngModelChange)="newCategory.set($event)"
/>
{{ opt.label }}
</label>
}
<label class="keep-author">
<input
type="checkbox"
name="keepAuthor"
[ngModel]="keepAuthor()"
(ngModelChange)="keepAuthor.set($event)"
/>
등록 후 작성자 유지
</label>
</div>
<textarea
[ngModel]="newContent()"
(ngModelChange)="newContent.set($event)"
name="content"
placeholder="내용을 입력하세요"
rows="3"
required
></textarea>
</form>
<!-- 2. 자식 툴바 컴포넌트: input(), model(), output() 한 번에 연결! -->
<app-board-toolbar
[totalCount]="posts().length"
[selectedId]="selectedId()"
[(pageSize)]="pageSize"
(refresh)="loadPosts()"
/>
<!-- 3. 로딩 및 게시글 목록 -->
@if (isLoading()) {
<div class="loading-state">데이터를 불러오는 중입니다...</div>
} @else {
<table class="data-table">
<thead>
<tr>
<th style="width: 55px;">번호</th>
<th>제목</th>
<th style="width: 100px;">작성자</th>
<th style="width: 120px;">작성일</th>
<th style="width: 80px;">동작</th>
</tr>
</thead>
<tbody #tableBody>
@for (post of posts(); track post.id; let idx = $index) {
<tr
class="data-row"
[class.row-selected]="post.id === selectedId()"
[class.row-new]="post.id === lastCreatedId()"
(click)="selectedId.set(post.id)"
>
<td>{{ idx + 1 }}</td>
<td>
<span [class]="'category-badge category-' + post.category.toLowerCase()">
{{ post.category }}
</span>
<span class="post-title">{{ post.title }}</span>
</td>
<td>{{ post.author }}</td>
<td>{{ post.createdAt }}</td>
<td>
<button
type="button"
class="btn-delete"
(click)="onDeleteClick(post.id, $event)"
>
삭제
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5" class="empty-state">등록된 게시글이 없습니다.</td>
</tr>
}
</tbody>
</table>
}
</div>추가된 CSS 스타일(board-app.component.css 하단에 덧붙이기):
css
.option-row { display: flex; gap: 16px; align-items: center; margin-bottom: 10px; font-size: 14px; color: #475569; }
.option-row .keep-author { margin-left: auto; }
.data-row { cursor: pointer; transition: background-color 0.15s; }
.row-selected { background: #eff6ff; }
.row-new { animation: flash 1.5s ease-out; }
@keyframes flash { from { background: #fef9c3; } to { background: transparent; } }
.category-badge { display: inline-block; font-size: 11px; font-weight: 700; padding: 2px 6px; border-radius: 4px; margin-right: 6px; }
.category-dev { background: #dbeafe; color: #1d4ed8; }
.category-notice { background: #fee2e2; color: #b91c1c; }
.category-free { background: #dcfce7; color: #15803d; }🔬 심화(건너뛰어도 됨): 왜 메서드 안의 afterNextRender에는 { injector: this.injector }를 넘겼나요?
afterNextRender()는 기본적으로 주입 컨텍스트(Injection Context), 즉 컴포넌트의 constructor 안에서 호출될 때 현재 컴포넌트 인스턴스를 자동으로 파악합니다. 반면 handleSubmit()의 비동기 HTTP 응답 콜백(next:) 안에서 호출할 때는 이미 생성자 실행이 끝난 뒤이므로, Angular에게 "어느 컴포넌트의 렌더링 주기에 맞출지" 알려주기 위해 필드에서 inject(Injector)로 받아둔 this.injector를 옵션으로 넘겨주는 것입니다.
1.5.7 jQuery 개발자를 위한 Angular 22 치트시트
| 하고 싶은 일 | jQuery 방식 | Angular 22 방식 |
|---|---|---|
| 요소에 포커스 주기 | $('#title').focus() | #titleInput + viewChild('titleInput') + .nativeElement.focus() |
| DOM 렌더링 완료 후 실행 | (동기 조작 후 바로 실행) | afterNextRender(() => { ... }) |
| 조건부 클래스 토글 | addClass / removeClass | [class.row-selected]="post.id === selectedId()" |
| 인라인 스타일 수치 변경 | .css({ marginLeft: '20px' }) | [style.margin-left.px]="depth() * 20" |
| 이벤트 버블링 차단 | e.stopPropagation() | (click)="onDeleteClick(post.id, $event)" 내부에서 event.stopPropagation() |
| 부모 → 자식 값 전달 | (함수 인자 또는 data 속성) | 자식 input.required<T>() + 부모 [prop]="val()" |
| 부모 ↔ 자식 양방향 연결 | (콜백 수동 동기화) | 자식 model<T>() + 부모 [(prop)]="signalRef" |
1.5.8 [직접 해보기] 실습 미션 💪
방금 만든 게시판에서 선택된 행을 다시 한 번 클릭하거나 ESC 키를 누르면 선택이 해제(selectedId.set(null))되는 기능을 추가해 보십시오.
📝 미션 요구사항
BoardAppComponent에toggleSelect(id: number): void메서드를 작성하십시오. 이미 선택된 행(this.selectedId() === id)을 다시 클릭하면null로 바꾸고, 다른 행을 클릭하면 해당id로 설정합니다.- 템플릿
<tr>의(click)="selectedId.set(post.id)"를(click)="toggleSelect(post.id)"로 교체하십시오. BoardToolbarComponent에서 "선택된 글 ID: #..." 배지 옆에[선택 해제]작은 버튼을 두고, 클릭 시selectedId를 양방향model()로 바꾸어 부모의 선택 상태를 즉시 해제하도록 만들어 보십시오.
💡 미션 정답 코드 확인하기
typescript
// 1. board-app.component.ts에 toggleSelect 추가
toggleSelect(id: number): void {
this.selectedId.update((current) => (current === id ? null : id));
}typescript
// 2. board-toolbar.component.ts에서 selectedId를 input() 대신 model()로 업그레이드!
export class BoardToolbarComponent {
totalCount = input.required<number>();
selectedId = model<number | null>(null); // 🌟 양방향 ModelSignal로 변경!
pageSize = model<number>(20);
refresh = output<void>();
clearSelection(): void {
this.selectedId.set(null); // 부모의 selectedId 신호도 즉시 null로 변경됨!
}
}html
<!-- 3. board-app.component.html에서 [(selectedId)] 양방향 바인딩 적용 -->
<app-board-toolbar
[totalCount]="posts().length"
[(selectedId)]="selectedId"
[(pageSize)]="pageSize"
(refresh)="loadPosts()"
/>1.5.9 이번 장 핵심 요약 및 다음 단계 🚀
핵심 요약 3줄
- 실제 DOM 요소 제어(포커스, 스크롤 측정)가 꼭 필요할 때만
#이름표와 **viewChild()Signal 쿼리 +afterNextRender()**를 사용하고, 스타일과 클래스는[class.xxx]바인딩으로 선언합니다. - 자식 컴포넌트와의 통신은 데코레이터(
@Input,@Output) 대신 함수형 **input(),output(), 그리고 양방향 신호를 직결하는model()**로 통일합니다. @for반복문 안에서는$index,$first,$last컨텍스트 변수를 바로 사용할 수 있으며, 부모 행 클릭과의 충돌은$event.stopPropagation()으로 차단합니다.
다음 단계: 이제 화면과 컴포넌트를 다루는 손기술이 갖춰졌습니다. 그런데 Angular Signals는 대체 내부적으로 어떻게 동작하길래 과거의 무거운 Zone.js 없이도 바뀐 DOM 노드만 0.001초 만에 찾아내어 고치는 걸까요? Chapter 02에서 Signals의 심층 원리와 의존성 그래프를 파헤쳐 봅시다!