Front-End프로그래밍

[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 컴포넌트 트리 내에서만 직접적으로 사용할 수 있다.

해결 방향

  1. 전역 상태 관리(예: Pinia, Vuex, 또는 provide/inject, event bus 등)를 이용해

“경고 팝업을 띄워야 한다”는 신호를 보내고,

  1. 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을 호출해서 팝업을 띄울 수 있습니다.

error: Content is protected !!