향상된 코드 패턴을 위한 TypeScript 데코레이터 이해 및 구현
Wenhao Wang
Dev Intern · Leapcell

소개
현대 웹 개발의 끊임없이 진화하는 환경에서 깨끗하고 유지보수 가능하며 확장 가능한 코드를 작성하는 것이 중요합니다. 애플리케이션이 복잡해짐에 따라 개발자는 종종 코드베이스의 다양한 부분에서 메서드 호출 로깅, 입력 유효성 검사 또는 액세스 제어 적용과 같은 반복적인 작업에 직면합니다. 전통적인 객체 지향 프로그래밍은 상속 및 컴포지션과 같은 메커니즘을 제공하지만, 때로는 상용구 코드 또는 복잡하게 얽힌 종속성으로 이어질 수 있습니다. 바로 이 지점에서 TypeScript 데코레이터가 강력하고 우아한 솔루션으로 등장합니다. 데코레이터는 원래 구현을 수정하지 않고도 클래스, 메서드, 속성 및 매개변수에 메타데이터를 추가하고 동작을 변경할 수 있는 선언적 방법을 제공합니다. 데코레이터를 이해하고 활용하면 코드 가독성을 크게 향상시키고, 중복을 줄이며, 더 강력한 아키텍처 패턴을 촉진할 수 있습니다. 이 글에서는 TypeScript 데코레이터의 기본 개념, 그 기본 메커니즘을 탐구하고, 로깅 및 권한 확인에 대한 설득력 있는 예제를 통해 실제 적용을 시연합니다.
TypeScript 데코레이터의 핵심 개념
세부 사항을 살펴보기 전에 데코레이터와 관련된 핵심 용어를 명확히 이해해 봅시다.
- 데코레이터(Decorator): 클래스 선언, 메서드, 접근자, 속성 또는 매개변수에 첨부할 수 있는 특수 종류의 선언입니다. 데코레이터는
@
기호 뒤에 함수 이름이 붙습니다. - 데코레이터 팩토리(Decorator Factory): 런타임에 데코레이터가 호출할 표현을 반환하는 함수입니다. 이를 통해 데코레이터에 인수를 전달할 수 있습니다.
- 타겟(Target): 데코레이터가 적용되는 엔터티(예: 클래스 생성자, 메서드 설명자, 속성 설명자 또는 매개변수 인덱스)입니다.
- 속성 설명자(Property Descriptor): 속성의 속성(예:
value
,writable
,enumerable
,configurable
)을 설명하는 객체입니다. 이는 메서드 및 접근자 데코레이터와 관련이 있습니다.
데코레이터는 본질적으로 선언 시점(코드가 실행될 때가 아니라 정의될 때)에 특정 인수에 따라 실행되는 함수입니다. 이 실행 순서는 코드를 수정하는 방식을 이해하는 데 중요합니다.
데코레이터의 내부 작동 방식
근본적으로 TypeScript는 컴파일 중에 데코레이터를 만나면 데코레이션된 코드를 변환합니다. 이 변환은 본질적으로 데코레이터 함수에 정의된 로직을 사용하여 타겟을 래핑하거나 수정합니다.
실행 순서를 고려해 봅시다:
- **매개변수 데코레이터(Parameter Decorators)**는 각 매개변수에 대해 먼저 적용됩니다.
- **메서드, 접근자 또는 속성 데코레이터(Method, Accessor, or Property Decorators)**는 다음으로, 나타나는 순서대로 적용됩니다.
- **클래스 데코레이터(Class Decorators)**는 마지막으로 적용됩니다.
같은 타겟에 여러 데코레이터가 있으면 가장 낮은 데코레이터부터 적용되며, 선언에 가장 가까운 데코레이터가 먼저 적용되고 그 결과가 다음 데코레이터에 전달됩니다.
데코레이터 구현: 단계별 안내
데코레이터는 함수로 정의할 수 있습니다. 함수의 시그니처는 어떤 것을 데코레이션하는지에 따라 달라집니다. 데코레이터 지원을 활성화하려면 tsconfig.json
에 다음이 포함되어 있는지 확인하십시오.
{ "compilerOptions": { "experimentalDecorators": true, "emitDecoratorMetadata": true // 반사를 위해 유용하지만 기본 데코레이터에는 엄격하게 필요하지는 않습니다. } }
클래스 데코레이터
클래스 데코레이터는 클래스의 생성자 함수를 유일한 인수로 받습니다. 클래스 정의를 관찰, 수정 또는 교체할 수 있습니다.
function ClassLogger(constructor: Function) { console.log(`Class: ${constructor.name} was defined.`); // 속성, 메서드를 추가하거나 생성자를 교체할 수 있습니다. // 시연을 위해 단순히 로깅하겠습니다. } @ClassLogger class UserService { constructor(public name: string) {} getUserName() { return this.name; } } const user = new UserService("Alice"); // 출력: Class: UserService was defined. (정의 시점)
메서드 데코레이터
메서드 데코레이터는 세 가지 인수를 받습니다:
target
: 클래스의 프로토타입(인스턴스 메서드의 경우) 또는 생성자 함수(정적 메서드의 경우).propertyKey
: 메서드의 이름.descriptor
: 메서드의 속성 설명자.
메서드 정의를 검사, 수정 또는 교체할 수 있습니다.
function MethodLogger(target: any, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; // 원본 메서드를 저장 console.log(`Decorating method: ${propertyKey} on class: ${target.constructor.name}`); descriptor.value = function(...args: any[]) { console.log(`Calling method: ${propertyKey} with arguments: ${JSON.stringify(args)}`); const result = originalMethod.apply(this, args); // 원본 메서드를 호출 console.log(`Method: ${propertyKey} returned: ${JSON.stringify(result)}`); return result; }; return descriptor; // 수정된 설명자를 반환 } class ProductService { constructor(private products: string[] = []) {} @MethodLogger getProduct(id: number): string | undefined { return this.products[id]; } @MethodLogger addProduct(name: string) { this.products.push(name); return `Added ${name}`; } } const productService = new ProductService(["Laptop", "Mouse"]); productService.getProduct(0); // 출력: // Decorating method: getProduct on class: ProductService // Decorating method: addProduct on class: ProductService // Calling method: getProduct with arguments: [0] // Method: getProduct returned: "Laptop" productService.addProduct("Keyboard"); // 출력: // Calling method: addProduct with arguments: ["Keyboard"] // Method: addProduct returned: "Added Keyboard"
속성 데코레이터
속성 데코레이터는 두 가지 인수를 받습니다:
target
: 클래스의 프로토타입(인스턴스 속성의 경우) 또는 생성자 함수(정적 속성의 경우).propertyKey
: 속성의 이름.
속성 데코레이터는 다소 제한적입니다. 선언된 속성을 관찰할 수만 있으며, 설명자를 직접 수정할 수는 없습니다. 설명자를 받지 않기 때문입니다. 하지만 팩토리로 사용되는 경우 새 속성 설명자를 반환할 수 있습니다. 일반적으로 메타데이터를 등록하거나 접근자 함수를 추가하는 데 사용됩니다.
function PropertyValidation(target: any, propertyKey: string) { let value: string; // 속성에 대한 내부 저장소 const getter = function() { console.log(`Getting value for ${propertyKey}: ${value}`); return value; }; const setter = function(newVal: string) { if (newVal.length < 3) { console.warn(`Validation failed for ${propertyKey}: Value too short.`); } console.log(`Setting value for ${propertyKey}: ${newVal}`); value = newVal; }; // 속성을 getter와 setter로 교체 Object.defineProperty(target, propertyKey, { get: getter, set: setter, enumerable: true, configurable: true, }); } class User { @PropertyValidation username: string = ""; constructor(username: string) { this.username = username; } } const user2 = new User("Bob"); // 출력: Setting value for username: Bob user2.username; // 출력: Getting value for username: Bob user2.username = "Al"; // 출력: Validation failed for username: Value too short. // 출력: Setting value for username: Al user2.username = "Charlie"; // 출력: Setting value for username: Charlie
매개변수 데코레이터
매개변수 데코레이터는 세 가지 인수를 받습니다:
target
: 클래스의 프로토타입(인스턴스 멤버의 경우) 또는 생성자 함수(정적 멤버의 경우).propertyKey
: 메서드의 이름.parameterIndex
: 메서드 인자 목록에서 매개변수의 인덱스.
매개변수 데코레이터는 일반적으로 매개변수를 유효성 검사 또는 종속성 주입을 위해 표시하는 것과 같은 메타데이터 반사에 사용됩니다. 매개변수의 타입이나 동작을 직접 수정할 수는 없습니다.
function Required(target: Object, propertyKey: string | symbol, parameterIndex: number) { // 필수 매개변수에 대한 메타데이터 저장 const existingRequiredParameters: number[] = Reflect.getOwnMetadata("required", target, propertyKey) || []; existingRequiredParameters.push(parameterIndex); Reflect.defineMetadata("required", existingRequiredParameters, target, propertyKey); } // 이를 위해 'reflect-metadata'를 설치해야 합니다: `npm install reflect-metadata --save` // 그리고 임포트해야 합니다: `import "reflect-metadata";` class UserController { registerUser( @Required username: string, password: string, @Required email: string ) { console.log(`Registering user: ${username}, ${email}`); // ... 로직 } } // 예제 사용 (데코레이터 외부, 메타데이터 확인용) function validate(instance: any, methodName: string, args: any[]) { const requiredParams: number[] = Reflect.getOwnMetadata("required", instance, methodName); if (requiredParams) { for (const index of requiredParams) { if (args[index] === undefined || args[index] === null || args[index] === "") { throw new Error(`Parameter at index ${index} is required for method ${methodName}.`); } } } } const userController = new UserController(); try { userController.registerUser("JohnDoe", "password123", "john.doe@example.com"); validate(userController, "registerUser", ["JohnDoe", "password123", "john.doe@example.com"]); userController.registerUser("", "pass", "email@test.com"); // 함수 호출은 통과하지만, 우리의 사용자 정의 유효성 검사 도우미는 실패합니다. validate(userController, "registerUser", ["", "pass", "email@test.com"]); } catch (error: any) { console.error(error.message); // 출력: Parameter at index 0 is required for method registerUser. }
실제 적용
데코레이터는 코드베이스의 다양한 부분에 횡단 적용(cross-cutting concerns)을 반복적으로 추가해야 하는 시나리오에서 빛을 발합니다.
메서드 호출 로깅
위의 MethodLogger
에서 시연한 대로 데코레이터는 메서드 실행(인수 및 반환 값 포함)을 자동으로 로깅하는 데 탁월합니다. 이는 디버깅, 모니터링 및 감사에 매우 유용합니다.
// 간결성을 위해 위에서 MethodLogger 재사용 // function MethodLogger(target: any, propertyKey: string, descriptor: PropertyDescriptor) { ... } class AuthService { private users: { [key: string]: string } = { admin: "securepass" }; @MethodLogger login(username: string, pass: string): boolean { if (this.users[username] === pass) { console.log(`User ${username} logged in successfully.`); return true; } console.warn(`Login failed for user ${username}.`); return false; } @MethodLogger changePassword(username: string, oldPass: string, newPass: string): boolean { if (this.users[username] === oldPass) { this.users[username] = newPass; console.log(`Password changed for user ${username}.`); return true; } console.error(`Failed to change password for user ${username}. Incorrect old password.`); return false; } } const authService = new AuthService(); authService.login("admin", "securepass"); authService.changePassword("admin", "securepass", "newSecurePass"); authService.login("admin", "wrongpass");
이것은 기본 로직을 수정하지 않고 login
및 changePassword
호출에 대한 상세 정보를 자동으로 로깅하여 비즈니스 로직을 깨끗하게 유지합니다.
액세스 제어 강제 (권한)
메서드 데코레이터는 메서드를 실행하기 전에 현재 사용자가 필요한 권한을 가지고 있는지 확인하여 역할 기반 액세스 제어(RBAC)를 구현하는 데 사용될 수 있습니다. 이 작업은 필요한 역할을 전달하기 위해 데코레이터 팩토리를 사용하는 경우가 많습니다.
enum UserRole { Admin = "admin", Editor = "editor", Viewer = "viewer" } // 현재 사용자의 역할을 시뮬레이션 let currentUserRoles: UserRole[] = [UserRole.Editor]; function HasRole(requiredRole: UserRole) { return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) { const originalMethod = descriptor.value; descriptor.value = function(...args: any[]) { if (!currentUserRoles.includes(requiredRole)) { console.warn(`Access Denied: User does not have the '${requiredRole}' role to call ${propertyKey}.`); return; // 또는 오류 발생 } return originalMethod.apply(this, args); }; return descriptor; }; } class AdminDashboard { @HasRole(UserRole.Admin) deleteUser(userId: string) { console.log(`Admin deleting user ${userId}...`); // ... 실제 삭제 로직 } @HasRole(UserRole.Editor) editArticle(articleId: string, content: string) { console.log(`Editor editing article ${articleId}: ${content.substring(0, 20)}...`); // ... 실제 편집 로직 } @HasRole(UserRole.Viewer) viewReports() { console.log("Viewer accessing reports..."); // ... 실제 보고서 보기 로직 } } const dashboard = new AdminDashboard(); console.log("Current User Roles:", currentUserRoles); dashboard.deleteUser("user123"); dashboard.editArticle("article456", "New article content..."); dashboard.viewReports(); console.log("\nChanging user roles to Admin..."); currentUserRoles = [UserRole.Admin]; dashboard.deleteUser("user123"); dashboard.editArticle("article456", "Updated content..."); // Admin은 다른 수단을 통해 구성된 경우 Editor 권한도 가집니다. dashboard.viewReports();
이 예제에서 HasRole
데코레이터는 메서드가 실행되기 전에 동적으로 사용자 권한을 확인합니다. 이렇게 하면 권한 로직이 중앙 집중화되어 여러 메서드에 쉽게 적용하고 재사용할 수 있습니다.
결론
TypeScript 데코레이터는 메타프로그래밍을 위한 강력하고 우아한 메커니즘을 제공하여 개발자가 클래스 멤버 및 매개변수를 선언적으로 확장하고 수정할 수 있습니다. 로깅 및 액세스 제어와 같은 횡단 적용을 핵심 비즈니스 로직에서 분리함으로써 데코레이터는 더 깨끗하고 유지보수성이 높으며 재사용 가능한 코드를 촉진합니다. 데코레이터를 채택하면 코드 아키텍처 및 개발자 효율성을 크게 향상시킬 수 있습니다.