---
title: "메서드에 기능을 얹은 JS Proxy가 vi.spyOn을 삼킨다"
description: "query 호출에 SQL 태그를 붙이는 Proxy 래퍼를 만들었더니 vi.spyOn 이 전혀 먹지 않았습니다. get 트랩이 spy 를 가리는 이유와 원본 identity 비교로 푸는 방법을 정리했습니다."
date: 2026-10-18
updated: 2026-10-18T07:23:33.355Z
tags: [javascript, proxy, vitest, testing]
canonical: https://blog.wooncloud.com/posts/js-proxy-swallows-vitest-spy
---

DB 클라이언트의 `query()` 호출마다 SQL 주석 태그(`/* service */ ...`)를 자동으로 붙이고 싶었습니다. 그래서 클라이언트를 `Proxy` 로 감싸 `query` 만 가로채는 래퍼를 만들었습니다.

```ts
function wrapClient(client) {
  const real = client.query.bind(client);
  const taggingQuery = (sql, params) => real(tag(sql), params);
  return new Proxy(client, {
    get(target, prop) {
      if (prop === "query") return taggingQuery; // 항상 래퍼 반환
      return Reflect.get(target, prop, target);
    },
  });
}
```

운영에서는 잘 돌았습니다. 그런데 테스트에서 DB 실패를 흉내 내려고 이렇게 썼더니 mock 이 전혀 먹지 않았습니다.

```ts
vi.spyOn(wrapped, "query").mockRejectedValue(new Error("boom"));
// wrapped.query(...) 는 여전히 진짜 쿼리를 실행한다 (spy 미적용)
```

에러도 없이 그냥 진짜 코드가 도니, 원인을 찾기가 꽤 까다로웠습니다.

![get 트랩이 항상 고정된 래퍼를 돌려주면 심어 둔 spy는 읽히지 못하지만, 원본과 같을 때만 래퍼를 쓰도록 identity를 비교하면 spy가 그대로 드러난다.](/images/posts/js-proxy-swallows-vitest-spy/c4745561-132a-481b-b67e-6def4a435c9d.webp)

## spy 는 어디로 갔을까

`vi.spyOn(obj, "method")` 는 내부적으로 `obj.method` 를 읽어 원본을 저장한 뒤 `obj.method = spy` 로 교체합니다. Proxy 에는 `set` 트랩이 없으니 이 대입은 타깃(원본 객체)에 own 프로퍼티 `query` 를 만듭니다.

그런데 `get` 트랩은 `prop === "query"` 이면 무조건 `taggingQuery` 클로저를 반환합니다. 심어 둔 spy 는 읽힐 기회가 없죠. 프록시가 spy 를 삼킨 겁니다.

그럼 "own `query` 가 있으면 그걸 돌려주자"(`hasOwnProperty`)로 고치면 될까요? 이러면 새 함정에 빠집니다. pg 같은 클라이언트의 `query` 는 프로토타입 메서드라 인스턴스에 own `query` 가 없어서 운영은 괜찮습니다. 하지만 테스트 stub 처럼 처음부터 own `query` 를 가진 객체를 감싸면 그것마저 spy 로 오인해 태깅을 건너뜁니다. 어떤 클라이언트든 `query` 를 태깅한다는 래퍼의 본래 계약이 깨지는 거죠.

![get 트랩이 항상 taggingQuery 를 돌려주면 spy 는 읽히지 않고 진짜 쿼리가 실행되지만, 원본과 같을 때만 태깅하도록 비교하면 spy 가 그대로 노출된다.](/images/posts/js-proxy-swallows-vitest-spy/4a7fa11e-ee7b-439b-a5c6-7053e0992505.svg)

## 존재 여부가 아니라 원본과의 identity 비교

wrap 시점의 원본 `query` 를 기억해 두고, 읽는 시점의 `query` 가 그 원본과 같으면 래퍼를, 달라졌으면 그대로 노출합니다. 달라졌다는 건 wrap 이후에 누군가(`spyOn`)가 교체했다는 뜻입니다.

래퍼는 항상 wrap 시점의 원본(`real`)만 호출합니다. 그래서 spy 가 원본으로 폴백해도 재귀가 생기지 않습니다.

```ts
function wrapClient(client) {
  const originalQuery = client.query;      // wrap 시점 원본 (프로토타입 메서드 또는 stub own)
  const real = originalQuery.bind(client);
  const taggingQuery = (sql, params) => real(tag(sql), params); // 고정 원본만 호출

  return new Proxy(client, {
    get(target, prop) {
      if (prop === "query") {
        const cur = Reflect.get(target, "query", target);
        // 원본 그대로면 태깅, 교체됐으면(=spy) 노출
        return cur === originalQuery ? taggingQuery : cur;
      }
      return Reflect.get(target, prop, target);
    },
  });
}
```

운영과 stub 은 `query` 교체가 없으니 항상 `taggingQuery` 로 가서 태깅이 유지됩니다. `vi.spyOn` 은 own `query` 가 원본과 다르니 spy 가 그대로 노출되고, `mockRejectedValue` 도 제대로 먹습니다.

## 하나 더, 복원은 꼭 걸어 두기

여러 테스트 파일이 한 프로세스에서 상태를 공유하는 설정(예: Vitest `isolate: false`)에서는 주의가 필요합니다. spy 를 심고 `vi.restoreAllMocks()` 를 빠뜨리면 공유 클라이언트가 오염되어 뒤 파일이 연쇄로 깨집니다. spy 를 쓰는 테스트는 `afterEach` 에서 복원을 꼭 걸어 둡니다.

메서드에 부가 동작을 얹는 Proxy 는 "방금 쓴 값을 읽으면 그 값이 나온다"는 기본 불변식을 깨서 `spyOn` 류 mock 을 조용히 무력화합니다. **override 를 주입하는 경로(테스트 spy)를 어떻게 존중할지**는 처음 설계할 때 같이 정해 두는 게 좋겠습니다.

참고

- [MDN, Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy)
- [Vitest, vi.spyOn](https://vitest.dev/api/vi.html#vi-spyon)
