Skip to content

Appendix D. 기존 jQuery 페이지에 Angular Standalone 심기: @angular/elements 공존과 점진적 전환 가이드 ​

"수백 개 사내 JSP/jQuery 화면을 하루아침에 Angular SPA로 갈아엎을 수는 없다. 공식 @angular/elements를 활용해 Angular 22 Standalone 컴포넌트를 브라우저 표준 Web Component(<board-stats-widget>)로 포장하여 오늘 당장 jQuery 화면 한 귀퉁이에 심고, 반대로 Angular 안에서 레거시 jQuery 플러그인을 안전하게 감싸 쓰는 양방향 공존 아키텍처를 완성하자."


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

개념한 줄 요약설명 위치
스트랭글러(Strangler Fig) 전환 전략레거시 시스템을 한 번에 부수지 않고 새 위젯으로 조금씩 감싸며 교체하는 기법D.1
@angular/elements (createCustomElement)Angular Standalone 컴포넌트를 표준 웹 컴포넌트(Custom Element)로 변환D.2 [코드 D-1], [코드 D-2]
CustomEvent 양방향 통신jQuery 영역과 Angular 웹 컴포넌트가 서로의 내부를 몰라도 신호를 주고받는 표준 이벤트D.3 [코드 D-3]
jQuery 플러그인 래퍼 컴포넌트afterNextRender와 DestroyRef로 레거시 jQuery UI 플러그인을 Angular 안에 격리D.4 [코드 D-4]

D.1 왜 한 번에 바꾸지 않는가: 스트랭글러(Strangler Fig) 전략 ​

1장에서는 학습을 위해 게시판을 별도 SPA로 통째로 옮겼지만, 실제 사내 인트라넷 현장에서는 수백 개의 JSP, 타임리프(Thymeleaf), jQuery 화면이 세션과 레이아웃으로 얽혀 있어 빅뱅(Big-Bang) 방식의 전면 재작성이 불가능에 가깝습니다.

해답은 스트랭글러(Strangler Fig) 전략입니다. 열대 무화과나무 덩굴이 기존 고목을 조금씩 감싸며 결국 나무 전체를 대체하듯, 기존 legacy-board.html 페이지는 그대로 두고 새로 추가되는 통계 위젯이나 댓글 영역부터 Angular 22 웹 컴포넌트로 하나씩 심어 나가는 것입니다.


D.2 [실습 1] Angular 22 컴포넌트를 표준 웹 컴포넌트(<board-stats-widget>)로 만들기 ​

목표: 1장의 jQuery 게시판(legacy-board.html) 상단에 장착할 **"카테고리별 실시간 게시글 통계 위젯"**을 Angular 22 Standalone 컴포넌트로 작성하고, @angular/elements의 createCustomElement()로 감싸 브라우저 커스텀 태그로 등록합니다.

먼저 패키지를 설치합니다:

bash
cd frontend
npm install @angular/elements

[코드 D-1] Signals 기반 통계 위젯 컴포넌트 (frontend/src/app/widgets/board-stats-widget.component.ts) ​

typescript
// frontend/src/app/widgets/board-stats-widget.component.ts
import {
  Component,
  ChangeDetectionStrategy,
  DestroyRef,
  OnInit,
  computed,
  inject,
  input,
  output,
  signal
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { PostResponse, isPageResponse, isPostResponse } from '../models/post.model';

@Component({
  selector: 'app-board-stats-widget',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="stats-widget-box">
      <div class="widget-title">
        <strong>📊 {{ widgetTitle() }} (Angular 22 Web Component)</strong>
        @if (lastUpdated()) {
          <small class="time">최근 동기화: {{ lastUpdated() }}</small>
        }
      </div>

      <div class="chip-list">
        <button type="button" class="stat-chip all" (click)="selectCategory('ALL')">
          전체 {{ totalCount() }}건
        </button>
        @for (entry of categoryEntries(); track entry.category) {
          <button
            type="button"
            class="stat-chip"
            (click)="selectCategory(entry.category)"
          >
            {{ entry.category }} {{ entry.count }}건
          </button>
        }
      </div>
    </div>
  `,
  styles: [`
    .stats-widget-box {
      display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap;
      gap: 12px; padding: 14px 18px; background: #f0fdf4; border: 1px solid #bbf7d0;
      border-radius: 10px; margin-bottom: 16px;
    }
    .widget-title { display: flex; flex-direction: column; gap: 2px; color: #166534; }
    .time { font-size: 11px; color: #15803d; }
    .chip-list { display: flex; gap: 8px; flex-wrap: wrap; }
    .stat-chip {
      background: #fff; color: #166534; border: 1px solid #86efac;
      padding: 6px 12px; border-radius: 999px; font-size: 12px; font-weight: 700; cursor: pointer;
    }
    .stat-chip.all { background: #16a34a; color: #fff; border-color: #16a34a; }
  `]
})
export class BoardStatsWidgetComponent implements OnInit {
  private readonly http = inject(HttpClient);
  private readonly destroyRef = inject(DestroyRef);

  // 🌟 1. HTML 속성(widget-title="...")이 자동으로 Signal Input으로 연결됨!
  readonly widgetTitle = input<string>('카테고리별 게시글 현황');

  // 🌟 2. output()은 표준 DOM CustomEvent('categorySelected')로 방출되어 jQuery가 수신 가능!
  readonly categorySelected = output<string>();

  private readonly _posts = signal<PostResponse[]>([]);
  readonly lastUpdated = signal<string>('');

  readonly totalCount = computed(() => this._posts().length);

  readonly categoryEntries = computed(() => {
    const counts: Record<string, number> = { NOTICE: 0, DEV: 0, FREE: 0 };
    for (const post of this._posts()) {
      counts[post.category] = (counts[post.category] ?? 0) + 1;
    }
    return Object.entries(counts).map(([category, count]) => ({ category, count }));
  });

  ngOnInit(): void {
    this.loadStats();

    // 🌟 3. jQuery 레거시 화면이 글을 등록·삭제할 때 쏘는 'legacy:posts-changed' 이벤트 수신!
    const handleLegacyChange = () => {
      this.loadStats();
    };
    window.addEventListener('legacy:posts-changed', handleLegacyChange);

    // 위젯 태그가 DOM에서 제거될 때 리스너 자동 정리
    this.destroyRef.onDestroy(() => {
      window.removeEventListener('legacy:posts-changed', handleLegacyChange);
    });
  }

  async loadStats(): Promise<void> {
    try {
      const raw = await firstValueFrom(this.http.get<unknown>('/api/posts?page=1&size=100'));
      if (isPageResponse(raw, isPostResponse)) {
        this._posts.set(raw.content);
      } else if (Array.isArray(raw) && raw.every(isPostResponse)) {
        this._posts.set(raw);
      }
      this.lastUpdated.set(new Date().toLocaleTimeString());
    } catch (err) {
      console.error('통계 위젯 로드 실패:', err);
    }
  }

  selectCategory(category: string): void {
    this.categorySelected.emit(category);
  }
}

[코드 D-2] 웹 컴포넌트 등록 엔트리포인트 (frontend/src/elements-main.ts) ​

typescript
// frontend/src/elements-main.ts
import { createApplication } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
import { createCustomElement } from '@angular/elements';
import { BoardStatsWidgetComponent } from './app/widgets/board-stats-widget.component';

(async () => {
  // 1. 전체 화면을 마운트하지 않는 헤드리스 Angular ApplicationRef 생성
  const appRef = await createApplication({
    providers: [provideHttpClient()]
  });

  // 2. Standalone 컴포넌트를 브라우저 표준 Custom Element 생성자로 변환
  const StatsWidgetElement = createCustomElement(BoardStatsWidgetComponent, {
    injector: appRef.injector
  });

  // 3. 브라우저에 <board-stats-widget> 커스텀 태그 등록 (중복 등록 방어 포함)
  if (!customElements.get('board-stats-widget')) {
    customElements.define('board-stats-widget', StatsWidgetElement);
  }
})();

D.3 [실습 2] legacy-board.html에 <board-stats-widget> 심고 jQuery와 대화하기 ​

이제 구식 jQuery 파일(legacy-board.html)에 <board-stats-widget> 태그를 일반 HTML 태그처럼 한 줄만 넣으면 됩니다!

  • jQuery → Angular 방향: jQuery가 글을 등록하거나 삭제한 직후 window.dispatchEvent(new CustomEvent('legacy:posts-changed'))를 호출하면 Angular 위젯이 즉시 재집계합니다.
  • Angular → jQuery 방향: 사용자가 Angular 위젯의 카테고리 칩을 클릭하면 output()이 표준 DOM CustomEvent로 방출되므로, jQuery에서 $('#stats-widget').on('categorySelected', ...)로 받아 e.originalEvent.detail을 읽습니다!

[코드 D-3] legacy-board.html 연동 코드 ​

html
<!-- backend/src/main/resources/static/legacy-board.html 내부 -->
<div class="legacy-container">
  <h1>사내 기술 Q&A 게시판 (jQuery 레거시 + Angular 22 위젯 공존)</h1>

  <!-- 🌟 1. Angular 22 웹 컴포넌트를 일반 HTML 태그처럼 장착! -->
  <board-stats-widget
    id="stats-widget"
    widget-title="실시간 카테고리별 질문 통계"
  ></board-stats-widget>

  <!-- 기존 jQuery 검색바 및 #board-table 테이블 영역 그대로 유지 -->
  <table id="board-table">...</table>
</div>

<!-- 빌드된 Angular Elements 번들 로드 -->
<script type="module" src="/elements/angular-elements.js"></script>

<script>
  // 🌟 2. Angular 위젯이 방출한 (categorySelected) output 이벤트를 jQuery에서 수신!
  $('#stats-widget').on('categorySelected', function (e) {
    var selectedCategory = e.originalEvent.detail; // output<string>() 페이로드가 detail에 담김!
    console.log('[jQuery 수신] Angular 위젯에서 선택된 카테고리:', selectedCategory);
    $('#category-filter').val(selectedCategory);
    loadPosts(); // 기존 jQuery 목록 조회 함수 실행
  });

  // 🌟 3. 기존 jQuery의 글 등록·삭제 ajax success 콜백 맨 끝에 아래 한 줄만 추가!
  function notifyAngularWidget() {
    window.dispatchEvent(new CustomEvent('legacy:posts-changed'));
  }
</script>

WARNING

공존의 제1원칙: 서로의 DOM 영역을 직접 침범하지 마십시오!
<board-stats-widget> 안쪽의 DOM을 jQuery로 $('#stats-widget .stat-chip').remove()처럼 직접 조작하면, Angular의 Signals 렌더링 상태와 실제 DOM이 어긋나 오류가 발생합니다. 반대로 jQuery가 $('#board-tbody').html(...)로 통째로 덮어쓰는 영역 안에 Angular 컴포넌트를 넣으면 다음 html() 호출 순간 위젯이 증발합니다. 영역은 철저히 분리하고, 대화는 오직 속성(input)과 CustomEvent(output)로만 나누십시오.


D.4 반대 방향: Angular 22 컴포넌트 안에서 레거시 jQuery 플러그인 감싸 쓰기 ​

새 화면은 Angular 22로 개발하고 있는데, 사내 표준 달력(datepicker)이나 구형 그리드 플러그인이 jQuery 기반이라 당장 버릴 수 없는 경우도 있습니다. 이때는 **viewChild + afterNextRender + DestroyRef**를 조합해 jQuery 플러그인을 컴포넌트 하나 안에 완벽히 가두어(Wrapping) 씁니다.

[코드 D-4] jQuery Datepicker 래퍼 컴포넌트 (frontend/src/app/components/legacy-date-picker.component.ts) ​

typescript
// frontend/src/app/components/legacy-date-picker.component.ts
import {
  Component,
  ChangeDetectionStrategy,
  DestroyRef,
  ElementRef,
  afterNextRender,
  effect,
  inject,
  model,
  viewChild
} from '@angular/core';

// 전역 window.$ (jQuery + jQuery UI datepicker가 로드되어 있다고 가정)
declare const $: any;

@Component({
  selector: 'app-legacy-date-picker',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <!-- Angular는 이 input 엘리먼트 하나만 그리고, 달력 팝업 제어는 플러그인에 위임 -->
    <input
      #dateInput
      type="text"
      class="legacy-date-input"
      readonly
      placeholder="날짜 선택 (YYYY-MM-DD)"
    />
  `,
  styles: [`
    .legacy-date-input { padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; cursor: pointer; }
  `]
})
export class LegacyDatePickerComponent {
  private readonly destroyRef = inject(DestroyRef);
  private readonly dateInputRef = viewChild.required<ElementRef<HTMLInputElement>>('dateInput');

  // 🌟 양방향 바인딩 신호 model(): 부모가 [(value)]="dueDate"로 평범하게 사용 가능!
  readonly value = model<string>('');

  constructor() {
    // ① DOM이 생성된 직후(afterNextRender) jQuery 플러그인 초기화
    afterNextRender(() => {
      const el = this.dateInputRef().nativeElement;
      if (typeof $ === 'undefined' || !$.fn?.datepicker) {
        return;
      }

      $(el).datepicker({
        dateFormat: 'yy-mm-dd',
        // ② 플러그인 -> Angular: 날짜 선택 콜백에서 model 신호 값을 갱신!
        onSelect: (dateText: string) => {
          this.value.set(dateText);
        }
      });

      if (this.value()) {
        $(el).datepicker('setDate', this.value());
      }

      // ④ 컴포넌트 파괴 시 jQuery 플러그인 인스턴스와 전역 팝업 DOM 완벽 제거 (메모리 누수 방지!)
      this.destroyRef.onDestroy(() => {
        $(el).datepicker('destroy');
      });
    });

    // ③ Angular -> 플러그인: 부모가 신호 값을 바꾸면 jQuery 플러그인 UI도 동기화
    effect(() => {
      const currentVal = this.value();
      const el = this.dateInputRef().nativeElement;
      if (typeof $ !== 'undefined' && $.fn?.datepicker && el) {
        $(el).datepicker('setDate', currentVal);
      }
    });
  }
}

사용하는 부모 컴포넌트 쪽에서는 이 안에 jQuery가 들어 있는지조차 모른 채 <app-legacy-date-picker [(value)]="dueDate" />로 깔끔하게 사용할 수 있습니다. 나중에 순수 Angular 달력(예: Angular Material Datepicker)으로 교체할 때도 이 파일 하나만 바꾸면 됩니다!


D.5 점진적 레거시 청산 로드맵 (Exit Strategy) ​

공존은 안전한 이주를 위한 다리(Bridge)이지 영구적인 목적지가 아닙니다. 사내 팀에서 다음 3단계 종료 조건을 분기별 목표로 삼으십시오:

  1. 신규 화면 원칙: 오늘부터 만드는 모든 신규 메뉴와 위젯은 100% Angular 22 Standalone 컴포넌트로 작성한다.
  2. jQuery 격리 원칙: Angular 코드베이스 안에서 $ 접근은 오직 src/app/components/legacy-* 래퍼 컴포넌트 내부로만 제한한다.
  3. 최종 졸업: 래퍼 컴포넌트들을 부록 B의 Angular Material / NG-ZORRO 컴포넌트로 하나씩 교체하고, 모든 레거시 HTML이 라우트(app.routes.ts)로 편입되는 날 jquery.min.js <script> 태그를 영구 삭제한다!

Moon HomeLab Engineering Knowledge Hub