devgrr

frontend

JavaScript Proxy 객체의 힘: 프로처럼 연산을 가로채기

Proxy는 ES6에 들어온 메타프로그래밍 도구입니다. 객체에서 일어나는 기본 연산, 즉 속성 읽기와 쓰기, in 검사, 함수 호출, new를 실제로 일어나기 전에 가로채서 원하는 동작을 끼워 넣습니다. 객체를 감싸는 보호막이자 확장 레이어입니다.

caller에서 target으로 가는 선이 get, set, has 트랩을 가진 Proxy 상자를 통과하는 그림

Proxy란

Proxy 객체는 인수 두 개로 만듭니다.

  • target: 프록시로 감쌀 객체. 함수, 배열, 다른 프록시도 됩니다.
  • handler: 트랩(trap) 메서드를 담은 객체. 어떤 연산을 가로챌지 여기서 정합니다.
const target = {};  // 원본 객체
const handler = {}; // 트랩 메서드를 가진 객체
const proxy = new Proxy(target, handler);

handler가 비어 있으면 proxy는 target과 똑같이 동작합니다. 트랩을 하나씩 채울 때마다 그 연산만 가로채집니다.

주요 트랩 이해하기

get: 속성 읽기 가로채기

const person = {
  name: "Alice",
  age: 30
};

const personProxy = new Proxy(person, {
  get(target, property) {
    console.log(`속성 ${property}에 접근 중...`);
    return target[property];
  }
});

console.log(personProxy.name);
// 출력: 속성 name에 접근 중...
// 출력: Alice

set: 속성 할당 가로채기

const validatedUser = new Proxy({}, {
  set(target, property, value) {
    if (property === 'age' && typeof value !== 'number') {
      throw new TypeError('나이는 숫자여야 합니다!');
    }

    if (property === 'age' && value < 0) {
      throw new RangeError('나이는 음수일 수 없습니다!');
    }

    target[property] = value;
    return true; // 할당 성공을 나타냄
  }
});

validatedUser.name = "Bob"; // 성공
validatedUser.age = 25;     // 성공
validatedUser.age = -5;     // RangeError

has: in 연산자 가로채기

const hiddenProps = new Proxy({ visible: true, _secret: 'hidden' }, {
  has(target, property) {
    if (property.startsWith('_')) {
      return false; // 밑줄로 시작하는 속성은 숨긴다
    }
    return property in target;
  }
});

console.log('visible' in hiddenProps); // true
console.log('_secret' in hiddenProps); // false (실제로는 존재한다)

실용적인 사용 사례

데이터 보안과 접근 제어

const secureData = new Proxy({
  publicInfo: '모든 사용자가 접근 가능',
  confidential: '기밀 정보'
}, {
  get(target, property) {
    if (property === 'confidential') {
      throw new Error('인증되지 않은 접근입니다!');
    }
    return target[property];
  }
});

console.log(secureData.publicInfo);   // 접근 허용
console.log(secureData.confidential); // Error

기본값 제공

const defaultValues = {
  name: "익명",
  age: 0,
  country: "미국"
};

const userWithDefaults = new Proxy({}, {
  get(target, property) {
    return property in target ? target[property] : defaultValues[property];
  }
});

userWithDefaults.name = "Charlie";
console.log(userWithDefaults.name);    // Charlie
console.log(userWithDefaults.age);     // 0 (기본값)
console.log(userWithDefaults.country); // 미국 (기본값)

메모이제이션

계산 결과를 속성으로 저장해 두면, 두 번째 접근부터는 get 트랩이 캐시를 돌려줍니다.

function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

const memoizedFib = new Proxy({}, {
  get(target, property) {
    const n = Number(property);
    if (!(property in target)) {
      console.log(`피보나치(${n}) 계산 중...`);
      target[property] = fibonacci(n);
    } else {
      console.log(`피보나치(${n}) 캐시에서 반환`);
    }
    return target[property];
  }
});

console.log(memoizedFib[10]); // 계산된다
console.log(memoizedFib[10]); // 캐시에서 반환

디버깅과 로깅

function createLogger(obj, name) {
  return new Proxy(obj, {
    get(target, property) {
      console.log(`[${name}] 속성 '${property}' 읽기: ${target[property]}`);
      return target[property];
    },
    set(target, property, value) {
      console.log(`[${name}] 속성 '${property}' 설정: ${value}`);
      target[property] = value;
      return true;
    }
  });
}

const loggedUser = createLogger({ name: "Dave" }, "User");
loggedUser.age = 28;          // [User] 속성 'age' 설정: 28
console.log(loggedUser.name); // [User] 속성 'name' 읽기: Dave

고급 기능

revocable: 철회할 수 있는 프록시

외부에 객체를 잠깐 빌려주고 나중에 끊고 싶을 때 씁니다. revoke()를 부른 뒤에는 어떤 연산도 통하지 않습니다.

const target = { message: "안녕하세요!" };
const { proxy, revoke } = Proxy.revocable(target, {
  get(target, property) {
    return `가로채짐: ${target[property]}`;
  }
});

console.log(proxy.message); // "가로채짐: 안녕하세요!"

revoke(); // 프록시 비활성화

console.log(proxy.message); // TypeError: 철회된 프록시에서 'get' 수행 불가

Reflect와 함께 쓰기

트랩 안에서 원래 동작을 그대로 수행하고 싶을 때는 target[property] 대신 Reflect를 씁니다. 트랩 이름과 인수가 1:1로 대응합니다.

const smartProxy = new Proxy(target, {
  get(target, property, receiver) {
    console.log(`${property} 속성에 접근`);
    return Reflect.get(target, property, receiver);
  },
  set(target, property, value, receiver) {
    console.log(`${property} 속성을 ${value}로 설정`);
    return Reflect.set(target, property, value, receiver);
  }
});

주의사항과 제한사항

  • 성능. 연산마다 트랩이 한 겹 더 실행됩니다. 대부분은 체감이 안 되지만, 핫 루프 안의 객체를 감싸는 것은 신중해야 합니다.
  • 브라우저 호환성. IE는 지원하지 않고 폴리필도 불가능합니다. 최신 브라우저와 Node.js에서는 완전히 지원됩니다.
  • 투명성의 한계. === 비교는 가로챌 수 없습니다. 프록시와 원본은 서로 다른 객체입니다. Map, Date 같은 내장 객체의 내부 슬롯도 프록시를 통과하지 못해 메서드 호출이 깨질 수 있습니다.

언제 Proxy를 써야 할까

쓰면 좋은 경우

  • 객체 상호작용을 세밀하게 제어해야 할 때
  • 로깅과 디버깅
  • 유효성 검사 규칙 적용, 불변성 강제
  • 동적 데이터 구조(예: 음수 인덱스가 되는 배열)
  • 함수 동작의 확장과 래핑

피해야 하는 경우

  • 평범한 객체 조작으로 충분할 때
  • 성능이 매우 중요한 경로
  • 코드 복잡도만 올리는 경우

결론

Proxy는 객체의 동작을 바꿔 끼울 수 있는 다재다능한 도구입니다. 로깅과 유효성 검사부터 동적 속성 생성과 특수한 경우 처리까지, 객체 뒤에 한 겹을 두는 것만으로 많은 일이 됩니다. 트랩의 종류와 쓰임을 알아 두면 더 정교하고 통제 가능한 코드를 쓸 수 있습니다.

다만 그 한 겹은 복잡도이기도 합니다. 프레임워크나 라이브러리를 만들 때, 또는 객체 동작에 대한 세밀한 제어가 정말 필요할 때 꺼내 쓰고, 평소에는 평범한 객체로 충분합니다.

출처