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