
기존 자바스크립트 사용자들은 객체의 속성 접근을 감지하거나 동작을 변경하기 위해 Object.defineProperty() 같은 저수준 API를 활용해 왔다. 이 방식은 특정 프로퍼티의 getter나 setter를 재정의하는 데에는 유용했지만, 동적으로 추가되는 속성이나 함수 호출, 삭제 연산 같은 보다 포괄적인 동작을 감지하는 데에는 명확한 한계가 있었다.
이러한 제약을 해결하기 위해 도입된 기능이 바로 Proxy 처리기다. Proxy는 get, set, has, deleteProperty, apply, construct 등 다양한 트랩(trap)을 제공하며, 객체에 가해지는 거의 모든 연산을 가로채고 재정의할 수 있도록 한다. 이 덕분에 객체의 속성 접근, 값 변경, 삭제, 함수 호출까지도 자유롭게 제어할 수 있다.
아마도 이것이 Proxy 처리기에 대한 가장 일반적인 설명일 것이다. 나 역시 큰 틀에서는 이 설명에 동의한다. 다만 한 가지 사소하지만 결정적인 문제가 있었다. 나는 “객체의 속성 접근을 감지하거나 동작을 변경해야 할 필요성” 자체를 거의 느껴본 적이 없었다는 점이다.
조금 더 구체적으로 설명해보자. 예를 들어 name과 age라는 두 프로퍼티를 가진 단순한 객체가 있고, 비즈니스 정책상 이름은 최대 5글자, 나이는 최대 120살까지만 허용해야 한다고 가정해보자. 이 요구사항을 Proxy로 구현하면 다음과 같은 코드가 된다.
function createUser(name, age) {
const user = { name, age };
return new Proxy(user, {
set(target, prop, value) {
if (prop === "name") {
if (typeof value !== "string") {
throw new TypeError("Name must be a string");
}
if (value.length > 5) {
throw new RangeError("Name must be less than 5 characters");
}
}
if (prop === "age") {
if (typeof value !== "number") {
throw new TypeError("Age must be a number");
}
if (value < 0 || value > 120) {
throw new RangeError("Age must be between 0 and 120");
}
}
target[prop] = value;
return true;
},
});
}하지만 이 코드를 처음 작성했을 때 든 생각은 단순했다. “이걸 왜 Proxy로 해야 하지?” 우리에게는 이미 ES6부터 제공되는 클래스 문법이 있다. 동일한 요구사항은 훨씬 익숙한 방식으로도 표현할 수 있다.
class User {
#name;
#age;
constructor(name, age) {
this.name = name;
this.age = age;
}
get name() {
return this.#name;
}
set name(value) {
if (typeof value !== "string") {
throw new TypeError("Name must be a string");
}
if (value.length > 5) {
throw new RangeError("Name must be less than 5 characters");
}
this.#name = value;
}
get age() {
return this.#age;
}
set age(value) {
if (typeof value !== "number") {
throw new TypeError("Age must be a number");
}
if (value < 0 || value > 120) {
throw new RangeError("Age must be between 0 and 120");
}
this.#age = value;
}
}이 시점에서 나는 이렇게 결론 내렸다.
“Proxy가 할 수 있는 대부분의 일은 클래스 문법으로 대체 가능하다. 그렇다면 Proxy는 굳이 사용할 이유가 없다.”
지금 와서 돌아보면, 이것이 Proxy에 대한 가장 큰 오해였다. 나는 Proxy가 무엇을 할 수 있는지에만 집중했고, Proxy가 무엇이 될 수 있는지는 전혀 생각하지 못했다.
위의 예제에서는 Proxy의 대상이 되는 객체를 만드는 주체가 바로 나다. 내가 객체를 만들고, 내가 규칙을 정하고, 내가 클래스를 설계한다. 이런 상황에서는 Proxy가 사실상 의미가 없다. 클래스와 캡슐화로 충분히 해결 가능하기 때문이다.
하지만 상황이 달라지는 지점이 있다. 객체를 생산하는 주체가 내가 아니라 라이브러리인 경우다. 라이브러리가 반환한 객체는 내부 구현을 직접 수정할 수 없다. 클래스를 상속할 수도 없고, setter를 추가할 수도 없다. 그 객체가 어떤 방식으로 동작하는지는 라이브러리 작성자의 결정이다. 이때, 해당 객체의 동작을 우리 코드의 규칙에 맞게 감시하거나 제어할 수 있는 거의 유일한 수단이 Proxy다.
const externalUser = someLibrary.createUser();
const safeUser = new Proxy(externalUser, {
set(target, prop, value) {
if (prop === "age" && value > 120) {
throw new Error("Invalid age");
}
target[prop] = value;
return true;
},
});이 순간 Proxy는 단순한 “감싸기 도구”가 아니다. 외부에서 들어온 객체를 우리 시스템의 규칙에 맞게 적응시키는 어댑터(Adapter) 가 된다.
이 관점에서 보면 Proxy는 객체의 속성을 감지하는 도구가 아니라, 외부 세계와 내부 세계 사이의 인터페이스를 조율하는 장치다. 라이브러리나 프레임워크가 제공하는 객체를 그대로 신뢰하지 않고, 우리 코드의 경계에서 한 번 더 통제할 수 있게 해주는 안전장치인 셈이다. 이 지점에서 Proxy의 역할과 가능성은 완전히 다르게 보이기 시작했다.
Proxy 이야기를 하다 보면 자연스럽게 함께 등장하는 객체가 있다. 바로 Reflect다. 일반적으로 Reflect는 “Proxy 처리기 안에서 기본 동작을 위임하기 위한 보조 도구” 정도로 소개된다. 나 역시 한동안은 Reflect가 Proxy 내부에서만 의미를 갖는 특수한 객체라고 오해하고 있었다.
하지만 Reflect는 Proxy와 무관하게도 충분히 유용한 API다. Reflect의 핵심 가치는 객체의 기본 동작을 명령형이고 일관된 방식으로 호출할 수 있게 해준다는 점에 있다. 대표적인 예가 프로퍼티 삭제다. 일반적으로 우리는 delete 연산자를 사용한다.
delete obj.prop;하지만 이 연산은 상황에 따라 동작이 달라진다. 특히 엄격 모드에서, 혹은 non-configurable 속성을 삭제하려고 할 경우 예외를 던질 수 있다.
try {
delete obj.nonConfigurableProp;
} catch (e) {
console.error("삭제 실패");
}반면 Reflect.deleteProperty()는 훨씬 일관된 인터페이스를 제공한다.
if (!Reflect.deleteProperty(obj, "nonConfigurableProp")) {
console.error("삭제 실패");
}예외를 던지는 대신 항상 boolean 값을 반환한다. 이 차이는 코드의 안정성과 가독성에 꽤 큰 영향을 미친다. Reflect는 이 외에도 get, set, has 같은 메서드를 제공한다.
Reflect.get(obj, "name");
Reflect.set(obj, "age", 30);
Reflect.has(obj, "id");이 메서드들은 모두 Proxy의 트랩 이름과 정확히 대응된다. 하지만 Proxy 내부에서만 쓰라고 존재하는 것은 아니다. 오히려 객체 조작을 함수 호출 형태로 명시적으로 표현하고 싶을 때, Reflect는 매우 깔끔한 대안이 된다.
정리해보면, Proxy와 Reflect는 단순히 “객체를 건드리는 신기한 문법”이 아니다. Proxy는 우리가 소유하지 않은 객체를 다루는 방법이고, Reflect는 객체의 기본 동작을 예측 가능하게 호출하는 도구다. 이 둘을 함께 바라보지 않고 각각 따로 이해하면, Proxy는 과한 도구처럼 보이고 Reflect는 쓸모없는 API처럼 느껴지기 쉽다.
하지만 라이브러리 경계, 시스템 경계, 외부 객체와 내부 규칙이 만나는 지점에 이르면 이 둘은 갑자기 매우 현실적인 도구가 된다. Proxy에 대한 나의 인식 변화는 문법을 새로 배웠기 때문이 아니라, 사용 맥락을 다시 생각했기 때문이었다. 자바스크립트의 많은 기능이 그렇듯, Proxy 역시 “언제 쓰느냐”를 이해했을 때 비로소 제 역할을 드러낸다.
더 읽어보기
2026.03.13
Streams API 3. 바이트 스트림과 실전 파이프라인
앞선 두 글에서 우리는 Web Streams API의 표면과 의미론을 정리했다. 이제 남은 질문은 하나이다. 이 지식을 실제 어디에 써먹을 것인가. 이 질문에 답하는 순간 스트림 학습은 비로소 완성된다. 표준을 읽고 메서드를 아는 것만으로는 충분하지 않다. fetch() 응답 본문을 어…
2026.03.13
Streams API 2. 상태와 백프레셔의 의미론
스트림을 처음 배울 때는 읽고 쓰는 예제가 꽤 단순해 보인다. getReader()로 읽고, getWriter()로 쓰고, pipeTo()로 연결하면 끝나는 것처럼 느껴진다. 실제로 짧은 데모는 이 정도만 알아도 돌아간다. 그러나 실무에서 스트림 코드를 망가뜨리는 원인은 거의 언제나 더…
2026.03.13
Streams API 1. 읽기와 쓰기의 출발점
자바스크립트에서 비동기를 배울 때 우리는 대개 Promise와 async, await부터 익힌다. 이 조합은 한 번의 결과를 기다리는 문제에는 매우 강력하다. 그러나 데이터가 한 번에 끝나지 않고 계속 흘러들어오는 상황에서는 이야기가 달라진다. 네트워크 응답이 길게 이어지거나, 큰 파일…
2026.03.04
JavaScript를 위한 더 나은 Streams API가 필요하다
이 포스트는 node.js의 코어 컨트리뷰트이며 Cloudflare Workers 팀 소속 개발자 James M Snell이 cloudflare 블로그에 올린 We deserve a better streams API for JavaScript 게시글을 번역한 것이다. 번역하는 과정에서…
2026.04.11
Trie 자료구조
문자열 데이터를 다룰 때 단순히 “이 단어가 있나?”만으로는 부족한 순간이 있다. 자동 완성처럼 특정 접두사로 시작하는 후보를 모아야 할 때도 있고, 어떤 키에 값을 두고 빠르게 찾고 싶을 때도 있다. 이럴 때 가장 자연스럽게 떠올릴 수 있는 자료구조가 바로 Trie이다.Trie는 무엇…
2026.03.19
Streams API 부록 2. 왜 이미지는 위에서 아래로 나타날까
웹 페이지에서 이미지를 로드할 때 흥미로운 장면을 종종 볼 수 있다. 이미지가 한 번에 완전히 나타나는 것이 아니라, 위에서 아래로 조금씩 채워지면서 나타나는 경우가 있기 때문이다. 특히 네트워크가 느리거나 이미지가 큰 경우 이런 현상이 더 분명하게 보인다. 마치 이미지가 위쪽부터 스캔…
2026.03.19
Streams API 부록 1. HTTP 다운로드 진행률은 어떻게 계산될까
파일을 다운로드할 때 가끔 몇 퍼센트 진행되었는지 혹은 진행 막대(progress bar)가 조금씩 채워지는 모습을 볼 수 있다. 그런데 모든 다운로드가 이런 식으로 진행률을 보여 주는 것은 아니다. 어떤 다운로드는 퍼센트가 표시되지만, 어떤 경우에는 진행 막대 없이 로딩 스피너만 계속…
2026.03.13
Streams API 4. 왜 모든 언어에는 Stream API가 존재할까
Streams API를 공부하다 보면 묘한 기시감을 느끼게 된다. JavaScript에서 ReadableStream, WritableStream, TransformStream을 살펴보고 있는데, 어딘가 낯설지 않다. Java를 써 본 사람이라면 InputStream, OutputStre…
댓글
댓글을 불러오는 중...