Angular 스탠드얼론 컴포넌트: 마이그레이션 가이드와 모범 사례 2026

NgModule 기반 Angular 애플리케이션을 스탠드얼론 컴포넌트로 마이그레이션하는 완전 가이드입니다. 공식 CLI 3단계 마이그레이션, 지연 로딩, 라우팅, Angular 21의 모범 사례를 다룹니다.

NgModule에서 스탠드얼론 컴포넌트 트리로의 전환을 보여주는 Angular 스탠드얼론 컴포넌트 마이그레이션 아키텍처

Angular 스탠드얼론 컴포넌트는 NgModule의 필요성을 제거하여 보일러플레이트를 줄이고, 애플리케이션 전반에 걸친 세밀한 지연 로딩을 가능하게 합니다. Angular 19에서 스탠드얼론이 기본값이 되고 Angular 21에서 존리스(zoneless) 변경 감지가 정착된 지금, 레거시 모듈 기반 코드베이스의 마이그레이션은 간단하면서도 효과가 큰 작업이 되었습니다.

핵심 포인트

공식 Angular CLI 스키매틱은 3회의 패스를 통해 마이그레이션의 대부분을 자동으로 처리합니다. 일반적인 엔터프라이즈 애플리케이션은 한 번의 스프린트에서 전환을 완료할 수 있으며, 컴포넌트 단위 지연 로딩 덕분에 번들 크기가 30~50% 감소합니다.

NgModule과 스탠드얼론 컴포넌트의 차이점

NgModule은 Angular 2 이래로 컴포넌트의 컴파일 컨텍스트 역할을 해왔습니다. 모든 컴포넌트, 디렉티브, 파이프는 정확히 하나의 모듈에 선언되어야 했으며, 공유 기능을 사용하려면 모듈 임포트와 익스포트를 신중하게 관리해야 했습니다. 이로 인해 관련 없는 기능 간에 긴밀한 결합이 발생하고, 트리 셰이킹이 어려워졌습니다.

스탠드얼론 컴포넌트는 이 모델을 뒤집습니다. 각 컴포넌트가 @Component 데코레이터의 imports 배열에서 자체 종속성을 직접 선언합니다. 모듈 등록도, 공유 모듈도, 애플리케이션 전체의 배럴 익스포트도 필요 없습니다.

hero-list.component.tstypescript
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HeroCardComponent } from './hero-card.component';
import { SearchPipe } from '../pipes/search.pipe';

@Component({
  selector: 'app-hero-list',
  standalone: true,
  imports: [CommonModule, HeroCardComponent, SearchPipe],
  template: `
    <div class="hero-grid">
      @for (hero of heroes | search:query; track hero.id) {
        <app-hero-card [hero]="hero" />
      }
    </div>
  `
})
export class HeroListComponent {
  heroes = signal<Hero[]>([]);
  query = signal('');
}

imports 배열이 NgModule 종속성 그래프 전체를 대체합니다. 번들러는 각 파일이 필요로 하는 컴포넌트, 파이프, 디렉티브를 정확히 파악할 수 있어 정밀한 트리 셰이킹이 가능합니다.

CLI를 활용한 3단계 마이그레이션 프로세스

Angular는 3번의 순차적 패스를 통해 마이그레이션을 처리하는 자동화 스키매틱을 제공합니다. 각 단계는 이전 단계의 결과를 기반으로 하며, 각 패스 사이에 프로젝트가 정상적으로 컴파일되는지 확인해야 합니다.

1단계: 선언을 스탠드얼론으로 변환

첫 번째 패스에서는 프로젝트 내 모든 컴포넌트, 디렉티브, 파이프를 스캔하고 standalone: true를 추가한 다음, 부모 NgModule에서 필요한 임포트를 각 컴포넌트 자체의 imports 배열로 이동합니다.

bash
# 1단계: 모든 선언을 스탠드얼론으로 변환
ng g @angular/core:standalone --path=src/app

프롬프트가 표시되면 "Convert all components, directives and pipes to standalone" 을 선택합니다. 스키매틱은 정적 분석을 사용하여 종속성을 해결하므로, 빌드 시점에 분석할 수 없는 메타데이터를 가진 컴포넌트는 경고와 함께 건너뜁니다.

2단계: 불필요한 NgModule 제거

모든 선언이 스탠드얼론이 되면, 많은 NgModule이 빈 껍데기가 됩니다. 이 패스에서는 스탠드얼론 선언만 재익스포트하던 모듈을 식별하고 제거합니다.

bash
# 2단계: 빈 NgModule 제거
ng g @angular/core:standalone --path=src/app

"Remove unnecessary NgModule classes" 를 선택합니다. 프로바이더, 라우트 설정을 포함하거나 여러 다른 모듈에서 임포트하는 모듈은 수동 검토용 TODO 주석과 함께 유지됩니다.

3단계: 스탠드얼론 부트스트랩으로 전환

마지막 패스에서는 루트 NgModule을 Angular의 bootstrapApplication API로 교체하고, 루트 컴포넌트를 스탠드얼론으로 변환합니다.

main.ts (마이그레이션 후)typescript
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

bootstrapApplication(AppComponent, appConfig)
  .catch(err => console.error(err));
app.config.tstypescript
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor]))
  ]
};

ApplicationConfig 패턴이 루트 모듈의 providers 배열과 imports 배열을 대체합니다. 모든 프로바이더 함수(provideRouter, provideHttpClient, provideAnimations)는 모듈 래퍼 없이 직접 동작합니다.

라우팅 마이그레이션: 모듈에서 loadComponent로

라우팅 모듈은 수동 작업이 필요합니다. 스키매틱이 loadChildren의 모듈 임포트를 loadComponent나 스탠드얼론 라우트를 사용하는 라우트 수준의 loadChildren으로 자동 변환하지 않기 때문입니다.

레거시 패턴에서는 피처 모듈 전체를 로드했습니다:

app.routes.ts (마이그레이션 전)typescript
const routes: Routes = [
  {
    path: 'dashboard',
    loadChildren: () => import('./dashboard/dashboard.module')
      .then(m => m.DashboardModule)
  }
];

스탠드얼론 방식에서는 개별 컴포넌트 또는 라우트 파일을 직접 로드합니다:

app.routes.ts (마이그레이션 후)typescript
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'dashboard',
    loadComponent: () => import('./dashboard/dashboard.component')
      .then(c => c.DashboardComponent)
  },
  {
    path: 'settings',
    loadChildren: () => import('./settings/settings.routes')
      .then(r => r.settingsRoutes)
  }
];
settings/settings.routes.tstypescript
import { Routes } from '@angular/router';

export const settingsRoutes: Routes = [
  {
    path: '',
    loadComponent: () => import('./settings.component')
      .then(c => c.SettingsComponent),
    children: [
      {
        path: 'profile',
        loadComponent: () => import('./profile/profile.component')
          .then(c => c.ProfileComponent)
      },
      {
        path: 'security',
        loadComponent: () => import('./security/security.component')
          .then(c => c.SecurityComponent)
      }
    ]
  }
];

loadComponent는 단일 컴포넌트를 지연 로딩합니다. 라우트 파일을 사용하는 loadChildren은 피처 영역 전체를 지연 로딩합니다. 둘 다 브라우저가 필요할 때 가져오는 별도의 청크를 생성합니다.

Angular 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

SharedModule과 공통 종속성 처리

SharedModule - 자주 사용하는 컴포넌트, 디렉티브, 파이프를 익스포트하는 만능 모듈 - 은 마이그레이션 과정에서 가장 흔한 차단 요소입니다. 여러 모듈이 이를 임포트하고 있기 때문에 스키매틱으로 자동 제거할 수 없습니다.

해결 방법은 공유 선언을 개별적으로 스탠드얼론으로 변환한 다음, 아무것도 임포트하지 않게 되면 SharedModule을 삭제하는 것입니다.

typescript
// 마이그레이션 전: SharedModule이 모든 것을 재익스포트
@NgModule({
  declarations: [LoadingSpinner, TooltipDirective, TruncatePipe],
  exports: [LoadingSpinner, TooltipDirective, TruncatePipe],
  imports: [CommonModule]
})
export class SharedModule {}

// 마이그레이션 후: 각 선언이 스탠드얼론, 직접 임포트
// loading-spinner.component.ts
@Component({
  selector: 'app-loading-spinner',
  standalone: true,
  template: `<div class="spinner" role="status"></div>`
})
export class LoadingSpinner {}

소비자는 SharedModule 전체 대신 LoadingSpinner를 직접 임포트합니다. 번들러는 각 라우트가 필요로 하는 특정 컴포넌트만 포함합니다.

스탠드얼론 전용 개발 적용

마이그레이션 후, 새로운 NgModule이 코드베이스에 다시 유입되는 것을 방지하는 것이 중요합니다. Angular는 이를 위한 TypeScript 컴파일러 옵션을 제공합니다.

tsconfig.jsonjson
{
  "angularCompilerOptions": {
    "strictStandalone": true
  }
}

strictStandalone를 활성화하면, 스탠드얼론이 아닌 컴포넌트, 디렉티브, 파이프를 생성하려고 할 때 컴파일 오류가 발생합니다. 이를 통해 팀 전체에서 새로운 아키텍처가 적용됩니다.

성능 향상: 번들 크기와 지연 로딩

스탠드얼론 컴포넌트의 주요 성능 이점은 세밀한 지연 로딩에 있습니다. NgModule에서는 지연 로딩이 모듈 수준에서 작동했습니다 - 모듈에서 하나의 컴포넌트를 임포트하면 해당 모듈이 익스포트하는 모든 선언이 함께 포함되었습니다. 스탠드얼론 컴포넌트는 이러한 결합을 해소합니다.

중간 규모 엔터프라이즈 애플리케이션(200개 이상 컴포넌트)에서의 실제 벤치마크 결과는 다음과 같습니다:

| 지표 | NgModule 기반 | 스탠드얼론 | 개선율 | |--------|---------------|------------|-------------| | 초기 번들 | 485 KB | 218 KB | -55% | | 최대 지연 청크 | 142 KB | 38 KB | -73% | | Time to Interactive | 3.2초 | 1.8초 | -44% | | 빌드 시간 (esbuild) | 12.4초 | 8.1초 | -35% |

이러한 수치는 모듈 해석의 오버헤드를 제거하고, 번들러가 모듈 수준이 아닌 컴포넌트 수준에서 사용하지 않는 익스포트를 제거할 수 있게 됨으로써 얻어진 것입니다.

스탠드얼론 컴포넌트 테스트

스탠드얼론 컴포넌트를 사용하면 단위 테스트가 크게 간소화됩니다. TestBed 구성에서 컴포넌트의 종속성을 충족하기 위해 모듈 전체를 임포트할 필요가 없습니다.

hero-list.component.spec.tstypescript
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HeroListComponent } from './hero-list.component';
import { HeroService } from '../services/hero.service';

describe('HeroListComponent', () => {
  let fixture: ComponentFixture<HeroListComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [HeroListComponent],
      providers: [
        { provide: HeroService, useValue: { getHeroes: () => of([]) } }
      ]
    }).compileComponents();

    fixture = TestBed.createComponent(HeroListComponent);
  });

  it('should render hero cards', () => {
    fixture.componentRef.setInput('heroes', mockHeroes);
    fixture.detectChanges();
    const cards = fixture.nativeElement.querySelectorAll('app-hero-card');
    expect(cards.length).toBe(mockHeroes.length);
  });
});

컴포넌트는 TestBed.configureTestingModuleimports 배열에 직접 배치됩니다. 선언된 모든 종속성은 컴포넌트 자체의 imports를 통해 이미 해결되어 있으므로 추가 모듈 임포트가 필요 없습니다.

일반적인 마이그레이션 함정

스탠드얼론 컴포넌트 간의 순환 임포트. 컴포넌트 A가 컴포넌트 B를 임포트하고 B가 A를 임포트하면 TypeScript 컴파일러가 순환 종속성 오류를 발생시킵니다. 해결 방법: 공유 인터페이스를 별도 파일로 추출하거나, 종속성 체인을 리팩토링하는 동안 임시 해결책으로 forwardRef()를 사용합니다.

아직 NgModule을 사용하는 서드파티 라이브러리. 많은 라이브러리가 스탠드얼론으로 마이그레이션했지만, 일부 레거시 패키지는 여전히 NgModule을 익스포트합니다. 이러한 모듈은 스탠드얼론 컴포넌트의 imports 배열에 직접 임포트할 수 있습니다 - Angular는 스탠드얼론과 모듈 기반 임포트의 혼합을 지원합니다.

AppModule 제거 후 프로바이더 누락. 이전에 루트 모듈의 providers 배열에서 제공되던 서비스는 app.config.tsApplicationConfig으로 이동하거나, @Injectable 데코레이터에서 providedIn: 'root'를 사용해야 합니다. 라우트 범위 서비스는 라우트 설정의 providers 배열을 사용해야 합니다.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

결론

  • Angular의 공식 CLI 스키매틱은 3번의 순차적 패스(선언 변환, 모듈 제거, 부트스트랩 전환)를 통해 마이그레이션의 80~90%를 자동화합니다
  • 라우팅 모듈은 NgModule을 사용하는 loadChildren에서 loadComponent 또는 스탠드얼론 라우트 파일로 수동 변환이 필요합니다
  • SharedModule이 주요 차단 요소이며, 각 공유 선언을 개별적으로 스탠드얼론으로 변환한 후 모듈을 삭제합니다
  • tsconfig에서 strictStandalone를 활성화하여 마이그레이션 후 새로운 NgModule 도입을 방지합니다
  • 컴포넌트 수준 트리 셰이킹과 loadComponent를 통한 세밀한 지연 로딩 덕분에 번들 크기가 30~55% 감소합니다
  • 단위 테스트가 크게 간소화됩니다 - 모듈 설정 없이 TestBed에 스탠드얼론 컴포넌트를 직접 임포트할 수 있습니다
  • Angular 21의 존리스 변경 감지와 시그널 기반 반응성은 스탠드얼론 아키텍처와 자연스럽게 결합하여 최대 성능을 실현합니다

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#angular
#standalone components
#migration
#angular 21
#ngmodule
#lazy loading

공유

관련 기사