Template Ref는 Vue의 선언적 렌더링으로 해결할 수 없는, DOM을 직접 조작해야 하는 경우에 사용하는 탈출구(escape hatch)입니다.

면접에서 "Vue에서 DOM에 직접 접근해야 할 때 어떻게 하나요?"라고 물으면, ref 속성과 onMounted 타이밍을 함께 설명할 수 있어야 합니다.


기본 사용법

VUE
<script setup lang="ts">
import { ref, onMounted } from 'vue'

// 변수명이 template의 ref 속성값과 일치해야 함
const inputRef = ref<HTMLInputElement | null>(null)

onMounted(() => {
  // onMounted 이후에 DOM 접근 가능
  inputRef.value?.focus()
})

const focusInput = () => {
  inputRef.value?.focus()
}
</script>

<template>
  <!-- ref 속성으로 DOM 엘리먼트 참조 -->
  <input ref="inputRef" placeholder="자동 포커스" />
  <button @click="focusInput">포커스</button>
</template>

**주의 **: ref는 onMounted 이후에만 값이 채워집니다. setup() 실행 시점에는 null입니다.


v-for에서의 Ref

VUE
<script setup lang="ts">
import { ref, onMounted } from 'vue'

const items = ref(['사과', '바나나', '오렌지'])
const itemRefs = ref<HTMLLIElement[]>([])

onMounted(() => {
  // 배열로 모든 엘리먼트 참조를 받음
  console.log(itemRefs.value.length) // 3
  itemRefs.value.forEach((el, i) => {
    console.log(`${i}: ${el.textContent}`)
  })
})
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item" ref="itemRefs">
      {{ item }}
    </li>
  </ul>
</template>

** 주의 **: ref 배열의 순서가 소스 배열의 순서와 반드시 일치하지는 않습니다.


함수 Ref

더 세밀한 제어가 필요할 때 함수를 사용할 수 있습니다.

VUE
<script setup lang="ts">
import { ref } from 'vue'

const dynamicRefs = ref<Map<string, HTMLElement>>(new Map())

const setRef = (el: HTMLElement | null, key: string) => {
  if (el) {
    dynamicRefs.value.set(key, el)
  } else {
    dynamicRefs.value.delete(key)
  }
}

const scrollTo = (key: string) => {
  dynamicRefs.value.get(key)?.scrollIntoView({ behavior: 'smooth' })
}
</script>

<template>
  <div v-for="section in ['intro', 'features', 'pricing']" :key="section">
    <div :ref="(el) => setRef(el as HTMLElement, section)">
      <h2>{{ section }}</h2>
    </div>
  </div>

  <nav>
    <button @click="scrollTo('intro')">소개</button>
    <button @click="scrollTo('features')">기능</button>
    <button @click="scrollTo('pricing')">가격</button>
  </nav>
</template>

컴포넌트 Ref

VUE
<!-- ChildComponent.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
const message = ref('안녕하세요')

const increment = () => { count.value++ }
const reset = () => { count.value = 0 }

// script setup에서는 명시적으로 expose해야 외부에서 접근 가능
defineExpose({ count, increment, reset })
</script>

<template>
  <div>{{ message }} — {{ count }}</div>
</template>
VUE
<!-- 부모 컴포넌트 -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ChildComponent from './ChildComponent.vue'

const childRef = ref<InstanceType<typeof ChildComponent> | null>(null)

onMounted(() => {
  // expose된 속성만 접근 가능
  console.log(childRef.value?.count)   // 0
  childRef.value?.increment()
  // childRef.value?.message  // expose하지 않았으므로 접근 불가
})
</script>

<template>
  <ChildComponent ref="childRef" />
  <button @click="childRef?.reset()">리셋</button>
</template>

실전 활용 패턴

외부 라이브러리와 통합

VUE
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'

const canvasRef = ref<HTMLCanvasElement | null>(null)
let animationId: number

onMounted(() => {
  const canvas = canvasRef.value
  if (!canvas) return

  const ctx = canvas.getContext('2d')
  if (!ctx) return

  // 캔버스 크기 설정
  canvas.width = canvas.offsetWidth
  canvas.height = canvas.offsetHeight

  // 애니메이션 루프
  const animate = () => {
    ctx.clearRect(0, 0, canvas.width, canvas.height)
    // 그리기 로직...
    animationId = requestAnimationFrame(animate)
  }
  animate()
})

onUnmounted(() => {
  cancelAnimationFrame(animationId)
})
</script>

<template>
  <canvas ref="canvasRef" style="width: 100%; height: 400px;"></canvas>
</template>

스크롤 위치 복원

VUE
<script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue'

const scrollContainerRef = ref<HTMLDivElement | null>(null)
let savedScrollTop = 0

// KeepAlive와 함께 사용 시 스크롤 위치 복원
onActivated(() => {
  if (scrollContainerRef.value) {
    scrollContainerRef.value.scrollTop = savedScrollTop
  }
})

const saveScrollPosition = () => {
  if (scrollContainerRef.value) {
    savedScrollTop = scrollContainerRef.value.scrollTop
  }
}
</script>

<template>
  <div ref="scrollContainerRef" @scroll="saveScrollPosition" style="overflow-y: auto; height: 500px;">
    <!-- 긴 컨텐츠 -->
  </div>
</template>

포커스 트래핑 (모달)

VUE
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'

const modalRef = ref<HTMLDivElement | null>(null)
const isOpen = ref(false)

const open = async () => {
  isOpen.value = true
  // DOM 업데이트를 기다린 후 포커스 설정
  await nextTick()
  const firstFocusable = modalRef.value?.querySelector<HTMLElement>(
    'button, [href], input, select, textarea'
  )
  firstFocusable?.focus()
}
</script>

<template>
  <button @click="open">모달 열기</button>

  <div v-if="isOpen" ref="modalRef" role="dialog" aria-modal="true">
    <h2>모달 제목</h2>
    <input placeholder="입력..." />
    <button @click="isOpen = false">닫기</button>
  </div>
</template>

nextTick과 함께 사용

VUE
<script setup lang="ts">
import { ref, nextTick } from 'vue'

const showInput = ref(false)
const dynamicInputRef = ref<HTMLInputElement | null>(null)

const toggleAndFocus = async () => {
  showInput.value = true
  // v-if로 인해 DOM이 아직 없으므로 nextTick 필요
  await nextTick()
  dynamicInputRef.value?.focus()
}
</script>

<template>
  <button @click="toggleAndFocus">입력 필드 표시</button>
  <input v-if="showInput" ref="dynamicInputRef" />
</template>

면접 팁

  • Template Ref는 ** 탈출구 **입니다. 가능하면 선언적 바인딩을 사용하고, DOM 직접 접근이 불가피할 때만 사용하세요
  • defineExpose를 사용해야 하는 이유를 ** 캡슐화 **와 연결지어 설명하면 좋습니다
  • nextTick의 존재 이유를 Vue의 비동기 DOM 업데이트 큐 와 연결해서 설명할 수 있으면 깊이 있어 보입니다

요약

Template Ref는 ref 속성으로 DOM 엘리먼트나 컴포넌트 인스턴스에 직접 접근하는 방법입니다. onMounted 이후에만 사용 가능하고, nextTick으로 DOM 업데이트를 기다려야 할 때가 있습니다. 컴포넌트 ref는 defineExpose로 공개된 속성만 접근 가능합니다.

댓글 로딩 중...