[Vue3&TypeScript] api.interceptors.response.use 에서 alert 대신 Vue 컴포넌트(ex > AlertPopup.vue) 사용하는 방법
api.interceptors.response.use에서 “이중 로그인” 등 특정 에러 상황이 발생했을 때, 기존에는 alert()을 사용했지만,이제는 alertPopup 컴포넌트를 띄우고는 예제이다.
- api.ts는 일반적으로 Vue 인스턴스의 context(예: this.$root, this.$refs, this.$store 등)에 접근할 수 없는 순수 TS 파일이다.
- AlertPopup은 Vue 컴포넌트이므로, Vue 컴포넌트 트리 내에서만 직접적으로 사용할 수 있다.
해결 방향
- 전역 상태 관리(예: Pinia, Vuex, 또는 provide/inject, event bus 등)를 이용해
“경고 팝업을 띄워야 한다”는 신호를 보내고,
- App.vue 또는 최상위 레이아웃 컴포넌트에서 AlertPopup을 항상 렌더링해두고,
전역 상태가 바뀌면 팝업이 뜨도록 처리한다.
구현 예시 코드 (Pinia 사용)
1. Pinia 스토어 생성 (예: src/stores/ui.ts에 추가)
// src/stores/ui.ts
import { defineStore } from 'pinia';
export const useUiStore = defineStore('ui', {
state: () => ({
isWarnPopup: false,
warnMessage: '',
}),
actions: {
showWarnPopup(message: string) {
this.warnMessage = message;
this.isWarnPopup = true;
},
closeWarnPopup() {
this.isWarnPopup = false;
this.warnMessage = '';
},
},
});
또는
// ... 기존 코드 ...
export const useUiStore = defineStore('ui', () => {
// ... 기존 코드 ...
// WarnPopup 상태 추가
const isWarnPopup = ref(false);
const warnMessage = ref('');
function showWarnPopup(message: string) {
warnMessage.value = message;
isWarnPopup.value = true;
}
function closeWarnPopup() {
isWarnPopup.value = false;
warnMessage.value = '';
}
// ... 기존 코드 ...
return {
... 기존 코드 ...
isWarnPopup,
warnMessage,
showWarnPopup,
closeWarnPopup,
};
});
2. App.vue(또는 GlobalLayout.vue)에 alertPopup 추가
<script setup lang="ts">
import AlertPopup from '@/components/common/AlertPopup.vue';
import { useUiStore } from '@/stores/ui';
const uiStore = useUiStore();
</script>
<template>
<!-- ...기존 코드... -->
<WarnPopup
v-if="uiStore.isWarnPopup"
:message="uiStore.warnMessage"
@close="uiStore.closeWarnPopup"
/>
<!-- ...기존 코드... -->
</template>
3. api.ts에서 Pinia 스토어 사용
// src/utils/api.ts
import { useUiStore } from '@/stores/ui';
api.interceptors.response.use(
response => response,
error => {
// 예시: 이중 로그인 에러 코드가 409라고 가정
if (error.response?.status === 409) {
const uiStore = useUiStore();
uiStore.showWarnPopup('이중 로그인 감지! 다시 로그인 해주세요.');
// 필요하다면 추가 처리
}
return Promise.reject(error);
}
);
AlertPopup.vue 파일
<template>
<PopupWraper
title="알림"
:is-open="props.isOpen"
:depth="props.isDepth"
:is-cancel="props.isCancel"
@close-popup="
() => {
emits('onCancelClick');
emits('update:isOpen', false);
}
"
>
<template #content>
<p class="whitespace-pre text-center">{{ props.content }}</p>
</template>
<template #btn-box>
<div class="flex items-center gap-3 mt-4 justify-center">
<button
type="button"
class="custom-btn-popup-primary !h-[40px]"
@click="
() => {
emits('onConfirmClick');
emits('update:isOpen', false);
}
"
>
확인
</button>
<button
v-if="props.isCancel"
type="button"
class="custom-btn-popup !h-[40px]"
@click="
() => {
emits('onCancelClick');
emits('update:isOpen', false);
}
"
>
취소
</button>
</div>
</template>
</PopupContainer>
</template>
<script setup lang="ts">
import PopupContainer from './PopupContainer.vue';
interface Props {
isOpen: boolean;
isDepth?: boolean;
isCancel?: boolean;
content: string;
}
interface Emits {
(e: 'onConfirmClick'): void;
(e: 'onCancelClick'): void;
(e: 'update:isOpen', value: boolean): void;
}
const props = withDefaults(defineProps<Props>(), {
isDepth: false,
isCancel: false,
});
const emits = defineEmits<Emits>();
</script>
<style scoped></style>
PopupWraper.vue 파일
<!-- * 공통 팝업 컨테이너 -->
<template>
<div
v-if="props.isOpen"
class="fixed left-0 top-0 z-[99991] flex h-screen w-full items-center justify-center bg-black bg-opacity-60"
>
<!-- ** 팝업 -->
<div class="rounded-md">
<!-- ** 헤더 -->
<div
class="flex h-[3.375rem] items-center justify-between rounded-t-md bg-[#40496A] pl-9 pr-5"
>
<div class="text-xl font-bold text-white">{{ props.title }}</div>
<button ref="focus"
type="button"
class="inline-block h-4 w-4 bg-[url('/src/assets/images/ico_popup_close.svg')] bg-center bg-no-repeat text-[0rem]"
@click="emits('closePopup')"
>
팝업 닫기
</button>
</div>
<div
class="box-border rounded-b-md bg-white"
:class="
props.contentPaddig ? 'px-[2.125rem] pb-[1.875rem] pt-[2.188rem]' : ''
"
>
<!-- ** 본문 -->
<slot name="content" />
<!-- ** 버튼 박스 -->
<div class="text-center">
<slot name="btn-box" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { watch, ref, nextTick } from 'vue';
interface Props {
isOpen: boolean;
title: string;
depth?: boolean; // 이중 팝업창 옵션
contentPaddig?: boolean;
}
interface Emits {
(e: 'closePopup'): void;
}
const focus = ref();
const props = withDefaults(defineProps<Props>(), {
depth: false,
contentPaddig: true,
});
const emits = defineEmits<Emits>();
// * 팝업 감지 및 바디 스크롤 방지
watch(
() => props.isOpen,
async (newVal) => {
if (props.depth) return;
if (newVal) {
// console.log('팝업 오픈');
document.body.classList.add('prevent');
await nextTick(); // DOM이 그려진 다음에 포커스 줌
focus.value?.focus(); // 팝업이 열릴 때 포커스 설정
} else {
// console.log('팝업 닫기');
document.body.classList.remove('prevent');
}
}
);
</script>
<style lang="postcss" scoped>
/* 팝업창 테이블 overflow 설정시 테이블 헤더가 깨짐 */
:deep(.p-datatable-scrollable-table) {
@apply border-separate;
th:before {
@apply pointer-events-none absolute -left-[1px] top-0 h-full w-full border-l-[1px] border-[#DBDEE4] content-[''];
}
th.checkbox-header + th,
th:first-child {
&:before {
@apply hidden;
}
}
}
</style>
주의사항:
Pinia 스토어를 api.ts에서 직접 사용할 때, setup() 함수 내부 또는 Vue 인스턴스가 생성된 이후에만 정상 동작한다.
만약 SSR이나 초기화 타이밍 이슈가 있다면,
import { setActivePinia, createPinia }로 Pinia 인스턴스를 명시적으로 활성화해야 할 수도 있다.
요약
- alert 대신 WarnPopup을 띄우려면, 전역 상태 관리로 신호를 보내고, App.vue에서 WarnPopup을 렌더링하세요.
- api.ts에서는 Pinia 스토어의 action을 호출해서 팝업을 띄울 수 있습니다.