๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
Front/Vue

Vue 3 Teleport ์ดํ•ดํ•˜๊ธฐ

by Awesome-SH 2025. 8. 11.

๐ŸŽฏ Teleport๊ฐ€ ํ•ด๊ฒฐํ•˜๋Š” ๊ทผ๋ณธ์ ์ธ ๋ฌธ์ œ

Vue 3์˜ Teleport๋Š” ๋‹จ์ˆœํ•œ ํŽธ์˜ ๊ธฐ๋Šฅ์ด ์•„๋‹™๋‹ˆ๋‹ค. ๋ชจ๋˜ ์›น ๊ฐœ๋ฐœ์—์„œ ๊ฐ€์žฅ ๊นŒ๋‹ค๋กœ์šด ๋ฌธ์ œ ์ค‘ ํ•˜๋‚˜์ธ ์ปดํฌ๋„ŒํŠธ ๊ณ„์ธต๊ตฌ์กฐ์™€ DOM ๊ตฌ์กฐ์˜ ๋ถˆ์ผ์น˜๋ฅผ ์šฐ์•„ํ•˜๊ฒŒ ํ•ด๊ฒฐํ•˜๋Š” ํ˜์‹ ์ ์ธ ์†”๋ฃจ์…˜์ž…๋‹ˆ๋‹ค.

๋ชจ๋‹ฌ, ํˆดํŒ, ๋“œ๋กญ๋‹ค์šด, ํ† ์ŠคํŠธ ์•Œ๋ฆผ ๋“ฑ์„ ๊ตฌํ˜„ํ•  ๋•Œ ๊ฒช๋Š” z-index ์ง€์˜ฅ, CSS ์ƒ์† ๋ฌธ์ œ, ์Šคํฌ๋กค ์ด์Šˆ๋“ค์„ ํ•œ ๋ฒˆ์— ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ํฌ์ŠคํŒ…์—์„œ๋Š” Teleport์˜ ๋‚ด๋ถ€ ๋™์ž‘ ์›๋ฆฌ๋ถ€ํ„ฐ ๊ณ ๊ธ‰ ํ™œ์šฉ ํŒจํ„ด๊นŒ์ง€, ์‹ค๋ฌด์—์„œ ๋งˆ์ฃผ์น˜๋Š” ๋ชจ๋“  ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ์™„๋ฒฝํ•˜๊ฒŒ ๋‹ค๋ฃน๋‹ˆ๋‹ค.


๐Ÿ“‹ ๋ชฉ์ฐจ

  1. Teleport์˜ ํ•ต์‹ฌ ๊ฐœ๋…๊ณผ ๋™์ž‘ ์›๋ฆฌ
  2. ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•๊ณผ ์‹ค๋ฌด ์ ์šฉ
  3. ๊ณ ๊ธ‰ ํŒจํ„ด: ์กฐ๊ฑด๋ถ€ Teleport์™€ ๋™์  ํƒ€๊ฒŸ
  4. ๋ชจ๋‹ฌ ์‹œ์Šคํ…œ ์™„์ „ ๊ตฌํ˜„
  5. ํˆดํŒ๊ณผ ๋“œ๋กญ๋‹ค์šด ๊ณ ๊ธ‰ ํ™œ์šฉ
  6. Teleport์™€ ์• ๋‹ˆ๋ฉ”์ด์…˜ ํ†ตํ•ฉ
  7. ์„ฑ๋Šฅ ์ตœ์ ํ™”์™€ ๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ
  8. ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๋ Œ๋”๋ง(SSR) ๋Œ€์‘ ์ „๋žต

๐ŸŒŸ Teleport์˜ ํ•ต์‹ฌ ๊ฐœ๋…๊ณผ ๋™์ž‘ ์›๋ฆฌ

Teleport ์ด์ „์˜ ๋ฌธ์ œ์ 

<!-- โŒ Teleport ์—†์ด ๋ชจ๋‹ฌ์„ ๊ตฌํ˜„ํ•  ๋•Œ์˜ ๋ฌธ์ œ์  -->
<template>
  <div class="page-container">
    <header class="header">
      <h1>My App</h1>
    </header>
    
    <main class="main-content">
      <div class="card">
        <h2>์‚ฌ์šฉ์ž ์ •๋ณด</h2>
        <button @click="showModal = true">ํŽธ์ง‘</button>
        
        <!-- ๋ชจ๋‹ฌ์ด ์—ฌ๊ธฐ์— ๋ Œ๋”๋ง๋จ -->
        <div v-if="showModal" class="modal-backdrop">
          <div class="modal">
            <h3>์‚ฌ์šฉ์ž ํŽธ์ง‘</h3>
            <form @submit.prevent="handleSave">
              <input v-model="userName" />
              <button type="submit">์ €์žฅ</button>
              <button @click="showModal = false">์ทจ์†Œ</button>
            </form>
          </div>
        </div>
      </div>
    </main>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const showModal = ref(false)
const userName = ref('๊น€๊ฐœ๋ฐœ')

const handleSave = () => {
  console.log('์ €์žฅ:', userName.value)
  showModal.value = false
}
</script>

<style scoped>
.page-container {
  position: relative;
  overflow: hidden; /* ์ด๊ฒƒ์ด ๋ชจ๋‹ฌ์„ ๊ฐ€๋ฆผ! */
}

.main-content {
  transform: translateZ(0); /* ์ƒˆ๋กœ์šด stacking context ์ƒ์„ฑ */
}

.card {
  position: relative;
  z-index: 1;
}

.modal-backdrop {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  z-index: 9999; /* ๋†’์€ z-index๋„ ์†Œ์šฉ์—†์Œ! */
}

.modal {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: white;
  padding: 2rem;
  border-radius: 8px;
}
</style>

Teleport์˜ ํ•ด๊ฒฐ์ฑ…

<!-- โœ… Teleport๋ฅผ ์‚ฌ์šฉํ•œ ๊น”๋”ํ•œ ํ•ด๊ฒฐ์ฑ… -->
<template>
  <div class="page-container">
    <header class="header">
      <h1>My App</h1>
    </header>
    
    <main class="main-content">
      <div class="card">
        <h2>์‚ฌ์šฉ์ž ์ •๋ณด</h2>
        <button @click="showModal = true">ํŽธ์ง‘</button>
      </div>
    </main>
    
    <!-- ๋ชจ๋‹ฌ์ด body ์งํ•˜์œ„๋กœ ์ด๋™๋จ -->
    <Teleport to="body">
      <div v-if="showModal" class="modal-backdrop" @click="handleBackdropClick">
        <div class="modal" @click.stop>
          <h3>์‚ฌ์šฉ์ž ํŽธ์ง‘</h3>
          <form @submit.prevent="handleSave">
            <input v-model="userName" />
            <button type="submit">์ €์žฅ</button>
            <button @click="showModal = false">์ทจ์†Œ</button>
          </form>
        </div>
      </div>
    </Teleport>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const showModal = ref(false)
const userName = ref('๊น€๊ฐœ๋ฐœ')

const handleSave = () => {
  console.log('์ €์žฅ:', userName.value)
  showModal.value = false
}

const handleBackdropClick = () => {
  showModal.value = false
}
</script>

<style scoped>
/* ๋ถ€๋ชจ ์ปดํฌ๋„ŒํŠธ์˜ CSS๋Š” ๊ทธ๋Œ€๋กœ ์œ ์ง€ */
.page-container {
  position: relative;
  overflow: hidden; /* ๋” ์ด์ƒ ๋ฌธ์ œ์—†์Œ! */
}

.main-content {
  transform: translateZ(0); /* stacking context์™€ ๋ฌด๊ด€ํ•จ! */
}
</style>

<style>
/* ์ „์—ญ ์Šคํƒ€์ผ๋กœ ๋ชจ๋‹ฌ ์ •์˜ */
.modal-backdrop {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal {
  background: white;
  padding: 2rem;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  max-width: 500px;
  width: 90%;
}
</style>

Teleport์˜ ๋‚ด๋ถ€ ๋™์ž‘ ์›๋ฆฌ

// Vue 3 ๋‚ด๋ถ€์—์„œ Teleport๊ฐ€ ๋™์ž‘ํ•˜๋Š” ๋ฐฉ์‹ (๋‹จ์ˆœํ™”)
class TeleportRenderer {
  constructor() {
    this.teleportMap = new Map() // ํƒ€๊ฒŸ๋ณ„ ํ…”๋ ˆํฌํŠธ ๊ด€๋ฆฌ
  }
  
  renderTeleport(vnode, container) {
    const { to, children } = vnode.props
    
    // 1. ํƒ€๊ฒŸ ์—˜๋ฆฌ๋จผํŠธ ์ฐพ๊ธฐ
    const target = typeof to === 'string' 
      ? document.querySelector(to)
      : to
      
    if (!target) {
      console.warn(`Teleport target "${to}" not found`)
      return
    }
    
    // 2. ํ…”๋ ˆํฌํŠธ ์ปจํ…Œ์ด๋„ˆ ์ƒ์„ฑ
    if (!this.teleportMap.has(target)) {
      const teleportContainer = document.createElement('div')
      teleportContainer.className = 'vue-teleport-container'
      target.appendChild(teleportContainer)
      this.teleportMap.set(target, teleportContainer)
    }
    
    const teleportContainer = this.teleportMap.get(target)
    
    // 3. ์ž์‹ ์ปดํฌ๋„ŒํŠธ๋“ค์„ ํƒ€๊ฒŸ์— ๋ Œ๋”๋ง
    children.forEach(child => {
      this.renderComponent(child, teleportContainer)
    })
  }
  
  unmountTeleport(vnode) {
    const { to } = vnode.props
    const target = typeof to === 'string' 
      ? document.querySelector(to) 
      : to
      
    if (this.teleportMap.has(target)) {
      const container = this.teleportMap.get(target)
      container.remove()
      this.teleportMap.delete(target)
    }
  }
}

// ์‹ค์ œ Vue 3์—์„œ๋Š” ๋” ๋ณต์žกํ•œ ์ตœ์ ํ™”์™€ ์—๋Ÿฌ ์ฒ˜๋ฆฌ๊ฐ€ ํฌํ•จ๋จ

๐Ÿš€ ๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•๊ณผ ์‹ค๋ฌด ์ ์šฉ

๋‹ค์–‘ํ•œ ํƒ€๊ฒŸ ์„ ํƒ์ž ํ™œ์šฉ

<template>
  <div class="app">
    <!-- 1. CSS ์„ ํƒ์ž๋กœ ํƒ€๊ฒŸ ์ง€์ • -->
    <Teleport to="#modal-root">
      <Modal v-if="showModal1" @close="showModal1 = false" />
    </Teleport>
    
    <!-- 2. ํด๋ž˜์Šค ์„ ํƒ์ž ์‚ฌ์šฉ -->
    <Teleport to=".tooltip-container">
      <Tooltip v-if="showTooltip" :content="tooltipContent" />
    </Teleport>
    
    <!-- 3. body์— ์ง์ ‘ ๋ Œ๋”๋ง -->
    <Teleport to="body">
      <Toast v-for="toast in toasts" :key="toast.id" :toast="toast" />
    </Teleport>
    
    <!-- 4. ๋™์  ํƒ€๊ฒŸ (computed ํ™œ์šฉ) -->
    <Teleport :to="dynamicTarget">
      <ContextMenu v-if="showContextMenu" :x="menuX" :y="menuY" />
    </Teleport>
    
    <!-- 5. DOM ์—˜๋ฆฌ๋จผํŠธ ์ง์ ‘ ์ฐธ์กฐ -->
    <Teleport :to="portalElement">
      <CustomWidget v-if="showWidget" />
    </Teleport>
    
    <!-- ์‹ค์ œ ์ปจํ…์ธ  -->
    <main class="main-content">
      <button @click="showModal1 = true">๋ชจ๋‹ฌ ์—ด๊ธฐ</button>
      <button @click="showTooltip = !showTooltip">ํˆดํŒ ํ† ๊ธ€</button>
      <button @click="addToast">ํ† ์ŠคํŠธ ์ถ”๊ฐ€</button>
      <div 
        @contextmenu.prevent="showContextMenu = true"
        class="context-area"
      >
        ์šฐํด๋ฆญํ•˜์—ฌ ์ปจํ…์ŠคํŠธ ๋ฉ”๋‰ด ํ‘œ์‹œ
      </div>
    </main>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'

// ์ƒํƒœ ๊ด€๋ฆฌ
const showModal1 = ref(false)
const showTooltip = ref(false)
const showContextMenu = ref(false)
const showWidget = ref(false)

const tooltipContent = ref('๋„์›€๋ง ํ…์ŠคํŠธ')
const toasts = ref([])
const menuX = ref(0)
const menuY = ref(0)

// ๋™์  ํƒ€๊ฒŸ ๊ณ„์‚ฐ
const dynamicTarget = computed(() => {
  return window.innerWidth > 768 ? '.desktop-menu' : '.mobile-menu'
})

// DOM ์—˜๋ฆฌ๋จผํŠธ ์ง์ ‘ ์ฐธ์กฐ
const portalElement = ref(null)

onMounted(() => {
  // ํŠน์ • ์—˜๋ฆฌ๋จผํŠธ๋ฅผ ํฌํ„ธ ํƒ€๊ฒŸ์œผ๋กœ ์„ค์ •
  portalElement.value = document.getElementById('widget-container')
})

// ํ† ์ŠคํŠธ ์ถ”๊ฐ€ ํ•จ์ˆ˜
const addToast = () => {
  const toast = {
    id: Date.now(),
    message: `ํ† ์ŠคํŠธ ๋ฉ”์‹œ์ง€ ${toasts.value.length + 1}`,
    type: 'info'
  }
  toasts.value.push(toast)
  
  // 3์ดˆ ํ›„ ์ž๋™ ์ œ๊ฑฐ
  setTimeout(() => {
    toasts.value = toasts.value.filter(t => t.id !== toast.id)
  }, 3000)
}
</script>

<!-- index.html์— ๋ฏธ๋ฆฌ ์ •์˜๋œ ํฌํ„ธ ์ปจํ…Œ์ด๋„ˆ๋“ค -->
<!--
<div id="modal-root"></div>
<div class="tooltip-container"></div>
<div class="desktop-menu"></div>
<div class="mobile-menu"></div>
<div id="widget-container"></div>
-->

์ปดํฌ๋„ŒํŠธ๋ณ„ ์ตœ์ ํ™”๋œ Teleport ์‚ฌ์šฉ

<!-- BaseModal.vue - ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ -->
<template>
  <Teleport to="body">
    <Transition name="modal" appear>
      <div v-if="modelValue" class="modal-backdrop" @click="handleBackdropClick">
        <div 
          class="modal-container" 
          :class="[`modal-${size}`, customClass]"
          @click.stop
          role="dialog"
          :aria-labelledby="titleId"
          :aria-describedby="contentId"
        >
          <!-- ํ—ค๋” -->
          <header v-if="$slots.header || title" class="modal-header">
            <slot name="header">
              <h2 :id="titleId" class="modal-title">{{ title }}</h2>
            </slot>
            <button 
              v-if="closable"
              @click="handleClose" 
              class="modal-close"
              aria-label="๋‹ซ๊ธฐ"
            >
              โœ•
            </button>
          </header>
          
          <!-- ๋ณธ๋ฌธ -->
          <main :id="contentId" class="modal-content">
            <slot />
          </main>
          
          <!-- ํ‘ธํ„ฐ -->
          <footer v-if="$slots.footer" class="modal-footer">
            <slot name="footer" />
          </footer>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<script setup>
import { computed, nextTick, watch } from 'vue'

// Props ์ •์˜
const props = defineProps({
  modelValue: {
    type: Boolean,
    default: false
  },
  title: {
    type: String,
    default: ''
  },
  size: {
    type: String,
    default: 'md',
    validator: (value) => ['sm', 'md', 'lg', 'xl', 'full'].includes(value)
  },
  closable: {
    type: Boolean,
    default: true
  },
  closeOnBackdrop: {
    type: Boolean,
    default: true
  },
  customClass: {
    type: String,
    default: ''
  },
  lockScroll: {
    type: Boolean,
    default: true
  }
})

// Events ์ •์˜
const emit = defineEmits(['update:modelValue', 'close', 'open'])

// ์œ ๋‹ˆํฌํ•œ ID ์ƒ์„ฑ
const titleId = computed(() => `modal-title-${Math.random().toString(36).substr(2, 9)}`)
const contentId = computed(() => `modal-content-${Math.random().toString(36).substr(2, 9)}`)

// ์ด๋ฒคํŠธ ํ•ธ๋“ค๋Ÿฌ
const handleClose = () => {
  emit('update:modelValue', false)
  emit('close')
}

const handleBackdropClick = () => {
  if (props.closeOnBackdrop) {
    handleClose()
  }
}

// ์Šคํฌ๋กค ์ž ๊ธˆ ๊ธฐ๋Šฅ
watch(() => props.modelValue, async (newValue) => {
  if (props.lockScroll) {
    await nextTick()
    
    if (newValue) {
      document.body.style.overflow = 'hidden'
      emit('open')
    } else {
      document.body.style.overflow = ''
    }
  }
})

// ESC ํ‚ค ์ฒ˜๋ฆฌ
const handleKeydown = (event) => {
  if (event.key === 'Escape' && props.closable) {
    handleClose()
  }
}

watch(() => props.modelValue, (newValue) => {
  if (newValue) {
    document.addEventListener('keydown', handleKeydown)
  } else {
    document.removeEventListener('keydown', handleKeydown)
  }
})
</script>

<style scoped>
.modal-backdrop {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  padding: 1rem;
}

.modal-container {
  background: white;
  border-radius: 8px;
  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
  max-height: 90vh;
  overflow: auto;
  position: relative;
}

/* ํฌ๊ธฐ๋ณ„ ์Šคํƒ€์ผ */
.modal-sm { max-width: 400px; width: 100%; }
.modal-md { max-width: 600px; width: 100%; }
.modal-lg { max-width: 900px; width: 100%; }
.modal-xl { max-width: 1200px; width: 100%; }
.modal-full { width: 100%; height: 100%; max-height: none; border-radius: 0; }

.modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1.5rem 1.5rem 0;
  border-bottom: 1px solid #e5e7eb;
}

.modal-title {
  margin: 0;
  font-size: 1.25rem;
  font-weight: 600;
  color: #111827;
}

.modal-close {
  background: none;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
  color: #6b7280;
  padding: 0.5rem;
  border-radius: 4px;
  transition: all 0.2s;
}

.modal-close:hover {
  background: #f3f4f6;
  color: #374151;
}

.modal-content {
  padding: 1.5rem;
}

.modal-footer {
  padding: 0 1.5rem 1.5rem;
  border-top: 1px solid #e5e7eb;
  margin-top: 1rem;
  display: flex;
  gap: 0.75rem;
  justify-content: flex-end;
}

/* ์• ๋‹ˆ๋ฉ”์ด์…˜ */
.modal-enter-active, .modal-leave-active {
  transition: opacity 0.3s ease;
}

.modal-enter-from, .modal-leave-to {
  opacity: 0;
}

.modal-enter-active .modal-container,
.modal-leave-active .modal-container {
  transition: transform 0.3s ease;
}

.modal-enter-from .modal-container,
.modal-leave-to .modal-container {
  transform: scale(0.95) translateY(-20px);
}
</style>

๐ŸŽจ ๊ณ ๊ธ‰ ํŒจํ„ด: ์กฐ๊ฑด๋ถ€ Teleport์™€ ๋™์  ํƒ€๊ฒŸ

์กฐ๊ฑด๋ถ€ Teleport ๊ตฌํ˜„

<template>
  <div class="responsive-layout">
    <!-- ๋ฐ์Šคํฌํ†ฑ: ์‚ฌ์ด๋“œ๋ฐ”์— ์œ„์ ฏ ํ‘œ์‹œ -->
    <!-- ๋ชจ๋ฐ”์ผ: ๋ชจ๋‹ฌ๋กœ ์œ„์ ฏ ํ‘œ์‹œ -->
    <Teleport :to="teleportTarget" :disabled="!shouldTeleport">
      <div class="widget-container" :class="widgetClass">
        <UserWidget 
          v-if="showWidget" 
          @close="showWidget = false"
          :is-modal="shouldTeleport"
        />
      </div>
    </Teleport>
    
    <main class="main-content">
      <button @click="showWidget = true">์œ„์ ฏ ํ‘œ์‹œ</button>
      
      <!-- ๋ชจ๋ฐ”์ผ์—์„œ๋Š” ์—ฌ๊ธฐ์— ์œ„์ ฏ์ด ๋ Œ๋”๋ง๋จ (disabled=true) -->
      <div v-if="!shouldTeleport" class="mobile-widget-space">
        <!-- Teleport๊ฐ€ disabled์ผ ๋•Œ ์—ฌ๊ธฐ์— ๋ Œ๋”๋ง -->
      </div>
    </main>
    
    <!-- ๋ฐ์Šคํฌํ†ฑ ์‚ฌ์ด๋“œ๋ฐ” -->
    <aside class="sidebar">
      <div id="sidebar-widgets"></div>
    </aside>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'

const showWidget = ref(false)
const windowWidth = ref(window.innerWidth)

// ๋ฐ˜์‘ํ˜• ๋กœ์ง
const isMobile = computed(() => windowWidth.value < 768)
const shouldTeleport = computed(() => !isMobile.value)

const teleportTarget = computed(() => {
  return isMobile.value ? 'body' : '#sidebar-widgets'
})

const widgetClass = computed(() => ({
  'widget-modal': isMobile.value,
  'widget-sidebar': !isMobile.value
}))

// ์œˆ๋„์šฐ ํฌ๊ธฐ ๋ณ€ํ™” ๊ฐ์ง€
const handleResize = () => {
  windowWidth.value = window.innerWidth
}

onMounted(() => {
  window.addEventListener('resize', handleResize)
})

onUnmounted(() => {
  window.removeEventListener('resize', handleResize)
})
</script>

<style scoped>
.responsive-layout {
  display: flex;
  height: 100vh;
}

.main-content {
  flex: 1;
  padding: 2rem;
}

.sidebar {
  width: 300px;
  background: #f8f9fa;
  padding: 1rem;
}

.mobile-widget-space {
  /* ๋ชจ๋ฐ”์ผ์—์„œ ์œ„์ ฏ์ด ์ธ๋ผ์ธ์œผ๋กœ ํ‘œ์‹œ๋  ๊ณต๊ฐ„ */
  margin-top: 1rem;
}

/* ์œ„์ ฏ ์Šคํƒ€์ผ */
.widget-modal {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: white;
  padding: 1rem;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  z-index: 1000;
  max-width: 90vw;
  max-height: 90vh;
  overflow: auto;
}

.widget-sidebar {
  background: white;
  border-radius: 8px;
  padding: 1rem;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

@media (max-width: 767px) {
  .sidebar {
    display: none;
  }
  
  .main-content {
    width: 100%;
  }
}
</style>

๋‹ค์ค‘ Teleport ๊ด€๋ฆฌ ์‹œ์Šคํ…œ

<!-- TeleportManager.vue - ์ค‘์•™์ง‘์ค‘์‹ Teleport ๊ด€๋ฆฌ -->
<template>
  <div>
    <!-- ๋ชจ๋‹ฌ ๋ ˆ์ด์–ด -->
    <Teleport to="body">
      <div v-if="hasModals" class="modal-layer">
        <component
          v-for="modal in modals"
          :key="modal.id"
          :is="modal.component"
          v-bind="modal.props"
          @close="closeModal(modal.id)"
        />
      </div>
    </Teleport>
    
    <!-- ํ† ์ŠคํŠธ ๋ ˆ์ด์–ด -->
    <Teleport to="body">
      <TransitionGroup
        v-if="hasToasts"
        name="toast"
        tag="div"
        class="toast-container"
      >
        <ToastComponent
          v-for="toast in toasts"
          :key="toast.id"
          :toast="toast"
          @close="removeToast(toast.id)"
        />
      </TransitionGroup>
    </Teleport>
    
    <!-- ๋“œ๋กญ๋‹ค์šด ๋ ˆ์ด์–ด -->
    <Teleport to="body">
      <div v-if="hasDropdowns" class="dropdown-layer">
        <component
          v-for="dropdown in dropdowns"
          :key="dropdown.id"
          :is="dropdown.component"
          v-bind="dropdown.props"
          @close="closeDropdown(dropdown.id)"
        />
      </div>
    </Teleport>
    
    <!-- ํˆดํŒ ๋ ˆ์ด์–ด -->
    <Teleport to="body">
      <div v-if="hasTooltips" class="tooltip-layer">
        <component
          v-for="tooltip in tooltips"
          :key="tooltip.id"
          :is="tooltip.component"
          v-bind="tooltip.props"
        />
      </div>
    </Teleport>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useTeleportStore } from '@/stores/teleport'

// Pinia ์Šคํ† ์–ด ์‚ฌ์šฉ
const teleportStore = useTeleportStore()

// Computed properties
const modals = computed(() => teleportStore.modals)
const toasts = computed(() => teleportStore.toasts)
const dropdowns = computed(() => teleportStore.dropdowns)
const tooltips = computed(() => teleportStore.tooltips)

const hasModals = computed(() => modals.value.length > 0)
const hasToasts = computed(() => toasts.value.length > 0)
const hasDropdowns = computed(() => dropdowns.value.length > 0)
const hasTooltips = computed(() => tooltips.value.length > 0)

// ๋ฉ”์†Œ๋“œ
const closeModal = (id) => {
  teleportStore.removeModal(id)
}

const removeToast = (id) => {
  teleportStore.removeToast(id)
}

const closeDropdown = (id) => {
  teleportStore.removeDropdown(id)
}
</script>

<script>
// stores/teleport.js - Teleport ์ƒํƒœ ๊ด€๋ฆฌ ์Šคํ† ์–ด
import { defineStore } from 'pinia'

export const useTeleportStore = defineStore('teleport', {
  state: () => ({
    modals: [],
    toasts: [],
    dropdowns: [],
    tooltips: [],
    zIndexCounter: 1000
  }),
  
  getters: {
    getNextZIndex: (state) => {
      return ++state.zIndexCounter
    },
    
    getActiveModal: (state) => {
      return state.modals[state.modals.length - 1] || null
    }
  },
  
  actions: {
    // ๋ชจ๋‹ฌ ๊ด€๋ฆฌ
    addModal(modal) {
      const modalWithDefaults = {
        id: Date.now() + Math.random(),
        zIndex: this.getNextZIndex,
        closable: true,
        closeOnBackdrop: true,
        ...modal
      }
      
      this.modals.push(modalWithDefaults)
      
      // body ์Šคํฌ๋กค ์ž ๊ธˆ
      document.body.style.overflow = 'hidden'
      
      return modalWithDefaults.id
    },
    
    removeModal(id) {
      const index = this.modals.findIndex(modal => modal.id === id)
      if (index > -1) {
        this.modals.splice(index, 1)
        
        // ๋งˆ์ง€๋ง‰ ๋ชจ๋‹ฌ์ด ๋‹ซํžˆ๋ฉด ์Šคํฌ๋กค ๋ณต๊ตฌ
        if (this.modals.length === 0) {
          document.body.style.overflow = ''
        }
      }
    },
    
    closeAllModals() {
      this.modals = []
      document.body.style.overflow = ''
    },
    
    // ํ† ์ŠคํŠธ ๊ด€๋ฆฌ
    addToast(toast) {
      const toastWithDefaults = {
        id: Date.now() + Math.random(),
        type: 'info',
        duration: 3000,
        closable: true,
        ...toast
      }
      
      this.toasts.push(toastWithDefaults)
      
      // ์ž๋™ ์ œ๊ฑฐ
      if (toastWithDefaults.duration > 0) {
        setTimeout(() => {
          this.removeToast(toastWithDefaults.id)
        }, toastWithDefaults.duration)
      }
      
      return toastWithDefaults.id
    },
    
    removeToast(id) {
      const index = this.toasts.findIndex(toast => toast.id === id)
      if (index > -1) {
        this.toasts.splice(index, 1)
      }
    },
    
    // ๋“œ๋กญ๋‹ค์šด ๊ด€๋ฆฌ
    addDropdown(dropdown) {
      // ๊ธฐ์กด ๋“œ๋กญ๋‹ค์šด ๋ชจ๋‘ ๋‹ซ๊ธฐ (ํ•˜๋‚˜๋งŒ ์—ด๋ฆผ)
      this.dropdowns = []
      
      const dropdownWithDefaults = {
        id: Date.now() + Math.random(),
        zIndex: this.getNextZIndex,
        ...dropdown
      }
      
      this.dropdowns.push(dropdownWithDefaults)
      
      return dropdownWithDefaults.id
    },
    
    removeDropdown(id) {
      const index = this.dropdowns.findIndex(dropdown => dropdown.id === id)
      if (index > -1) {
        this.dropdowns.splice(index, 1)
      }
    },
    
    // ํˆดํŒ ๊ด€๋ฆฌ
    addTooltip(tooltip) {
      const tooltipWithDefaults = {
        id: Date.now() + Math.random(),
        delay: 300,
        ...tooltip
      }
      
      this.tooltips.push(tooltipWithDefaults)
      
      return tooltipWithDefaults.id
    },
    
    removeTooltip(id) {
      const index = this.tooltips.findIndex(tooltip => tooltip.id === id)
      if (index > -1) {
        this.tooltips.splice(index, 1)
      }
    },
    
    // ์ „์ฒด ์ •๋ฆฌ
    clearAll() {
      this.modals = []
      this.toasts = []
      this.dropdowns = []
      this.tooltips = []
      document.body.style.overflow = ''
    }
  }
})
</script>

<style scoped>
.modal-layer {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1000;
}

.toast-container {
  position: fixed;
  top: 1rem;
  right: 1rem;
  z-index: 2000;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.dropdown-layer {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 1500;
  pointer-events: none;
}

.dropdown-layer > * {
  pointer-events: auto;
}

.tooltip-layer {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 3000;
  pointer-events: none;
}

.tooltip-layer > * {
  pointer-events: auto;
}

/* ํ† ์ŠคํŠธ ์• ๋‹ˆ๋ฉ”์ด์…˜ */
.toast-enter-active {
  transition: all 0.3s ease-out;
}

.toast-leave-active {
  transition: all 0.3s ease-in;
}

.toast-enter-from {
  transform: translateX(100%);
  opacity: 0;
}

.toast-leave-to {
  transform: translateX(100%);
  opacity: 0;
}

.toast-move {
  transition: transform 0.3s ease;
}
</style>

๐ŸŽญ ๋ชจ๋‹ฌ ์‹œ์Šคํ…œ ์™„์ „ ๊ตฌํ˜„

๊ณ ๊ธ‰ ๋ชจ๋‹ฌ ์ปดํฌ์ €๋ธ”

// composables/useModal.js - ๋ชจ๋‹ฌ ๊ด€๋ฆฌ ์ปดํฌ์ €๋ธ”
import { ref, computed, nextTick, onUnmounted } from 'vue'
import { useTeleportStore } from '@/stores/teleport'

export function useModal() {
  const teleportStore = useTeleportStore()
  const activeModals = ref(new Map())
  
  // ๋ชจ๋‹ฌ ์—ด๊ธฐ
  const openModal = async (component, props = {}, options = {}) => {
    const modalConfig = {
      component,
      props: {
        ...props,
        modelValue: true
      },
      ...options
    }
    
    const modalId = teleportStore.addModal(modalConfig)
    activeModals.value.set(modalId, modalConfig)
    
    // DOM ์—…๋ฐ์ดํŠธ ๋Œ€๊ธฐ
    await nextTick()
    
    // ํฌ์ปค์Šค ๊ด€๋ฆฌ
    if (options.autoFocus !== false) {
      const modalElement = document.querySelector(`[data-modal-id="${modalId}"]`)
      if (modalElement) {
        const focusableElement = modalElement.querySelector(
          'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
        )
        focusableElement?.focus()
      }
    }
    
    return {
      id: modalId,
      close: () => closeModal(modalId),
      update: (newProps) => updateModal(modalId, newProps)
    }
  }
  
  // ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ
  const closeModal = (modalId) => {
    if (activeModals.value.has(modalId)) {
      teleportStore.removeModal(modalId)
      activeModals.value.delete(modalId)
    }
  }
  
  // ๋ชจ๋‹ฌ ์—…๋ฐ์ดํŠธ
  const updateModal = (modalId, newProps) => {
    const modal = activeModals.value.get(modalId)
    if (modal) {
      modal.props = { ...modal.props, ...newProps }
      teleportStore.updateModal(modalId, modal)
    }
  }
  
  // ํ™•์ธ ๋ชจ๋‹ฌ
  const confirm = (message, title = 'ํ™•์ธ') => {
    return new Promise((resolve) => {
      const modal = openModal('ConfirmModal', {
        title,
        message,
        onConfirm: () => {
          modal.then(m => m.close())
          resolve(true)
        },
        onCancel: () => {
          modal.then(m => m.close())
          resolve(false)
        }
      })
    })
  }
  
  // ์•Œ๋ฆผ ๋ชจ๋‹ฌ
  const alert = (message, title = '์•Œ๋ฆผ') => {
    return new Promise((resolve) => {
      const modal = openModal('AlertModal', {
        title,
        message,
        onClose: () => {
          modal.then(m => m.close())
          resolve()
        }
      })
    })
  }
  
  // ํ”„๋กฌํ”„ํŠธ ๋ชจ๋‹ฌ
  const prompt = (message, defaultValue = '', title = '์ž…๋ ฅ') => {
    return new Promise((resolve) => {
      const modal = openModal('PromptModal', {
        title,
        message,
        defaultValue,
        onConfirm: (value) => {
          modal.then(m => m.close())
          resolve(value)
        },
        onCancel: () => {
          modal.then(m => m.close())
          resolve(null)
        }
      })
    })
  }
  
  // ๋ชจ๋“  ๋ชจ๋‹ฌ ๋‹ซ๊ธฐ
  const closeAllModals = () => {
    activeModals.value.clear()
    teleportStore.closeAllModals()
  }
  
  // ํ˜„์žฌ ์—ด๋ฆฐ ๋ชจ๋‹ฌ ์ˆ˜
  const modalCount = computed(() => activeModals.value.size)
  
  // ์ตœ์ƒ์œ„ ๋ชจ๋‹ฌ ์—ฌ๋ถ€ ํ™•์ธ
  const isTopModal = (modalId) => {
    const modals = Array.from(activeModals.value.keys())
    return modals[modals.length - 1] === modalId
  }
  
  // ์ •๋ฆฌ
  onUnmounted(() => {
    closeAllModals()
  })
  
  return {
    openModal,
    closeModal,
    updateModal,
    confirm,
    alert,
    prompt,
    closeAllModals,
    modalCount,
    isTopModal
  }
}

// ์ „์—ญ ๋ชจ๋‹ฌ ์ธ์Šคํ„ด์Šค
let globalModalInstance = null

export function useGlobalModal() {
  if (!globalModalInstance) {
    globalModalInstance = useModal()
  }
  return globalModalInstance
}

์‹ค๋ฌด์šฉ ๋ชจ๋‹ฌ ์ปดํฌ๋„ŒํŠธ๋“ค

<!-- ConfirmModal.vue -->
<template>
  <BaseModal
    :model-value="true"
    :title="title"
    size="sm"
    @close="handleCancel"
  >
    <div class="confirm-content">
      <div class="confirm-icon">
        <component :is="iconComponent" />
      </div>
      <p class="confirm-message">{{ message }}</p>
    </div>
    
    <template #footer>
      <button 
        class="btn btn-secondary" 
        @click="handleCancel"
        :disabled="loading"
      >
        {{ cancelText }}
      </button>
      <button 
        class="btn btn-primary" 
        @click="handleConfirm"
        :disabled="loading"
        :class="{ 'btn-loading': loading }"
      >
        {{ confirmText }}
      </button>
    </template>
  </BaseModal>
</template>

<script setup>
import { ref, computed } from 'vue'
import BaseModal from './BaseModal.vue'

const props = defineProps({
  title: { type: String, default: 'ํ™•์ธ' },
  message: { type: String, required: true },
  type: { type: String, default: 'info' }, // info, warning, danger, success
  confirmText: { type: String, default: 'ํ™•์ธ' },
  cancelText: { type: String, default: '์ทจ์†Œ' },
  onConfirm: { type: Function, required: true },
  onCancel: { type: Function, required: true },
  asyncAction: { type: Boolean, default: false }
})

const loading = ref(false)

const iconComponent = computed(() => {
  const icons = {
    info: 'InfoIcon',
    warning: 'WarningIcon', 
    danger: 'DangerIcon',
    success: 'SuccessIcon'
  }
  return icons[props.type] || icons.info
})

const handleConfirm = async () => {
  if (props.asyncAction) {
    loading.value = true
    try {
      await props.onConfirm()
    } finally {
      loading.value = false
    }
  } else {
    props.onConfirm()
  }
}

const handleCancel = () => {
  if (!loading.value) {
    props.onCancel()
  }
}
</script>

<style scoped>
.confirm-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  padding: 1rem 0;
}

.confirm-icon {
  width: 64px;
  height: 64px;
  margin-bottom: 1rem;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f3f4f6;
}

.confirm-message {
  font-size: 1.1rem;
  line-height: 1.5;
  color: #374151;
  margin: 0;
}

.btn {
  padding: 0.75rem 1.5rem;
  border-radius: 6px;
  font-weight: 500;
  border: none;
  cursor: pointer;
  transition: all 0.2s;
  min-width: 80px;
}

.btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.btn-secondary {
  background: #f3f4f6;
  color: #374151;
}

.btn-secondary:hover:not(:disabled) {
  background: #e5e7eb;
}

.btn-primary {
  background: #3b82f6;
  color: white;
}

.btn-primary:hover:not(:disabled) {
  background: #2563eb;
}

.btn-loading::after {
  content: '';
  display: inline-block;
  width: 14px;
  height: 14px;
  margin-left: 8px;
  border: 2px solid currentColor;
  border-radius: 50%;
  border-right-color: transparent;
  animation: spin 0.75s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}
</style>

๐ŸŽจ Teleport์™€ ์• ๋‹ˆ๋ฉ”์ด์…˜ ํ†ตํ•ฉ

๊ณ ๊ธ‰ ์• ๋‹ˆ๋ฉ”์ด์…˜ ํŒจํ„ด

<!-- AnimatedTeleport.vue - ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ํฌํ•จ๋œ Teleport ๋ž˜ํผ -->
<template>
  <Teleport :to="to" :disabled="disabled">
    <Transition
      :name="transitionName"
      :appear="appear"
      :mode="mode"
      @before-enter="onBeforeEnter"
      @enter="onEnter"
      @after-enter="onAfterEnter"
      @before-leave="onBeforeLeave"
      @leave="onLeave"
      @after-leave="onAfterLeave"
    >
      <slot />
    </Transition>
  </Teleport>
</template>

<script setup>
const props = defineProps({
  to: { type: [String, Element], required: true },
  disabled: { type: Boolean, default: false },
  transitionName: { type: String, default: 'fade' },
  appear: { type: Boolean, default: true },
  mode: { type: String, default: undefined }
})

const emit = defineEmits([
  'before-enter',
  'enter', 
  'after-enter',
  'before-leave',
  'leave',
  'after-leave'
])

// ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ด๋ฒคํŠธ ์ „๋‹ฌ
const onBeforeEnter = (el) => emit('before-enter', el)
const onEnter = (el, done) => emit('enter', el, done)
const onAfterEnter = (el) => emit('after-enter', el)
const onBeforeLeave = (el) => emit('before-leave', el)
const onLeave = (el, done) => emit('leave', el, done)
const onAfterLeave = (el) => emit('after-leave', el)
</script>

<style>
/* ๊ธฐ๋ณธ ํŽ˜์ด๋“œ ์• ๋‹ˆ๋ฉ”์ด์…˜ */
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
  opacity: 0;
}

/* ๋ชจ๋‹ฌ ์• ๋‹ˆ๋ฉ”์ด์…˜ */
.modal-enter-active {
  transition: all 0.3s ease-out;
}
.modal-leave-active {
  transition: all 0.2s ease-in;
}
.modal-enter-from {
  opacity: 0;
  transform: scale(0.95) translateY(-20px);
}
.modal-leave-to {
  opacity: 0;
  transform: scale(1.05);
}

/* ๋“œ๋กญ๋‹ค์šด ์• ๋‹ˆ๋ฉ”์ด์…˜ */
.dropdown-enter-active, .dropdown-leave-active {
  transition: all 0.2s ease;
  transform-origin: top;
}
.dropdown-enter-from, .dropdown-leave-to {
  opacity: 0;
  transform: scaleY(0);
}

/* ํ† ์ŠคํŠธ ์• ๋‹ˆ๋ฉ”์ด์…˜ */
.toast-enter-active {
  transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.toast-leave-active {
  transition: all 0.3s ease-in;
}
.toast-enter-from {
  opacity: 0;
  transform: translateX(100%) scale(0.8);
}
.toast-leave-to {
  opacity: 0;
  transform: translateX(100%);
}

/* ์Šฌ๋ผ์ด๋“œ ์• ๋‹ˆ๋ฉ”์ด์…˜ */
.slide-up-enter-active, .slide-up-leave-active {
  transition: all 0.3s ease;
}
.slide-up-enter-from {
  opacity: 0;
  transform: translateY(100%);
}
.slide-up-leave-to {
  opacity: 0;
  transform: translateY(100%);
}

.slide-down-enter-active, .slide-down-leave-active {
  transition: all 0.3s ease;
}
.slide-down-enter-from {
  opacity: 0;
  transform: translateY(-100%);
}
.slide-down-leave-to {
  opacity: 0;
  transform: translateY(-100%);
}

/* ํ™•๋Œ€/์ถ•์†Œ ์• ๋‹ˆ๋ฉ”์ด์…˜ */
.zoom-enter-active, .zoom-leave-active {
  transition: all 0.3s ease;
}
.zoom-enter-from, .zoom-leave-to {
  opacity: 0;
  transform: scale(0);
}

/* ํšŒ์ „ ์• ๋‹ˆ๋ฉ”์ด์…˜ */
.rotate-enter-active, .rotate-leave-active {
  transition: all 0.5s ease;
}
.rotate-enter-from {
  opacity: 0;
  transform: rotate(-180deg) scale(0.5);
}
.rotate-leave-to {
  opacity: 0;
  transform: rotate(180deg) scale(0.5);
}
</style>

JavaScript ๊ธฐ๋ฐ˜ ๋ณต์žกํ•œ ์• ๋‹ˆ๋ฉ”์ด์…˜

<!-- ComplexAnimatedModal.vue -->
<template>
  <AnimatedTeleport 
    to="body"
    :transition-name="''"
    @before-enter="beforeEnter"
    @enter="enter"
    @leave="leave"
  >
    <div v-if="modelValue" ref="backdrop" class="modal-backdrop">
      <div ref="modal" class="modal-container">
        <slot />
      </div>
    </div>
  </AnimatedTeleport>
</template>

<script setup>
import { ref, nextTick } from 'vue'
import { gsap } from 'gsap'

const props = defineProps({
  modelValue: Boolean,
  animationType: {
    type: String,
    default: 'bounce',
    validator: (value) => ['bounce', 'elastic', 'flip', 'slide'].includes(value)
  }
})

const backdrop = ref(null)
const modal = ref(null)

// ์ง„์ž… ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ „ ์„ค์ •
const beforeEnter = (el) => {
  gsap.set(el, { opacity: 0 })
  gsap.set(modal.value, { 
    scale: 0, 
    rotation: props.animationType === 'flip' ? 180 : 0,
    y: props.animationType === 'slide' ? 100 : 0
  })
}

// ์ง„์ž… ์• ๋‹ˆ๋ฉ”์ด์…˜
const enter = (el, done) => {
  const tl = gsap.timeline({ onComplete: done })
  
  // ๋ฐฐ๊ฒฝ ํŽ˜์ด๋“œ ์ธ
  tl.to(el, {
    opacity: 1,
    duration: 0.3,
    ease: 'power2.out'
  })
  
  // ๋ชจ๋‹ฌ ์• ๋‹ˆ๋ฉ”์ด์…˜ (ํƒ€์ž…๋ณ„๋กœ ๋‹ค๋ฆ„)
  switch (props.animationType) {
    case 'bounce':
      tl.to(modal.value, {
        scale: 1,
        duration: 0.6,
        ease: 'back.out(1.7)'
      }, 0.1)
      break
      
    case 'elastic':
      tl.to(modal.value, {
        scale: 1,
        duration: 0.8,
        ease: 'elastic.out(1, 0.75)'
      }, 0.1)
      break
      
    case 'flip':
      tl.to(modal.value, {
        scale: 1,
        rotation: 0,
        duration: 0.6,
        ease: 'power2.out'
      }, 0.1)
      break
      
    case 'slide':
      tl.to(modal.value, {
        scale: 1,
        y: 0,
        duration: 0.5,
        ease: 'power3.out'
      }, 0.1)
      break
  }
}

// ์ข…๋ฃŒ ์• ๋‹ˆ๋ฉ”์ด์…˜
const leave = (el, done) => {
  const tl = gsap.timeline({ onComplete: done })
  
  // ๋ชจ๋‹ฌ ๋จผ์ € ์• ๋‹ˆ๋ฉ”์ด์…˜
  tl.to(modal.value, {
    scale: 0.8,
    opacity: 0,
    duration: 0.2,
    ease: 'power2.in'
  })
  
  // ๋ฐฐ๊ฒฝ ํŽ˜์ด๋“œ ์•„์›ƒ
  tl.to(el, {
    opacity: 0,
    duration: 0.2,
    ease: 'power2.in'
  }, 0.1)
}

// ๋งˆ์šฐ์Šค ๋”ฐ๋ผ๋‹ค๋‹ˆ๋Š” ํšจ๊ณผ (์„ ํƒ์ )
const handleMouseMove = (event) => {
  if (!modal.value) return
  
  const rect = modal.value.getBoundingClientRect()
  const centerX = rect.left + rect.width / 2
  const centerY = rect.top + rect.height / 2
  
  const deltaX = (event.clientX - centerX) * 0.02
  const deltaY = (event.clientY - centerY) * 0.02
  
  gsap.to(modal.value, {
    x: deltaX,
    y: deltaY,
    duration: 0.3,
    ease: 'power2.out'
  })
}
</script>

<style scoped>
.modal-backdrop {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
}

.modal-container {
  background: white;
  border-radius: 12px;
  padding: 2rem;
  box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 
              0 10px 10px -5px rgba(0, 0, 0, 0.04);
  max-width: 90vw;
  max-height: 90vh;
  overflow: auto;
}
</style>

โšก ์„ฑ๋Šฅ ์ตœ์ ํ™”์™€ ๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ

Teleport ์„ฑ๋Šฅ ๋ชจ๋‹ˆํ„ฐ๋ง

// composables/useTeleportPerformance.js
import { ref, onMounted, onUnmounted, watch } from 'vue'

export function useTeleportPerformance() {
  const renderCount = ref(0)
  const averageRenderTime = ref(0)
  const memoryUsage = ref(0)
  const teleportElements = ref(0)
  
  let renderTimes = []
  let observer = null
  
  // ๋ Œ๋”๋ง ์„ฑ๋Šฅ ์ธก์ •
  const measureRender = (callback) => {
    const start = performance.now()
    
    return new Promise((resolve) => {
      callback()
      
      // ๋‹ค์Œ ํ”„๋ ˆ์ž„์—์„œ ์ธก์ •
      requestAnimationFrame(() => {
        const end = performance.now()
        const renderTime = end - start
        
        renderTimes.push(renderTime)
        renderCount.value++
        
        // ์ตœ๊ทผ 10ํšŒ ํ‰๊ท  ๊ณ„์‚ฐ
        if (renderTimes.length > 10) {
          renderTimes.shift()
        }
        averageRenderTime.value = renderTimes.reduce((a, b) => a + b, 0) / renderTimes.length
        
        resolve(renderTime)
      })
    })
  }
  
  // ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ์ธก์ •
  const measureMemory = () => {
    if (performance.memory) {
      memoryUsage.value = performance.memory.usedJSHeapSize / 1024 / 1024 // MB
    }
  }
  
  // Teleport ์—˜๋ฆฌ๋จผํŠธ ์ˆ˜ ๊ณ„์‚ฐ
  const countTeleportElements = () => {
    teleportElements.value = document.querySelectorAll('[data-v-teleport]').length
  }
  
  // DOM ๋ณ€ํ™” ๊ด€์ฐฐ
  const startObserving = () => {
    observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.type === 'childList') {
          // Teleport ๊ด€๋ จ DOM ๋ณ€ํ™” ๊ฐ์ง€
          const addedTeleports = Array.from(mutation.addedNodes)
            .filter(node => node.nodeType === 1 && node.hasAttribute?.('data-v-teleport'))
          
          if (addedTeleports.length > 0) {
            countTeleportElements()
            measureMemory()
          }
        }
      })
    })
    
    observer.observe(document.body, {
      childList: true,
      subtree: true
    })
  }
  
  // ์„ฑ๋Šฅ ๋ฆฌํฌํŠธ ์ƒ์„ฑ
  const generateReport = () => {
    return {
      renderMetrics: {
        totalRenders: renderCount.value,
        averageRenderTime: averageRenderTime.value,
        lastRenderTime: renderTimes[renderTimes.length - 1] || 0
      },
      memoryMetrics: {
        currentUsage: memoryUsage.value,
        teleportElements: teleportElements.value
      },
      recommendations: generateRecommendations()
    }
  }
  
  // ์„ฑ๋Šฅ ๊ฐœ์„  ๊ถŒ์žฅ์‚ฌํ•ญ
  const generateRecommendations = () => {
    const recommendations = []
    
    if (averageRenderTime.value > 16) {
      recommendations.push('๋ Œ๋”๋ง ์‹œ๊ฐ„์ด 16ms๋ฅผ ์ดˆ๊ณผํ•ฉ๋‹ˆ๋‹ค. ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ตœ์ ํ™”๋ฅผ ๊ณ ๋ คํ•˜์„ธ์š”.')
    }
    
    if (teleportElements.value > 10) {
      recommendations.push('Teleport ์—˜๋ฆฌ๋จผํŠธ๊ฐ€ ๋งŽ์Šต๋‹ˆ๋‹ค. ๋ถˆํ•„์š”ํ•œ Teleport ์ œ๊ฑฐ๋ฅผ ๊ณ ๋ คํ•˜์„ธ์š”.')
    }
    
    if (memoryUsage.value > 100) {
      recommendations.push('๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰์ด ๋†’์Šต๋‹ˆ๋‹ค. ์ปดํฌ๋„ŒํŠธ ์ •๋ฆฌ๋ฅผ ํ™•์ธํ•˜์„ธ์š”.')
    }
    
    return recommendations
  }
  
  // ์ •๋ฆฌ
  const cleanup = () => {
    if (observer) {
      observer.disconnect()
      observer = null
    }
    renderTimes = []
  }
  
  onMounted(() => {
    startObserving()
    // ์ฃผ๊ธฐ์ ์œผ๋กœ ๋ฉ”ํŠธ๋ฆญ ์—…๋ฐ์ดํŠธ
    const interval = setInterval(() => {
      measureMemory()
      countTeleportElements()
    }, 1000)
    
    onUnmounted(() => {
      clearInterval(interval)
      cleanup()
    })
  })
  
  return {
    renderCount,
    averageRenderTime,
    memoryUsage,
    teleportElements,
    measureRender,
    generateReport,
    cleanup
  }
}

๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๋ฐฉ์ง€ ์ „๋žต

<!-- OptimizedTeleport.vue - ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๋ฐฉ์ง€ Teleport -->
<template>
  <Teleport :to="computedTarget" :disabled="disabled" ref="teleportRef">
    <div v-if="shouldRender" :key="componentKey" class="teleport-content">
      <slot />
    </div>
  </Teleport>
</template>

<script setup>
import { 
  ref, 
  computed, 
  watch, 
  onBeforeUnmount, 
  nextTick,
  onMounted
} from 'vue'

const props = defineProps({
  to: { type: [String, Element], required: true },
  disabled: { type: Boolean, default: false },
  visible: { type: Boolean, default: true },
  lazy: { type: Boolean, default: false },
  keepAlive: { type: Boolean, default: false }
})

const teleportRef = ref(null)
const componentKey = ref(0)
const shouldRender = ref(!props.lazy)
const cleanupTasks = ref([])

// ๋™์  ํƒ€๊ฒŸ ๊ณ„์‚ฐ (๋ฉ”๋ชจ์ด์ œ์ด์…˜)
const computedTarget = computed(() => {
  if (typeof props.to === 'string') {
    // ์บ์‹œ๋œ ์—˜๋ฆฌ๋จผํŠธ ์ฐธ์กฐ ์‚ฌ์šฉ
    return getOrCreateTargetElement(props.to)
  }
  return props.to
})

// ํƒ€๊ฒŸ ์—˜๋ฆฌ๋จผํŠธ ์บ์‹œ
const targetElementCache = new Map()

const getOrCreateTargetElement = (selector) => {
  if (targetElementCache.has(selector)) {
    const cached = targetElementCache.get(selector)
    if (document.contains(cached)) {
      return cached
    } else {
      targetElementCache.delete(selector)
    }
  }
  
  const element = document.querySelector(selector)
  if (element) {
    targetElementCache.set(selector, element)
  }
  
  return element
}

// ์ง€์—ฐ ๋ Œ๋”๋ง ์ฒ˜๋ฆฌ
watch(() => props.visible, (newVisible) => {
  if (newVisible && props.lazy && !shouldRender.value) {
    shouldRender.value = true
  }
  
  if (!newVisible && !props.keepAlive) {
    // Keep-alive๊ฐ€ ๋น„ํ™œ์„ฑํ™”๋œ ๊ฒฝ์šฐ ์ปดํฌ๋„ŒํŠธ ์žฌ์ƒ์„ฑ
    componentKey.value++
  }
}, { immediate: true })

// ResizeObserver๋กœ ํƒ€๊ฒŸ ์—˜๋ฆฌ๋จผํŠธ ๋ณ€ํ™” ๊ฐ์ง€
let resizeObserver = null

const setupResizeObserver = () => {
  if (typeof ResizeObserver !== 'undefined') {
    resizeObserver = new ResizeObserver((entries) => {
      // ํƒ€๊ฒŸ ์—˜๋ฆฌ๋จผํŠธ ํฌ๊ธฐ ๋ณ€ํ™” ์‹œ ์ฒ˜๋ฆฌ
      entries.forEach(entry => {
        if (entry.target === computedTarget.value) {
          // ํ•„์š”ํ•œ ๊ฒฝ์šฐ ๋ ˆ์ด์•„์›ƒ ์žฌ๊ณ„์‚ฐ
          nextTick(() => {
            // ํฌ์ง€์…”๋‹ ์—…๋ฐ์ดํŠธ ๋“ฑ
          })
        }
      })
    })
    
    if (computedTarget.value) {
      resizeObserver.observe(computedTarget.value)
      cleanupTasks.value.push(() => {
        resizeObserver?.disconnect()
      })
    }
  }
}

// IntersectionObserver๋กœ ๊ฐ€์‹œ์„ฑ ์ตœ์ ํ™”
let intersectionObserver = null

const setupIntersectionObserver = () => {
  if (typeof IntersectionObserver !== 'undefined') {
    intersectionObserver = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          // ํ™”๋ฉด์— ๋ณด์ผ ๋•Œ๋งŒ ์—…๋ฐ์ดํŠธ
          shouldRender.value = true
        } else if (!props.keepAlive) {
          // ํ™”๋ฉด์—์„œ ๋ฒ—์–ด๋‚˜๊ณ  keep-alive๊ฐ€ ์•„๋‹ ๋•Œ
          shouldRender.value = false
        }
      })
    }, {
      threshold: 0.1
    })
    
    cleanupTasks.value.push(() => {
      intersectionObserver?.disconnect()
    })
  }
}

// ์ด๋ฒคํŠธ ๋ฆฌ์Šค๋„ˆ ์ •๋ฆฌ
const cleanupEventListeners = () => {
  cleanupTasks.value.forEach(cleanup => cleanup())
  cleanupTasks.value = []
}

// MutationObserver๋กœ DOM ๋ณ€ํ™” ๊ฐ์ง€
let mutationObserver = null

const setupMutationObserver = () => {
  if (typeof MutationObserver !== 'undefined') {
    mutationObserver = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        // ํƒ€๊ฒŸ ์—˜๋ฆฌ๋จผํŠธ๊ฐ€ ์ œ๊ฑฐ๋˜์—ˆ๋Š”์ง€ ํ™•์ธ
        if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
          const removedNodes = Array.from(mutation.removedNodes)
          const targetRemoved = removedNodes.some(node => 
            node === computedTarget.value || 
            (node.contains && node.contains(computedTarget.value))
          )
          
          if (targetRemoved) {
            console.warn('Teleport target element was removed from DOM')
            shouldRender.value = false
          }
        }
      })
    })
    
    mutationObserver.observe(document.body, {
      childList: true,
      subtree: true
    })
    
    cleanupTasks.value.push(() => {
      mutationObserver?.disconnect()
    })
  }
}

onMounted(() => {
  setupResizeObserver()
  setupIntersectionObserver()
  setupMutationObserver()
})

// ์ปดํฌ๋„ŒํŠธ ์–ธ๋งˆ์šดํŠธ ์‹œ ์ •๋ฆฌ
onBeforeUnmount(() => {
  cleanupEventListeners()
  
  // ์บ์‹œ ์ •๋ฆฌ (ํ•„์š”ํ•œ ๊ฒฝ์šฐ)
  if (!props.keepAlive) {
    targetElementCache.clear()
  }
})

// ๊ฐ•์ œ ์ƒˆ๋กœ๊ณ ์นจ ๋ฉ”์†Œ๋“œ ๋…ธ์ถœ
const refresh = () => {
  componentKey.value++
  shouldRender.value = true
}

defineExpose({
  refresh,
  cleanup: cleanupEventListeners
})
</script>

<style scoped>
.teleport-content {
  /* ๊ธฐ๋ณธ ์Šคํƒ€์ผ */
}
</style>

๐ŸŒ ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๋ Œ๋”๋ง(SSR) ๋Œ€์‘ ์ „๋žต

SSR ํ˜ธํ™˜ Teleport ๊ตฌํ˜„

<!-- SSRSafeTeleport.vue -->
<template>
  <div>
    <!-- SSR ์ค‘์—๋Š” ์ธ๋ผ์ธ์œผ๋กœ ๋ Œ๋”๋ง -->
    <div v-if="!isClient" class="ssr-fallback">
      <slot />
    </div>
    
    <!-- ํด๋ผ์ด์–ธํŠธ์—์„œ๋Š” Teleport ์‚ฌ์šฉ -->
    <Teleport v-else :to="target" :disabled="disabled">
      <slot />
    </Teleport>
  </div>
</template>

<script setup>
import { ref, onMounted, computed } from 'vue'

const props = defineProps({
  to: { type: [String, Element], required: true },
  disabled: { type: Boolean, default: false },
  ssrFallback: { type: Boolean, default: true }
})

// ํด๋ผ์ด์–ธํŠธ ๊ฐ์ง€
const isClient = ref(false)

// ์•ˆ์ „ํ•œ ํƒ€๊ฒŸ ๊ณ„์‚ฐ
const target = computed(() => {
  if (!isClient.value) return null
  
  if (typeof props.to === 'string') {
    // ํด๋ผ์ด์–ธํŠธ์—์„œ ์—˜๋ฆฌ๋จผํŠธ๊ฐ€ ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธ
    const element = document.querySelector(props.to)
    if (!element) {
      console.warn(`Teleport target "${props.to}" not found, falling back to body`)
      return 'body'
    }
    return element
  }
  
  return props.to
})

onMounted(() => {
  isClient.value = true
})
</script>

<style scoped>
.ssr-fallback {
  /* SSR ์‹œ ๋Œ€์ฒด ์Šคํƒ€์ผ */
  position: static;
}
</style>

Nuxt 3์—์„œ์˜ Teleport ์ตœ์ ํ™”

<!-- components/NuxtTeleport.vue -->
<template>
  <div>
    <!-- Nuxt์˜ <ClientOnly> ์‚ฌ์šฉ -->
    <ClientOnly>
      <Teleport :to="target" :disabled="disabled">
        <slot />
      </Teleport>
      
      <template #fallback>
        <div v-if="showFallback" class="teleport-fallback">
          <slot name="fallback">
            <!-- ๋กœ๋”ฉ ์Šคํ”ผ๋„ˆ ๋˜๋Š” ํ”Œ๋ ˆ์ด์Šคํ™€๋” -->
            <div class="loading-placeholder">๋กœ๋”ฉ ์ค‘...</div>
          </slot>
        </div>
      </template>
    </ClientOnly>
  </div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  to: { type: [String, Element], required: true },
  disabled: { type: Boolean, default: false },
  showFallback: { type: Boolean, default: true }
})

// Nuxt์˜ process.client ์‚ฌ์šฉ
const target = computed(() => {
  if (process.server) return null
  
  if (typeof props.to === 'string') {
    return document.querySelector(props.to) || 'body'
  }
  
  return props.to
})
</script>

<!-- nuxt.config.ts ์„ค์ • -->
<script>
// nuxt.config.ts
export default defineNuxtConfig({
  ssr: true,
  app: {
    // Teleport๋ฅผ ์œ„ํ•œ ์ถ”๊ฐ€ ๋ฃจํŠธ ์—˜๋ฆฌ๋จผํŠธ
    rootAttrs: {
      id: 'nuxt-root'
    }
  },
  
  // ํ”Œ๋Ÿฌ๊ทธ์ธ์œผ๋กœ ์ „์—ญ ํฌํ„ธ ์ปจํ…Œ์ด๋„ˆ ์ƒ์„ฑ
  plugins: [
    '~/plugins/teleport-containers.client.ts'
  ]
})

// plugins/teleport-containers.client.ts
export default defineNuxtPlugin(() => {
  // ํด๋ผ์ด์–ธํŠธ์—์„œ๋งŒ ์‹คํ–‰
  if (process.client) {
    // ํฌํ„ธ ์ปจํ…Œ์ด๋„ˆ๋“ค ์ƒ์„ฑ
    const containers = [
      { id: 'modal-root', className: 'modal-layer' },
      { id: 'toast-root', className: 'toast-layer' },
      { id: 'tooltip-root', className: 'tooltip-layer' },
      { id: 'dropdown-root', className: 'dropdown-layer' }
    ]
    
    containers.forEach(({ id, className }) => {
      if (!document.getElementById(id)) {
        const container = document.createElement('div')
        container.id = id
        container.className = className
        document.body.appendChild(container)
      }
    })
  }
})
</script>

<style scoped>
.teleport-fallback {
  /* SSR ๋Œ€์ฒด ์ปจํ…์ธ  ์Šคํƒ€์ผ */
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2rem;
  background: #f9fafb;
  border: 1px dashed #d1d5db;
  border-radius: 8px;
}

.loading-placeholder {
  color: #6b7280;
  font-size: 0.9rem;
}
</style>

๐ŸŽฏ ๊ฒฐ๋ก : Vue 3 Teleport 

Vue 3์˜ Teleport๋Š” ๋‹จ์ˆœํ•œ DOM ์ด๋™ ๊ธฐ๋Šฅ์„ ๋„˜์–ด ๋ชจ๋˜ ์›น ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์˜ UX ๋ฌธ์ œ๋ฅผ ๊ทผ๋ณธ์ ์œผ๋กœ ํ•ด๊ฒฐํ•˜๋Š” ํ˜์‹ ์ ์ธ ๋„๊ตฌ์ž…๋‹ˆ๋‹ค. z-index ์ง€์˜ฅ์—์„œ ๋ฒ—์–ด๋‚˜ ๊น”๋”ํ•œ ๋ ˆ์ด์–ด ๊ด€๋ฆฌ๋ฅผ ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•˜๊ณ , ์ปดํฌ๋„ŒํŠธ์˜ ๋…ผ๋ฆฌ์  ๊ตฌ์กฐ์™€ ์‹œ๊ฐ์  ํ‘œํ˜„์„ ๋ถ„๋ฆฌํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ค๋‹ˆ๋‹ค.

๐Ÿš€ ํ•ต์‹ฌ ํฌ์ธํŠธ ์š”์•ฝ

  1. ๋ฌธ์ œ ํ•ด๊ฒฐ์˜ ํ•ต์‹ฌ: CSS ์ƒ์† ๋ฌธ์ œ, stacking context ์ด์Šˆ, ๋ฐ˜์‘ํ˜• ๋ ˆ์ด์•„์›ƒ ์ถฉ๋Œ ์™„๋ฒฝ ํ•ด๊ฒฐ
  2. ์„ฑ๋Šฅ ์ตœ์ ํ™”: ์กฐ๊ฑด๋ถ€ ๋ Œ๋”๋ง, ๋ฉ”๋ชจ์ด์ œ์ด์…˜, ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๋ฐฉ์ง€๋กœ ์ตœ์ ์˜ ์„ฑ๋Šฅ ๋‹ฌ์„ฑ
  3. ์‹ค๋ฌด ํ™œ์šฉ: ๋ชจ๋‹ฌ, ํ† ์ŠคํŠธ, ๋“œ๋กญ๋‹ค์šด, ํˆดํŒ ๋“ฑ ๋ชจ๋“  ์˜ค๋ฒ„๋ ˆ์ด ์ปดํฌ๋„ŒํŠธ์˜ ์™„๋ฒฝํ•œ ๊ตฌํ˜„
  4. ์• ๋‹ˆ๋ฉ”์ด์…˜ ํ†ตํ•ฉ: Transition๊ณผ์˜ ์™„๋ฒฝํ•œ ์กฐํ•ฉ์œผ๋กœ ๋ถ€๋“œ๋Ÿฌ์šด ์‚ฌ์šฉ์ž ๊ฒฝํ—˜ ์ œ๊ณต
  5. SSR ๋Œ€์‘: ์„œ๋ฒ„ ์‚ฌ์ด๋“œ ๋ Œ๋”๋ง ํ™˜๊ฒฝ์—์„œ๋„ ์•ˆ์ •์ ์ธ ๋™์ž‘ ๋ณด์žฅ

๐Ÿ“ˆ ์‹ค๋ฌด ์ ์šฉ์„ ์œ„ํ•œ ์ฒดํฌ๋ฆฌ์ŠคํŠธ

  • โœ… ๊ธฐ๋ณธ ๊ตฌ์กฐ ์„ค์ •: ํฌํ„ธ ์ปจํ…Œ์ด๋„ˆ๋“ค์„ index.html์— ๋ฏธ๋ฆฌ ์ •์˜
  • โœ… ์ปดํฌ๋„ŒํŠธ ์„ค๊ณ„: ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ Teleport ๋ž˜ํผ ์ปดํฌ๋„ŒํŠธ ๊ตฌ์ถ•
  • โœ… ์ƒํƒœ ๊ด€๋ฆฌ: ์ค‘์•™์ง‘์ค‘์‹ ์˜ค๋ฒ„๋ ˆ์ด ์ƒํƒœ ๊ด€๋ฆฌ ์‹œ์Šคํ…œ ๊ตฌํ˜„
  • โœ… ์„ฑ๋Šฅ ๋ชจ๋‹ˆํ„ฐ๋ง: ๋ Œ๋”๋ง ์„ฑ๋Šฅ๊ณผ ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ์ฃผ๊ธฐ์  ์ธก์ •
  • โœ… ์ ‘๊ทผ์„ฑ ํ™•๋ณด: ARIA ์†์„ฑ๊ณผ ํฌ์ปค์Šค ๊ด€๋ฆฌ๋กœ ์™„๋ฒฝํ•œ ์ ‘๊ทผ์„ฑ ์ง€์›
  • โœ… SSR ์ตœ์ ํ™”: ์„œ๋ฒ„ ๋ Œ๋”๋ง ํ™˜๊ฒฝ์—์„œ์˜ ์•ˆ์ •์ ์ธ ๋™์ž‘ ๋ณด์žฅ

๐Ÿ”ฎ ๋ฏธ๋ž˜ ์ „๋ง๊ณผ ๋ฐœ์ „ ๋ฐฉํ–ฅ

Vue 3์˜ Teleport๋Š” ์•ž์œผ๋กœ Web Components์™€์˜ ๋”์šฑ ๊ธด๋ฐ€ํ•œ ํ†ตํ•ฉ, View Transitions API์™€์˜ ๊ฒฐํ•ฉ, Container Queries์™€์˜ ์—ฐ๋™ ๋“ฑ์„ ํ†ตํ•ด ๋”์šฑ ๊ฐ•๋ ฅํ•ด์งˆ ๊ฒƒ์œผ๋กœ ์˜ˆ์ƒ๋ฉ๋‹ˆ๋‹ค. ํŠนํžˆ ๋ชจ๋ฐ”์ผ ํ™˜๊ฒฝ์—์„œ์˜ ๋„ค์ดํ‹ฐ๋ธŒ ์•ฑ๊ณผ ๊ฐ™์€ UX ๊ตฌํ˜„์— ์žˆ์–ด์„œ ํ•ต์‹ฌ์ ์ธ ์—ญํ• ์„ ํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.


๐Ÿ’ก ๋งˆ๋ฌด๋ฆฌ ํŒ

Teleport๋ฅผ ๋„์ž…ํ•  ๋•Œ๋Š” ๋‹จ๊ณ„์  ์ ์šฉ์„ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค. ๋จผ์ € ๊ฐ€์žฅ ๋ฌธ์ œ๊ฐ€ ๋˜๋Š” ๋ชจ๋‹ฌ๋ถ€ํ„ฐ ์‹œ์ž‘ํ•˜์—ฌ, ์ ์ฐจ ํ† ์ŠคํŠธ, ๋“œ๋กญ๋‹ค์šด, ํˆดํŒ ๋“ฑ์œผ๋กœ ํ™•์žฅํ•ด๋‚˜๊ฐ€์„ธ์š”. ๊ฐ ๋‹จ๊ณ„์—์„œ ์„ฑ๋Šฅ ์ธก์ •์„ ํ†ตํ•ด ์ตœ์ ํ™” ํฌ์ธํŠธ๋ฅผ ์ฐพ์•„๊ฐ€๋Š” ๊ฒƒ์ด ์ค‘์š”ํ•ฉ๋‹ˆ๋‹ค.

Vue 3 Teleport๋กœ ๋” ๋‚˜์€ ์‚ฌ์šฉ์ž ๊ฒฝํ—˜์„ ์ œ๊ณตํ•˜๊ณ , DOM ์กฐ์ž‘์˜ ์ƒˆ๋กœ์šด ํŒจ๋Ÿฌ๋‹ค์ž„์„ ๊ฒฝํ—˜ํ•ด๋ณด์„ธ์š”!

๋Œ“๊ธ€