Skip to content

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로 빌드하면 평소처럼 .tsx JSX 문법을 그대로 쓸 수 있습니다).

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 스크립트를 제거한다.

Moon HomeLab Engineering Knowledge Hub