# Angular 18 Signals 완전 가이드: 새로운 리액티브 API와 Zoneless 변경 감지 > Angular 18의 Signals, Zoneless 변경 감지, signal 기반 API를 해설합니다. input(), model(), viewChild()의 실전 사용법을 코드 예제와 함께 소개합니다. - Published: 2026-01-18 - Updated: 2026-04-10 - Author: SharpSkill - Tags: angular 18, angular signals, zoneless, signal inputs, reactivity - Reading time: 12 min --- Angular 18은 Signals의 안정화를 통해 프레임워크 진화의 전환점을 맞이했습니다. 이 새로운 리액티브 프리미티브는 Angular 컴포넌트 구축 방식을 근본적으로 바꾸며, 기존 데코레이터의 모던한 대안을 제공하는 동시에 Zone.js 없는 변경 감지로 가는 길을 열어줍니다. > **이 글에서 배울 내용** > > Angular 18의 signal 기반 API: input(), model(), viewChild(), 그리고 더 가볍고 성능 좋은 애플리케이션을 위한 Zoneless 설정을 다룹니다. ## Angular 18에서의 Signals 기본 개념 Signals는 Angular에서 리액티비티에 대한 새로운 접근법입니다. Zone.js 변경 감지에 의존하는 `@Input()` 같은 기존 데코레이터와 달리, Signals는 세밀하고 명시적인 리액티비티를 제공합니다. 각 Signal은 값을 캡슐화하고, 해당 값이 변경될 때 소비자에게 자동으로 알림을 보냅니다. 이 접근법의 장점은 다음과 같습니다: 타깃 업데이트를 통한 성능 향상, `computed()`와 `effect()` 함수와의 네이티브 통합, 그리고 Angular의 Zoneless 미래를 위한 준비입니다. ```typescript // signals-basics.component.ts // Demonstration of fundamental Signal concepts import { Component, signal, computed, effect } from '@angular/core'; @Component({ selector: 'app-counter', standalone: true, template: `

Counter: {{ count() }}

Double: {{ doubleCount() }}

` }) export class CounterComponent { // Writable signal - value can be modified count = signal(0); // Computed signal - automatically derived from count // Only recalculates when count changes doubleCount = computed(() => this.count() * 2); constructor() { // Effect - executed on every count change // Useful for side effects (logs, API calls, etc.) effect(() => { console.log(`New counter value: ${this.count()}`); }); } increment() { // update() allows modification based on previous value this.count.update(value => value + 1); } decrement() { this.count.update(value => value - 1); } reset() { // set() directly replaces the value this.count.set(0); } } ``` Signals는 리액티브 컨테이너로 작동합니다. `signal()`은 쓰기 가능한 Signal을 생성하고, `computed()`는 파생된 계산 값을 만들며, `effect()`는 변경에 따른 액션 실행을 가능하게 합니다. ## input()을 사용한 Signal Inputs `input()` 함수는 기존의 `@Input()` 데코레이터를 대체합니다. 읽기 전용 `InputSignal`을 반환하므로 데이터가 항상 부모에서 자식으로 단방향으로 흐르며, 실수로 변경될 일이 없습니다. ```typescript // book-card.component.ts // Component using signal inputs import { Component, input, computed } from '@angular/core'; interface Book { id: string; title: string; author: string; price: number; discountPercent?: number; } @Component({ selector: 'app-book-card', standalone: true, template: `

{{ book().title }}

By {{ book().author }}

@if (hasDiscount()) {

\${{ book().price }} \${{ discountedPrice() }}

} @else {

\${{ book().price }}

} @if (featured()) { Featured }
` }) export class BookCardComponent { // Required input - template won't compile without this prop book = input.required(); // Optional input with default value featured = input(false); // Computed based on input - automatically recalculated hasDiscount = computed(() => { const discount = this.book().discountPercent; return discount !== undefined && discount > 0; }); // Discounted price calculation discountedPrice = computed(() => { const { price, discountPercent } = this.book(); if (!discountPercent) return price; return (price * (100 - discountPercent) / 100).toFixed(2); }); } ``` 부모 템플릿에서의 사용법은 기존과 유사하지만, 타입 안전성과 Signal 리액티비티가 추가됩니다. ```typescript // book-list.component.ts // Parent component using book-card import { Component, signal } from '@angular/core'; import { BookCardComponent } from './book-card.component'; @Component({ selector: 'app-book-list', standalone: true, imports: [BookCardComponent], template: `
@for (book of books(); track book.id) { }
` }) export class BookListComponent { books = signal([ { id: '1', title: 'Clean Code', author: 'Robert C. Martin', price: 35 }, { id: '2', title: 'The Pragmatic Programmer', author: 'David Thomas', price: 42, discountPercent: 15 } ]); featuredBookId = signal('1'); } ``` `@Input()`과의 가장 큰 차이점은 signal inputs가 읽기 전용이라는 것입니다. 자식 컴포넌트에서 `this.book.set()`을 호출하는 것이 불가능하며, 이를 통해 단방향 데이터 흐름이 강화됩니다. ## model()을 사용한 양방향 바인딩 양방향 동기화가 필요한 경우를 위해 Angular 18은 `model()`을 도입했습니다. 이 함수는 쓰기 가능한 Signal을 생성하며, 변경 사항을 부모 컴포넌트에 자동으로 전파합니다. ```typescript // search-input.component.ts // Component with bidirectional binding via model() import { Component, model, output, computed } from '@angular/core'; @Component({ selector: 'app-search-input', standalone: true, template: `
@if (query().length > 0) { } {{ charCount() }} characters
` }) export class SearchInputComponent { // model() creates a bidirectional Signal // Modifications propagate to parent query = model(''); // Classic input for configuration placeholder = model('Search...'); // Output for additional events searchSubmitted = output(); // Computed based on model charCount = computed(() => this.query().length); onInput(event: Event) { const value = (event.target as HTMLInputElement).value; // Update model - propagates to parent this.query.set(value); } clear() { this.query.set(''); } submit() { if (this.query().length > 0) { this.searchSubmitted.emit(this.query()); } } } ``` 부모 컴포넌트에서는 바나나 인 어 박스 구문 `[()]`을 사용하여 양방향 바인딩을 구현합니다. ```typescript // app.component.ts // Using two-way binding with model() import { Component, signal, effect } from '@angular/core'; import { SearchInputComponent } from './search-input.component'; @Component({ selector: 'app-root', standalone: true, imports: [SearchInputComponent], template: `

Current search: {{ searchTerm() }}

@for (result of filteredResults(); track result.id) {
{{ result.name }}
}
` }) export class AppComponent { // Local signal synchronized with child component searchTerm = signal(''); results = signal([ { id: 1, name: 'Angular 18' }, { id: 2, name: 'React 19' }, { id: 3, name: 'Vue 3' } ]); // Reactive filtering based on searchTerm filteredResults = computed(() => { const term = this.searchTerm().toLowerCase(); if (!term) return this.results(); return this.results().filter(r => r.name.toLowerCase().includes(term) ); }); } ``` > **model()과 input()의 사용 구분** > > 읽기 전용 데이터(부모에서 자식으로의 단방향)에는 `input()`을 사용합니다. 자식 컴포넌트가 값을 변경해야 하는 경우(양방향)에는 `model()`을 사용합니다. ## viewChild()와 contentChild()를 사용한 Signal 쿼리 `viewChild()`, `viewChildren()`, `contentChild()`, `contentChildren()` 함수는 대응하는 데코레이터를 대체합니다. Signal을 반환하기 때문에 `ngAfterViewInit`과 같은 라이프사이클 훅이 더 이상 필요하지 않습니다. ```typescript // form-container.component.ts // Demonstration of signal queries import { Component, viewChild, viewChildren, ElementRef, effect, signal } from '@angular/core'; import { FormFieldComponent } from './form-field.component'; @Component({ selector: 'app-form-container', standalone: true, imports: [FormFieldComponent], template: `
` }) export class FormContainerComponent { // viewChild returns Signal formElement = viewChild('formElement'); // viewChild.required guarantees element exists firstInput = viewChild.required>('firstInput'); // Query on a component - returns the component itself firstFormField = viewChild(FormFieldComponent); // viewChildren for multiple elements allFormFields = viewChildren(FormFieldComponent); constructor() { // Effect replaces ngAfterViewInit for queries effect(() => { // Signal is automatically resolved const input = this.firstInput(); console.log('First input available:', input.nativeElement); }); // React to list changes effect(() => { const fields = this.allFormFields(); console.log(`${fields.length} form fields found`); }); } focusFirst() { // Direct access via Signal this.firstInput().nativeElement.focus(); } onSubmit(event: Event) { event.preventDefault(); // Access the form const form = this.formElement(); if (form) { console.log('Form submitted'); } } } ``` 콘텐츠 프로젝션과 그 접근에는 `contentChild()`가 동일한 방식으로 작동합니다. ```typescript // card.component.ts // Using contentChild for projected content import { Component, contentChild, contentChildren, TemplateRef } from '@angular/core'; @Component({ selector: 'app-card', standalone: true, template: `
@if (hasFooter()) {
}
` }) export class CardComponent { // Detect if footer was projected footerContent = contentChild('[card-footer]'); // Computed to check footer presence hasFooter = computed(() => this.footerContent() !== undefined); } ``` ## Zoneless 변경 감지 Angular 18은 실험적 모드로 Zone.js 없는 변경 감지를 도입했습니다. 이 기능은 번들 크기를 약 13KB 줄이고, 브라우저 비동기 API에 대한 몽키 패칭을 제거하여 성능을 향상시킵니다. ```typescript // main.ts // Configuring the application in zoneless mode import { bootstrapApplication } from '@angular/platform-browser'; import { provideExperimentalZonelessChangeDetection } from '@angular/core'; import { AppComponent } from './app/app.component'; bootstrapApplication(AppComponent, { providers: [ // Enable experimental zoneless detection provideExperimentalZonelessChangeDetection() ] }); ``` `angular.json` 설정도 Zone.js를 제거하도록 업데이트해야 합니다. ```json { "projects": { "my-app": { "architect": { "build": { "options": { "polyfills": [] } } } } } } ``` Zoneless 모드에서는 다음 경우에 변경 감지가 자동으로 트리거됩니다: Signal 업데이트, `markForCheck()` 호출, AsyncPipe를 통한 새 값 수신, 컴포넌트 attach/detach. ```typescript // zoneless-counter.component.ts // Component optimized for zoneless mode import { Component, signal, ChangeDetectionStrategy, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { toSignal } from '@angular/core/rxjs-interop'; @Component({ selector: 'app-zoneless-counter', standalone: true, // OnPush recommended for zoneless changeDetection: ChangeDetectionStrategy.OnPush, template: `

Counter: {{ count() }}

@if (loading()) {

Loading...

} @if (data()) {
{{ data() | json }}
}
` }) export class ZonelessCounterComponent { private http = inject(HttpClient); count = signal(0); loading = signal(false); data = signal(null); increment() { // Signal update triggers detection this.count.update(c => c + 1); } async fetchData() { this.loading.set(true); try { // Signals guarantee view updates const response = await fetch('/api/data'); const json = await response.json(); this.data.set(json); } finally { this.loading.set(false); } } } ``` > **Zoneless 호환성** > > `ChangeDetectionStrategy.OnPush`와 Signals를 사용하는 컴포넌트는 일반적으로 Zoneless 모드와 호환됩니다. Signal이 아닌 프로퍼티를 직접 변경하는 것은 피해야 합니다. ## 기존 컴포넌트 마이그레이션 signal 기반 API로의 마이그레이션은 점진적으로 진행할 수 있습니다. 기존 컴포넌트를 리팩터링하는 예시를 살펴봅니다. ```typescript // BEFORE: Component with classic decorators // user-profile-legacy.component.ts import { Component, Input, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; @Component({ selector: 'app-user-profile-legacy', template: `

{{ user.name }}

{{ user.email }}

` }) export class UserProfileLegacyComponent implements AfterViewInit { @Input() user!: { name: string; email: string }; @ViewChild('container') container!: ElementRef; ngAfterViewInit() { console.log('Container ready:', this.container.nativeElement); } } ``` ```typescript // AFTER: Component migrated to Signals // user-profile.component.ts import { Component, input, viewChild, ElementRef, effect } from '@angular/core'; interface User { name: string; email: string; } @Component({ selector: 'app-user-profile', standalone: true, template: `

{{ user().name }}

{{ user().email }}

` }) export class UserProfileComponent { // input.required replaces @Input() with ! user = input.required(); // viewChild.required replaces @ViewChild with ! container = viewChild.required('container'); constructor() { // effect replaces ngAfterViewInit for queries effect(() => { console.log('Container ready:', this.container().nativeElement); }); } } ``` 이 마이그레이션의 장점은 더 엄격한 타입 지정, 자동 리액티비티, 보일러플레이트 코드 감소, 그리고 Zoneless 모드와의 호환성입니다. ## Signals 베스트 프랙티스 Angular 18에서 Signals를 최대한 활용하기 위한 핵심 권장 사항을 소개합니다. ```typescript // best-practices.component.ts // Example of best practices with Signals import { Component, signal, computed, effect, untracked, ChangeDetectionStrategy } from '@angular/core'; interface Product { id: string; name: string; price: number; quantity: number; } @Component({ selector: 'app-cart', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: `

Cart ({{ itemCount() }} items)

@for (item of items(); track item.id) {
{{ item.name }} {{ item.quantity }} × \${{ item.price }}
}
Total: \${{ total() }}
` }) export class CartComponent { // Signal for mutable data items = signal([]); // Computed for derived values - avoids unnecessary recalculations itemCount = computed(() => this.items().length); total = computed(() => this.items().reduce((sum, item) => sum + item.price * item.quantity, 0) ); constructor() { // Effect for side effects (analytics, persistence) effect(() => { const currentItems = this.items(); // untracked avoids creating a dependency untracked(() => { localStorage.setItem('cart', JSON.stringify(currentItems)); }); }); } addItem(product: Product) { // update() for modifications based on previous state this.items.update(current => { const existing = current.find(i => i.id === product.id); if (existing) { return current.map(i => i.id === product.id ? { ...i, quantity: i.quantity + 1 } : i ); } return [...current, { ...product, quantity: 1 }]; }); } removeItem(id: string) { this.items.update(current => current.filter(i => i.id !== id)); } } ``` 기억해야 할 핵심 사항은 다음과 같습니다. - 템플릿에서 재계산하는 대신 파생 값에는 `computed()`를 사용할 것 - 새 값이 이전 값에 의존하는 경우 `set()`보다 `update()`를 우선 사용할 것 - effect 내에서 순환 의존성을 피하기 위해 `untracked()`를 사용할 것 - 렌더링 최적화를 위해 `@for` 루프에서 반드시 `track`을 지정할 것 ## 결론 Angular 18은 Signals를 통해 Zone.js 없는 미래의 기반을 마련했습니다. 주요 요점은 다음과 같습니다. - **input()** 은 `@Input()`을 대체하며, 더 엄격한 타입 지정과 읽기 전용 접근을 보장합니다 - **model()** 은 부모-자식 간 리액티브 양방향 바인딩을 구현합니다 - **viewChild()** 와 **contentChild()** 는 라이프사이클 훅의 필요성을 제거합니다 - **Zoneless** 는 번들 크기를 줄이고 성능을 향상시킵니다 - **computed()** 와 **effect()** 가 리액티브 에코시스템을 완성합니다 - 컴포넌트 단위의 점진적 마이그레이션이 가능합니다 Signals 도입은 Zoneless 모드가 표준이 될 미래의 Angular 버전을 위한 준비입니다. 이 전환은 장기적인 유지보수성과 성능을 위한 현명한 투자라 할 수 있습니다. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/angular/angular-18-signals-new-features