메타프로그래밍은 코드를 데이터처럼 다루어 프로그램의 구조나 동작을 런타임에 변경하는 기법입니다. ES6 Proxy와 Reflect를 활용하여 동적으로 객체 동작을 제어할 수 있습니다.
학습 목표
이 가이드를 학습하면 다음을 수행할 수 있습니다.
- 메타프로그래밍의 정의를 설명할 수 있다.
- Proxy로 객체 동작을 가로채고 수정할 수 있다.
- Reflect로 안전한 메타 연산을 수행할 수 있다.
- Symbol로 고유 식별자를 생성하고 활용할 수 있다.
메타프로그래밍의 정의
메타프로그래밍을 사용하면 다음이 가능합니다.
- 런타임에 객체 속성과 메서드를 추가하거나 수정한다.
- 함수 호출을 가로채서 사용자 정의 동작을 실행한다.
- 객체 구조를 검사하고 분석한다.
메타프로그래밍의 3가지 기능
1. 내성(Introspection) - 자기 검사
프로그램이 자신의 구조나 특성을 조사하고 분석하는 능력입니다.
function analyzeFunction(fn) { console.log('함수명:', fn.name); console.log('매개변수 개수:', fn.length); console.log('소스코드:', fn.toString()); } function greet(name, age) { return `Hello, ${name}! You are ${age} years old.`; } analyzeFunction(greet); // 함수명: greet // 매개변수 개수: 2 // 소스코드: function greet(name, age) { ... }
객체 구조 분석 예제
function inspectObject(obj) { const info = { properties: Object.getOwnPropertyNames(obj), prototype: Object.getPrototypeOf(obj), constructor: obj.constructor.name, type: typeof obj }; return info; } const user = { name: 'John', age: 30 }; console.log(inspectObject(user));
2. 중재(Intercession) - 동작 가로채기
프로그램의 기본 동작을 가로채서 사용자 정의 동작으로 변경하는 능력입니다.
const user = { name: 'John', age: 30 }; const proxy = new Proxy(user, { get(target, prop) { console.log(`${prop} 프로퍼티에 접근함`); return target[prop]; }, set(target, prop, value) { console.log(`${prop} 프로퍼티를 ${value}로 설정함`); target[prop] = value; return true; }, has(target, prop) { console.log(`${prop} 프로퍼티 존재 여부 확인`); return prop in target; } }); proxy.name; // "name 프로퍼티에 접근함" proxy.email = 'john@test.com'; // "email 프로퍼티를 john@test.com로 설정함" 'age' in proxy; // "age 프로퍼티 존재 여부 확인"
동적 API 클라이언트 예제
const api = new Proxy({}, { get(target, method) { return function(data) { return fetch(`/api/${method}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); }; } }); // 실제로 존재하지 않는 메서드들이 동적으로 생성됨 api.getUser({ id: 123 }); // POST /api/getUser api.createPost({ title: 'Hello' }); // POST /api/createPost api.deleteComment({ id: 456 }); // POST /api/deleteComment
3. 자기 수정(Self-modification) - 구조 변경
프로그램이 자신의 구조나 동작을 런타임에 수정하는 능력입니다.
// 클래스에 동적으로 메서드 추가 class User { constructor(name) { this.name = name; } } // 런타임에 메서드 추가 User.prototype.greet = function() { return `Hello, I'm ${this.name}`; }; User.prototype.getAge = function() { return this.age || '나이를 설정하지 않음'; }; const user = new User('John'); console.log(user.greet()); // "Hello, I'm John"
객체 동적 확장 예제
function addLogging(obj) { const originalMethods = {}; Object.getOwnPropertyNames(Object.getPrototypeOf(obj)).forEach(method => { if (typeof obj[method] === 'function' && method !== 'constructor') { originalMethods[method] = obj[method]; obj[method] = function(...args) { console.log(`${method} 호출됨:`, args); const result = originalMethods[method].apply(this, args); console.log(`${method} 결과:`, result); return result; }; } }); } class Calculator { add(a, b) { return a + b; } multiply(a, b) { return a * b; } } const calc = new Calculator(); addLogging(calc); calc.add(2, 3); // 로깅과 함께 실행됨 calc.multiply(4, 5); // 로깅과 함께 실행됨
ES6 메타프로그래밍 도구
Proxy 활용법
Proxy는 객체의 기본 동작을 재정의할 수 있는 도구입니다.
기본 문법
const proxy = new Proxy(target, handler);
주요 트랩(trap) 메서드들
const obj = { name: 'John', age: 30 }; const proxy = new Proxy(obj, { // 프로퍼티 읽기 get(target, property, receiver) { if (property in target) { return Reflect.get(target, property, receiver); } return `${property}는 존재하지 않는 프로퍼티입니다`; }, // 프로퍼티 쓰기 set(target, property, value, receiver) { if (typeof value === 'string' && value.length > 50) { throw new Error('문자열이 너무 깁니다'); } return Reflect.set(target, property, value, receiver); }, // 프로퍼티 열거 ownKeys(target) { return Object.keys(target).filter(key => !key.startsWith('_')); }, // 함수 호출 apply(target, thisArg, argumentsList) { console.log('함수가 호출되었습니다'); return Reflect.apply(target, thisArg, argumentsList); } });
유효성 검사 예제
function createValidator(schema) { return new Proxy({}, { set(target, property, value) { const rule = schema[property]; if (rule) { if (rule.required && (value === undefined || value === null)) { throw new Error(`${property}는 필수 항목입니다`); } if (rule.type && typeof value !== rule.type) { throw new Error(`${property}는 ${rule.type} 타입이어야 합니다`); } if (rule.min && value < rule.min) { throw new Error(`${property}는 ${rule.min} 이상이어야 합니다`); } } target[property] = value; return true; } }); } const userSchema = { name: { required: true, type: 'string' }, age: { required: true, type: 'number', min: 0 }, email: { required: false, type: 'string' } }; const user = createValidator(userSchema); user.name = 'John'; // 성공 user.age = 25; // 성공 // user.age = -5; // Error: age는 0 이상이어야 합니다 // user.name = 123; // Error: name는 string 타입이어야 합니다
Reflect 활용법
Reflect는 Proxy와 함께 사용되어 메타프로그래밍을 더 안전하게 만듭니다.
주요 Reflect 메서드들
const obj = { name: 'John', age: 30 }; // 프로퍼티 값 가져오기 const name = Reflect.get(obj, 'name'); // 'John' // 프로퍼티 설정하기 Reflect.set(obj, 'city', 'Seoul'); // true // 프로퍼티 존재 여부 확인 const hasAge = Reflect.has(obj, 'age'); // true // 프로퍼티 삭제 Reflect.deleteProperty(obj, 'age'); // true // 객체의 모든 키 가져오기 const keys = Reflect.ownKeys(obj); // ['name', 'city'] // 프로퍼티 정의 Reflect.defineProperty(obj, 'readonly', { value: 'cannot change', writable: false }); // true
Reflect와 Proxy의 조합
const debugProxy = new Proxy({}, { get(target, property, receiver) { console.log(`GET: ${property}`); return Reflect.get(target, property, receiver); }, set(target, property, value, receiver) { console.log(`SET: ${property} = ${value}`); return Reflect.set(target, property, value, receiver); }, has(target, property) { console.log(`HAS: ${property}`); return Reflect.has(target, property); } });
Symbol 활용법
Symbol은 고유한 식별자를 만들어 메타프로그래밍에서 충돌 없는 프로퍼티 키로 사용됩니다.
// 고유한 심볼 생성 const META_KEY = Symbol('metadata'); const PRIVATE_KEY = Symbol('private'); class User { constructor(name) { this.name = name; this[META_KEY] = { created: Date.now() }; this[PRIVATE_KEY] = { secret: 'hidden data' }; } getMetadata() { return this[META_KEY]; } } const user = new User('John'); console.log(user.name); // 'John' console.log(user.getMetadata()); // { created: 1234567890 } // Symbol 키는 일반적인 방법으로 접근 불가 console.log(Object.keys(user)); // ['name'] - Symbol 키는 제외됨
실무 적용 가이드
메타프로그래밍을 사용해야 하는 경우
다음 상황에서 메타프로그래밍을 사용하세요.
1. 라이브러리/프레임워크 개발
// ORM 스타일의 동적 쿼리 생성 const User = new Proxy({}, { get(target, method) { if (method.startsWith('findBy')) { const field = method.slice(6).toLowerCase(); return (value) => { return fetch(`/users?${field}=${value}`); }; } } }); User.findByName('john'); // /users?name=john User.findByEmail('john@test.com'); // /users?email=john@test.com
2. 개발 도구 및 디버깅
// 성능 모니터링 래퍼 function performanceWrap(obj) { return new Proxy(obj, { get(target, property) { const value = target[property]; if (typeof value === 'function') { return function(...args) { const start = performance.now(); const result = value.apply(this, args); const end = performance.now(); console.log(`${property} 실행 시간: ${end - start}ms`); return result; }; } return value; } }); }
3. 설정 객체 유효성 검사
function createConfig(defaults, validators = {}) { return new Proxy(defaults, { set(target, property, value) { const validator = validators[property]; if (validator && !validator(value)) { throw new Error(`${property}에 유효하지 않은 값: ${value}`); } target[property] = value; return true; } }); }
메타프로그래밍을 피해야 하는 경우
다음 상황에서는 메타프로그래밍을 피하세요.
1. 단순한 작업
// 나쁜 예: 단순한 객체 접근에 Proxy 사용 const badProxy = new Proxy(user, { get(target, prop) { return target[prop]; } }); // 좋은 예: 직접 접근 const name = user.name;
2. 성능이 중요한 경우
- 직접 접근: ~0.001ms
- Proxy 접근: ~0.005ms (약 5배 느림)
3. 디버깅이 중요한 경우
- Proxy는 스택 트레이스를 복잡하게 만듭니다.
- 동적 생성된 메서드는 IDE에서 자동완성이 어렵습니다.
- 타입 추론이 어려워집니다.
모범 사례
1. 명확한 의도 표현
const apiClient = new Proxy({}, { get(target, endpoint) { return (params) => fetch(`/api/${endpoint}`, { method: 'POST', body: JSON.stringify(params) }); } });
2. 에러 처리 강화
const safeProxy = new Proxy(target, { get(target, property) { try { return Reflect.get(target, property); } catch (error) { console.error(`Property access failed: ${property}`, error); return undefined; } } });
3. 문서화와 타입 정의
// TypeScript를 사용한 메타프로그래밍 interface DynamicAPI { [K: string]: (params: any) => Promise<any>; } const api: DynamicAPI = new Proxy({}, { // 구현... });
결론
JavaScript 메타프로그래밍은 코드를 더 유연하고 동적으로 만들 수 있는 도구입니다. 실제 필요성과 복잡도를 신중히 고려하여 사용하세요. 메타프로그래밍이 해결하려는 문제가 명확하고, 그 이익이 추가적인 복잡성을 상쇄할 만큼 클 때 사용하는 것이 좋습니다.