


‘Maximum call stack size exceeded’ 발생
Vue 기반 프로젝트를 Nuxt에서 구축하던 중, 영수증 처리 기능을 구현하면서 개발 환경(localhost)에서는 아무런 문제 없이 동작했지만, 배포(Vercel) 환경에서는 페이지 진입 시마다 바로 무한 루프에 빠져 브라우저가 멈춰버리는 이슈가 발생하였다.
해당 이슈가 어디서 발생한 것인지 코드 상에서 예상되는 곳이 직관적이지 않아, 이전 commit을 통해 배포되었던 이력을 확인하며, 트래킹을 진행하였다.


그 결과, tanstack의 useMutation 훅 추가 이후에 이슈가 발생한 것을 확인할 수 있었으며, 이와 관련하여 어디서 문제가 생겼는지 확인해보았다.
다음은 실제로 사용하던 mutation 코드다.
const { mutate: submitReceiptExpense } = useMutation({
mutationKey: ["submitReceiptExpense", store], // 문제의 핵심
mutationFn: async () =>
(
await patchReceiptProcess({
companyId: Number(store.businessNumber),
receiptId: Number(receiptId),
progressType: store.category,
progressDetail: store.description,
fileName: store.filename || undefined,
})
).data,
onSuccess: () => {
toast.add({
title: "영수 처리 성공",
description: "영수증 처리 요청이 성공적으로 전송되었습니다.",
color: "success",
});
onClickNext(); // 다음 스텝으로 이동
},
});

GPT피셜 store를 mutationKey에 넣은 것이 문제가 됐다고 한다.
개인적으로는 pinia의 store 객체가 변경되는 모든 상황에 mutation을 새로 할 수 있도록 key를 부여한다고 생각했다.
또한, mutation은 query함수처럼 계속해서 호출되는 요소가 아님에도 왜 무한 호출에 빠지게 된걸까?
그리고 왜 pinia store를 mutationKey에 넣은 것이 문제가 되었을까?
Vue의 반응형 시스템과 TanStack Query의 내부 동작 방식이 충돌한 것이 그 원인이 되었다.
문제의 핵심 원인은 mutationKey에 store라는 반응형 객체를 그대로 전달했다는 점이다.
mutationKey: ["submitReceiptExpense", store] // ❌
store는 pinia에서 생성한 reactive proxy 객체이며,
이를 읽기만 해도 Vue는 해당 setup() 또는 컴포넌트 렌더링을 종속성으로 등록한다.
const store = useReceiptSubmitStore(); // pinia의 reactive store
Vue는 다음과 같은 방식으로 작동한다:
reactive 객체의 값을 읽는 순간(track), 그것이 어디서 읽혔는지를 기억
그 객체가 변경되면, 그걸 읽었던 곳을 자동으로 다시 실행(trigger)
이때 useMutation()은 mutationKey의 변화 여부에 따라 내부적으로 재등록 로직이 포함되어 있는데, mutationKey에 store가 들어간 상태라면, 렌더링 중 또는 반응성 트리거가 발생할 때마다 다시 useMutation()이 평가 → 등록 → 렌더링을 유발
결과적으로, 렌더링 과정에서 읽힌 store로 인해 useMutation이 무한 반복 호출되며,
그 안에서 렌더를 유발하는 onClickNext() 등의 함수가 연결되면
→ 브라우저가 빠져나오지 못하는 무한 루프에 갇히게 된다.
개발 환경에서는 이러한 루프가 억제되거나 감춰질 수 있지만, production에서는 최적화와 엄격한 평가 타이밍으로 인해 루프가 명확히 드러나고, 결국 Maximum call stack size exceeded가 발생한 것이다.
❌ 잘못된 코드 (반응형 객체 직접 포함)
mutationKey: ["submitReceiptExpense", store] // 문제 발생
✅ 수정된 코드 (원시값만 추출해서 전달)
mutationKey: [
"submitReceiptExpense",
store.businessNumber,
store.category,
receiptId,
.... 등등 기타 dependency
]
mutationKey에는 반드시 string, number, boolean 등의 원시값만 전달해야 하며,
reactive 객체, ref, computed, store 등은 절대로 직접 포함하지 말아야 한다.
| 항목 | 설명 |
|---|---|
mutationKey, queryKey | 반드시 원시값으로 구성해야 함 |
reactive 객체는 track만으로도 의존성 등록됨 | "읽는 것만으로도" 무한 루프 유발 가능 |
ref, store, computed는 .value 또는 toRaw()를 통해 해체하여 사용 | 내부 proxy를 피해야 함 |
setup()이나 <script setup>에서 평가되는 값은 모두 reactivity 영향권에 있음 | 초기 렌더링 타이밍 주의 |
이번 이슈를 통해, 단순히 “작동하는 코드”를 넘어서,Vue의 반응성 시스템이 얼마나 민감하게 동작하는지,그리고 TanStack Query가 내부적으로 어떻게 키 기반으로 상태를 관리하는지에 대해 깊이 이해할 수 있었다.
특히 useMutation은 단순한 "한 번 쓰는 함수"처럼 보여도, 등록 시점의 key, fn, state를 기반으로 작동하는 구조이므로 Vue의 반응성과 조합할 때는 매우 주의해서 사용해야 한다.