이미지 로딩 중...

Prototype Pattern 테스트 전략 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 5. · 3 Views

Prototype Pattern 테스트 전략 완벽 가이드

Prototype Pattern을 사용하는 코드를 효과적으로 테스트하는 방법을 다룹니다. 얕은 복사와 깊은 복사 검증, 복제 성능 테스트, 불변성 보장 등 실무에서 꼭 필요한 테스트 전략을 상세히 설명합니다.


목차

  1. 얕은 복사 vs 깊은 복사 테스트 - 복제의 정확성 검증하기
  2. 복제 성능 테스트 - 대량 객체 복제 시 성능 측정하기
  3. 불변성 테스트 - 원본 객체 보호 검증하기
  4. 프로토타입 체인 테스트 - 상속 관계 검증하기
  5. 레지스트리 패턴 테스트 - 프로토타입 관리 시스템 검증하기
  6. 비동기 복제 테스트 - 지연 로딩과 비동기 초기화 검증하기
  7. 메모이제이션 테스트 - 복제 결과 캐싱으로 성능 향상하기
  8. 순환 참조 테스트 - 자기 참조 객체 복제 검증하기

1. 얕은 복사 vs 깊은 복사 테스트 - 복제의 정확성 검증하기

시작하며

여러분이 Prototype Pattern으로 객체를 복제할 때 이런 상황을 겪어본 적 있나요? 복제한 객체의 속성을 변경했는데, 원본 객체까지 함께 변경되어 버그가 발생하는 경우 말이죠.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 얕은 복사(Shallow Copy)와 깊은 복사(Deep Copy)의 차이를 이해하지 못하고 사용하면, 객체 참조로 인한 의도하지 않은 부작용이 발생할 수 있습니다.

특히 중첩된 객체나 배열을 다룰 때 이런 문제는 더욱 심각해집니다. 바로 이럴 때 필요한 것이 철저한 복사 테스트입니다.

복제된 객체가 원본과 완전히 독립적인지, 중첩된 속성까지 제대로 복사되었는지 검증하면 이런 문제를 사전에 방지할 수 있습니다.

개요

간단히 말해서, 복사 테스트는 복제된 객체가 원본 객체와 독립적으로 동작하는지 확인하는 과정입니다. 얕은 복사는 객체의 최상위 속성만 복사하고 중첩된 객체는 참조를 공유합니다.

반면 깊은 복사는 모든 중첩 레벨의 객체를 완전히 새롭게 생성합니다. 예를 들어, 사용자 프로필에 주소 객체가 포함되어 있다면, 얕은 복사로는 주소 변경 시 원본이 영향을 받지만 깊은 복사는 완전히 독립적입니다.

기존에는 복제 후 육안으로 확인했다면, 이제는 자동화된 테스트로 모든 시나리오를 검증할 수 있습니다. 테스트의 핵심 특징은 1) 참조 독립성 검증 2) 중첩 객체 복사 확인 3) 원본 불변성 보장입니다.

이러한 특징들이 복제 로직의 정확성을 보장하고 운영 환경에서의 버그를 사전에 차단합니다.

코드 예제

// 사용자 프로필 프로토타입
class UserProfile {
  constructor(name, address) {
    this.name = name;
    this.address = address; // 중첩 객체
    this.settings = { theme: 'dark', notifications: true };
  }

  // 얕은 복사 메서드
  shallowClone() {
    return Object.assign(Object.create(Object.getPrototypeOf(this)), this);
  }

  // 깊은 복사 메서드
  deepClone() {
    return JSON.parse(JSON.stringify(this));
  }
}

// 얕은 복사 테스트
test('얕은 복사는 중첩 객체의 참조를 공유한다', () => {
  const original = new UserProfile('김철수', { city: '서울', street: '강남대로' });
  const clone = original.shallowClone();

  // 최상위 속성은 독립적
  clone.name = '이영희';
  expect(original.name).toBe('김철수');

  // 중첩 객체는 참조 공유
  clone.address.city = '부산';
  expect(original.address.city).toBe('부산'); // 원본도 변경됨!
});

// 깊은 복사 테스트
test('깊은 복사는 모든 레벨에서 독립적이다', () => {
  const original = new UserProfile('김철수', { city: '서울', street: '강남대로' });
  const clone = original.deepClone();

  // 중첩 객체도 완전히 독립적
  clone.address.city = '부산';
  expect(original.address.city).toBe('서울'); // 원본 유지

  clone.settings.theme = 'light';
  expect(original.settings.theme).toBe('dark'); // 원본 유지
});

설명

이것이 하는 일: 복제된 객체가 원본 객체와 얼마나 독립적인지를 체계적으로 검증합니다. 첫 번째 테스트에서는 얕은 복사의 특성을 확인합니다.

shallowClone() 메서드는 Object.assign()을 사용하여 최상위 속성만 복사하므로, name 같은 원시 타입은 독립적이지만 address 같은 중첩 객체는 참조를 공유합니다. 이것이 바로 얕은 복사의 한계이며, 테스트를 통해 이 동작을 명확히 문서화할 수 있습니다.

두 번째 테스트는 깊은 복사의 완전한 독립성을 검증합니다. JSON.parse(JSON.stringify())를 사용하면 모든 중첩 레벨이 새로운 객체로 생성되므로, address.city나 settings.theme을 변경해도 원본에 전혀 영향을 주지 않습니다.

내부적으로 객체를 JSON 문자열로 직렬화한 후 다시 파싱하여 완전히 새로운 메모리 공간에 객체를 생성하기 때문입니다. 마지막으로, expect() 단언문을 통해 원본 객체의 값이 그대로 유지되는지 확인합니다.

이 검증 단계가 없다면 복제 로직의 정확성을 보장할 수 없으며, 운영 환경에서 예상치 못한 데이터 변경 버그가 발생할 수 있습니다. 여러분이 이 테스트를 사용하면 복제 메서드의 동작을 명확히 이해하고, 어떤 상황에서 어떤 복사 방식을 사용해야 하는지 결정할 수 있습니다.

또한 리팩토링 시에도 기존 동작이 그대로 유지되는지 자동으로 검증할 수 있어 코드 변경에 대한 자신감을 얻을 수 있습니다.

실전 팁

💡 JSON.parse(JSON.stringify())는 함수, Date, undefined를 제대로 복사하지 못합니다. 이런 타입이 포함된 경우 lodash의 cloneDeep()이나 커스텀 깊은 복사 함수를 사용하세요.

💡 복제 테스트 시 toEqual()이 아닌 toBe()를 사용하면 참조 동일성까지 검증할 수 있습니다. expect(clone).not.toBe(original)로 서로 다른 객체임을 확인하세요.

💡 중첩 레벨이 깊을수록 깊은 복사의 성능 비용이 증가합니다. 성능이 중요한 경우 필요한 레벨까지만 복사하는 부분 깊은 복사(Partial Deep Copy)를 고려하세요.

💡 순환 참조가 있는 객체는 JSON 방식으로 복사할 수 없습니다. 이런 경우를 테스트하려면 try-catch로 예외를 검증하거나 순환 참조를 처리하는 라이브러리를 사용하세요.

💡 복제 후 원본 객체를 Object.freeze()로 동결시켜 테스트하면, 혹시라도 원본이 변경되는 버그를 즉시 발견할 수 있습니다.


2. 복제 성능 테스트 - 대량 객체 복제 시 성능 측정하기

시작하며

여러분이 대규모 데이터를 다루는 애플리케이션을 개발할 때 이런 고민을 해본 적 있나요? 수천 개의 객체를 복제해야 하는데, 어떤 복제 방식이 가장 효율적인지 판단하기 어려운 상황 말이죠.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 복제 로직의 성능을 측정하지 않고 사용하면, 운영 환경에서 사용자 경험을 해치는 심각한 성능 저하를 초래할 수 있습니다.

특히 실시간 데이터 처리나 대량 배치 작업에서 이런 문제는 치명적입니다. 바로 이럴 때 필요한 것이 체계적인 성능 테스트입니다.

다양한 복제 방식의 성능을 정량적으로 측정하고 비교하면, 여러분의 상황에 가장 적합한 방식을 선택할 수 있습니다.

개요

간단히 말해서, 성능 테스트는 복제 작업에 소요되는 시간과 메모리를 측정하여 최적의 방식을 찾는 과정입니다. 복제 성능은 객체의 크기, 중첩 깊이, 복제 방식에 따라 크게 달라집니다.

예를 들어, Object.assign()은 얕은 복사에서는 매우 빠르지만, JSON 방식은 직렬화/역직렬화 비용 때문에 큰 객체에서는 느릴 수 있습니다. 반면 structuredClone()은 깊은 복사에서 뛰어난 성능을 보입니다.

기존에는 추측으로 복제 방식을 선택했다면, 이제는 실제 데이터로 성능을 측정하여 데이터 기반으로 결정할 수 있습니다. 성능 테스트의 핵심 특징은 1) 실행 시간 측정 2) 다양한 방식 비교 3) 임계값 설정입니다.

이러한 특징들이 성능 회귀를 방지하고 최적의 복제 전략을 수립하는 데 도움을 줍니다.

코드 예제

// 성능 측정 유틸리티
function measureClonePerformance(cloneFn, data, iterations = 1000) {
  const startTime = performance.now();

  for (let i = 0; i < iterations; i++) {
    cloneFn(data);
  }

  const endTime = performance.now();
  return endTime - startTime;
}

// 테스트 데이터 생성
const createTestData = (size) => {
  return Array.from({ length: size }, (_, i) => ({
    id: i,
    name: `User ${i}`,
    metadata: {
      created: new Date(),
      tags: ['tag1', 'tag2', 'tag3'],
      settings: { active: true, level: i % 10 }
    }
  }));
};

// 성능 테스트
describe('복제 성능 테스트', () => {
  const testData = createTestData(100);

  test('Object.assign 성능 측정', () => {
    const time = measureClonePerformance(
      (data) => data.map(item => Object.assign({}, item)),
      testData,
      1000
    );

    console.log(`Object.assign: ${time.toFixed(2)}ms`);
    expect(time).toBeLessThan(100); // 100ms 이내 완료
  });

  test('JSON 방식 성능 측정', () => {
    const time = measureClonePerformance(
      (data) => JSON.parse(JSON.stringify(data)),
      testData,
      1000
    );

    console.log(`JSON method: ${time.toFixed(2)}ms`);
    expect(time).toBeLessThan(500); // 500ms 이내 완료
  });

  test('structuredClone 성능 측정', () => {
    const time = measureClonePerformance(
      (data) => structuredClone(data),
      testData,
      1000
    );

    console.log(`structuredClone: ${time.toFixed(2)}ms`);
    expect(time).toBeLessThan(200); // 200ms 이내 완료
  });
});

설명

이것이 하는 일: 다양한 복제 방식의 성능을 정량적으로 측정하고 비교하여 최적의 선택을 가능하게 합니다. 첫 번째로, measureClonePerformance 함수는 복제 작업을 여러 번 반복하여 평균적인 성능을 측정합니다.

performance.now()는 고정밀 타임스탬프를 제공하므로 밀리초 단위의 정확한 측정이 가능합니다. iterations 매개변수로 반복 횟수를 조절하면, 미세한 성능 차이도 확대하여 측정할 수 있습니다.

그 다음으로, createTestData 함수가 실제 운영 환경과 유사한 복잡한 중첩 객체를 생성합니다. 단순한 평면 객체가 아닌 metadata와 같은 중첩 구조를 포함하면, 각 복제 방식의 실제 성능을 더 정확히 파악할 수 있습니다.

Date 객체와 배열을 포함시켜 다양한 데이터 타입에 대한 복제 성능도 함께 테스트합니다. 세 번째로, 각 테스트는 서로 다른 복제 방식의 성능을 측정하고 결과를 콘솔에 출력합니다.

Object.assign()은 얕은 복사에서 가장 빠르고, structuredClone()은 깊은 복사에서 JSON 방식보다 우수한 성능을 보입니다. expect().toBeLessThan()으로 성능 임계값을 설정하면, 코드 변경으로 인한 성능 저하를 CI/CD 파이프라인에서 자동으로 감지할 수 있습니다.

여러분이 이 테스트를 사용하면 추측이 아닌 데이터를 기반으로 복제 전략을 수립할 수 있습니다. 실제 운영 환경의 데이터 크기와 구조로 테스트하면, 사용자가 경험할 성능을 사전에 예측하고 최적화할 수 있습니다.

또한 라이브러리 버전 업그레이드나 리팩토링 후에도 성능이 유지되는지 자동으로 검증할 수 있습니다.

실전 팁

💡 성능 테스트는 여러 번 실행하여 평균을 내세요. 첫 실행은 JIT 컴파일이나 캐시 워밍업 때문에 느릴 수 있으므로, 최소 3회 이상 측정 후 중앙값을 사용하는 것이 정확합니다.

💡 Node.js 환경과 브라우저 환경에서 성능이 다를 수 있습니다. 각 환경에서 별도로 테스트하고, 실제 배포 환경과 동일한 조건에서 측정하세요.

💡 메모리 사용량도 함께 측정하려면 process.memoryUsage()나 performance.memory를 사용하세요. 속도는 빠르지만 메모리를 많이 사용하는 방식은 대량 작업에서 문제가 될 수 있습니다.

💡 임계값 설정 시 CI 환경의 성능을 고려하세요. CI 서버는 로컬보다 느릴 수 있으므로 여유를 두어 false positive를 방지하세요.

💡 프로파일러를 함께 사용하면 병목 지점을 정확히 파악할 수 있습니다. Chrome DevTools의 Performance 탭이나 Node.js의 --prof 플래그를 활용하세요.


3. 불변성 테스트 - 원본 객체 보호 검증하기

시작하며

여러분이 객체를 복제한 후 원본 데이터가 의도치 않게 변경되어 버그를 찾느라 몇 시간을 허비한 경험이 있나요? 특히 여러 개발자가 협업하는 프로젝트에서 이런 문제는 더욱 찾기 어렵습니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 불변성(Immutability)이 보장되지 않으면 데이터의 예측 가능성이 떨어지고, 디버깅이 매우 어려워집니다.

특히 Redux나 React 같은 상태 관리 라이브러리를 사용할 때 불변성은 필수적입니다. 바로 이럴 때 필요한 것이 철저한 불변성 테스트입니다.

복제 후 원본 객체가 절대 변경되지 않는다는 것을 자동화된 테스트로 보장하면, 데이터 무결성을 유지하고 예측 가능한 코드를 작성할 수 있습니다.

개요

간단히 말해서, 불변성 테스트는 복제 작업 후에도 원본 객체가 그대로 유지되는지 확인하는 과정입니다. 불변성은 함수형 프로그래밍의 핵심 원칙 중 하나로, 데이터가 생성된 후 변경되지 않음을 보장합니다.

예를 들어, 사용자 목록을 복제하여 필터링 작업을 수행할 때, 원본 목록이 그대로 유지되어야 다른 컴포넌트에서도 안전하게 사용할 수 있습니다. 기존에는 복제 후 원본을 확인하는 것을 잊어버렸다면, 이제는 테스트가 자동으로 불변성을 검증하여 실수를 방지할 수 있습니다.

불변성 테스트의 핵심 특징은 1) Object.freeze() 활용 2) 변경 시도 감지 3) 스냅샷 비교입니다. 이러한 특징들이 데이터 무결성을 보장하고 예측 가능한 애플리케이션을 만드는 데 기여합니다.

코드 예제

// 불변 객체 프로토타입
class ImmutableProduct {
  constructor(id, name, price, inventory) {
    this.id = id;
    this.name = name;
    this.price = price;
    this.inventory = { ...inventory }; // 중첩 객체 복사
  }

  clone() {
    return new ImmutableProduct(
      this.id,
      this.name,
      this.price,
      { ...this.inventory }
    );
  }

  updatePrice(newPrice) {
    const cloned = this.clone();
    cloned.price = newPrice;
    return cloned; // 원본 대신 복사본 반환
  }
}

// 불변성 테스트
describe('불변성 보장 테스트', () => {
  test('복제 후 원본이 변경되지 않는다', () => {
    const original = new ImmutableProduct(1, '노트북', 1000000, {
      stock: 10,
      warehouse: 'Seoul'
    });

    // 원본을 동결하여 변경 시도를 즉시 감지
    Object.freeze(original);
    Object.freeze(original.inventory);

    const clone = original.clone();
    clone.price = 900000;
    clone.inventory.stock = 5;

    // 원본이 그대로 유지되는지 검증
    expect(original.price).toBe(1000000);
    expect(original.inventory.stock).toBe(10);
  });

  test('updatePrice는 새 객체를 반환한다', () => {
    const original = new ImmutableProduct(1, '노트북', 1000000, { stock: 10 });
    const originalSnapshot = { ...original, inventory: { ...original.inventory } };

    const updated = original.updatePrice(900000);

    // 서로 다른 객체임을 확인
    expect(updated).not.toBe(original);
    expect(updated.price).toBe(900000);

    // 원본이 변경되지 않았는지 스냅샷과 비교
    expect(original).toEqual(originalSnapshot);
  });

  test('동결된 객체 수정 시 에러 발생', () => {
    const frozen = new ImmutableProduct(1, '노트북', 1000000, { stock: 10 });
    Object.freeze(frozen);

    expect(() => {
      frozen.price = 500000; // strict mode에서 에러
    }).toThrow();
  });
});

설명

이것이 하는 일: 복제 및 수정 작업 후에도 원본 객체가 절대 변경되지 않는다는 것을 보장합니다. 첫 번째 테스트에서는 Object.freeze()를 사용하여 원본 객체를 동결합니다.

동결된 객체는 수정이 불가능하므로, 만약 코드에서 실수로 원본을 변경하려고 시도하면 strict mode에서 에러가 발생하여 즉시 문제를 발견할 수 있습니다. 중첩 객체인 inventory도 함께 동결하여 모든 레벨에서 불변성을 보장합니다.

두 번째 테스트는 데이터를 수정하는 메서드가 원본 대신 새 객체를 반환하는지 확인합니다. updatePrice() 메서드는 내부적으로 clone()을 호출하여 복사본을 만든 후 가격을 변경합니다.

expect(updated).not.toBe(original)로 서로 다른 메모리 주소를 가진 객체임을 검증하고, toEqual()로 원본 값이 스냅샷과 동일하게 유지되는지 확인합니다. 세 번째 테스트는 실제로 동결된 객체를 수정하려 할 때 에러가 발생하는지 검증합니다.

이는 개발 중에 불변성 위반을 즉시 감지할 수 있게 해주는 안전장치입니다. toThrow() 매처를 사용하여 예외 발생을 명시적으로 테스트합니다.

여러분이 이 테스트를 사용하면 데이터 변경에 대한 명확한 계약을 코드에 명시할 수 있습니다. 다른 개발자가 코드를 수정하거나 리팩토링할 때도 불변성이 깨지지 않았는지 자동으로 검증되므로, 팀 전체의 코드 품질이 향상됩니다.

특히 React나 Redux 같은 프레임워크에서 불변성은 성능 최적화와 직결되므로, 이런 테스트는 필수적입니다.

실전 팁

💡 Object.freeze()는 얕은 동결만 수행합니다. 중첩 객체까지 완전히 동결하려면 재귀적으로 모든 속성을 동결하는 deepFreeze() 함수를 구현하세요.

💡 프로덕션 코드에서는 Object.freeze()의 성능 오버헤드를 고려하세요. 개발 환경에서만 동결을 활성화하고, 프로덕션에서는 비활성화하는 조건부 동결을 사용할 수 있습니다.

💡 Immer 같은 불변성 라이브러리를 사용하면 더 쉽게 불변 업데이트를 구현할 수 있습니다. produce() 함수로 가변적인 코드를 작성하면 자동으로 불변 업데이트가 수행됩니다.

💡 toMatchSnapshot()을 사용하면 복잡한 객체의 불변성을 더 쉽게 테스트할 수 있습니다. 초기 상태를 스냅샷으로 저장하고 작업 후에도 동일한지 비교하세요.

💡 TypeScript의 Readonly<T>와 as const를 활용하면 컴파일 타임에 불변성을 강제할 수 있어, 런타임 테스트와 함께 사용하면 더 강력한 보장을 얻을 수 있습니다.


4. 프로토타입 체인 테스트 - 상속 관계 검증하기

시작하며

여러분이 복잡한 클래스 계층 구조에서 객체를 복제할 때 이런 문제를 겪어본 적 있나요? 복제된 객체가 원본의 메서드를 사용할 수 없거나, 프로토타입 체인이 깨져서 instanceof 체크가 실패하는 경우 말이죠.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 단순히 속성만 복사하는 방식은 프로토타입 체인을 유지하지 못하므로, 객체의 타입 정보와 메서드가 손실됩니다.

특히 복잡한 도메인 모델이나 OOP 패턴을 사용하는 경우 이런 문제는 치명적입니다. 바로 이럴 때 필요한 것이 프로토타입 체인 테스트입니다.

복제된 객체가 원본과 동일한 프로토타입 체인을 유지하는지 검증하면, 타입 안전성과 메서드 접근성을 보장할 수 있습니다.

개요

간단히 말해서, 프로토타입 체인 테스트는 복제된 객체가 원본과 동일한 프로토타입을 가지며, 상속받은 메서드를 정상적으로 사용할 수 있는지 확인하는 과정입니다. JavaScript의 프로토타입 기반 상속에서는 객체가 다른 객체의 속성과 메서드를 상속받습니다.

예를 들어, Animal 클래스를 상속받은 Dog 객체를 복제할 때, 복사본도 Animal의 메서드에 접근할 수 있어야 합니다. JSON.parse(JSON.stringify())는 이런 프로토타입 정보를 보존하지 못합니다.

기존에는 복제 후 메서드가 없어졌다는 것을 런타임에 발견했다면, 이제는 테스트로 프로토타입 체인이 올바르게 유지되는지 사전에 검증할 수 있습니다. 프로토타입 체인 테스트의 핵심 특징은 1) instanceof 검증 2) 메서드 접근 가능성 확인 3) 프로토타입 동일성 비교입니다.

이러한 특징들이 객체 지향 설계의 무결성을 보장하고 타입 안전성을 유지합니다.

코드 예제

// 기본 도형 클래스
class Shape {
  constructor(color) {
    this.color = color;
  }

  getInfo() {
    return `Shape with color: ${this.color}`;
  }

  clone() {
    // 프로토타입을 유지하는 복제
    return Object.assign(Object.create(Object.getPrototypeOf(this)), this);
  }
}

// 원 클래스 (Shape 상속)
class Circle extends Shape {
  constructor(color, radius) {
    super(color);
    this.radius = radius;
  }

  getArea() {
    return Math.PI * this.radius ** 2;
  }

  getInfo() {
    return `Circle with color: ${this.color}, radius: ${this.radius}`;
  }
}

// 프로토타입 체인 테스트
describe('프로토타입 체인 테스트', () => {
  test('복제된 객체가 올바른 프로토타입을 유지한다', () => {
    const original = new Circle('red', 5);
    const clone = original.clone();

    // instanceof로 타입 확인
    expect(clone instanceof Circle).toBe(true);
    expect(clone instanceof Shape).toBe(true);

    // 프로토타입 동일성 확인
    expect(Object.getPrototypeOf(clone)).toBe(Object.getPrototypeOf(original));
  });

  test('복제된 객체가 상속받은 메서드를 사용할 수 있다', () => {
    const original = new Circle('blue', 10);
    const clone = original.clone();

    // 자신의 메서드 호출
    expect(clone.getArea()).toBeCloseTo(314.159, 2);

    // 부모 클래스의 메서드 호출
    expect(clone.getInfo()).toBe('Circle with color: blue, radius: 10');

    // 메서드가 함수임을 확인
    expect(typeof clone.getArea).toBe('function');
    expect(typeof clone.getInfo).toBe('function');
  });

  test('JSON 방식은 프로토타입을 잃는다', () => {
    const original = new Circle('green', 7);
    const jsonClone = JSON.parse(JSON.stringify(original));

    // 타입 정보 손실
    expect(jsonClone instanceof Circle).toBe(false);
    expect(jsonClone instanceof Shape).toBe(false);

    // 메서드 손실
    expect(jsonClone.getArea).toBeUndefined();
    expect(jsonClone.getInfo).toBeUndefined();

    // 단순 객체로 변환됨
    expect(Object.getPrototypeOf(jsonClone)).toBe(Object.prototype);
  });
});

설명

이것이 하는 일: 복제된 객체가 원본의 프로토타입 체인을 그대로 유지하여 타입 정보와 메서드를 보존하는지 검증합니다. 첫 번째 테스트에서는 instanceof 연산자로 복제된 객체의 타입을 확인합니다.

Circle의 인스턴스를 복제하면, 복사본도 Circle이면서 동시에 부모 클래스인 Shape의 인스턴스여야 합니다. Object.getPrototypeOf()로 프로토타입 객체 자체를 비교하면, 두 객체가 정확히 동일한 프로토타입을 공유하는지 확인할 수 있습니다.

이는 clone() 메서드가 Object.create(Object.getPrototypeOf(this))를 사용하여 프로토타입을 명시적으로 복사했기 때문입니다. 두 번째 테스트는 복제된 객체가 실제로 메서드를 호출할 수 있는지 검증합니다.

getArea()는 Circle 클래스에 정의된 메서드이고, getInfo()는 Shape에서 상속받아 Circle에서 오버라이드한 메서드입니다. 복사본이 이 모든 메서드를 정상적으로 사용할 수 있다면, 프로토타입 체인이 올바르게 유지된 것입니다.

typeof 체크로 메서드가 실제로 함수로 존재하는지도 확인합니다. 세 번째 테스트는 잘못된 복제 방식의 문제를 명시적으로 보여줍니다.

JSON.parse(JSON.stringify())를 사용하면 모든 프로토타입 정보가 손실되고 단순한 평면 객체(Plain Object)로 변환됩니다. instanceof가 false를 반환하고 메서드가 undefined가 되는 것을 테스트로 확인하여, 이 방식이 프로토타입 기반 객체에는 부적합함을 증명합니다.

여러분이 이 테스트를 사용하면 객체 지향 설계의 무결성을 보장할 수 있습니다. 복잡한 클래스 계층 구조에서도 복제가 안전하게 작동하는지 자동으로 검증되므로, 리팩토링이나 복제 로직 변경 시 타입 안전성이 깨지지 않았는지 확인할 수 있습니다.

특히 도메인 모델이나 비즈니스 로직이 복잡한 엔터프라이즈 애플리케이션에서 이런 테스트는 필수적입니다.

실전 팁

💡 Object.create()와 Object.assign()을 함께 사용하면 프로토타입과 속성을 모두 복사할 수 있습니다. Object.create(proto)로 프로토타입을 설정하고 Object.assign()으로 속성을 복사하세요.

💡 TypeScript를 사용한다면 제네릭을 활용한 타입 안전 복제 메서드를 구현하세요. clone<T extends this>(): T로 정의하면 복제된 객체의 타입이 정확히 유지됩니다.

💡 프로토타입 체인이 깊은 경우, 모든 레벨의 프로토타입이 올바른지 재귀적으로 확인하는 헬퍼 함수를 만들어 사용하세요.

💡 structuredClone()은 프로토타입을 보존하지 못합니다. 클래스 인스턴스를 복제할 때는 커스텀 clone() 메서드를 구현하는 것이 가장 안전합니다.

💡 Reflect.getPrototypeOf()와 Reflect.setPrototypeOf()를 사용하면 더 명시적이고 에러 처리가 용이한 프로토타입 조작이 가능합니다.


5. 레지스트리 패턴 테스트 - 프로토타입 관리 시스템 검증하기

시작하며

여러분이 여러 종류의 프로토타입 객체를 관리하고 필요할 때마다 복제해서 사용하는 시스템을 구축할 때 이런 고민을 해본 적 있나요? 어떤 프로토타입이 등록되어 있는지 추적하기 어렵고, 잘못된 키로 접근하면 에러가 발생하는 상황 말이죠.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 프로토타입을 개별적으로 관리하면 코드가 분산되고 일관성이 떨어집니다.

특히 게임 개발이나 UI 컴포넌트 라이브러리처럼 다양한 타입의 객체를 동적으로 생성해야 하는 경우, 중앙화된 관리 시스템이 필요합니다. 바로 이럴 때 필요한 것이 레지스트리 패턴입니다.

모든 프로토타입을 중앙에서 관리하고, 이름으로 쉽게 복제할 수 있는 시스템을 구축하면 코드의 유지보수성과 확장성이 크게 향상됩니다.

개요

간단히 말해서, 레지스트리 패턴은 프로토타입 객체를 중앙 저장소에 등록하고 관리하는 디자인 패턴입니다. 이 패턴을 사용하면 팩토리 패턴과 프로토타입 패턴을 결합한 강력한 객체 생성 메커니즘을 구축할 수 있습니다.

예를 들어, UI 컴포넌트 라이브러리에서 Button, Input, Select 등의 프로토타입을 등록해두고, 필요할 때 registry.clone('Button')처럼 간단히 복제할 수 있습니다. 기존에는 각 프로토타입을 직접 import하고 new로 생성했다면, 이제는 문자열 키만으로 동적으로 객체를 생성할 수 있습니다.

레지스트리 패턴 테스트의 핵심 특징은 1) 등록/조회 기능 검증 2) 에러 처리 확인 3) 복제 무결성 보장입니다. 이러한 특징들이 안정적이고 확장 가능한 프로토타입 관리 시스템을 만드는 데 기여합니다.

코드 예제

// 프로토타입 레지스트리
class PrototypeRegistry {
  constructor() {
    this.prototypes = new Map();
  }

  // 프로토타입 등록
  register(name, prototype) {
    if (!prototype.clone || typeof prototype.clone !== 'function') {
      throw new Error(`${name} must have a clone() method`);
    }
    this.prototypes.set(name, prototype);
  }

  // 프로토타입 복제
  clone(name, overrides = {}) {
    const prototype = this.prototypes.get(name);
    if (!prototype) {
      throw new Error(`Prototype "${name}" not found`);
    }

    const cloned = prototype.clone();
    return Object.assign(cloned, overrides);
  }

  // 등록된 프로토타입 목록
  list() {
    return Array.from(this.prototypes.keys());
  }

  // 프로토타입 존재 확인
  has(name) {
    return this.prototypes.has(name);
  }
}

// 예제 프로토타입 클래스
class Widget {
  constructor(type, config) {
    this.type = type;
    this.config = config;
  }

  clone() {
    return new Widget(this.type, { ...this.config });
  }
}

// 레지스트리 테스트
describe('프로토타입 레지스트리 테스트', () => {
  let registry;

  beforeEach(() => {
    registry = new PrototypeRegistry();
  });

  test('프로토타입을 등록하고 복제할 수 있다', () => {
    const buttonPrototype = new Widget('button', { color: 'blue', size: 'medium' });
    registry.register('Button', buttonPrototype);

    const button1 = registry.clone('Button');
    const button2 = registry.clone('Button', { color: 'red' });

    expect(button1.config.color).toBe('blue');
    expect(button2.config.color).toBe('red');
    expect(button1).not.toBe(button2); // 서로 다른 인스턴스
  });

  test('등록되지 않은 프로토타입 접근 시 에러 발생', () => {
    expect(() => {
      registry.clone('NonExistent');
    }).toThrow('Prototype "NonExistent" not found');
  });

  test('clone 메서드 없는 객체 등록 시 에러 발생', () => {
    const invalidPrototype = { name: 'invalid' };

    expect(() => {
      registry.register('Invalid', invalidPrototype);
    }).toThrow('Invalid must have a clone() method');
  });

  test('등록된 프로토타입 목록 조회', () => {
    registry.register('Button', new Widget('button', {}));
    registry.register('Input', new Widget('input', {}));

    const list = registry.list();
    expect(list).toContain('Button');
    expect(list).toContain('Input');
    expect(list.length).toBe(2);
  });

  test('프로토타입 존재 여부 확인', () => {
    registry.register('Button', new Widget('button', {}));

    expect(registry.has('Button')).toBe(true);
    expect(registry.has('Input')).toBe(false);
  });
});

설명

이것이 하는 일: 프로토타입 객체를 중앙 저장소에 등록하고, 이름으로 검색하여 복제하는 시스템의 모든 기능을 검증합니다. 첫 번째로, register() 메서드는 프로토타입 객체를 Map에 저장하면서 clone() 메서드의 존재 여부를 검증합니다.

이 검증 단계가 없다면 나중에 clone()을 호출할 때 예상치 못한 에러가 발생할 수 있으므로, 등록 시점에 미리 차단하는 것이 중요합니다. Map을 사용하면 O(1) 시간 복잡도로 빠른 조회가 가능합니다.

두 번째로, clone() 메서드는 등록된 프로토타입을 조회하고 복제한 후, overrides 매개변수로 전달된 속성을 덮어씁니다. 이 기능을 통해 기본 프로토타입을 유지하면서 특정 인스턴스만 커스터마이징할 수 있습니다.

예를 들어, 파란색 버튼 프로토타입에서 빨간색 버튼을 간단히 만들 수 있습니다. 존재하지 않는 프로토타입에 접근하면 명확한 에러 메시지를 던져 디버깅을 쉽게 만듭니다.

세 번째로, list()와 has() 같은 유틸리티 메서드는 레지스트리의 현재 상태를 조회할 수 있게 해줍니다. 개발 도구나 디버깅 화면에서 어떤 프로토타입이 등록되어 있는지 확인할 때 유용하며, has()는 조건부 로직에서 프로토타입 존재 여부를 미리 확인할 때 사용됩니다.

각 테스트는 정상 동작뿐만 아니라 에러 상황도 철저히 검증합니다. 등록되지 않은 프로토타입 접근, 유효하지 않은 객체 등록 등의 예외 상황을 toThrow()로 테스트하여, 시스템이 예측 가능하게 동작하고 명확한 에러 메시지를 제공하는지 확인합니다.

여러분이 이 패턴을 사용하면 객체 생성 로직을 중앙화하여 코드 중복을 제거하고 유지보수성을 크게 향상시킬 수 있습니다. 새로운 프로토타입을 추가할 때도 레지스트리에 등록만 하면 되므로 확장이 매우 쉬우며, 설정 파일이나 데이터베이스에서 프로토타입 목록을 동적으로 로드하는 것도 가능합니다.

게임 개발에서 몬스터 타입, UI 라이브러리에서 컴포넌트 타입, 또는 문서 편집기에서 블록 타입 등을 관리할 때 특히 유용합니다.

실전 팁

💡 레지스트리를 싱글톤으로 구현하면 애플리케이션 전체에서 하나의 중앙 저장소를 공유할 수 있습니다. 하지만 테스트 격리를 위해 각 테스트마다 새 인스턴스를 생성하는 것이 좋습니다.

💡 프로토타입 이름에 네임스페이스를 사용하세요. 'ui.Button', 'ui.Input'처럼 구조화하면 충돌을 방지하고 조직화된 관리가 가능합니다.

💡 프로토타입 등록 시 메타데이터(설명, 버전, 태그 등)도 함께 저장하면, 동적 UI 생성이나 문서 자동 생성에 활용할 수 있습니다.

💡 복제 횟수나 사용 빈도를 추적하는 메트릭을 추가하면, 어떤 프로토타입이 많이 사용되는지 분석하여 최적화할 수 있습니다.

💡 TypeScript를 사용한다면 제네릭으로 타입 안전한 레지스트리를 구현하세요. register<T>()와 clone<T>()로 정의하면 컴파일 타임에 타입 체크가 가능합니다.


6. 비동기 복제 테스트 - 지연 로딩과 비동기 초기화 검증하기

시작하며

여러분이 대용량 데이터나 외부 리소스를 포함한 객체를 복제할 때 이런 어려움을 겪어본 적 있나요? 이미지나 파일 같은 외부 리소스를 로드하는 동안 애플리케이션이 멈추거나, 비동기 초기화가 완료되지 않은 채 복제가 실행되어 불완전한 객체가 생성되는 경우 말이죠.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 동기적 복제만 가능한 시스템은 무거운 리소스를 다룰 때 성능 문제를 일으키고, 사용자 경험을 해칩니다.

특히 SPA나 모바일 앱처럼 반응성이 중요한 환경에서는 비동기 처리가 필수적입니다. 바로 이럴 때 필요한 것이 비동기 복제 패턴입니다.

Promise를 활용하여 리소스 로딩과 초기화를 비동기로 처리하고, 완전히 준비된 객체만 반환하도록 보장하면 안정적이고 빠른 애플리케이션을 만들 수 있습니다.

개요

간단히 말해서, 비동기 복제는 객체 복제 과정에서 외부 리소스 로딩이나 시간이 걸리는 초기화 작업을 비동기로 처리하는 패턴입니다. 동기적 복제는 모든 작업이 완료될 때까지 스레드를 블로킹하지만, 비동기 복제는 await/async나 Promise를 사용하여 논블로킹 방식으로 동작합니다.

예를 들어, 사용자 아바타 이미지를 포함한 프로필 객체를 복제할 때, 이미지 다운로드가 완료될 때까지 기다리되 다른 작업은 계속 진행할 수 있습니다. 기존에는 모든 리소스가 로드될 때까지 UI가 멈췄다면, 이제는 로딩 상태를 보여주면서 백그라운드에서 복제를 진행할 수 있습니다.

비동기 복제 테스트의 핵심 특징은 1) Promise 반환 검증 2) 완료 시점 확인 3) 에러 처리 검증입니다. 이러한 특징들이 복잡한 비동기 로직의 정확성을 보장하고 경쟁 조건(Race Condition)을 방지합니다.

코드 예제

// 비동기 리소스를 가진 프로토타입
class ImageDocument {
  constructor(title, imageUrl) {
    this.title = title;
    this.imageUrl = imageUrl;
    this.imageData = null;
    this.loaded = false;
  }

  // 비동기 초기화
  async initialize() {
    // 이미지 로딩 시뮬레이션
    this.imageData = await this.loadImage(this.imageUrl);
    this.loaded = true;
    return this;
  }

  async loadImage(url) {
    // 실제로는 fetch()나 다른 API 사용
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(`ImageData from ${url}`);
      }, 100);
    });
  }

  // 비동기 복제
  async cloneAsync() {
    const cloned = new ImageDocument(this.title, this.imageUrl);
    await cloned.initialize();
    return cloned;
  }

  // 동기 복제 (초기화 없음)
  cloneSync() {
    return new ImageDocument(this.title, this.imageUrl);
  }
}

// 비동기 복제 테스트
describe('비동기 복제 테스트', () => {
  test('비동기 복제는 완전히 초기화된 객체를 반환한다', async () => {
    const original = new ImageDocument('Profile', 'https://example.com/avatar.jpg');
    await original.initialize();

    const clone = await original.cloneAsync();

    // 복제가 완전히 초기화되었는지 확인
    expect(clone.loaded).toBe(true);
    expect(clone.imageData).toBe('ImageData from https://example.com/avatar.jpg');
    expect(clone.title).toBe('Profile');
  });

  test('동기 복제는 초기화되지 않은 객체를 반환한다', () => {
    const original = new ImageDocument('Profile', 'https://example.com/avatar.jpg');
    const clone = original.cloneSync();

    // 초기화되지 않은 상태
    expect(clone.loaded).toBe(false);
    expect(clone.imageData).toBe(null);
  });

  test('여러 객체를 병렬로 복제할 수 있다', async () => {
    const docs = [
      new ImageDocument('Doc1', 'https://example.com/1.jpg'),
      new ImageDocument('Doc2', 'https://example.com/2.jpg'),
      new ImageDocument('Doc3', 'https://example.com/3.jpg')
    ];

    // 병렬 초기화
    await Promise.all(docs.map(doc => doc.initialize()));

    // 병렬 복제
    const startTime = performance.now();
    const clones = await Promise.all(docs.map(doc => doc.cloneAsync()));
    const duration = performance.now() - startTime;

    // 모든 복제본이 완전히 초기화됨
    clones.forEach(clone => {
      expect(clone.loaded).toBe(true);
      expect(clone.imageData).toBeTruthy();
    });

    // 병렬 처리로 시간 단축 (순차 처리보다 빠름)
    expect(duration).toBeLessThan(500); // 각각 100ms지만 병렬로 처리
  });

  test('복제 중 에러 발생 시 적절히 처리한다', async () => {
    const failingDoc = new ImageDocument('Fail', 'invalid-url');
    failingDoc.loadImage = async () => {
      throw new Error('Network error');
    };

    await expect(failingDoc.cloneAsync()).rejects.toThrow('Network error');
  });
});

설명

이것이 하는 일: 비동기 작업이 필요한 객체 복제를 안전하고 효율적으로 수행하며, 모든 리소스가 준비된 후에만 복제를 완료합니다. 첫 번째로, initialize() 메서드는 async/await 패턴을 사용하여 이미지 로딩 같은 비동기 작업을 수행합니다.

loadImage()가 Promise를 반환하므로 await 키워드로 완료를 기다릴 수 있으며, 완료 후 loaded 플래그를 true로 설정하여 초기화 상태를 추적합니다. 이 패턴은 복제본이 사용 가능한 상태인지 명확히 구분할 수 있게 해줍니다.

두 번째로, cloneAsync()와 cloneSync()의 차이를 명확히 보여줍니다. 비동기 복제는 새 인스턴스를 생성한 후 즉시 initialize()를 호출하여 완전히 준비된 객체를 반환하지만, 동기 복제는 빈 객체를 즉시 반환하므로 사용 전에 별도로 초기화해야 합니다.

테스트에서 loaded와 imageData를 확인하여 이 차이를 검증합니다. 세 번째로, Promise.all()을 활용한 병렬 복제 테스트는 성능 최적화의 중요성을 보여줍니다.

세 개의 문서를 순차적으로 복제하면 300ms가 걸리지만, 병렬로 처리하면 100ms 정도로 단축됩니다. performance.now()로 실행 시간을 측정하고 toBeLessThan()으로 임계값을 검증하여, 병렬 처리가 실제로 성능 향상을 가져오는지 확인합니다.

마지막으로, 에러 처리 테스트는 네트워크 실패나 리소스 부재 같은 예외 상황을 검증합니다. rejects.toThrow()를 사용하면 async 함수가 던지는 에러를 우아하게 테스트할 수 있으며, 실제 애플리케이션에서는 try-catch나 .catch()로 이런 에러를 처리하여 사용자에게 적절한 피드백을 제공해야 합니다.

여러분이 이 패턴을 사용하면 대용량 데이터나 외부 API 의존성이 있는 객체도 안전하게 복제할 수 있습니다. 로딩 상태를 UI에 표시하고, 백그라운드에서 복제를 진행하며, 완료 후 자동으로 업데이트하는 매끄러운 사용자 경험을 제공할 수 있습니다.

특히 이미지 편집기, 문서 관리 시스템, 또는 데이터 시각화 도구처럼 리소스가 무거운 애플리케이션에서 필수적인 패턴입니다.

실전 팁

💡 초기화 상태를 enum이나 상수로 관리하세요. UNINITIALIZED, LOADING, LOADED, ERROR로 구분하면 더 명확한 상태 관리가 가능합니다.

💡 Promise.allSettled()를 사용하면 일부 복제가 실패해도 성공한 것들은 받을 수 있습니다. Promise.all()은 하나라도 실패하면 전체가 거부되므로, 부분 성공이 허용되는 경우 allSettled()가 적합합니다.

💡 AbortController를 활용하여 복제 작업을 중단할 수 있게 만드세요. 사용자가 작업을 취소하거나 컴포넌트가 언마운트될 때 진행 중인 복제를 정리할 수 있습니다.

💡 대용량 데이터는 청크 단위로 나누어 복제하고 progress 이벤트를 발생시켜, 진행률 바를 표시할 수 있게 하세요.

💡 복제 작업에 타임아웃을 설정하세요. Promise.race()로 복제 Promise와 타임아웃 Promise를 경쟁시켜, 일정 시간 내에 완료되지 않으면 에러를 던지도록 할 수 있습니다.


7. 메모이제이션 테스트 - 복제 결과 캐싱으로 성능 향상하기

시작하며

여러분이 동일한 객체를 반복적으로 복제해야 하는 상황에서 매번 같은 계산을 반복하느라 성능이 저하되는 경험을 해본 적 있나요? 특히 복제 비용이 큰 객체를 여러 곳에서 동시에 복제할 때 불필요한 중복 작업이 발생하는 경우 말이죠.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 무거운 계산이나 리소스 로딩을 포함한 복제를 캐싱 없이 반복하면 CPU와 메모리를 낭비하고, 사용자 경험을 해칩니다.

특히 실시간 렌더링이나 대량 데이터 처리에서 이런 비효율은 치명적입니다. 바로 이럴 때 필요한 것이 메모이제이션(Memoization) 패턴입니다.

복제 결과를 캐시에 저장하고, 동일한 요청이 오면 캐시에서 즉시 반환하여 성능을 극적으로 향상시킬 수 있습니다.

개요

간단히 말해서, 메모이제이션은 함수의 결과를 캐시에 저장하여 동일한 입력에 대해 재계산을 피하는 최적화 기법입니다. 프로토타입 복제에 메모이제이션을 적용하면, 동일한 파라미터로 복제를 요청할 때 이전 결과를 재사용할 수 있습니다.

예를 들어, 특정 설정으로 UI 컴포넌트를 복제할 때, 같은 설정이면 캐시된 인스턴스를 반환하여 생성 비용을 절약할 수 있습니다. 단, 캐시된 객체는 불변이거나 방어적 복사를 해야 합니다.

기존에는 매번 새로 복제했다면, 이제는 캐시 히트율을 높여 성능을 몇 배 향상시킬 수 있습니다. 메모이제이션 테스트의 핵심 특징은 1) 캐시 히트/미스 검증 2) 캐시 무효화 확인 3) 메모리 누수 방지입니다.

이러한 특징들이 성능을 향상시키면서도 정확성과 안정성을 유지합니다.

코드 예제

// 메모이제이션을 지원하는 프로토타입 팩토리
class MemoizedPrototypeFactory {
  constructor() {
    this.cache = new Map();
    this.stats = { hits: 0, misses: 0 };
  }

  // 캐시 키 생성
  generateKey(type, config) {
    return `${type}:${JSON.stringify(config)}`;
  }

  // 메모이제이션된 복제
  clone(type, config, prototype) {
    const key = this.generateKey(type, config);

    // 캐시 확인
    if (this.cache.has(key)) {
      this.stats.hits++;
      // 캐시된 객체의 복사본 반환 (원본 보호)
      return this.deepClone(this.cache.get(key));
    }

    // 캐시 미스 - 새로 생성
    this.stats.misses++;
    const cloned = prototype.clone();
    Object.assign(cloned, config);

    // 캐시에 저장
    this.cache.set(key, this.deepClone(cloned));
    return cloned;
  }

  deepClone(obj) {
    return JSON.parse(JSON.stringify(obj));
  }

  // 캐시 무효화
  invalidate(type, config = null) {
    if (config) {
      const key = this.generateKey(type, config);
      return this.cache.delete(key);
    } else {
      // 특정 타입의 모든 캐시 삭제
      let deleted = 0;
      for (const key of this.cache.keys()) {
        if (key.startsWith(`${type}:`)) {
          this.cache.delete(key);
          deleted++;
        }
      }
      return deleted;
    }
  }

  // 캐시 통계
  getStats() {
    return {
      ...this.stats,
      total: this.stats.hits + this.stats.misses,
      hitRate: this.stats.hits / (this.stats.hits + this.stats.misses) || 0
    };
  }

  clear() {
    this.cache.clear();
    this.stats = { hits: 0, misses: 0 };
  }
}

// 테스트
describe('메모이제이션 테스트', () => {
  let factory;
  const buttonPrototype = { type: 'button', clone: function() { return { ...this }; } };

  beforeEach(() => {
    factory = new MemoizedPrototypeFactory();
  });

  test('동일한 설정으로 복제 시 캐시를 사용한다', () => {
    const config = { color: 'blue', size: 'medium' };

    const first = factory.clone('button', config, buttonPrototype);
    const second = factory.clone('button', config, buttonPrototype);

    const stats = factory.getStats();
    expect(stats.hits).toBe(1); // 두 번째 호출이 캐시 히트
    expect(stats.misses).toBe(1); // 첫 번째 호출만 미스
    expect(stats.hitRate).toBe(0.5); // 50% 히트율
  });

  test('다른 설정으로 복제 시 새로 생성한다', () => {
    const config1 = { color: 'blue' };
    const config2 = { color: 'red' };

    factory.clone('button', config1, buttonPrototype);
    factory.clone('button', config2, buttonPrototype);

    const stats = factory.getStats();
    expect(stats.hits).toBe(0); // 모두 다른 설정이므로 히트 없음
    expect(stats.misses).toBe(2);
  });

  test('캐시 무효화가 정상 작동한다', () => {
    const config = { color: 'blue' };

    factory.clone('button', config, buttonPrototype);
    expect(factory.cache.size).toBe(1);

    // 특정 캐시 삭제
    factory.invalidate('button', config);
    expect(factory.cache.size).toBe(0);

    // 다시 생성하면 캐시 미스
    factory.clone('button', config, buttonPrototype);
    expect(factory.getStats().misses).toBe(2);
  });

  test('캐시된 객체 수정이 원본에 영향을 주지 않는다', () => {
    const config = { color: 'blue' };

    const first = factory.clone('button', config, buttonPrototype);
    first.color = 'red'; // 수정

    const second = factory.clone('button', config, buttonPrototype);
    expect(second.color).toBe('blue'); // 캐시된 원본은 보호됨
  });
});

설명

이것이 하는 일: 복제 결과를 Map에 캐시하고, 동일한 요청을 빠르게 처리하면서도 데이터 무결성을 보장합니다. 첫 번째로, generateKey() 메서드는 타입과 설정을 조합하여 고유한 캐시 키를 생성합니다.

JSON.stringify()를 사용하여 객체를 직렬화하므로, 속성 순서가 같으면 동일한 키가 생성됩니다. 이 키를 Map의 키로 사용하여 O(1) 시간에 캐시를 조회할 수 있습니다.

복잡한 객체의 경우 해시 함수를 사용하거나 특정 속성만 키로 사용하는 전략도 고려할 수 있습니다. 두 번째로, clone() 메서드는 캐시 존재 여부를 확인하고 통계를 업데이트합니다.

캐시 히트 시에는 즉시 deepClone()으로 복사본을 반환하여, 사용자가 캐시된 원본을 수정해도 다른 사용자에게 영향을 주지 않도록 보호합니다. 캐시 미스 시에는 프로토타입을 복제하고 설정을 적용한 후, 역시 deepClone()으로 캐시에 저장하여 이후 요청에 대비합니다.

세 번째로, getStats() 메서드는 캐시 성능을 모니터링할 수 있게 해줍니다. 히트율(Hit Rate)이 높을수록 캐싱의 효과가 크다는 의미이며, 운영 중에 이 지표를 추적하여 캐시 전략을 조정할 수 있습니다.

예를 들어, 히트율이 낮다면 캐시 키 생성 전략을 변경하거나 캐시 크기를 늘리는 것을 고려할 수 있습니다. 마지막으로, invalidate() 메서드는 데이터가 변경되었을 때 캐시를 무효화하는 기능을 제공합니다.

특정 설정의 캐시만 삭제하거나, 특정 타입의 모든 캐시를 삭제할 수 있어 유연한 캐시 관리가 가능합니다. 예를 들어, 프로토타입 자체가 업데이트되면 해당 타입의 모든 캐시를 무효화해야 오래된 데이터가 반환되지 않습니다.

여러분이 이 패턴을 사용하면 반복적인 복제 작업의 성능을 극적으로 향상시킬 수 있습니다. 특히 무거운 계산이나 리소스 로딩이 포함된 복제에서는 캐시 히트 시 수십~수백 배의 속도 향상을 얻을 수 있습니다.

게임 개발에서 몬스터 인스턴스 생성, 데이터 시각화에서 차트 객체 생성, 또는 폼 빌더에서 컴포넌트 생성 등에 활용할 수 있습니다. 단, 메모리 사용량을 모니터링하고 LRU 같은 제거 정책을 구현하여 메모리 누수를 방지해야 합니다.

실전 팁

💡 캐시 크기 제한을 구현하세요. LRU(Least Recently Used) 알고리즘으로 오래 사용하지 않은 항목을 자동 삭제하여 메모리를 효율적으로 관리할 수 있습니다.

💡 WeakMap을 사용하면 객체가 더 이상 참조되지 않을 때 자동으로 가비지 컬렉션됩니다. 메모리 누수 걱정 없이 객체를 키로 사용할 수 있습니다.

💡 캐시 TTL(Time To Live)을 설정하여 일정 시간 후 자동으로 만료되게 만드세요. 데이터 신선도가 중요한 경우 유용합니다.

💡 개발 환경에서는 캐시를 비활성화하거나, 캐시 미스 시 경고를 표시하여 캐시 전략이 효과적인지 확인하세요.

💡 복잡한 객체의 경우 얕은 비교 대신 구조적 공유(Structural Sharing)를 구현하면, 일부만 변경된 객체도 효율적으로 캐싱할 수 있습니다.


8. 순환 참조 테스트 - 자기 참조 객체 복제 검증하기

시작하며

여러분이 객체가 자기 자신을 참조하는 복잡한 구조를 복제하려다가 무한 루프에 빠지거나 스택 오버플로 에러를 경험한 적 있나요? 트리나 그래프 같은 자료구조에서 부모-자식 양방향 참조가 있을 때 이런 문제가 자주 발생합니다.

이런 문제는 실제 개발 현장에서 자주 발생합니다. 순환 참조(Circular Reference)를 처리하지 못하는 복제 로직은 런타임 에러를 일으키거나, JSON.stringify()처럼 에러를 던집니다.

특히 DOM 노드, React 컴포넌트 트리, 또는 복잡한 비즈니스 객체 그래프에서 순환 참조는 흔합니다. 바로 이럴 때 필요한 것이 순환 참조 감지 및 처리 로직입니다.

이미 방문한 객체를 추적하여 순환을 감지하고, 동일한 참조 구조를 유지하면서 안전하게 복제할 수 있습니다.

개요

간단히 말해서, 순환 참조 처리는 객체가 직접 또는 간접적으로 자기 자신을 참조할 때 무한 루프 없이 안전하게 복제하는 기법입니다. 순환 참조는 객체 A가 B를 참조하고, B가 다시 A를 참조하는 형태입니다.

예를 들어, 트리 노드가 parent 속성으로 부모를 참조하고, 부모가 children 배열로 자식을 참조하는 구조가 전형적입니다. 순진한 복제 알고리즘은 이런 구조에서 무한히 재귀 호출을 시도하여 실패합니다.

기존에는 순환 참조를 피하기 위해 구조를 단순화했다면, 이제는 WeakMap을 사용하여 원래 구조를 그대로 유지하면서 복제할 수 있습니다. 순환 참조 테스트의 핵심 특징은 1) 순환 감지 2) 참조 보존 3) 무한 루프 방지입니다.

이러한 특징들이 복잡한 객체 그래프도 안전하게 복제할 수 있게 해줍니다.

코드 예제

// 순환 참조를 안전하게 처리하는 깊은 복사
function deepCloneWithCircular(obj, visited = new WeakMap()) {
  // 원시 타입이나 null 처리
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

  // 이미 방문한 객체면 캐시된 복사본 반환 (순환 처리)
  if (visited.has(obj)) {
    return visited.get(obj);
  }

  // 배열과 객체 구분
  const clone = Array.isArray(obj) ? [] : {};

  // 순환 참조 대비 미리 등록
  visited.set(obj, clone);

  // 재귀적으로 복사
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      clone[key] = deepCloneWithCircular(obj[key], visited);
    }
  }

  return clone;
}

// 트리 노드 클래스
class TreeNode {
  constructor(value) {
    this.value = value;
    this.parent = null;
    this.children = [];
  }

  addChild(child) {
    child.parent = this; // 순환 참조 생성
    this.children.push(child);
  }

  clone() {
    return deepCloneWithCircular(this);
  }
}

// 순환 참조 테스트
describe('순환 참조 테스트', () => {
  test('순환 참조가 있는 객체를 복제할 수 있다', () => {
    const root = new TreeNode('root');
    const child1 = new TreeNode('child1');
    const child2 = new TreeNode('child2');

    root.addChild(child1);
    root.addChild(child2);

    // 복제 실행 (순환 참조가 있지만 에러 없음)
    const cloned = root.clone();

    // 구조 검증
    expect(cloned.value).toBe('root');
    expect(cloned.children.length).toBe(2);
    expect(cloned.children[0].value).toBe('child1');

    // 순환 참조 유지 검증
    expect(cloned.children[0].parent).toBe(cloned);
    expect(cloned.children[1].parent).toBe(cloned);
  });

  test('자기 참조 객체를 복제할 수 있다', () => {
    const obj = { name: 'self-ref' };
    obj.self = obj; // 자기 자신 참조

    const cloned = deepCloneWithCircular(obj);

    // 자기 참조 유지
    expect(cloned.self).toBe(cloned);
    expect(cloned.self.self).toBe(cloned);

    // 원본과는 독립적
    expect(cloned).not.toBe(obj);
  });

  test('복잡한 그래프 구조를 복제할 수 있다', () => {
    const nodeA = { name: 'A', refs: [] };
    const nodeB = { name: 'B', refs: [] };
    const nodeC = { name: 'C', refs: [] };

    // 복잡한 순환 참조 생성
    nodeA.refs.push(nodeB, nodeC);
    nodeB.refs.push(nodeA, nodeC);
    nodeC.refs.push(nodeA, nodeB);

    const cloned = deepCloneWithCircular(nodeA);

    // 참조 구조 유지
    expect(cloned.refs[0].refs[0]).toBe(cloned); // B의 첫 참조가 A
    expect(cloned.refs[1].refs[1]).toBe(cloned.refs[0]); // C의 두번째 참조가 B
  });

  test('JSON 방식은 순환 참조를 처리하지 못한다', () => {
    const obj = { name: 'circular' };
    obj.self = obj;

    expect(() => {
      JSON.stringify(obj);
    }).toThrow(); // TypeError: Converting circular structure to JSON
  });
});

설명

이것이 하는 일: 객체 그래프를 순회하면서 이미 방문한 객체를 추적하여, 순환 참조를 만나도 무한 루프에 빠지지 않고 올바른 참조 구조를 복제합니다. 첫 번째로, WeakMap을 사용하여 방문한 객체와 그 복사본을 매핑합니다.

일반 Map 대신 WeakMap을 사용하는 이유는 원본 객체가 가비지 컬렉션될 때 자동으로 엔트리가 제거되어 메모리 누수를 방지하기 때문입니다. 복제 과정에서 동일한 객체를 다시 만나면 visited.has()로 감지하고, 이미 생성한 복사본을 visited.get()으로 가져와 반환합니다.

이것이 순환을 끊는 핵심 메커니즘입니다. 두 번째로, 객체를 복사하기 전에 미리 visited에 등록하는 것이 중요합니다.

빈 객체를 먼저 visited.set()으로 등록한 후 속성을 채우면, 재귀 호출 중에 순환 참조를 만나더라도 이미 등록된 객체를 참조하게 됩니다. 만약 속성을 모두 채운 후에 등록한다면, 그 사이에 순환 참조를 만나 무한 루프에 빠질 수 있습니다.

세 번째로, TreeNode의 addChild() 메서드는 의도적으로 순환 참조를 생성합니다. 부모가 자식을 children 배열로 참조하고, 자식은 parent 속성으로 부모를 참조하는 양방향 링크입니다.

이런 구조는 트리 순회나 조상 찾기 같은 작업을 효율적으로 만들지만, 복제 시에는 특별한 처리가 필요합니다. deepCloneWithCircular()는 이런 구조를 안전하게 복제하면서 참조 관계도 정확히 유지합니다.

네 번째 테스트는 자기 자신을 직접 참조하는 극단적인 경우를 보여줍니다. obj.self = obj는 가장 단순한 순환이지만, 처리하지 못하면 즉시 무한 루프에 빠집니다.

복제된 객체에서도 cloned.self === cloned가 성립하는지 확인하여, 참조 구조가 올바르게 재현되었는지 검증합니다. 여러분이 이 패턴을 사용하면 복잡한 데이터 구조도 안전하게 복제할 수 있습니다.

DOM 트리, 컴포넌트 계층, 그래프 알고리즘의 노드, ORM 엔티티 관계 등 실무에서 흔히 마주치는 순환 참조 구조를 모두 처리할 수 있습니다. 다만 성능을 위해 순환 참조가 없는 단순한 경우에는 더 빠른 복제 방법을 사용하고, 순환이 예상되는 경우에만 이 로직을 적용하는 것이 좋습니다.

실전 팁

💡 WeakMap은 객체만 키로 사용할 수 있으므로, 원시 타입 값은 별도 처리가 필요합니다. 하지만 순환 참조는 객체에서만 발생하므로 대부분의 경우 문제없습니다.

💡 복제 깊이 제한을 두면 의도하지 않은 거대한 객체 그래프 복제를 방지할 수 있습니다. 재귀 깊이를 추적하여 임계값을 넘으면 에러를 던지세요.

💡 성능이 중요하다면 순환 참조 여부를 먼저 검사하는 단계를 추가하세요. 순환이 없다면 더 빠른 알고리즘을 사용할 수 있습니다.

💡 structuredClone()은 브라우저 네이티브 API로 순환 참조를 자동으로 처리합니다. 하지만 함수나 심볼은 복제하지 못하므로 제한사항을 확인하세요.

💡 순환 참조를 시각화하는 디버깅 도구를 만들면 복잡한 객체 그래프를 이해하는 데 도움이 됩니다. 방문한 객체 ID를 로깅하여 참조 경로를 추적하세요.


#JavaScript#Prototype#Testing#Jest#TDD

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.