Engineering note · Vue/Nuxt

부모 컴포넌트의 초기화 완료 시점 감지하기 (Promise.withResolvers)

2026년 01월 19일이현수
태그vuetypescriptjavascript

Vue에서 부모 컴포넌트의 비동기 초기화 작업이 완료된 시점을 자식 컴포넌트에게 안전하게 전달하는 방법을 알아봅니다. Promise.withResolvers를 활용한 패턴을 소개

일반적으로 Vue에서는 부모가 자식에게 값을 전달하고, 자식은 이벤트를 통해 부모에게 데이터를 전달하는 Props Down, Events Up 방식을 따른다. 부모가 자식의 내부 함수를 직접 호출하는 방식은 컴포넌트 간 결합도를 높이므로 지양하는 편이다. 하지만 부모 컴포넌트의 특정 비동기 작업이 완료된 시점을 자식 컴포넌트가 정확히 알아야 할 때가 있다. 예를 들어, 부모가 비동기로 초기화 데이터를 모두 가져온 후에만 자식 컴포넌트가 로직을 수행해야 하는 경우다.

  • 부모의 isInitialized 변수를 자식에게 전달하고, 자식은 watch 로 감지하기.
  • 자식의 parentInitialized() 함수를 defineExpose 로 export 한 후 template ref 로 호출하기.
  • promise 를 전달해서 적절한 시점에 자식이 then 블럭에서 기능 수행하기.

이 중 3번째 방법을 고안해냈다.

첫 번째 방법(watch)은 의도하지 않은 시점에 변수가 변경되어 사이드 이펙트가 발생할 가능성이 있다. 단순한 값이 아닌 복잡한 초기화 흐름을 제어하고 싶을 때, 예기치 않은 타이밍에 감지가 일어나 로직이 꼬일 수 있다. 또한 자식 컴포넌트가 마운트되기 전에 이미 부모의 초기화가 끝나버렸다면 감지가 불가능할 수도 있다.

두 번째 방법(defineExpose)은 부모가 자식의 구현 상세(메서드명 등)를 알아야 하므로 결합도가 높아진다.

Promise 전달해서 자식도 초기화 시점 감지하기

ECMAScript 2024 에 추가된 Promise.withResolvers를 활용하면 완료 시점을 깔끔하게 싱크할 수 있다. 일반적인 비동기 함수는 실행과 동시에 Promise를 반환한다. 하지만 Promise.withResolvers를 사용하면 Promise 객체와 이를 해결할 수 있는 resolve 함수를 분리하여 선언할 수 있다. 덕분에 부모는 원하는 복잡한 로직을 모두 수행한 뒤, 아주 정밀한 타이밍에 자식에게 신호를 보낸다.

vue
<script setup lang="ts">
// Promise와 resolve 함수를 외부로 추출하여 선언
const initReady = Promise.withResolvers<void>();

async function runInit() {
  try {
    // API 호출, 로컬 스토리지 확인, 권한 체크 등 복잡한 초기화 수행
    await doSomethingComplex();
    
    // 모든 작업이 끝난 원하는 시점에 약속 이행
    initReady.resolve();
  } catch (error) {
    initReady.reject(error);
  }
}

onMounted(() => {
  runInit();
});
</script>
<template>
  <Child :init-ready="initReady.promise" />
</template>

부모는 initReady라는 통신 창구를 만들어둔다. initReady.resolve()가 호출되는 순간 대기하고 있던 자식 컴포넌트들에게 신호가 전달된다. 자식에게는 initReady.promise 객체만 Props로 전달하면 된다.

vue
<script setup lang="ts">
const props = defineProps<{
  initReady: Promise<void>
}>();

onMounted(async () => {
  // 부모의 초기화가 끝날 때까지 대기
  await props.initReady;
  
  // 이후 로직 실행
  console.log("부모의 준비가 끝나 자식 로직을 시작합니다.");
});
</script>

await props.initReady를 통해 부모의 초기화가 완료된 이후에만 로직이 실행되도록 보장할 수 있다.

자식이 v-if 로 마운트의 on off 를 반복할 때

자식 컴포넌트가 v-if에 의해 언마운트되었다가 다시 마운트되는 경우, initReady 신호를 놓치지 않을까 걱정할 수 있다. 하지만 Promise는 한 번 resolve 되면 그 상태(Fulfilled)가 유지된다. 따라서 자식 컴포넌트가 다시 생성되어 await props.initReady를 실행하더라도, 이미 완료된 Promise이므로 즉시 다음 로직이 실행된다. 매번 상태를 다시 확인할 필요 없이 안정적인 동작을 보장한다.

provide, inject 로 깊은 여러 컴포넌트에 전달

컴포넌트 계층이 깊거나(Prop Drilling 문제), 여러 자식 컴포넌트가 동시에 신호를 받아야 하는 경우 provideinject를 활용할 수 있다.

ts
const initReady = Promise.withResolvers<void>();
// promise 를 provide
provide('initReady', initReady.promise);

부모는 provide를 통해 Promise 객체를 하위 컴포넌트들에 공급한다.

ts
// promise 주입받기
const initReady = inject<Promise<void>>('initReady');

onMounted(async () => {
  if (initReady) {
    await initReady;
    console.log("초기화 완료 후 자식 로직 실행");
  }
});

inject를 통해 받아와서 사용하면 된다. 하지만 단순히 문자열 키('initReady')로 주입받으면 타입 추론이 안 되거나, 데이터의 출처를 명확히 알기 어려워 유지보수가 힘들어질 수 있다.

composable 를 활용해 추적을 용이하게 하기

provide/inject 패턴은 키 관리나 타입 정의가 번거로울 수 있으므로, Composable 함수(Hook)로 캡슐화하여 사용하는 것을 선호한다.

ts
import { provide, inject, InjectionKey } from 'vue';
// 심볼을 사용하여 고유한 키 정의 
const InitReadyKey: InjectionKey<Promise<void>> = Symbol('InitReady');

/**
 * 부모에서 호출할 provide 함수
 */
export function provideInit(promise: Promise<void>) {
  provide(InitReadyKey, promise);
}

/** 
 * 자식에서 사용할 composable
 */
export function useInit() {
  const initReady = inject(InitReadyKey);
  // provide 없으면 에러
  if (!initReady) {
    throw new Error('useInit()은 provideInit() 범위 내에서 사용해야 합니다.');
  }
  return initReady;
}

구조는 간단하다. provideinject 로직을 하나의 파일에서 관리하여 키(InjectionKey)와 타입의 일관성을 유지한다.

vue
<script setup lang="ts">
import { provideInit } from './useInit';
const initReady = Promise.withResolvers<void>();
provideInit(initReady.promise); // 추상화된 함수 호출
</script>

<script setup lang="ts">
import { useInit } from './useInit';
const initReady = useInit(); // 내부 로직을 몰라도 안전하게 주입받음
</script>

타입 추론도 제공하고, 단일 파일에서 파악이 가능하기 때문에 가독성도 높고 유지보수하기 좋다.

좋아요와 댓글

댓글 남기기

댓글 0개

댓글을 불러오는 중입니다.