structuredClone 뒤 클래스 메서드가 사라질 때: AI 도구 캐시 복사와 복원

AI 도구의 검색 결과를 캐시에 넣으려고 structuredClone()으로 복사했는데, 꺼낸 객체에서 topTitle is not a function이 발생할 수 있다. 데이터는 남아 있어도 사용자 정의 클래스의 메서드까지 보존되는 것은 아니기 때문이다. 복사 전후의 값만 비교하면 이 차이를 놓치기 쉽다.

먼저 캐시의 용도를 정해야 한다. 결과 데이터만 보관한다면 일반 객체로 저장하고 계산 함수를 분리하면 된다. 캐시에서 꺼낸 뒤 클래스 메서드를 호출해야 한다면, 저장 형식을 검증한 다음 생성자나 복원 함수로 인스턴스를 다시 만들어야 한다.

데이터는 복사됐는데 메서드 호출만 실패한다

다음은 AI 검색 도구의 실행 결과를 애플리케이션 메모리에서 관리한다고 가정한 합성 실험이다. 실제 서비스 장애 기록이나 MCP 전송 형식에 관한 설명은 아니다. 외부 요청 없이 Node.js v24.22.1에서 실행했다. 아래 코드를 clone-check.mjs에 저장하고 node clone-check.mjs로 실행하면 된다.

import assert from 'node:assert/strict';

class SearchResult {
  constructor(items) { this.items = items; }
  topTitle() { return this.items[0]?.title ?? ''; }
}

const original = new SearchResult([
  { title: '캐시 점검', score: 0.9 }
]);
const cached = structuredClone(original);

assert.equal(cached instanceof SearchResult, false);
assert.equal(Object.getPrototypeOf(cached), Object.prototype);
assert.equal(typeof cached.topTitle, 'undefined');
assert.throws(() => cached.topTitle(), TypeError);

cached.items[0].title = '복사본 수정';
assert.equal(original.topTitle(), '캐시 점검');
console.log('클래스 메서드 소실, 중첩 데이터 분리 확인');

이 실험에서는 두 결과가 함께 나온다. 복사본의 제목을 바꿔도 원본 제목은 그대로다. 하지만 복사본은 SearchResult 인스턴스가 아니며 topTitle()도 호출할 수 없다. 깊은 복사에 성공했다는 사실만으로, 원본 객체를 쓰던 코드를 그대로 적용할 수 있다고 판단해서는 안 된다.

프로토타입 메서드와 함수 속성은 실패 방식이 다르다

위 클래스의 topTitle()은 인스턴스 자체의 속성이 아니라 프로토타입에 있다. 이 사용자 정의 클래스의 복사본은 일반 객체가 되므로 해당 메서드가 따라오지 않는다. 반면 함수가 복사 대상 속성의 값으로 들어 있으면 복사 단계에서 예외가 발생한다.

class OwnMethod {
  run = () => 'ok';
}

assert.throws(
  () => structuredClone(new OwnMethod()),
  { name: 'DataCloneError' }
);
assert.throws(
  () => structuredClone({ nested: { callback() {} } }),
  { name: 'DataCloneError' }
);

둘째 코드는 앞의 코드 아래에 이어 붙일 수 있다. 첫 사례는 복사 후 메서드를 호출할 때 TypeError, 이번 사례는 structuredClone()을 호출할 때 DataCloneError다. 오류가 난 줄부터 구분하면, 복원 문제인지 복사할 수 없는 값이 섞인 문제인지 좁힐 수 있다.

WHATWG의 직렬화 절차는 호출 가능한 값을 만나면 DataCloneError를 던지도록 규정한다. 일반 객체 경로에서는 열거 가능한 자체 속성의 값을 읽고, 역직렬화 절차에서 새 객체에 데이터 속성을 만든다. 사용자 정의 프로토타입을 그대로 복제하는 절차는 아니다.

속성의 값과 속성 설정도 따로 확인한다

메서드만 살펴서는 부족할 때가 있다. 같은 런타임에서 Object.defineProperties()로 속성을 구성해 추가 검증한 결과는 다음과 같다.

  • 열거 가능한 fixed: 7을 writable: false로 만들었지만, 복사본에서는 writable: true였다. 읽기 전용 설정을 불변성 보장으로 이어받지 않았다.
  • 열거되지 않는 자체 속성 hidden은 복사본에 없었다.
  • 열거 가능한 자체 getter는 복사 중 한 번 실행됐고, 반환값 14가 일반 데이터 속성으로 저장됐다. getter 함수 자체는 보존되지 않았다.

이 결과는 실험에 넣은 일반 객체의 속성에 관한 것이다. 모든 객체가 일반 객체로 바뀐다는 뜻은 아니다. 같은 검증에서 Date와 Map은 각각의 타입을 유지했고 순환 참조도 복사본 내부에서 유지됐다. 지원되는 내장 타입과 사용자 정의 클래스는 구분해야 한다.

캐시에 데이터만 필요하다면 저장 형식을 드러낸다

검색 결과를 화면에 보여 주거나 다음 처리 단계에 넘기는 용도라면, 캐시에 저장할 필드를 명시하는 편이 단순하다. 아래 코드는 앞 실험의 original을 사용한다. 계산은 일반 객체를 받는 함수로 분리한다.

const snapshot = structuredClone({
  version: 1,
  items: original.items
});

function topTitle(data) {
  return data.items[0]?.title ?? '';
}

assert.equal(topTitle(snapshot), '캐시 점검');

이 방식에서는 캐시 값에 topTitle()이 있어야 한다는 전제가 사라진다. 다만 items 안에 함수 등 복사할 수 없는 값이 새로 들어오면 여전히 실패한다. version을 붙였다는 것만으로 입력 검증이 되는 것도 아니다. 캐시 쓰기 경계에서 허용할 필드와 타입을 정하고 검사해야 한다.

클래스가 필요하다면 검증한 데이터로 다시 만든다

메서드가 필요한 코드라면 캐시를 읽는 경계에서 복원한다. 다음 함수는 이번 예제에 필요한 필드만 골라 옮기고, 항목 객체도 새로 만들어 캐시와 복원본이 참조를 공유하지 않게 한다.

function restoreSearchResult(data) {
  if (data?.version !== 1 || !Array.isArray(data.items)
      || !Array.from(data.items).every(item =>
        item !== null && typeof item === 'object'
        && typeof item.title === 'string'
        && Number.isFinite(item.score))) {
    throw new TypeError('Invalid search-result snapshot');
  }

  return new SearchResult(
    data.items.map(({ title, score }) => ({ title, score }))
  );
}

const restored = restoreSearchResult(snapshot);
assert.ok(restored instanceof SearchResult);
assert.equal(restored.topTitle(), '캐시 점검');
restored.items[0].title = '복원본 수정';
assert.equal(snapshot.items[0].title, '캐시 점검');

이 검증은 제목과 유한한 점수만 확인한다. 점수의 허용 범위, 항목 개수, 중복 ID 같은 서비스 규칙은 별도로 넣어야 한다. 항목에 중첩 객체를 추가한다면 그 필드의 복사·검증 방식도 다시 정해야 한다. 생성자가 외부 요청이나 파일 접근을 수행하는 설계라면, 복원만 담당하는 경로를 분리하는 편이 안전하다.

Object.setPrototypeOf()로 복사본의 프로토타입만 붙이는 수정은 일반적인 복원법으로 삼기 어렵다. 추가 실험에서는 이 조작 뒤 instanceof가 참이 됐지만, 클래스의 #count private 필드를 읽는 메서드는 TypeError를 던졌다. 프로토타입 변경만으로 생성 과정의 상태가 생기지는 않았다.

실제 코드에서는 복사 경계 앞뒤를 비교한다

진단할 때는 캐시 전체를 출력하기보다 문제가 난 값의 구조부터 확인한다. AI 도구 결과에는 민감한 내용이 들어갈 수 있으므로 제목이나 본문을 로그에 남길 필요는 없다.

  1. 오류가 structuredClone() 안에서 났는지, 복사본의 메서드를 호출할 때 났는지 확인한다.
  2. 복사 직전과 직후의 Object.getPrototypeOf(value), typeof value.topTitle, Object.hasOwn(value, "topTitle")을 비교한다.
  3. 읽기 전용 속성이나 getter에 의존했다면 Object.getOwnPropertyDescriptor()로 해당 속성의 설정도 비교한다.
  4. 중첩 데이터 수정이 원본에 전파되지 않는지와, 복원한 객체가 실제 메서드를 실행할 수 있는지를 각각 테스트한다.

참조 공유가 출발점인 문제는 정렬 뒤 원본 목록까지 바뀌는 경우의 배열·객체 참조 진단에서 이어서 볼 수 있다. 이 글의 실험은 복사로 참조를 분리한 뒤에도 호출 계약이 달라질 수 있다는 점을 다룬다.

캐시를 고친 뒤에는 “원본이 안 바뀐다”는 테스트와 “꺼낸 값을 소비자가 사용할 수 있다”는 테스트를 함께 남긴다. 데이터만 저장할지, 클래스 인스턴스로 복원할지를 코드의 경계에서 결정하면 두 조건을 따로 검증할 수 있다.

실행 및 공식 문서 확인일: 2026년 11월 8일 KST. 검증 범위: Node.js v24.22.1에서 node:assert/strict로 클래스·함수 속성·속성 설정·내장 타입·순환 참조·명시적 복원·private 필드 사례를 실행했다. 모두 통과했다. 실제 AI 서비스, 다른 런타임, 성능은 측정하지 않았다. Node의 제공 범위는 v24.22.1 공식 globals 문서에서 확인했다.


RUDA DIRECTOR · 루다(이루다)

작성자·공개 작업·편집 기준 · 문의·정정 요청

다른 글 보기 · 주제 탐색