다크 모드
Appendix D. 기존 jQuery 페이지에 React 19 심기: 공존과 점진적 전환 가이드
"사내 시스템 수백 개 화면을 하루아침에 React SPA로 갈아엎을 수는 없다. 오늘 당장 jQuery 페이지 한 귀퉁이에 React 19 위젯을 심고, CustomEvent 브릿지로 양방향 통신하며, 버리지 못한 jQuery 플러그인을 React 안에서 안전하게 감싸 쓰는 점진적 전환(Strangler) 길을 설계하자."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
| 스트랭글러(Strangler Fig) 전환 전략 | 레거시 시스템을 한 번에 부수지 않고 작은 위젯부터 감싸며 교체하기 | D.1 |
| React 아일랜드(위젯) & ESM CDN 마운트 | 빌드 도구 없이 기존 HTML/JSP 페이지 일부에만 createRoot로 React 심기 | D.2 [Step 1~3] |
CustomEvent 양방향 이벤트 브릿지 | jQuery 영역과 React 영역이 서로의 DOM을 침범하지 않고 신호 주고받기 | D.2 🆕 박스 |
jQuery 플러그인 래퍼 컴포넌트 (useRef + useEffect) | 사내 레거시 달력·그리드 플러그인의 초기화와 destroy() 수명을 격리 | D.3 [코드 D-1] |
root.unmount() 수동 해제 | jQuery가 탭이나 컨테이너를 갈아 끼우기 전에 React 루트를 안전하게 정리 | D.4 |
Vite 멀티 엔트리 빌드 (rollupOptions.input, manifest) | TypeScript로 작성한 React 위젯을 번들링해 레거시 JSP/HTML에 배포하기 | D.5 |
TIP
📍 언제 읽으면 좋은가요? — 1.5장 직후 선택 실습으로 읽어도 좋습니다!
D.1~D.2는 1.5장까지 배운 것(useState, useEffect)만으로 따라 할 수 있어서, *"내 사내 jQuery/JSP 페이지에 당장 내일 React 위젯을 하나 붙여 보고 싶다"*면 언제든 먼저 실습해 볼 수 있습니다.
D.1 왜 한 번에 바꾸지 않는가: 스트랭글러(Strangler Fig) 전략
1장에서는 학습을 위해 게시판을 별도 Vite SPA로 통째로 옮겼지만, 현업에서는 다음 이유로 빅뱅 재작성(Big Bang Rewrite)이 실패하기 쉽습니다:
- 수백 개의 기존 화면이 JSP/Thymeleaf 레이아웃과 레거시 세션 스크립트에 얽혀 있습니다.
- 사내 전용 jQuery 그리드, 달력, 리포팅 플러그인을 당장 대체할 수 없습니다.
- 전면 재개발 기간 동안 비즈니스 신규 요구사항을 멈출 수 없습니다.
그래서 덩굴이 고목을 조금씩 감싸 결국 대체하듯, 기존 페이지는 그대로 두고 새 기능이나 가장 복잡한 영역부터 React 19 위젯으로 바꿔 끼우는 스트랭글러 전략을 택합니다.
D.2 [실습] legacy-board.html에 React 19 통계 위젯 심기 (빌드 없이)
목표: 1장의 jQuery 게시판(backend/src/main/resources/static/legacy-board.html) 상단에 "카테고리별 실시간 게시글 통계 + 필터링" 위젯을 React 19로 심고, jQuery와 React가 CustomEvent 브릿지로 양방향 통신하게 만듭니다.
[Step 1] 위젯이 마운트될 빈 컨테이너 태그 추가
legacy-board.html의 게시글 테이블 바로 위에 다음 한 줄을 추가합니다. 이 <div> 안쪽만 React의 영토가 됩니다:
html
<!-- React 19 위젯이 마운트될 루트 컨테이너 -->
<div id="react-stats-widget" style="margin-bottom: 16px;"></div>[Step 2] ESM CDN으로 React 19를 불러와 위젯 마운트 및 양방향 브릿지 연결
legacy-board.html의 </body> 바로 위에 <script type="module">을 추가합니다:
html
<script type="module">
import React, { useState, useEffect, createElement as h } from 'https://esm.sh/react@19';
import { createRoot } from 'https://esm.sh/react-dom@19/client';
function StatsWidget() {
const [stats, setStats] = useState({});
const [lastUpdated, setLastUpdated] = useState('');
async function loadStats() {
const res = await fetch('/api/posts?size=100');
const data = await res.json();
const items = data.items || [];
const nextStats = {};
for (const post of items) {
nextStats[post.category] = (nextStats[post.category] || 0) + 1;
}
setStats(nextStats);
setLastUpdated(new Date().toLocaleTimeString());
}
useEffect(() => {
loadStats();
// 🌟 1. [jQuery → React] jQuery가 글을 등록/삭제했을 때 보낸 신호를 수신해 통계 재집계!
const onLegacyChanged = () => loadStats();
window.addEventListener('legacy:posts-changed', onLegacyChanged);
return () => {
window.removeEventListener('legacy:posts-changed', onLegacyChanged);
};
}, []);
// 🌟 2. [React → jQuery] 위젯에서 카테고리 칩을 클릭하면 jQuery 영역에 필터 변경 신호 발송!
function notifyCategoryClick(category) {
window.dispatchEvent(
new CustomEvent('react:category-selected', {
detail: { category }
})
);
}
return h(
'div',
{
style: {
padding: '12px 16px',
background: '#f8fafc',
border: '1px solid #cbd5e1',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
gap: '10px'
}
},
h('strong', null, '📊 카테고리별 현황 (React 19 위젯):'),
...Object.entries(stats).map(([cat, count]) =>
h(
'button',
{
key: cat,
type: 'button',
onClick: () => notifyCategoryClick(cat),
style: {
padding: '4px 10px',
borderRadius: '999px',
border: '1px solid #94a3b8',
background: '#fff',
cursor: 'pointer'
}
},
`${cat} ${count}건`
)
),
lastUpdated && h('small', { style: { color: '#64748b', marginLeft: 'auto' } }, `갱신: ${lastUpdated}`)
);
}
const container = document.getElementById('react-stats-widget');
if (container) {
const root = createRoot(container);
root.render(h(StatsWidget));
}
</script>[Step 3] 기존 jQuery 코드에서 CustomEvent 송수신 연결
기존 jQuery 스크립트의 등록/삭제 완료 콜백에 발신 코드 한 줄을, 초기화 블록에 수신 코드 한 줄을 추가합니다:
javascript
// 1. [jQuery -> React 발신] jQuery에서 글 등록 또는 삭제 성공 시:
window.dispatchEvent(new CustomEvent('legacy:posts-changed'));
// 2. [React -> jQuery 수신] React 위젯에서 카테고리 칩을 클릭했을 때 jQuery 셀렉트박스 변경:
window.addEventListener('react:category-selected', function (e) {
var selectedCat = e.detail.category;
$('#category-filter').val(selectedCat).trigger('change');
});🆕 처음 보는 문법: CustomEvent와 createElement as h
new CustomEvent('이벤트명', { detail: 데이터 }): 브라우저 표준 사용자 정의 이벤트입니다. jQuery와 React가 서로의 내부 변수나 DOM을 직접 건드리지 않고 느슨하게 데이터를 주고받는 가장 안전한 공식 통로입니다.createElement as h: 빌드 도구(Vite/Babel)가 없는 순수 HTML 파일에서는<div>같은 JSX 문법을 브라우저가 직접 해석하지 못하므로React.createElement('div', props, children)를 호출합니다(D.5처럼 Vite로 빌드하면 평소처럼.tsxJSX 문법을 그대로 쓸 수 있습니다).
WARNING
공존의 제1원칙: React 컨테이너(#react-stats-widget) 안의 DOM은 절대 jQuery로 건드리지 마라!
React가 관리하는 영역 안의 요소를 jQuery로 $('.chip').remove()처럼 직접 조작하면, React가 기억하는 가상 DOM(Fiber 트리)과 실제 브라우저 DOM이 어긋나면서 다음 리렌더링 때 NotFoundError: Failed to execute 'removeChild' on 'Node' 에러가 터지며 화면이 뻗습니다. 영역을 철저히 분리하고 오직 CustomEvent로만 대화하십시오.
D.3 React 컴포넌트 안에서 레거시 jQuery 플러그인 감싸 쓰기 (Wrapper)
반대 상황입니다. 새 화면은 React 19(.tsx)로 만들고 있는데, 사내 표준 달력(jQuery UI Datepicker)이나 레거시 그리드 플러그인을 당장 버릴 수 없는 경우입니다.
해답은 useRef와 useEffect로 jQuery 플러그인을 감싸는 래퍼 컴포넌트(JQueryDatePicker.tsx) 하나를 만들어 그 안에만 $를 가두는 것입니다.
[코드 D-1] jQuery Datepicker 래퍼 컴포넌트 (frontend/src/components/legacy/JQueryDatePicker.tsx)
tsx
// frontend/src/components/legacy/JQueryDatePicker.tsx
import React, { useEffect, useRef } from 'react';
// 전역 window.$ (jQuery) 타입 선언
declare const $: any;
export interface JQueryDatePickerProps {
value: string;
onChange: (nextDate: string) => void;
placeholder?: string;
}
export function JQueryDatePicker({
value,
onChange,
placeholder = '날짜 선택 (YYYY-MM-DD)'
}: JQueryDatePickerProps) {
const inputRef = useRef<HTMLInputElement | null>(null);
// 최신 onChange 콜백을 ref에 유지하여, 부모 리렌더링마다 datepicker가 재초기화되지 않게 방어!
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// ① 마운트 시 초기화 & ④ 언마운트 시 destroy() 해제
useEffect(() => {
const el = inputRef.current;
if (!el || typeof $ === 'undefined') return;
const $el = $(el);
$el.datepicker({
dateFormat: 'yy-mm-dd',
// ② 플러그인 -> React: 사용자가 날짜를 선택하면 React 상태 변경 콜백 호출
onSelect: (dateText: string) => {
onChangeRef.current(dateText);
}
});
// 초기값 반영
if (value) {
$el.datepicker('setDate', value);
}
// 🌟 ④ 언마운트 시 플러그인이 body에 붙인 팝업 DOM과 이벤트 리스너를 반드시 파괴(메모리 누수 방지!)
return () => {
try {
$el.datepicker('destroy');
} catch {
// 이미 제거된 경우 무시
}
};
}, []);
// ③ React -> 플러그인: 외부(부모)에서 value prop이 바뀌면 datepicker 표시값 동기화
useEffect(() => {
const el = inputRef.current;
if (!el || typeof $ === 'undefined') return;
$(el).datepicker('setDate', value);
}, [value]);
// React는 이 <input> 빈 껍데기 하나만 그리고, 내부 조작은 전적으로 플러그인에게 위임함
return (
<input
ref={inputRef}
type="text"
defaultValue={value}
placeholder={placeholder}
readOnly
className="jq-datepicker-input"
/>
);
}감싸기(Wrapper) 컴포넌트의 4대 철칙
| # | 규칙 | 이유 |
|---|---|---|
| ① | 플러그인 초기화는 useEffect(..., []) 마운트 시점에 수행 | 렌더링 도중에는 inputRef.current가 아직 null이기 때문 |
| ② | 플러그인 → React 전달은 플러그인 이벤트 콜백(onSelect)에서 onChange 호출 | 부모 컴포넌트는 내부가 jQuery인지조차 몰라야 함 |
| ③ | React → 플러그인 전달은 **별도 useEffect([value])**에서 메서드 호출 | React 상태가 단일 진실 공급원(Single Source of Truth)이 되도록 유지 |
| ④ | useEffect 클린업에서 반드시 destroy() 호출 | 화면 이동 후에도 달력 팝업 유령 DOM과 전역 리스너가 남는 누수 차단(13장) |
D.4 jQuery가 컨테이너를 교체할 때의 수동 해제: root.unmount()
레거시 페이지에서 탭 전환이나 $('#content').load('other.jsp')처럼 jQuery가 React 위젯이 들어 있는 부모 DOM을 통째로 덮어쓰는 경우, 덮어쓰기 직전에 반드시 root.unmount()를 호출해야 React 위젯의 useEffect 클린업(window 리스너 해제 등)이 정상 실행됩니다:
javascript
// 전역 레지스트리에 React 루트 인스턴스 보관
window.__REACT_WIDGET_ROOTS__ = window.__REACT_WIDGET_ROOTS__ || {};
function mountStatsWidget() {
const el = document.getElementById('react-stats-widget');
if (!el || window.__REACT_WIDGET_ROOTS__.stats) return;
const root = createRoot(el);
root.render(h(StatsWidget));
window.__REACT_WIDGET_ROOTS__.stats = root;
}
// 🌟 jQuery가 화면 영역을 갈아 끼우기 직전에 반드시 호출!
function unmountStatsWidget() {
if (window.__REACT_WIDGET_ROOTS__.stats) {
window.__REACT_WIDGET_ROOTS__.stats.unmount();
delete window.__REACT_WIDGET_ROOTS__.stats;
}
}D.5 Vite 멀티 엔트리 빌드로 TypeScript(.tsx) 위젯 배포하기
위젯 규모가 커져서 TypeScript, JSX, Zustand 스토어를 그대로 써서 빌드한 뒤 레거시 JSP/HTML 페이지에 <script>로 꽂고 싶다면 vite.config.ts에 **멀티 엔트리(Multiple Entry Points)**를 설정합니다:
typescript
// frontend/src/widgets/stats-widget.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { StatsWidget } from './StatsWidget';
const el = document.getElementById('react-stats-widget');
if (el) {
createRoot(el).render(<StatsWidget />);
}typescript
// frontend/vite.config.ts
export default defineConfig({
plugins: [react()],
build: {
manifest: true, // 해시가 붙은 파일명 매핑표(.vite/manifest.json) 생성
rollupOptions: {
input: {
main: 'index.html', // 기존 메인 SPA
statsWidget: 'src/widgets/stats-widget.tsx' // 🌟 레거시 페이지 삽입용 위젯 번들
}
}
}
});D.6 점진적 전환 종료 체크리스트
공존은 과도기 전략이지 최종 목적지가 아닙니다. 분기마다 아래 지표를 점검하며 레거시를 완전히 걷어내십시오:
- [ ] 신규 화면과 신규 기능은 100% React 19 컴포넌트로만 작성한다.
- [ ] 프로젝트 내에서
$를 직접 호출하는 코드는src/components/legacy/폴더 안의 래퍼 컴포넌트로만 엄격히 격리한다. - [ ] 래퍼 컴포넌트들을 부록 B의 사내 표준 UI 컴포넌트(Ant Design / shadcn/ui)로 하나씩 교체하고, 최종적으로
package.json과 HTML에서jquery스크립트를 제거한다.