Worker는 JavaScript 코드를 백그라운드 스레드에서 실행할 수 있게 해주는 강력한 기술입니다. Angular 애플리케이션에서 Worker를 통해 복잡한 연산을 백그라운드에서 처리하면 메인 스레드의 부하를 줄여 사용자 경험(UX)을 크게 향상시킬 수 있습니다.
이번 글에서는 최신 Angular의 Standalone Components, Signals, Control Flow를 사용하여 Worker를 효율적으로 만들고 사용하는 방법에 대해 알아보겠습니다.
Worker 생성 과정은 이전과 동일합니다. Angular CLI를 사용하면 필요한 설정을 자동으로 구성해주므로 매우 편리합니다.
ng generate web-worker [경로/이름]
# 예: ng generate web-worker core/workers/calculation
이 명령은 worker 파일과 함께 tsconfig.worker.json을 생성하고 angular.json에 필요한 설정을 자동으로 추가해줍니다.
Worker 파일 자체는 Angular 프레임워크와 독립적으로 실행되는 순수 TypeScript/JavaScript 코드입니다. 따라서 이 부분은 수정할 필요가 없습니다.
// calculation.worker.ts
addEventListener('message', ({ data }) => {
console.log('Worker: 메시지를 받았습니다 ->', data);
// 백그라운드에서 복잡한 연산 수행 (예시)
const result = data * data;
// 결과를 다시 메인 스레드로 전송
postMessage(result);
});
서비스는 Worker와의 통신을 관리하는 역할을 합니다. 기존의 RxJS Subject 대신, Angular의 Signal을 사용하여 상태를 관리하도록 리팩토링합니다. Signal을 사용하면 상태 변화를 더 직관적으로 추적하고, Zone.js에 덜 의존적인 변경 감지를 수행할 수 있습니다.
각 컴포넌트에서 독립적인 Worker 인스턴스를 갖도록 @Injectable() 데코레이터는 여전히 사용하지 않습니다.
Worker를 안전하게 종료하는 terminate() 메서드를 추가합니다.
// worker.service.ts
import { signal, WritableSignal } from '@angular/core';
export class WorkerService {
private worker: Worker;
// Signal을 사용하여 Worker의 상태를 관리합니다.
public readonly result: WritableSignal<number | undefined> = signal(undefined);
public readonly isLoading: WritableSignal<boolean> = signal(false);
public readonly error: WritableSignal<any | undefined> = signal(undefined);
constructor(workerPath: string) {
// new URL(workerPath, import.meta.url) 구문은 worker를 올바르게 참조하기 위함입니다.
this.worker = new Worker(new URL(workerPath, import.meta.url), { type: 'module' });
this.worker.onmessage = ({ data }) => {
this.result.set(data);
this.isLoading.set(false);
};
this.worker.onerror = (err) => {
this.error.set(err);
this.isLoading.set(false);
console.error('Worker error:', err);
};
}
runWorker(input: number): void {
// 이전 결과 및 에러 상태 초기화
this.result.set(undefined);
this.error.set(undefined);
this.isLoading.set(true);
// Worker에 작업 시작 메시지 전송
this.worker.postMessage(input);
}
// 컴포넌트가 파괴될 때 Worker를 정리합니다.
terminate(): void {
this.worker.terminate();
console.log('Worker가 종료되었습니다.');
}
}
마지막으로, Angular 컴포넌트에서 위에서 만든 서비스를 사용하여 웹 워커를 호출합니다.
// app.component.ts
import { Component, OnDestroy } from '@angular/core';
import { WorkerService } from './worker.service';
@Component({
selector: 'app-root',
standalone: true, // Standalone 컴포넌트로 선언
template: `
<h2>Angular Worker with Signals</h2>
<input #inputField type="number" value="10" />
<button (click)="runWorker(inputField.valueAsNumber)">Run Worker</button>
@if (worker.isLoading()) {
<p>작업을 처리하는 중...</p>
}
@if (worker.result(); as result) {
<p class="result">결과: {{ result }}</p>
}
@if (worker.error(); as error) {
<p class="error">에러 발생: {{ error.message }}</p>
}
`,
styles: [`
.result { color: green; font-weight: bold; }
.error { color: red; }
`]
})
export class AppComponent implements OnDestroy {
// 컴포넌트 내에서 독립적인 서비스 인스턴스 생성
// 'calculation.worker.ts'는 실제 워커 파일 경로에 맞게 수정해야 합니다.
readonly worker = new WorkerService('./calculation.worker.ts');
constructor() {}
runWorker(value: number) {
if (isNaN(value)) {
alert('유효한 숫자를 입력하세요.');
return;
}
this.worker.runWorker(value);
}
ngOnDestroy(): void {
// 컴포넌트가 소멸될 때 Worker도 함께 종료시켜 리소스를 정리합니다.
this.worker.terminate();
}
}
이처럼 최신 Angular의 기능을 활용하면 비동기 작업을 처리하는 코드를 훨씬 더 깔끔하고 유지보수하기 쉽게 작성할 수 있습니다
tsconfig.worker.json 파일을 tsconfig.json과 같은 위치에 생성하고 아래의 코드를 입력 합니다.
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/worker",
"lib": [
"es2018",
"webworker"
],
"types": []
},
"include": [
"src/**/*.worker.ts"
]
}
위에서 작성한 tsconfig.webworker.json 파일을 angular.json에 반드시 등록해주어야 합니다.
자동으로 생성했을 때 기본적으로 두 군데에 생성이 되며,
"webWorkerTsConfig": "tsconfig.worker.json"
자동으로 생성했을 때의 angular.json의 전체 코드는 다음과 같습니다.
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"webworker": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist/webworker",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.scss"
],
"scripts": [],
"webWorkerTsConfig": "tsconfig.worker.json"
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
}
],
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "webworker:build:production"
},
"development": {
"browserTarget": "webworker:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "webworker:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.scss"
],
"scripts": [],
"webWorkerTsConfig": "tsconfig.worker.json"
}
}
}
}
}
}