다크 모드
Chapter 03. Signals의 올바른 사용법과 신호 끊김(Reactivity Loss) 방어
"1장에서 만든
BoardAppComponent에 상세 보기/인라인 수정과 실시간 검색 기능을 추가해 보자. 그런데 코드를 깔끔하게 줄이려고 변수에 신호 값을 꺼내 담거나 배열에.push()를 하는 순간, 왜 반응성의 파이프가 끊어지고 마는가?"
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
| 신호 끊김 (Reactivity Loss) | this.currentPost().title처럼 신호를 조기 호출해 원시값을 일반 변수에 복사하여 반응성이 증발하는 버그 | 3.1 ~ 3.2 |
| 불변성(Immutability) 업데이트 | .push()나 속성 직접 변경 대신 .update(p => ({ ...p }))로 새 참조를 만들어 변경을 알리는 원칙 | 3.3 |
untracked() | effect() 내부에서 특정 Signal 값을 읽되 의존성 그래프에는 등록하지 않는 격리 함수 | 3.4 |
effect(onCleanup) & AbortController | 실시간 검색 시 250ms 디바운스 타이머 정리 및 이전 비행 중(In-flight) HTTP 요청 취소 | 3.5 |
linkedSignal() | 상위 신호가 바뀔 때마다 초기값으로 자동 리셋되면서 직접 수정도 가능한 Angular 최신 신호 | 3.3 🔬 심화 박스 |
TIP
🏁 이 장의 시작 파일
1장(또는 2장)을 마친 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/board-app/board-app.component.css
3.1 BoardAppComponent에 게시글 상세 보기 및 인라인 수정 추가하기
사용자가 목록에서 게시글 제목을 클릭하면 테이블 하단에 상세 보기 카드가 펼쳐지고, 제목과 본문을 바로 수정해 볼 수 있는 기능을 추가해 봅시다.
1) board-app.component.ts에 currentPost 신호와 selectPost() 추가
typescript
// 선택된 상세 게시글 상태 (null이면 선택되지 않은 상태)
currentPost = signal<Post | null>(null);
// 제목 클릭 시 서버에서 최신 상세 데이터를 가져와 currentPost 신호에 세팅
selectPost(id: number): void {
this.http.get<Post>(`/api/posts/${id}`).subscribe({
next: (post) => {
this.currentPost.set(post);
},
error: () => {
alert('게시글 상세 정보를 불러오지 못했습니다.');
}
});
}
// 상세 카드에서 제목이나 내용을 인라인 수정할 때 호출하는 불변 업데이트 메서드
updateCurrentPostField(field: 'title' | 'content', value: string): void {
this.currentPost.update((prev) => (prev ? { ...prev, [field]: value } : null));
}2) board-app.component.html 테이블 하단에 상세 카드 UI 배치
html
<!-- 상세 보기 및 인라인 수정 뷰 -->
@if (currentPost(); as selected) {
<div class="detail-card">
<div class="detail-header">
<h3>🔍 게시글 상세 보기 (#{{ selected.id }})</h3>
<button type="button" class="btn-close" (click)="currentPost.set(null)">닫기</button>
</div>
<div class="edit-row">
<label>제목:</label>
<input
type="text"
[ngModel]="selected.title"
(ngModelChange)="updateCurrentPostField('title', $event)"
/>
</div>
<div class="edit-row">
<label>작성자:</label>
<span>{{ selected.author }} ({{ selected.createdAt }})</span>
</div>
<div class="edit-row">
<label>내용:</label>
<textarea
rows="4"
[ngModel]="selected.content"
(ngModelChange)="updateCurrentPostField('content', $event)"
></textarea>
</div>
</div>
}🆕 처음 보는 문법: @if (currentPost(); as selected)와 계산된 속성명 [field]
@if (currentPost(); as selected):currentPost()신호가null이 아닐 때만 블록을 렌더링하면서, 동시에 그 값을selected라는 지역 변수에 담아 블록 안에서null걱정 없이(selected.title) 깔끔하게 쓸 수 있게 해 줍니다!{ ...prev, [field]: value }: 대괄호[field]는 변수field에 담긴 문자열('title'또는'content')을 속성 이름으로 삼아 덮어쓰는 ES6 계산된 속성명(Computed Property Name) 문법입니다.
3.2 [직접 터뜨려보는 실습] 실무 버그 1위: 조기 호출로 인한 신호 끊김(Reactivity Loss)
상세 화면의 제목을 컴포넌트 클래스 속성으로 짧게 꺼내 쓰고 싶어졌습니다.
많은 개발자가 무심코 다음과 같이 코드를 작성합니다. 직접 눈으로 버그를 확인해 봅시다!
typescript
// 🚨 [버그 재현 코드] board-app.component.ts
export class BoardAppComponent {
currentPost = signal<Post | null>(null);
// 💥 치명적 버그: 클래스가 생성될 때 딱 1번 currentPost()를 호출해 원시값을 일반 속성에 복사함!
selectedTitle = this.currentPost()?.title ?? '선택된 글 없음';
}그리고 템플릿에 다음과 같이 비교 출력 줄을 넣어 봅니다:
html
<p>
조기 호출한 변수: [{{ selectedTitle }}] /
신호 직접 호출: [{{ currentPost()?.title ?? '선택된 글 없음' }}]
</p>목록에서 아무 게시글 제목이나 클릭해 보십시오:
- 신호 직접 호출: 클릭한 게시글의 제목이 즉시 나타나고, 인풋을 수정하면 실시간으로 따라 바뀝니다.
- 조기 호출한 변수 (
selectedTitle): 영원히[선택된 글 없음]그대로 얼어붙어 있습니다!
왜 이런 일이 벌어지는가?
2.5장에서 우리가 직접 만든 createSignal을 떠올려 보십시오.
Signal의 의존성 추적은 렌더링 컨텍스트(effect나 템플릿 평가 중) 안에서 currentPost() 함수가 호출될 때만 작동합니다.
- 컴포넌트 인스턴스가 처음 만들어질 때(생성 시점),
this.currentPost()?.title ?? '선택된 글 없음'이 딱 1번 실행됩니다. - 그 순간
selectedTitle변수에는'선택된 글 없음'이라는 평범한 문자열 원시값이 복사됩니다. - 이 순간부터
selectedTitle은currentPost신호 파이프와 완전히 인연이 끊긴 죽은 문자열입니다!
🥤 비유: 상수도 배관(
computed) vs 종이컵에 떠 놓은 물(원시값 복사)
집에 연결된 상수도 배관(Signal)에서 수도꼭지를 틀어야 신선한 물이 계속 나오는데, 아침에 배관에서 물 한 모금을 종이컵(selectedTitle)에 덜어놓고는 저녁에 "이상하다? 정수장에서 따뜻한 물을 보냈다는데 왜 내 종이컵 물은 그대로 차갑지?" 하고 갸웃거리는 것과 같습니다. 종이컵은 배관과 이미 인연이 끊겼습니다! 항상 배관과 배관을 직결하는 **파생 신호(computed)**를 써야 합니다.
✅ 올바른 해결책: computed()로 파생 신호 파이프 연결하기
값을 가공하거나 하위 속성을 별도 이름으로 뽑아내고 싶다면, 무조건 **computed()**로 감싸야 합니다:
typescript
// ✅ 올바른 해법: 배관과 배관을 직결하는 파생 신호!
selectedTitle = computed(() => this.currentPost()?.title ?? '선택된 글 없음');그리고 템플릿에서도 함수 호출 형태인 **{{ selectedTitle() }}**로 읽으면, currentPost가 바뀔 때마다 0.001초 만에 화면이 함께 갱신됩니다!
3.3 실무 버그 2위: 객체·배열 내부 직접 변형(Mutation)의 함정
이번에는 또 다른 흔한 실수를 살펴봅시다. 상세 보기에서 제목을 수정하거나 새 글을 배열에 추가할 때 아래처럼 작성하면 왜 화면이 안 바뀔까요?
typescript
// ❌ 잘못된 코드 1: 객체 속성을 직접 수정한 뒤 같은 객체 참조를 다시 set 함
const post = this.currentPost();
if (post) {
post.title = '수정한 제목';
this.currentPost.set(post); // 💥 안 바뀜! (이전 참조 post와 새 참조 post가 동일한 메모리 주소!)
}
// ❌ 잘못된 코드 2: 배열에 직접 push 함
this.posts().push(createdPost); // 💥 안 바뀜! (신호의 setter가 아예 호출조차 안 됨!)왜 안 바뀔까요?
2.5장 Step 2에서 보았듯, Angular Signal의 .set()과 .update()는 내부적으로 **Object.is(oldValue, newValue)**를 통해 값이 바뀌었는지 검사합니다. 객체나 배열의 내부 속성만 슬쩍 고치고 같은 메모리 주소(참조)를 가진 객체를 그대로 넘기면, Object.is(post, post)가 true(안 바뀌었음!)를 반환하여 구독자들에게 알림을 보내지 않습니다.
✅ 올바른 해결책: 언제나 .update()와 스프레드 연산자(...)로 새 참조 만들기
typescript
// ✅ 객체 속성 업데이트: 중괄호 { ...prev }로 새 객체를 만들어 반환!
this.currentPost.update((prev) => (prev ? { ...prev, title: '수정한 제목' } : null));
// ✅ 배열 추가/삭제/수정: 대괄호 [ ...prev ]나 .filter(), .map()으로 새 배열을 만들어 반환!
this.posts.update((prev) => [createdPost, ...prev]);
this.posts.update((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));🔬 심화(건너뛰어도 됨): Angular 최신 linkedSignal()로 상세 폼 초안(Draft) 상태 다루기
상세 보기에서 인풋을 고칠 때 원본 currentPost를 바로 고치지 않고 "초안(Draft) 제목" 신호를 따로 두되, 사용자가 다른 게시글을 클릭해 currentPost가 바뀌면 초안 제목도 새 게시글 제목으로 자동 리셋되게 하고 싶다면 어떻게 할까요?
Angular 19+에서 도입되어 Angular 22에서 널리 쓰이는 **linkedSignal()**이 바로 이 문제를 해결합니다!
typescript
import { linkedSignal } from '@angular/core';
// currentPost()가 바뀔 때마다 그 글의 title로 자동 초기화되면서도,
// 사용자가 입력창에 타이핑할 때는 draftTitle.set('새 값')으로 자유롭게 수정 가능한 양방향 파생 신호!
draftTitle = linkedSignal(() => this.currentPost()?.title ?? '');3.4 untracked(): 원하지 않는 의존성 전선 끊어내기
가끔은 effect() 안에서 두 개의 신호를 읽지만, 오직 첫 번째 신호가 바뀔 때만 effect가 실행되고 두 번째 신호가 바뀔 때는 실행되지 않기를 바랄 때가 있습니다.
예를 들어 **"사용자가 상세 조회한 게시글(currentPost)이 바뀔 때마다 서버나 콘솔에 열람 로그를 남기되, 그 순간 검색창에 적혀 있던 검색어(searchKeyword)를 참고 정보로 함께 적고 싶다"**고 해 봅시다.
typescript
// ❌ 잘못된 작성: searchKeyword에 글자를 한 글자 칠 때마다 열람 로그 effect가 실행되어 버림!
effect(() => {
const post = this.currentPost();
const keyword = this.searchKeyword(); // 👈 실수로 의존성 등록됨!
if (post) {
console.log(`[열람 로그] #${post.id} "${post.title}" 열람 (당시 검색어: "${keyword}")`);
}
});
// ✅ 올바른 작성: untracked()로 감싸서 값은 읽되 구독 명단에는 등록하지 않음!
effect(() => {
const post = this.currentPost(); // 👈 오직 currentPost가 바뀔 때만 이 effect가 실행됨!
if (post) {
const keyword = untracked(() => this.searchKeyword());
console.log(`[열람 로그] #${post.id} "${post.title}" 열람 (당시 검색어: "${keyword}")`);
}
});3.5 [실습] 실시간 검색어 감시와 네트워크 낭비 방어 (effect(onCleanup) & AbortController)
이제 게시판 상단에 실시간 검색창을 달고, 사용자가 검색어를 타이핑하면 서버 /api/posts?keyword=...를 호출하도록 만들어 봅시다.
단순히 searchKeyword()가 바뀔 때마다 곧바로 HTTP 요청을 쏘면 두 가지 심각한 실무 장애가 발생합니다:
- 네트워크 요청 폭주:
Angular를 치는 동안A,An,Ang, ... 총 7번의 불필요한 API 요청이 발사됩니다. - 경쟁 상태 (Race Condition): 먼저 보낸
A검색 요청이 네트워크 지연으로 나중에 보낸Angular요청보다 늦게 도착하면, 검색창에는Angular가 적혀 있는데 화면 목록은A결과로 덮어써지는 버그가 터집니다.
해결책: effect((onCleanup) => ...) 안에서의 250ms 디바운스 + AbortController 요청 취소!
Angular의 effect() 콜백은 첫 번째 인자로 onCleanup 등록 함수를 제공합니다. 여기에 정리 함수를 등록해 두면, 다음번 effect가 재실행되기 직전(즉, 사용자가 다음 글자를 쳤을 때) 또는 컴포넌트가 파괴될 때 자동으로 실행됩니다!
typescript
searchKeyword = signal<string>('');
constructor() {
// 🌟 실시간 검색어 감시: 250ms 디바운스 + 이전 비행 요청 자동 취소(AbortController)
effect((onCleanup) => {
const keyword = this.searchKeyword().trim();
const size = this.pageSize();
// 1. 진행 중인 요청을 취소할 수 있는 브라우저 표준 리모컨 생성
const abortController = new AbortController();
// 2. 250ms 디바운스 타이머: 타자를 멈추고 0.25초가 지나야 실제 API 발사!
const timerId = setTimeout(() => {
this.fetchPostsWithAbort(keyword, size, abortController.signal);
}, 250);
// 3. 사용자가 0.25초 안에 새 글자를 치면: 이전 타이머 취소 + 이미 날아간 요청도 즉시 폭파(abort)!
onCleanup(() => {
clearTimeout(timerId);
abortController.abort();
});
});
}🆕 처음 보는 문법: effect((onCleanup) => ...)과 AbortController
onCleanup(() => { ... }):effect가 다시 실행되기 바로 직전에 이전 실행이 남긴 타이머(clearTimeout)나 네트워크 요청(abortController.abort())을 깔끔하게 치우는 청소부 함수입니다.AbortController&fetch/HttpClient: 최신 AngularHttpClient(또는 표준fetch)는 요청 옵션에signal이나 RxJS 구독 해제(sub.unsubscribe())를 지원하여, 새 검색어가 입력되는 즉시 이전 네트워크 패킷 대기열을(canceled)상태로 취소시킵니다!
3.6 3장 완성본 전체 소스 코드 (Full Hands-on Code)
상세 보기/인라인 수정(currentPost), 파생 신호(selectedTitle), untracked 열람 로깅, 그리고 effect(onCleanup) 기반 실시간 디바운스 검색이 모두 통합된 BoardAppComponent 전체 코드입니다.
typescript
// frontend/src/app/board-app/board-app.component.ts
import {
Component,
ChangeDetectionStrategy,
signal,
computed,
effect,
untracked,
inject,
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 {
private readonly http = inject(HttpClient);
private readonly injector = inject(Injector);
titleInput = viewChild<ElementRef<HTMLInputElement>>('titleInput');
tableBody = viewChild<ElementRef<HTMLTableSectionElement>>('tableBody');
readonly categoryOptions = [
{ value: 'DEV', label: '개발' },
{ value: 'NOTICE', label: '공지' },
{ value: 'FREE', label: '자유' }
] as const;
// 1. 목록 및 검색 상태 신호
posts = signal<Post[]>([]);
isLoading = signal<boolean>(false);
isSubmitting = signal<boolean>(false);
searchKeyword = signal<string>('');
pageSize = signal<number>(20);
keepAuthor = signal<boolean>(false);
lastCreatedId = signal<number | null>(null);
// 2. 상세 보기 상태 신호 및 반응성을 유지하는 computed 파생 신호
currentPost = signal<Post | null>(null);
selectedTitle = computed(() => this.currentPost()?.title ?? '선택된 글 없음');
// 3. 등록 폼 상태 신호
newTitle = signal<string>('');
newAuthor = signal<string>('');
newContent = signal<string>('');
newCategory = signal<string>('DEV');
constructor() {
afterNextRender(() => {
this.titleInput()?.nativeElement.focus();
});
// 🌟 [실습 3.4] untracked() 활용: currentPost가 바뀔 때만 로그를 남기고 searchKeyword 변경에는 반응하지 않음
effect(() => {
const post = this.currentPost();
if (post) {
const currentKeyword = untracked(() => this.searchKeyword());
console.log(
`[상세 열람 로그] #${post.id} "${post.title}" (열람 시점 검색어: "${currentKeyword || '없음'}")`
);
}
});
// 🌟 [실습 3.5] 실시간 검색어 디바운스(250ms) + 이전 HTTP 요청 자동 취소(onCleanup)
effect((onCleanup) => {
const keyword = this.searchKeyword().trim();
const size = this.pageSize();
const timerId = setTimeout(() => {
this.isLoading.set(true);
const sub = this.http
.get<PageResponse | Post[]>('/api/posts', {
params: { size, keyword }
})
.subscribe({
next: (res) => {
const list = Array.isArray(res) ? res : (res.items ?? []);
this.posts.set(list);
this.isLoading.set(false);
},
error: () => {
this.isLoading.set(false);
}
});
// 만약 HTTP 응답이 오기 전에 새 검색어가 입력되면 진행 중인 RxJS HTTP 요청을 즉시 취소(abort)!
onCleanup(() => sub.unsubscribe());
}, 250);
// 250ms 안에 다음 글자를 치면 이전 타이머 취소
onCleanup(() => clearTimeout(timerId));
});
}
loadPosts(): void {
this.isLoading.set(true);
this.http
.get<PageResponse | Post[]>('/api/posts', {
params: {
size: this.pageSize(),
keyword: this.searchKeyword().trim()
}
})
.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);
}
});
}
selectPost(id: number): void {
this.http.get<Post>(`/api/posts/${id}`).subscribe({
next: (post) => {
this.currentPost.set(post);
},
error: () => {
alert('상세 정보를 불러오지 못했습니다.');
}
});
}
// 🌟 [실습 3.3] 불변성을 지키는 객체 필드 업데이트
updateCurrentPostField(field: 'title' | 'content', value: string): void {
this.currentPost.update((prev) => (prev ? { ...prev, [field]: value } : null));
}
// 파생 신호(selectedTitle)를 통해서도 원본 신호를 안전하게 갱신하는 테스트 메서드
applyQuickTitlePrefix(): void {
this.currentPost.update((prev) =>
prev ? { ...prev, title: `[확인완료] ${prev.title}` } : null
);
}
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);
this.newTitle.set('');
this.newContent.set('');
if (!this.keepAuthor()) {
this.newAuthor.set('');
}
this.isSubmitting.set(false);
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();
if (!confirm('정말 삭제하시겠습니까?')) return;
this.http.delete<void>(`/api/posts/${id}`).subscribe({
next: () => {
this.posts.update((prev) => prev.filter((p) => p.id !== id));
if (this.currentPost()?.id === id) {
this.currentPost.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">현재 선택: {{ selectedTitle() }}</span>
</div>
<!-- 1. 실시간 디바운스 검색창 -->
<div class="search-bar">
<input
type="text"
[ngModel]="searchKeyword()"
(ngModelChange)="searchKeyword.set($event)"
placeholder="🔎 실시간 검색어 입력 (250ms 디바운스 & 이전 요청 자동 취소)..."
/>
</div>
<!-- 2. 게시글 등록 폼 -->
<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>
<textarea
[ngModel]="newContent()"
(ngModelChange)="newContent.set($event)"
name="content"
placeholder="내용을 입력하세요"
rows="3"
required
></textarea>
</form>
<!-- 3. 툴바 컴포넌트 -->
<app-board-toolbar
[totalCount]="posts().length"
[selectedId]="currentPost()?.id ?? null"
[(pageSize)]="pageSize"
(refresh)="loadPosts()"
/>
<!-- 4. 목록 테이블 -->
@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 === currentPost()?.id"
[class.row-new]="post.id === lastCreatedId()"
(click)="selectPost(post.id)"
>
<td>{{ idx + 1 }}</td>
<td>
<span class="post-title clickable">{{ 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>
}
<!-- 5. 상세 보기 및 인라인 수정 뷰 -->
@if (currentPost(); as selected) {
<div class="detail-card">
<div class="detail-header">
<h3>🔍 게시글 상세 보기 (#{{ selected.id }})</h3>
<div style="display: flex; gap: 8px;">
<button type="button" class="btn-close" (click)="applyQuickTitlePrefix()">
[확인완료] 머리말 붙이기
</button>
<button type="button" class="btn-close" (click)="currentPost.set(null)">
닫기
</button>
</div>
</div>
<p style="font-size: 13px; color: #059669; margin-bottom: 12px;">
파생 신호(computed) 제목: <strong>[{{ selectedTitle() }}]</strong>
</p>
<div class="edit-row">
<label>제목:</label>
<input
type="text"
[ngModel]="selected.title"
(ngModelChange)="updateCurrentPostField('title', $event)"
/>
</div>
<div class="edit-row">
<label>작성자:</label>
<span>{{ selected.author }} ({{ selected.createdAt }})</span>
</div>
<div class="edit-row">
<label>내용:</label>
<textarea
rows="4"
[ngModel]="selected.content"
(ngModelChange)="updateCurrentPostField('content', $event)"
></textarea>
</div>
</div>
}
</div>추가된 CSS 스타일(board-app.component.css 하단에 덧붙이기):
css
.search-bar { margin-bottom: 16px; }
.search-bar input { width: 100%; box-sizing: border-box; padding: 10px 14px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 14px; }
.clickable:hover { color: #10b981; text-decoration: underline; }
.detail-card { margin-top: 24px; padding: 16px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; }
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.detail-header h3 { margin: 0; font-size: 16px; }
.btn-close { background: #ffffff; border: 1px solid #cbd5e1; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; }
.edit-row { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px; }
.edit-row label { flex: 0 0 60px; font-weight: 600; color: #475569; padding-top: 6px; }
.edit-row input { flex: 1; padding: 6px 10px; border: 1px solid #cbd5e1; border-radius: 6px; }3.7 [직접 해보기] 실습 미션 💪
상세 보기 카드에서 사용자가 제목과 내용을 수정한 뒤 [목록에도 반영하기] 버튼을 누르면, 위쪽 게시글 테이블(posts 신호 배열)에 있는 해당 게시글의 제목과 내용도 불변성(Immutable)을 유지하며 즉시 업데이트되도록 만들어 보십시오!
📝 미션 요구사항
BoardAppComponent에syncDetailToList(): void메서드를 작성하십시오.- 현재 선택된
const edited = this.currentPost()가 존재한다면,this.posts.update(...)와 배열의.map()을 사용하여p.id === edited.id인 항목만{ ...p, title: edited.title, content: edited.content }로 교체한 새 배열을 반환하도록 구현하십시오. - 상세 카드 헤더의 버튼 그룹에
<button type="button" class="btn-close" (click)="syncDetailToList()">목록에 반영</button>버튼을 추가하고 동작을 확인하십시오.
💡 미션 정답 코드 확인하기
typescript
// board-app.component.ts 내부에 메서드 추가
syncDetailToList(): void {
const edited = this.currentPost();
if (!edited) return;
// 🌟 배열 내부 객체를 직접 수정하지 않고, .map()과 스프레드 연산자(...)로 새 배열 참조를 만들어 교체!
this.posts.update((prevList) =>
prevList.map((post) =>
post.id === edited.id
? { ...post, title: edited.title, content: edited.content }
: post
)
);
}html
<!-- board-app.component.html의 .detail-header 내부 버튼 영역에 추가 -->
<button type="button" class="btn-close" (click)="syncDetailToList()">
💾 목록에 반영
</button>3.8 이번 장 핵심 요약 및 다음 단계 🚀
핵심 요약 3줄
- 컴포넌트 생성 시점에
const title = this.currentPost().title처럼 신호 값을 미리 꺼내 일반 변수에 복사하면 반응성 파이프가 끊깁니다(Reactivity Loss). 파생 값은 반드시 **computed()**로 감싸십시오. - Signal이 들고 있는 객체나 배열을 변경할 때는
.push()나 속성 직접 대입 대신, 언제나.update(prev => [...prev])로 새로운 참조를 만들어 넘겨야Object.is비교기가 변경을 감지합니다. effect()안에서 의존성 추적을 피하고 싶을 때는 **untracked()**를 쓰고, 실시간 검색처럼 연속 발생하는 비동기 작업은 **effect((onCleanup) => ...)**으로 디바운스 타이머와 이전 HTTP 요청을 깔끔하게 취소합니다.
다음 단계: 기능이 늘어날수록 우리 BoardAppComponent 파일 하나가 점점 비대해지고 있습니다! 여기에 페이지네이션, 다중 필터, 폼 글자 수 유효성 검사까지 한 파일에 다 우겨넣으면 어떤 참사가 벌어질까요? 이어지는 Chapter 04에서 **Fat Component의 위기와 단일 책임 원칙(SRP)**을 직접 진단해 봅시다!