JavaScript 프록시를 사용한 동적 인터페이스 구축
Ethan Miller
Product Engineer · Leapcell

소개
현대 웹 개발에서 데이터 소스 및 외부 API와 상호 작용하는 것은 핵심 작업입니다. 종종 쿼리 구성, 데이터 직렬화 처리 또는 다양한 API 구조에 적응하기 위한 반복적인 상용구 코드가 필요합니다. 메서드 호출에서 당신의 의도를 마법처럼 추론하여 더 표현력이 풍부하고 덜 장황한 코드를 작성할 수 있는 데이터 액세스 계층을 상상해 보세요. 이것이 바로 JavaScript 프록시의 힘이 진정으로 빛나는 곳입니다. 프록시는 객체의 기본 작업을 가로채고 사용자 정의할 수 있는 고유한 방법을 제공하여 매우 동적이고 유연한 프로그래밍 패러다임을 열어줍니다. 이 글에서는 이 강력한 기능을 활용하여 컴팩트하면서도 효과적인 미니 ORM 또는 동적 API 클라이언트를 구축하는 방법과 이러한 시스템을 가능하게 하는 기본 원리를 이해하는 방법을 살펴봅니다.
JavaScript 프록시의 핵심 개념
구현으로 들어가기 전에 관련 핵심 개념을 확실하게 이해해 봅시다.
프록시란 무엇인가?
JavaScript Proxy는 또 다른 객체나 함수를 래핑하여 래핑된 객체에 수행되는 기본 작업(속성 조회, 할당, 함수 호출 등)을 가로채고 사용자 정의할 수 있는 객체입니다. 중간자 역할을 하여 객체의 동작에 대한 후크를 제공합니다.
대상(Target)과 핸들러(Handler)
A Proxy 생성자는 두 개의 인수를 받습니다:
target:Proxy가 가상화하는 객체입니다.Proxy가 작동하는 기본 객체입니다. 트랩이 정의되지 않은 경우Proxy에 대한 작업은target으로 직접 전달됩니다.handler: 특정 작업을 가로채는 메서드인 "트랩"을 포함하는 객체입니다. 각 트랩은 기본 작업(예:get,set,apply)에 해당합니다.
트랩(Traps)
트랩은 Proxy 기능의 핵심입니다. 특정 작업을 가로채는 handler 객체의 메서드입니다. 우리의 목적을 위해 가장 관련성이 높은 트랩은 다음과 같습니다.
get(target, prop, receiver): 속성 접근을 가로챕니다.Proxy에서 속성을 읽으려고 할 때 이 트랩이 호출됩니다.target: 프록시되는 원본 객체입니다.prop: 액세스되는 속성의 이름입니다.receiver: 속성이 프로토타입 체인을 통해 액세스된 경우Proxy자체 또는Proxy를 상속하는 객체입니다.
apply(target, thisArg, argumentsList): 함수 호출을 가로챕니다.target이 함수이고Proxy를 함수로 호출하면 이 트랩이 호출됩니다.target: 프록시되는 원본 함수입니다.thisArg: 함수 호출의this컨텍스트입니다.argumentsList: 함수에 전달된 인수 배열입니다.
프록시를 사용한 미니 ORM 구축
가장 자연스럽고 객체 지향적인 방식으로 데이터베이스 쿼리를 구성할 수 있는 단순화된 객체 관계형 매퍼(ORM)를 구축하여 프록시의 힘을 설명해 봅시다.
문제
전통적인 데이터베이스 상호 작용에는 종종 SQL 문자열을 작성하거나 장황한 쿼리 빌더를 사용하는 것이 포함됩니다. 일반적인 열망은 데이터베이스 테이블과 행을 JavaScript 객체 및 메서드로 나타내는 것입니다.
프록시 기반 솔루션
우리의 미니 ORM은 db.users.where('age').gt(25).orderBy('name').fetch()와 같은 코드를 작성할 수 있도록 할 것입니다.
class QueryBuilder { constructor(tableName) { this.tableName = tableName; this.conditions = []; this.orderByClause = null; this.limitClause = null; } where(field) { // 비교 연산자를 동적으로 처리하는 프록시를 반환합니다. return new Proxy({}, { get: (target, operator) => { return (value) => { this.conditions.push({ field, operator, value }); return this; // 체이닝 허용 }; } }); } orderBy(field, direction = 'ASC') { this.orderByClause = { field, direction }; return this; } limit(count) { this.limitClause = count; return this; } fetch() { // 데이터베이스 상호 작용을 시뮬레이션하고 약속을 반환합니다. let queryParts = [`SELECT * FROM ${this.tableName}`]; if (this.conditions.length > 0) { const conditionStrings = this.conditions.map(c => { switch (c.operator) { case 'eq': return `${c.field} = '${c.value}'`; case 'gt': return `${c.field} > ${c.value}`; case 'lt': return `${c.field} < ${c.value}`; default: return ''; // 기본 처리 } }); queryParts.push(`WHERE ${conditionStrings.join(' AND ')}`); } if (this.orderByClause) { queryParts.push(`ORDER BY ${this.orderByClause.field} ${this.orderByClause.direction}`); } if (this.limitClause) { queryParts.push(`LIMIT ${this.limitClause}`); } const simulatedQuery = queryParts.join(' '); console.log(`Executing query: ${simulatedQuery}`); // 실제 ORM에서는 데이터베이스 드라이버와 상호 작용합니다. return new Promise(resolve => { setTimeout(() => { console.log(`Simulating results for: ${this.tableName}`); resolve([ { id: 1, name: 'Alice', age: 30 }, { id: 2, name: 'Bob', age: 25 }, { id: 3, name: 'Charlie', age: 35 } ]); }, 500); }); } } // 프록시를 사용한 데이터베이스 파사드 const db = new Proxy({}, { get: (target, tableName) => { // db.tableName에 액세스할 때 해당 테이블의 새 QueryBuilder를 만듭니다. return new QueryBuilder(tableName); } }); // 사용법 async function runQueryExample() { console.log('--- ORM Example ---'); const users = await db.users.where('age').gt(28).orderBy('name', 'DESC').limit(5).fetch(); console.log('Fetched users:', users); const oldUsers = await db.users.where('age').lt(32).fetch(); console.log('Fetched old users:', oldUsers); } runQueryExample();
이 예제에서:
db는 속성 액세스를 가로채는Proxy입니다.db.users에 액세스하려고 하면db에 대한get트랩이 활성화됩니다.get트랩은users테이블에 대한QueryBuilder를 인스턴스화합니다. 이를 통해 액세스된 속성 이름을 기반으로 쿼리 컨텍스트를 동적으로 생성할 수 있습니다.QueryBuilder의where메서드는 자체적으로 다른Proxy를 반환합니다. 이 내부Proxy는 추가 속성 액세스(.gt,.lt,.eq)를 가로챕니다. 이 독창적인 계층화를 통해 비교 연산자를 직접 체인할 수 있습니다..gt에 액세스하면 내부 프록시의get트랩은value를 가져와 조건을 구성하고 체이닝을 위해QueryBuilder를 반환하는 함수를 반환합니다.
동적 API 클라이언트 구축
동일한 Proxy 원칙을 다양한 엔드포인트 및 HTTP 메서드에 적응하는 매우 유연한 API 클라이언트를 만드는 데 적용할 수 있습니다.
문제
RESTful API는 종종 일관된 패턴을 따릅니다: /users, /products/123, POST /users, GET /products. 각 엔드포인트와 메서드에 대해 fetch 호출을 수동으로 작성하는 것은 지루할 수 있습니다.
프록시 기반 솔루션
api.users.get(), api.products(123).delete() 또는 api.posts.create({ title: 'New Post' })와 같은 것을 달성하기를 원합니다.
class ApiClient { constructor(baseUrl = '') { this.baseUrl = baseUrl; } _request(method, path, data = null) { const url = `${this.baseUrl}${path}`; console.log(`Making ${method} request to: ${url}`, data ? `with data: ${JSON.stringify(data)}` : ''); // 네트워크 요청 시뮬레이션 return new Promise(resolve => { setTimeout(() => { if (method === 'GET') { if (path.includes('users') && !path.includes('/')) { resolve([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]); } else if (path.includes('products/')) { resolve({ id: parseInt(path.split('/').pop()), name: 'Product ' + path.split('/').pop() }); } else { resolve({ message: `${method} ${path} successful` }); } } else if (method === 'POST') { resolve({ id: Math.floor(Math.random() * 1000), ...data, status: 'created' }); } else if (method === 'PUT') { resolve({ id: path.split('/').pop(), ...data, status: 'updated' }); } else if (method === 'DELETE') { resolve({ id: path.split('/').pop(), status: 'deleted' }); } }, 300); }); } // 특정 경로 세그먼트(예: 'users', 'products')에 대한 프록시를 만듭니다. createPathProxy(currentPath) { return new Proxy(() => {}, { // apply 트랩을 위한 빈 함수 대상 get: (target, prop) => { if (['get', 'post', 'put', 'delete'].includes(prop)) { // 메서드가 액세스되는 경우(예: api.users.get) return (data) => this._request(prop.toUpperCase(), currentPath, data); } // 다른 경로 세그먼트가 액세스되는 경우(예: api.users.posts) return this.createPathProxy(`${currentPath}/${String(prop)}`); }, apply: (target, thisArg, argumentsList) => { // 프록시가 함수로 호출되는 경우(예: api.products(123)) const id = argumentsList[0]; return this.createPathProxy(`${currentPath}/${id}`); } }); } } const api = new Proxy(new ApiClient('https://api.example.com'), { get: (target, prop) => { if (target[prop]) { // 속성이 ApiClient 인스턴스에 있는 경우(예: `baseUrl`) return Reflect.get(target, prop); } // 그렇지 않으면 API 경로의 시작으로 가정합니다(예: api.users). return target.createPathProxy(`/${String(prop)}`); } }); // 사용법 async function runApiClientExample() { console.log('\n--- API Client Example ---'); const allUsers = await api.users.get(); console.log('All Users:', allUsers); const specificProduct = await api.products(123).get(); console.log('Specific Product:', specificProduct); const newUser = await api.users.post({ name: 'Charlie', email: 'charlie@example.com' }); console.log('New User:', newUser); const updatedProduct = await api.products(456).put({ price: 29.99 }); console.log('Updated Product:', updatedProduct); const deletedPost = await api.blog.posts(789).delete(); console.log('Deleted Post:', deletedPost); } runApiClientExample();
이 API 클라이언트 예제에서:
- 초기
api객체는ApiClient인스턴스를 래핑하는Proxy입니다.get트랩은api.users와 같은 호출을 가로챕니다. api.users에 액세스하면get트랩이target.createPathProxy('/users')를 호출합니다.createPathProxy는 다른 Proxy를 반환합니다. 이 내부 Proxy에는 두 가지 중요한 트랩이 있습니다.get트랩:get,post,put,delete(예:api.users.get)가 액세스되면 실제 HTTP 요청을 수행하는 함수를 반환합니다. 다른 경로 세그먼트(예:api.users.comments)에 액세스하면createPathProxy를 재귀적으로 호출하여 더 긴 경로를 만듭니다.apply트랩:Proxy가 함수로 호출되면(예:api.products(123))apply트랩은 인수(일반적으로 ID)를 가져와 경로를 확장하고 다른Proxy를 반환합니다.
get 및 apply 트랩을 기반으로 한 이 동적 체이닝을 통해 경로를 하드코딩하지 않고도 매우 직관적이고 유연한 API 상호 작용이 가능합니다.
내부 메커니즘
이러한 구현 뒤의 마법은 Proxy가 기본 작업을 가로채고 동적으로 새 Proxy 인스턴스 또는 함수를 생성할 수 있는 기능에 있습니다.
- 지연 평가 및 동적 생성: 모든 가능한 메서드 또는 경로를 미리 정의하는 대신 프록시를 사용하면 작업이 실제로 시도될 때까지 논리를 연기할 수 있습니다.
db.users에 액세스하면QueryBuilder가 생성되고;api.users.get이 호출되면GET요청이 구성됩니다. - 프록시 체이닝: 예제에서는 한
Proxy가 다른Proxy를 반환하는 방법을 보여줍니다(예:db는where메서드에서Proxy를 반환하는QueryBuilder를 반환함). 이를 통해 복잡한 다중 세그먼트 메서드 체인이 가능합니다. - 컨텍스트별 트랩 로직:
API Client의get트랩은prop이름을 동적으로 확인합니다..get또는.post인 경우 요청을 실행합니다. 그렇지 않으면 다른 경로 세그먼트로 가정하고 URL을 확장합니다. 이것은 트랩 로직이 매우 컨텍스트적일 수 있는 방법을 보여줍니다. ReflectAPI: 이러한 특정 예에서는 많이 사용되지 않았지만ReflectAPI는Proxy트랩을 미러링하는 정적 메서드를 제공합니다. 특정 트랩에 대한 사용자 정의 동작이 필요하지 않은 경우 기본 작업을 원래 대상으로 전달하여 기본 동작이 유지되도록 하기 위해 트랩 내에서(예:Reflect.get(target, prop, receiver)) 자주 사용됩니다.
결론
JavaScript 프록시는 개발자가 매우 추상적이고 표현력이 풍부하며 동적인 인터페이스를 구축할 수 있게 하는 매우 강력한 기능입니다. get 및 apply 트랩을 이해하고 활용함으로써 메서드 호출을 데이터베이스 쿼리로 변환하는 미니 ORM 또는 객체 탐색을 API 엔드포인트에 원활하게 매핑하는 동적 API 클라이언트와 같은 정교한 도구를 만들 수 있습니다. 이를 통해 더 선언적이고 상용구가 적은 코드를 작성하여 애플리케이션을 더 적응 가능하고 개발하기 즐겁게 만들 수 있습니다. 프록시는 객체와 상호 작용하는 방식을 근본적으로 변화시켜 우아하고 간결한 구문으로 깊이 사용자 정의된 동작을 구현할 수 있게 합니다.

