일반적으로 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 함수를 분리하여 선언할 수 있다. 덕분에 부모는 원하는 복잡한 로직을 모두 수행한 뒤, 아주 정밀한 타이밍에 자식에게 신호를 보낸다.
<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로 전달하면 된다.
<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 문제), 여러 자식 컴포넌트가 동시에 신호를 받아야 하는 경우 provide와 inject를 활용할 수 있다.
const initReady = Promise.withResolvers<void>();
// promise 를 provide
provide('initReady', initReady.promise);
부모는 provide를 통해 Promise 객체를 하위 컴포넌트들에 공급한다.
// promise 주입받기
const initReady = inject<Promise<void>>('initReady');
onMounted(async () => {
if (initReady) {
await initReady;
console.log("초기화 완료 후 자식 로직 실행");
}
});
inject를 통해 받아와서 사용하면 된다. 하지만 단순히 문자열 키('initReady')로 주입받으면 타입 추론이 안 되거나, 데이터의 출처를 명확히 알기 어려워 유지보수가 힘들어질 수 있다.
composable 를 활용해 추적을 용이하게 하기
provide/inject 패턴은 키 관리나 타입 정의가 번거로울 수 있으므로, Composable 함수(Hook)로 캡슐화하여 사용하는 것을 선호한다.
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;
}
구조는 간단하다. provide와 inject 로직을 하나의 파일에서 관리하여 키(InjectionKey)와 타입의 일관성을 유지한다.
<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개