SCRUM-16058 | 커밋 수: 30개 | 기간: 2025.11 ~ 2025.12


문제상황

Vue 컴포넌트를 외부 JSP 페이지에서 사용할 수 없었습니다. 레거시 시스템과의 통합이 어려웠고, Vue 앱 전체를 마운트해야만 컴포넌트를 활용할 수 있는 구조적 한계가 있었습니다.

Before (Vue 앱 전체 마운트)

장점

  • Vue 생태계 그대로 사용

고려할 점

  • JSP에서 Vue 컴포넌트 사용 불가
  • 전역 의존성 문제
  • 데이터 전달 어려움

After (Web Component 래핑)

장점

  • 플랫폼 독립
  • JSP에서 HTML 태그처럼 사용
  • 점진적 마이그레이션 가능

고려할 점

  • Shadow DOM 스타일 격리 추가 검토 필요

주요 개선사항

createWebComponent 모듈을 구현하여 Vue 컴포넌트를 Web Component로 래핑할 수 있게 했습니다. window.location 기반의 라우터 shim을 구현하여 Vue Router 의존성을 제거했고, href 빌드 시 보안 검증을 통합하여 XSS를 방지했습니다. 또한 Pinia 인스턴스를 분리하여 메인 앱과의 순환 참조 문제를 해결했습니다.

유틸 파일 통합
항목비교값 (파일)
Before1313 파일
After22 파일

정량적 성과

web-components 디렉토리를 신규로 구성했습니다. 기존에 13개로 흩어져 있던 유틸 파일을 router.ts와 setup.ts 2개로 통합했고, CommentAll Web Component를 구현하여 첫 번째 활용 사례를 만들었습니다.

🔌

플랫폼 독립

JSP/Vue 어디서든 HTML 태그로 사용

🔒

보안 검증

buildHref에 XSS 방지 URL 검증 통합

🔄

순환 참조 해결

Pinia 인스턴스 분리로 의존성 격리

비즈니스 임팩트

레거시 JSP 페이지에서 Vue 컴포넌트를 재사용할 수 있게 되었습니다. 플랫폼에 독립적인 UI를 제공할 수 있게 되었고, 전체 시스템을 한 번에 전환하지 않고도 점진적 마이그레이션을 진행할 수 있는 기반이 마련되었습니다.

  1. JSP에서 Vue 사용 불가
  2. Web Component 래핑 설계
  3. 라우터 shim + Pinia 분리
  4. CommentAll WC 구현
  5. 레거시 통합 완료

기술적 구현 상세

AS-IS

기존에는 Vue 컴포넌트를 JSP 페이지에서 사용할 수 없었습니다.

<!-- JSP 페이지에서 Vue 컴포넌트 사용 불가 -->
<div id="comment-section">
  <!-- Vue 앱 전체를 마운트해야 함 -->
</div>

문제점:

Vue 앱 전체를 마운트해야 하는 오버헤드가 있었습니다. 라우터, 스토어 등 전역 의존성 문제가 발생했고, JSP와 Vue 간 데이터 전달이 어려웠습니다.

TO-BE

Web Component로 래핑하여 독립적으로 사용 가능하게 구현했습니다.

<!-- JSP 페이지에서 Web Component 사용 -->
<comment-all
  content-type="recipe"
  content-id="12345"
></comment-all>

핵심 구현

1. createWebComponent 모듈

// web-components/createWebComponent.ts
import { defineCustomElement, type Component } from 'vue';
import { setupWebComponent } from './utils/setup';

export const createWebComponent = (
  component: Component,
  tagName: string
) => {
  const WebComponent = defineCustomElement(component, {
    configureApp: (app) => {
      setupWebComponent(app);
    },
  });

  customElements.define(tagName, WebComponent);

  return WebComponent;
};

2. Web Component 설정 유틸

// web-components/utils/setup.ts
import { createPinia } from 'pinia';
import { createRouterShim } from './router';
import { globalProperties } from './globalProps';

export const setupWebComponent = (app: App) => {
  // Pinia 인스턴스 분리 (순환 참조 방지)
  const pinia = createPinia();
  app.use(pinia);

  // 라우터 shim 적용
  const routerShim = createRouterShim();
  app.use(routerShim);

  // 전역 프로퍼티 설정
  Object.entries(globalProperties).forEach(([key, value]) => {
    app.config.globalProperties[key] = value;
  });
};

3. 라우터 Shim 구현

// web-components/utils/router.ts
import type { Router, RouteLocationNormalized } from 'vue-router';

export const createRouterShim = (): Partial<Router> => {
  const currentRoute: RouteLocationNormalized = {
    path: window.location.pathname,
    query: parseQueryString(window.location.search),
    params: {},
    // ...
  };

  const push = (to: string | { path: string; query?: Record<string, string> }) => {
    const url = buildHref(to);

    // 보안 검증
    if (!isValidUrl(url)) {
      console.error('Invalid URL:', url);
      return;
    }

    // 앱 환경에서는 네이티브 명령 호출
    if (isApp()) {
      window.emart?.app?.cmd?.('open_main_webpage', { url });
    } else {
      window.location.href = url;
    }
  };

  return {
    currentRoute,
    push,
    replace: push,
  };
};

const buildHref = (to: string | { path: string; query?: Record<string, string> }): string => {
  if (typeof to === 'string') {
    return to;
  }

  const { path, query } = to;
  const queryString = query
    ? '?' + new URLSearchParams(query).toString()
    : '';

  return path + queryString;
};

const isValidUrl = (url: string): boolean => {
  // XSS 방지를 위한 URL 검증
  const allowedProtocols = ['http:', 'https:'];
  try {
    const parsed = new URL(url, window.location.origin);
    return allowedProtocols.includes(parsed.protocol);
  } catch {
    return false;
  }
};

4. CommentAll Web Component

<!-- web-components/components/CommentAll.wc.vue -->
<script setup lang="ts">
import { CommentList } from '@/components/comment';
import { useCommentStore } from '@/stores/modules/comment';

const props = defineProps<{
  contentType: string;
  contentId: string;
}>();

const commentStore = useCommentStore();

onMounted(async () => {
  await commentStore.fetchComments({
    contentType: props.contentType,
    contentId: props.contentId,
  });
});

// 댓글 등록/수정 완료 시 커스텀 이벤트 발송
const emit = defineEmits<{
  'comment-created': [commentId: string];
  'comment-updated': [commentId: string];
}>();

const handleCommentCreated = (commentId: string) => {
  emit('comment-created', commentId);

  // Web Component 외부로 이벤트 전달
  const event = new CustomEvent('comment-created', {
    detail: { commentId },
    bubbles: true,
  });
  document.dispatchEvent(event);
};
</script>

<template>
  <div class="comment-all-wc">
    <CommentList
      :content-type="contentType"
      :content-id="contentId"
      @comment-created="handleCommentCreated"
    />
  </div>
</template>

5. Web Component 등록

// web-components/components/index.ts
import { createWebComponent } from '../createWebComponent';
import CommentAllWc from './CommentAll.wc.vue';

// Web Component 등록
createWebComponent(CommentAllWc, 'comment-all');

// 다른 Web Component 추가 시
// createWebComponent(AnotherComponent, 'another-component');

6. Pinia 인스턴스 분리

// stores/pinia.ts
import { createPinia } from 'pinia';

// 메인 앱용 Pinia 인스턴스
export const pinia = createPinia();

// main.ts
import { pinia } from '@/stores/pinia';

const app = createApp(App);
app.use(pinia);


회고

잘한 점

라우터 shim을 구현하여 Vue Router 의존성을 제거했습니다. 보안 검증을 buildHref에 통합하여 XSS를 방지했고, Pinia 인스턴스를 분리하여 순환 참조 문제를 해결했습니다.

개선할 점

Web Component의 스타일 격리를 위한 Shadow DOM 적용을 추가로 고려해야 합니다. 더 많은 컴포넌트를 Web Component로 제공하면 레거시 통합 범위를 넓힐 수 있을 것입니다.

배운 점

Vue 3의 defineCustomElement API를 활용하여 Web Component를 정의하는 방법을 익혔습니다. Web Component와 Vue 앱 간의 커스텀 이벤트 기반 통신 방법을 배웠고, 레거시 시스템과의 점진적 통합 전략을 수립하는 경험을 했습니다.