Skip to content

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() 함수가 호출될 때만 작동합니다.

  1. 컴포넌트 인스턴스가 처음 만들어질 때(생성 시점), this.currentPost()?.title ?? '선택된 글 없음'이 딱 1번 실행됩니다.
  2. 그 순간 selectedTitle 변수에는 '선택된 글 없음'이라는 평범한 문자열 원시값이 복사됩니다.
  3. 이 순간부터 selectedTitle은 currentPost 신호 파이프와 완전히 인연이 끊긴 죽은 문자열입니다!

🥤 비유: 상수도 배관(computed) vs 종이컵에 떠 놓은 물(원시값 복사)
집에 연결된 상수도 배관(Signal)에서 수도꼭지를 틀어야 신선한 물이 계속 나오는데, 아침에 배관에서 물 한 모금을 종이컵(selectedTitle)에 덜어놓고는 저녁에 "이상하다? 정수장에서 따뜻한 물을 보냈다는데 왜 내 종이컵 물은 그대로 차갑지?" 하고 갸웃거리는 것과 같습니다. 종이컵은 배관과 이미 인연이 끊겼습니다! 항상 배관과 배관을 직결하는 **파생 신호(computed)**를 써야 합니다.

✅ 올바른 해결책: computed()로 파생 신호 파이프 연결하기 ​

값을 가공하거나 하위 속성을 별도 이름으로 뽑아내고 싶다면, 무조건 **computed()**로 감싸야 합니다:

typescript
// ✅ 올바른 해법: 배관과 배관을 직결하는 파생 신호!
selectedTitle = computed(() => this.currentPost()?.title ?? '선택된 글 없음');

그리고 템플릿에서도 함수 호출 형태인 **&#123;&#123; selectedTitle() &#125;&#125;**로 읽으면, 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 요청을 쏘면 두 가지 심각한 실무 장애가 발생합니다:

  1. 네트워크 요청 폭주: Angular를 치는 동안 A, An, Ang, ... 총 7번의 불필요한 API 요청이 발사됩니다.
  2. 경쟁 상태 (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: 최신 Angular HttpClient(또는 표준 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)을 유지하며 즉시 업데이트되도록 만들어 보십시오!

📝 미션 요구사항 ​

  1. BoardAppComponent에 syncDetailToList(): void 메서드를 작성하십시오.
  2. 현재 선택된 const edited = this.currentPost()가 존재한다면, this.posts.update(...)와 배열의 .map()을 사용하여 p.id === edited.id인 항목만 { ...p, title: edited.title, content: edited.content }로 교체한 새 배열을 반환하도록 구현하십시오.
  3. 상세 카드 헤더의 버튼 그룹에 <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줄

  1. 컴포넌트 생성 시점에 const title = this.currentPost().title처럼 신호 값을 미리 꺼내 일반 변수에 복사하면 반응성 파이프가 끊깁니다(Reactivity Loss). 파생 값은 반드시 **computed()**로 감싸십시오.
  2. Signal이 들고 있는 객체나 배열을 변경할 때는 .push()나 속성 직접 대입 대신, 언제나 .update(prev => [...prev])로 새로운 참조를 만들어 넘겨야 Object.is 비교기가 변경을 감지합니다.
  3. effect() 안에서 의존성 추적을 피하고 싶을 때는 **untracked()**를 쓰고, 실시간 검색처럼 연속 발생하는 비동기 작업은 **effect((onCleanup) => ...)**으로 디바운스 타이머와 이전 HTTP 요청을 깔끔하게 취소합니다.

다음 단계: 기능이 늘어날수록 우리 BoardAppComponent 파일 하나가 점점 비대해지고 있습니다! 여기에 페이지네이션, 다중 필터, 폼 글자 수 유효성 검사까지 한 파일에 다 우겨넣으면 어떤 참사가 벌어질까요? 이어지는 Chapter 04에서 **Fat Component의 위기와 단일 책임 원칙(SRP)**을 직접 진단해 봅시다!

Moon HomeLab Engineering Knowledge Hub