자식 컴포넌트에서 submit 이벤트를 보낸 뒤, 부모가 비동기 API 요청을 끝마치는 시점에 맞춰 자식의 로딩 상태를 끄거나 입력 폼을 초기화하는 패턴을 구현하고 싶었다. Vue의 emit은 일방향으로 알리는 메커니즘이라 부모의 비동기 작업 완료 시점을 자식에게 회신하지 않는다. promise 를 전달해서 완료 통지를 받을 수 있도록 고안했다.
Promise 객체를 활용한 완료 통지 패턴
자식 컴포넌트에서 Promise를 생성해 이벤트 payload로 전달하고, 부모 컴포넌트가 작업 완료 시점에 resolve 또는 reject를 호출해 회신하는 전체 흐름이다.
자식 컴포넌트에서 Promise를 생성하고, resolve와 reject 함수를 emit payload 객체에 담아 부모에게 넘겨주는 방식이다.
// 자식 컴포넌트
type SubmitPayload = {
data: { contents: string }
resolve: () => void
reject: (error: unknown) => void
}
const emit = defineEmits<{
(e: 'submit', payload: SubmitPayload): void
}>()
const isSubmitting = ref(false)
const contents = ref('')
async function handleSubmit() {
isSubmitting.value = true
try {
await new Promise<void>((resolve, reject) => {
emit('submit', {
data: { contents: contents.value },
resolve,
reject,
})
})
// 부모의 API 처리가 정상 완료된 후 resolve 가 호출되면 실행됨!
contents.value = ''
} catch (error) {
showSubmitError(error)
} finally {
isSubmitting.value = false
}
}
부모 컴포넌트에서는 요청을 받아 비동기 처리를 수행하고 작업이 끝나면 resolve() 또는 reject()를 호출한다.
// 부모 컴포넌트
async function onSubmit(payload: SubmitPayload) {
try {
await api.createMemo(payload.data)
payload.resolve()
} catch (error) {
payload.reject(error)
}
}
이 방식은 자식 컴포넌트가 비동기 처리의 전후 생명주기(로딩 시작, 성공 후 초기화, 에러 처리, 로딩 해제 등)를 하나의 함수 흐름 안에서 관리할 수 있게 돕는다.
다만 부모 컴포넌트 핸들러가 resolve나 reject를 빠뜨리면 자식의 Promise가 계속 대기(pending) 상태로 남으므로, 부모-자식이 밀접하게 협력하는 컴포넌트 단위에서 사용하는 편이 적합하다.
콜백 함수 직접 전달 방식
Promise 대신 onSuccess, onError 같은 콜백 함수를 직접 payload에 담아 넘길 수도 있다.
function handleSubmit() {
isSubmitting.value = true
emit('submit', {
data: { contents: contents.value },
onSuccess: () => {
contents.value = ''
isSubmitting.value = false
},
onError: (err: unknown) => {
showSubmitError(err)
isSubmitting.value = false
},
})
}
단순한 완료 통지 목적이라면 콜백 함수 전달만으로도 충분히 목적을 달성할 수 있다.
composable로 emit 함수를 넘길 때 타입 공유하기
컴포넌트의 emit 함수를 외부 composable로 넘겨서 호출할 때는 이벤트 이름과 인자 타입이 어긋나지 않도록 타입을 공용으로 선언한다.
// types/popup-emits.ts
export type PopupEmits = {
'update:visible': [value: boolean]
submit: [payload: SubmitPayload]
}
컴포넌트에서는 이 타입을 defineEmits에 넘기고,
import type { PopupEmits } from '@/types/popup-emits'
const emit = defineEmits<PopupEmits>()
const { closePopup } = usePopupSaveRequest(emit)
composable에서는 EmitFn 타입을 사용해 인자를 정의한다.
import type { EmitFn } from 'vue'
import type { PopupEmits } from '@/types/popup-emits'
export function usePopupSaveRequest(emit: EmitFn<PopupEmits>) {
function closePopup() {
emit('update:visible', false)
}
return {
closePopup,
}
}
EmitFn은 Vue 3.5.8 이상에서 공식적으로 노출된 타입이다. 구버전이거나 단일 이벤트만 위임받는 composable이라면 일반 함수 시그니처((e: 'update:visible', value: boolean) => void)로 타입을 제한해도 무방하다.
Vue Test Utils에서 비동기 응답 테스트하기
단위 테스트에서는 wrapper.emitted()로 발생한 이벤트의 payload에서 resolve 함수를 꺼내 명시적으로 호출해 주면 된다.
import { flushPromises, mount } from '@vue/test-utils'
import MemoWriteForm from './MemoWriteForm.vue'
test('submit 완료 후 폼 입력값을 비운다', async () => {
const wrapper = mount(MemoWriteForm)
await wrapper.find('textarea').setValue('테스트 내용')
await wrapper.find('button[type="submit"]').trigger('click')
// emit된 이벤트의 resolve 콜백을 직접 실행
const payload = wrapper.emitted('submit')?.[0]?.[0] as SubmitPayload
payload.resolve()
// 마이크로태스크 큐(Promise) 플러시
await flushPromises()
expect(wrapper.find('textarea').element.value).toBe('')
})
nextTick()을 여러 번 호출하지 않고 flushPromises()를 활용해 대기 중인 모든 Promise 핸들러를 실행한 뒤 컴포넌트의 결과 상태를 검증한다.
댓글 0개