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 인스턴스를 분리하여 메인 앱과의 순환 참조 문제를 해결했습니다.
| 항목 | 비교 | 값 (파일) |
|---|---|---|
| Before | 13 파일 | |
| After | 2 파일 |
정량적 성과
web-components 디렉토리를 신규로 구성했습니다. 기존에 13개로 흩어져 있던 유틸 파일을 router.ts와 setup.ts 2개로 통합했고, CommentAll Web Component를 구현하여 첫 번째 활용 사례를 만들었습니다.
플랫폼 독립
JSP/Vue 어디서든 HTML 태그로 사용
보안 검증
buildHref에 XSS 방지 URL 검증 통합
순환 참조 해결
Pinia 인스턴스 분리로 의존성 격리
비즈니스 임팩트
레거시 JSP 페이지에서 Vue 컴포넌트를 재사용할 수 있게 되었습니다. 플랫폼에 독립적인 UI를 제공할 수 있게 되었고, 전체 시스템을 한 번에 전환하지 않고도 점진적 마이그레이션을 진행할 수 있는 기반이 마련되었습니다.
- JSP에서 Vue 사용 불가
- Web Component 래핑 설계
- 라우터 shim + Pinia 분리
- CommentAll WC 구현
- 레거시 통합 완료
기술적 구현 상세
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 앱 간의 커스텀 이벤트 기반 통신 방법을 배웠고, 레거시 시스템과의 점진적 통합 전략을 수립하는 경험을 했습니다.