JavaScript Proxy를 사용하여 동적 API 클라이언트 및 ORM 구축
Min-jun Kim
Dev Intern · Leapcell

소개
현대 웹 개발 세계에서 애플리케이션은 REST API 또는 기타 데이터 액세스 계층을 통해 백엔드 서비스와 자주 상호 작용합니다. 모든 엔드포인트나 데이터베이스 테이블에 대해 API 클라이언트 메서드를 수동으로 작성하는 것은 빠르게 지루하고 오류가 발생하기 쉬운 프로세스가 될 수 있습니다. 이로 인해 보일러플레이트 코드, 유지보수성 저하, 백엔드 스키마가 변경될 때 유연성 부족이 자주 발생합니다. 클라이언트 측 코드가 광범위한 수동 업데이트 없이 변경되는 백엔드에 마법처럼 적응할 수 있는 시나리오를 상상해 보세요. 바로 여기에서 JavaScript의 Proxy 객체가 진가를 발휘합니다. Proxy를 활용하면 개발자는 매우 동적이고 선언적인 API 클라이언트 또는 ORM과 유사한 인터페이스를 만들어 개발을 크게 간소화하고 코드 풋프린트를 줄이며 애플리케이션의 적응성을 향상시킬 수 있습니다. 이 글에서는 JavaScript Proxy를 효과적으로 사용하여 이 혁신적인 기능을 달성함으로써 백엔드 서비스와 보다 지능적이고 탄력적인 프런트엔드 상호 작용을 구축하는 방법을 자세히 알아봅니다.
핵심 개념 이해
구현에 들어가기 전에 동적 API 클라이언트 및 ORM과 유사한 솔루션의 기초가 되는 기본 개념을 명확히 하겠습니다.
Proxy객체: JavaScript에서Proxy객체는 대상이라고 하는 다른 객체의 자리 표시자 역할을 합니다. 이를 통해 해당 대상에 대한 기본 작업(예: 속성 조회, 할당, 함수 호출 등)을 가로채고 사용자 정의할 수 있습니다. 이 가로채기는 핸들러 객체에 의해 관리되며, 특정 작업이Proxy에서 수행될 때 트리거되는 트랩(메서드)을 포함합니다.Reflect객체:Reflect객체는Proxy핸들러와 동일한 메서드를 제공합니다. 이를 통해 기본 속성 작업을 호출할 수 있습니다.Reflect는 종종Proxy트랩 내에서 작업을 원본 대상으로 전달하거나 사용자 정의 트랩이 필요하지 않을 때 기본 동작을 제공하는 데 사용됩니다.- API 클라이언트: API(Application Programming Interface)와의 통신을 용이하게 하는 소프트웨어 구성 요소입니다. HTTP 요청, 인증 및 데이터 직렬화의 복잡성을 추상화하여 백엔드 서비스와 상호 작용하는 보다 편리한 방법을 제공합니다.
- ORM (객체 관계 매핑): 객체 지향 프로그래밍 언어를 사용하여 호환되지 않는 유형 시스템 간의 데이터를 변환하는 프로그래밍 기법입니다. 웹 애플리케이션에서 ORM은 일반적으로 데이터베이스 테이블을 객체에 매핑하여 개발자가 원시 SQL 또는 API 호출 대신 객체 지향 패러다임을 사용하여 데이터베이스와 상호 작용할 수 있도록 합니다. 우리의 솔루션이 완전한 ORM은 아니지만, 객체 속성을 백엔드 리소스 또는 작업에 매핑하는 개념을 차용할 것입니다.
원칙: 가로채고 변환하기
동적 API 클라이언트 또는 ORM에 Proxy를 사용하는 기본 원칙은 프록시 객체에서 속성 액세스 또는 메서드 호출을 가로챈 다음 해당 작업을 실제 API 요청 또는 데이터 조작으로 변환하는 것입니다. fetchUsers() 메서드나 user.save() 메서드를 명시적으로 정의하는 대신, Proxy가 액세스되는 방식에 따라 이러한 상호 작용을 동적으로 생성하도록 할 수 있습니다.
/users, /products/123 또는 /orders/create와 같은 엔드포인트를 가진 백엔드 API를 생각해 보세요. Proxy는 api.users, api.products(123) 또는 api.orders.create()를 가로채고 호출된 속성 또는 메서드를 기반으로 적절한 URL, HTTP 메서드 및 요청 본문을 구성할 수 있습니다.
구현: 동적 API 클라이언트 구축
실질적인 예시를 통해 이를 설명해 보겠습니다. 동적 API 클라이언트를 구축하는 것입니다. api.users.get(1)과 같이 ID로 사용자를 가져오거나, api.products.list()와 같이 모든 제품을 가져오거나, api.orders.create({ item: '...', quantity: 1 })과 같이 주문을 생성하는 사용 패턴을 사용하도록 설정하려고 합니다.
// 간단한 HTTP 클라이언트 유틸리티 (예: fetch API 사용) const httpClient = { get: (url, config = {}) => fetch(url, { method: 'GET', ...config }).then(res => res.json()), post: (url, data, config = {}) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), ...config }).then(res => res.json()), put: (url, data, config = {}) => fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), ...config }).then(res => res.json()), delete: (url, config = {}) => fetch(url, { method: 'DELETE', ...config }).then(res => res.json()), }; const createDynamicApiClient = (baseURL) => { // 이것은 Proxy의 핵심 핸들러입니다. const handler = { get: (target, prop, receiver) => { // 속성이 이미 대상에 존재하는 경우 반환합니다. // 이를 통해 API 객체에 기본 메서드 또는 속성을 포함할 수 있습니다. if (Reflect.has(target, prop)) { return Reflect.get(target, prop, receiver); } // 'prop'을 리소스 이름(예: 'users', 'products')으로 해석합니다. // 해당 리소스에 특정한 새 Proxy를 반환합니다. return new Proxy({}, { get: (resourceTarget, resourceProp, resourceReceiver) => { // console.log(`Intercepted resource: ${prop}, operation: ${resourceProp}`); // 표준 CRUD 작업 처리 switch (resourceProp) { case 'list': // 예: api.users.list() return (config) => httpClient.get(`${baseURL}/${prop}`, config); case 'get': // 예: api.users.get(1) return (id, config) => httpClient.get(`${baseURL}/${prop}/${id}`, config); case 'create': // 예: api.users.create({ name: 'John' }))) return (data, config) => httpClient.post(`${baseURL}/${prop}`, data, config); case 'update': // 예: api.users.update(1, { name: 'Jane' })) return (id, data, config) => httpClient.put(`${baseURL}/${prop}/${id}`, data, config); case 'delete': // 예: api.users.delete(1)) return (id, config) => httpClient.delete(`${baseURL}/${prop}/${id}`, config); default: // 중첩된 리소스 액세스의 경우, 예: api.users.1.posts (API 설계에서 지원하는 경우) // 이는 `api.users/1/posts`에 대한 또 다른 중첩된 프록시를 생성합니다. if (typeof resourceProp === 'string' && !isNaN(parseInt(resourceProp))) { return new Proxy({}, { get: (nestedResourceTarget, nestedResourceProp, nestedResourceReceiver) => { // console.log(`Intercepted nested resource: ${prop}/${resourceProp}, operation: ${nestedResourceProp}`); switch (nestedResourceProp) { case 'list': // 예: api.users.1.posts.list() return (config) => httpClient.get(`${baseURL}/${prop}/${resourceProp}/posts`, config); // 필요한 경우 다른 중첩 작업 추가 default: console.warn(`Unsupported nested operation: ${nestedResourceProp}`); return () => Promise.reject(new Error(`Unsupported nested operation: ${nestedResourceProp}`)); } } }); } // 필요한 경우 사용자 정의 메서드에 대한 폴백 console.warn(`Unsupported operation for resource ${prop}: ${resourceProp}`); return () => Promise.reject(new Error(`Unsupported operation: ${resourceProp}`)); } } }); }, apply: (target, thisArg, argumentsList) => { // 이 트랩은 프록시된 객체에 대한 직접 호출에 사용됩니다. 예: api() // 일반적으로 이 패턴에는 사용되지 않지만 알아두면 좋습니다. console.log('Direct call to proxy:', argumentsList); return Reflect.apply(target, thisArg, argumentsList); } }; // 초기 대상은 빈 객체일 수 있습니다. 모든 작업은 핸들러에 의해 가로채지기 때문입니다. // 또는 일반 API 메서드를 포함할 수 있습니다. return new Proxy({ // 필요한 경우 여기에 전역 API 메서드를 정의할 수 있습니다. // 예: auth: { login: (credentials) => httpClient.post(`${baseURL}/auth/login`, credentials) } }, handler); }; // --- 사용 예시 --- const api = createDynamicApiClient('https://api.example.com'); // 실제 API 기본 URL로 바꾸세요. // API 호출 시뮬레이션 (async () => { try { console.log("Fetching all users..."); const allUsers = await api.users.list(); console.log("All Users:", allUsers); console.log("\nFetching user with ID 1..."); const user1 = await api.users.get(1); console.log("User 1:", user1); console.log("\nCreating a new product..."); const newProduct = await api.products.create({ name: 'Super Widget', price: 29.99 }); console.log("New Product:", newProduct); console.log("\nUpdating product with ID 5 (simulated)..."); const updatedProduct = await api.products.update(5, { price: 34.99 }); console.log("Updated Product 5:", updatedProduct); console.log("\nDeleting user with ID 2 (simulated)..."); const deleteResult = await api.users.delete(2); console.log("Delete User 2 Result:", deleteResult); // API 설계에서 지원하는 경우 중첩된 리소스 예시 // 실제 API는 '/users/1/posts'를 가질 수 있습니다. // console.log("\nFetching posts for user 1..."); // const user1Posts = await api.users[1].posts.list(); // console.log("User 1 Posts:", user1Posts); } catch (error) { console.error("API call failed:", error); } })();
이 예시에서:
createDynamicApiClient는baseURL을 받아Proxy객체를 반환합니다.- 첫 번째 수준
get트랩은api.users또는api.products와 같은 속성에 대한 액세스를 가로챕니다. 이러한 각 액세스에 대해 또 다른 중첩된Proxy를 반환합니다. 이 중첩된Proxy는 특정 리소스(예:/users)를 나타냅니다. - 두 번째 수준
get트랩 (중첩된Proxy내부)는api.users.list또는api.products.get과 같은 호출을 가로챕니다.resourceProp(예: 'list', 'get', 'create')을 기반으로 올바른 URL을 동적으로 구성하고 적절한httpClient메서드를 호출합니다. - 결과 함수에 전달된 인수 (예:
get의id,create의data)는 API 요청을 완료하는 데 사용됩니다.
이 접근 방식은 보일러플레이트 코드를 크게 줄입니다. getUsers, getProductById, createProduct 함수를 명시적으로 만드는 대신, 속성 액세스 체인에서 API 호출을 추론하는 일반적인 메커니즘을 정의합니다.
적용 시나리오
- RESTful API 클라이언트: 시연에서와 같이 가장 직접적인 적용 사례입니다. 리소스(예:
api.users,api.products)를 API 경로에 매핑하고 작업(예:.list(),.get(id),.create(data))을 HTTP 메서드에 매핑할 수 있습니다. - 동적 쿼리를 사용한 GraphQL 클라이언트: 더 복잡하지만
Proxy를 사용하여 속성 액세스에 따라 동적으로 쿼리를 구성하는 GraphQL 클라이언트를 구축할 수 있습니다. 예를 들어,api.user(1).name.email은{ user(id: 1) { name, email } }과 같은 GraphQL 쿼리로 변환될 수 있습니다. - 프런트엔드 상태 관리용 ORM과 같은 인터페이스: 애플리케이션의 상태 또는 로컬 데이터베이스(예: IndexedDB)를 객체에 매핑한다고 상상해 보세요.
data.users.find(id)또는data.products.add(item)에 액세스하면 기본 데이터 저장소에서 해당 작업을 트리거하여 깔끔하고 선언적인 인터페이스를 제공합니다. - 기능 플래그 관리:
features.newDashboard가newDashboard가 활성화되어 있는지 확인하는Proxy로 기능 플래그 서비스를 래핑할 수 있으며, 정의되지 않은 플래그에 액세스하려는 시도를 기록할 수도 있습니다. - 로거 증강:
Proxy를 사용하여 표준console객체를 래핑하고 타임스탬프, 컨텍스트를 추가하거나 특정 로그 수준에 액세스할 때(예:logger.error('Something bad happened')) 로그를 원격 서비스로 보낼 수 있습니다.
장점 및 고려 사항
장점:
- 보일러플레이트 감소: API 상호 작용에 필요한 반복 코드 양을 대폭 줄입니다.
- 유연성 향상: 백엔드 API 경로 또는 리소스 변경에 더 쉽게 적응할 수 있습니다.
- 가독성 향상: 선언적 특성 (
api.users.get(1))은 명시적 함수 호출보다 더 자연스럽게 읽힙니다. - 탐색 용이성: 일관된 API 설계를 고려할 때 개발자는 종종 항상 문서를 참조하지 않고도 리소스에 액세스하는 방법을 직관적으로 알 수 있습니다.
고려 사항:
- 학습 곡선: 특히 이러한 고급 JavaScript 기능에 익숙하지 않은 개발자에게는
Proxy와Reflect를 이해하는 데 시간이 걸릴 수 있습니다. - 디버깅 복잡성: 프록시 가로채기 코드는 호출 스택에 프록시 트랩이 포함되어 있으므로 직접 함수 호출보다 디버깅하기가 덜 직관적일 수 있습니다.
- 과도한 추상화: 과도하게 사용되거나 명확한 규칙 없이 사용되면 프록시는 코드를 더 쉽게 만들기보다는 이해하기 어렵게 만들 수 있으며, 중요한 논리를 숨기는 "마법"으로 이어질 수 있습니다.
- 성능:
Proxy성능은 일반적으로 일반적인 용도로는 좋지만, 직접 속성 액세스에 비해 약간의 오버헤드가 있습니다. 매우 높은 빈도의 작업 또는 성능이 중요한 작업의 경우 이것이 요인이 될 수 있습니다. - 오류 처리: API 호출 실패 또는 지원되지 않는 작업 시 명확한 오류 메시지와 강력한 오류 처리를 제공하려면 신중한 설계가 필요합니다.
결론
JavaScript의 Proxy 객체를 활용하면 동적 API 클라이언트 및 ORM과 유사한 인터페이스를 만드는 강력하고 우아한 솔루션을 제공합니다. 속성 액세스 및 메서드 호출을 가로채어 간단하고 선언적인 코드를 복잡한 백엔드 상호 작용으로 변환하여 보일러플레이트를 크게 줄이고 애플리케이션의 적응성을 향상시킬 수 있습니다. 신중한 구현과 그 의미에 대한 신중한 고려가 필요하지만, Proxy 객체는 개발자가 외부 서비스와 상호 작용할 때 더 유연하고 유지보수 가능하며 궁극적으로 더 즐거운 코드베이스를 구축할 수 있도록 지원합니다. 이는 프런트엔드와 백엔드를 연결하는 보다 선언적이고 탄력적인 방식으로의 패러다임 전환입니다.

