Angular Signals와 Computed로 구현하는 세밀한 반응성: 2026년 기술 면접 대비

Angular 20 이후 표준이 된 Signals와 Computed, linkedSignal의 작동 원리를 설명합니다. 기술 면접에서 자주 묻는 반응성 설계 패턴과 RxJS와의 사용 구분을 상세히 다룹니다.

Angular Signals와 Computed를 통한 세밀한 반응성 다이어그램

Angular Signals는 프레임워크 탄생 이래 반응성 모델에서 가장 중대한 변화를 대표합니다. Angular 17에서 도입되고 Angular 20 이후 기본 접근 방식이 된 Signals는 불필요한 변경 감지 사이클을 제거하고 zoneless 애플리케이션과 원활하게 통합되는 세밀한 반응성을 제공합니다.

면접에서 핵심 포인트

Signals는 동기적이고 글리치가 없는 반응형 프리미티브입니다. Computed signal은 의존성이 변경될 때만 재계산되며, 얼마나 많이 읽히든 변경 사이클당 한 번만 실행됩니다.

Angular 20의 Signal 프리미티브 이해하기

Angular의 Signal 시스템은 세 가지 핵심 프리미티브로 구성됩니다: signal(), computed(), effect(). 각각은 반응형 그래프에서 고유한 역할을 담당합니다.

쓰기 가능한 signal은 .set() 또는 .update()로 업데이트할 수 있는 값을 보유합니다. signal을 읽으면 현재 값이 반환되고, 이에 의존하는 모든 computed signal이나 effect에 변경 사항이 통지됩니다.

counter.component.tstypescript
import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <p>Count: {{ count() }}</p>
    <p>Double: {{ doubleCount() }}</p>
    <button (click)="increment()">+1</button>
  `
})
export class CounterComponent {
  // Writable signal with initial value
  count = signal(0);
  
  // Computed signal derives from count
  // Only recalculates when count changes
  doubleCount = computed(() => this.count() * 2);
  
  increment(): void {
    // .update() receives current value as argument
    this.count.update(c => c + 1);
  }
}

RxJS BehaviorSubject와의 핵심적인 차이점은 signals가 동기적이고 글리치가 없다는 것입니다. count가 변경되면 doubleCount는 동일한 동기 블록 내에서 몇 번 읽히든 정확히 한 번만 재계산됩니다.

Computed Signals: 지연 평가와 메모이제이션

Computed signals는 의존성이 변경될 때까지 결과를 캐시합니다. 이 메모이제이션은 자동으로 이루어지며, RxJS에서 shareReplaydistinctUntilChanged를 수동으로 추가해야 하는 것과 다릅니다.

user-stats.component.tstypescript
import { Component, signal, computed } from '@angular/core';

interface User {
  id: number;
  name: string;
  purchases: number[];
}

@Component({
  selector: 'app-user-stats',
  standalone: true,
  template: `
    <div>
      <p>User: {{ user().name }}</p>
      <p>Total spent: {{ totalSpent() | currency }}</p>
      <p>Average purchase: {{ averagePurchase() | currency }}</p>
    </div>
  `
})
export class UserStatsComponent {
  user = signal<User>({
    id: 1,
    name: 'Alice',
    purchases: [99.99, 149.50, 29.99]
  });
  
  // Computed tracks only the properties it reads
  totalSpent = computed(() => {
    const purchases = this.user().purchases;
    return purchases.reduce((sum, p) => sum + p, 0);
  });
  
  // Depends on another computed signal
  averagePurchase = computed(() => {
    const total = this.totalSpent();
    const count = this.user().purchases.length;
    return count > 0 ? total / count : 0;
  });
}

Computed signal은 지연 평가됩니다. 계산 함수는 선언 시점이 아니라 signal이 처음 읽힐 때만 실행됩니다. 이후의 읽기는 의존성이 무효화될 때까지 캐시된 값을 반환합니다.

Effect: 반응형 그래프에서의 부수 효과

Effect는 signal 변경에 대응하여 코드를 실행합니다. 모든 동기 업데이트가 완료된 후 변경 감지 사이클 동안 비동기적으로 실행됩니다.

theme-sync.component.tstypescript
import { Component, signal, effect, inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';

@Component({
  selector: 'app-theme-sync',
  standalone: true,
  template: `
    <button (click)="toggleTheme()">Toggle Theme</button>
  `
})
export class ThemeSyncComponent {
  private document = inject(DOCUMENT);
  
  isDarkMode = signal(false);
  
  constructor() {
    // Effect syncs signal state to DOM attribute
    effect(() => {
      const theme = this.isDarkMode() ? 'dark' : 'light';
      this.document.documentElement.setAttribute('data-theme', theme);
    });
    
    // Effect for localStorage persistence
    effect(() => {
      localStorage.setItem('theme', this.isDarkMode() ? 'dark' : 'light');
    });
  }
  
  toggleTheme(): void {
    this.isDarkMode.update(dark => !dark);
  }
}

Effect는 의존성을 동적으로 추적합니다. 조건 분기가 실행되지 않으면 해당 분기에서만 읽히는 signal은 조건이 변경될 때까지 추적되지 않습니다.

안티패턴: 상태 파생을 위한 Effect 사용

Effect를 사용하여 한 signal에서 다른 signal로 데이터를 복사하는 것은 설계상의 문제를 나타냅니다. 파생 상태에는 computed를, 파생 값이 쓰기 가능해야 하는 경우에는 linkedSignal을 사용하는 것이 권장됩니다.

linkedSignal: 쓰기 가능한 파생 상태

Angular 19에서 도입되고 Angular 20에서 안정화된 linkedSignal은 소스가 변경될 때 리셋되는 쓰기 가능한 signal을 생성합니다. computed가 읽기 전용이지만 파생 값의 로컬 수정이 필요한 시나리오를 해결합니다.

pagination.component.tstypescript
import { Component, signal, linkedSignal, computed } from '@angular/core';

@Component({
  selector: 'app-pagination',
  standalone: true,
  template: `
    <select (change)="pageSize.set(+$any($event.target).value)">
      <option [value]="10">10 per page</option>
      <option [value]="25">25 per page</option>
      <option [value]="50">50 per page</option>
    </select>
    <p>Page {{ currentPage() }} of {{ totalPages() }}</p>
    <button (click)="prevPage()" [disabled]="currentPage() === 1">Prev</button>
    <button (click)="nextPage()" [disabled]="currentPage() === totalPages()">Next</button>
  `
})
export class PaginationComponent {
  totalItems = signal(243);
  pageSize = signal(10);
  
  totalPages = computed(() => 
    Math.ceil(this.totalItems() / this.pageSize())
  );
  
  // linkedSignal resets to 1 when pageSize or totalItems changes
  // but allows manual navigation via prevPage/nextPage
  currentPage = linkedSignal(() => 1);
  
  prevPage(): void {
    this.currentPage.update(p => Math.max(1, p - 1));
  }
  
  nextPage(): void {
    this.currentPage.update(p => Math.min(this.totalPages(), p + 1));
  }
}

linkedSignal이 없었다면 이 패턴은 다른 signal에 쓰는 effect(안티패턴)나 복잡한 RxJS 오케스트레이션이 필요했습니다.

Angular 면접 준비가 되셨나요?

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

Signal 기반 컴포넌트 API: input, model, viewChild

Angular 20에서는 signal 기반 컴포넌트 API가 안정 상태로 승격되었습니다. 이들은 데코레이터를 signal 그래프에 통합되는 반응형 대안으로 대체합니다.

user-card.component.tstypescript
import { 
  Component, 
  input, 
  model, 
  output, 
  computed,
  viewChild,
  ElementRef
} from '@angular/core';

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `
    <div #card class="card" [class.selected]="selected()">
      <h3>{{ fullName() }}</h3>
      <button (click)="toggle()">{{ selected() ? 'Deselect' : 'Select' }}</button>
    </div>
  `
})
export class UserCardComponent {
  // Signal input replaces @Input()
  firstName = input.required<string>();
  lastName = input<string>('');
  
  // model() creates two-way bindable signal
  // Parent uses [(selected)]="parentSignal"
  selected = model(false);
  
  // Output emitter for events
  cardClicked = output<void>();
  
  // viewChild returns Signal<ElementRef | undefined>
  cardElement = viewChild<ElementRef>('card');
  
  // Computed derives from signal inputs
  fullName = computed(() => 
    `${this.firstName()} ${this.lastName()}`.trim()
  );
  
  toggle(): void {
    this.selected.update(s => !s);
    this.cardClicked.emit();
  }
}

Signal inputs는 더 나은 타입 안전성을 제공합니다. input.required()InputSignal<T>를 반환하고, input()은 기본값이 제공되지 않는 한 InputSignal<T | undefined>를 반환합니다.

Signals vs RxJS: 고부하 상황에서의 성능

Angular 21+의 벤치마크에서 signals가 UI 중심 시나리오에서 변경 감지 오버헤드를 줄이는 것으로 나타났습니다. 빈번한 업데이트가 있는 고부하 상황에서 signals는 RxJS 기반 바인딩에 비해 더 깔끔한 메모리 프로파일과 낮은 CPU 사용량을 보여주었습니다.

관심사의 분리는 명확합니다:

사용 사례권장 접근 방식
로컬 컴포넌트 상태signal()
파생된 UI 값computed()
외부 API와 동기화effect()
HTTP 요청RxJS Observable + toSignal()
WebSocket 스트림RxJS with operators
디바운스된 사용자 입력RxJS debounceTime, 그 후 toSignal()
복잡한 비동기 오케스트레이션RxJS pipelines

RxJS는 비동기 스트림, 취소, 백프레셔를 위한 도구로 남아 있습니다. Signals는 동기 상태를 처리합니다.

RxJS 상호 운용성: toSignal과 toObservable

Angular는 @angular/core/rxjs-interop에서 signals와 observables를 연결하는 유틸리티를 제공합니다.

search.component.tstypescript
import { Component, signal, inject } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { debounceTime, switchMap, distinctUntilChanged } from 'rxjs';

interface SearchResult {
  id: number;
  title: string;
}

@Component({
  selector: 'app-search',
  standalone: true,
  template: `
    <input 
      [value]="query()" 
      (input)="query.set($any($event.target).value)"
      placeholder="Search..."
    />
    @if (results(); as items) {
      <ul>
        @for (item of items; track item.id) {
          <li>{{ item.title }}</li>
        }
      </ul>
    }
  `
})
export class SearchComponent {
  private http = inject(HttpClient);
  
  query = signal('');
  
  // Convert signal to observable for RxJS operators
  private query$ = toObservable(this.query);
  
  // RxJS pipeline with debounce, then back to signal
  results = toSignal(
    this.query$.pipe(
      debounceTime(300),
      distinctUntilChanged(),
      switchMap(q => q.length > 2 
        ? this.http.get<SearchResult[]>(`/api/search?q=${q}`)
        : []
      )
    ),
    { initialValue: [] as SearchResult[] }
  );
}

toSignal은 생성 시 구독하고 컴포넌트가 소멸되면 자동으로 구독 해제합니다. initialValue 옵션은 signal에서 undefined 타입을 제거합니다.

기술 면접의 Angular Signals 질문

2026년 기술 면접에서는 signal 이해도를 자주 테스트합니다. 시니어 후보자를 구별하는 패턴들을 소개합니다.

Q: Computed signal이 에러를 던지면 어떻게 됩니까?

에러는 이를 읽는 모든 effect나 computed에 전파됩니다. Angular는 에러를 캐시하지 않으므로 다음 읽기 시 계산이 재시도됩니다. 프로덕션 코드에서는 computed 함수 내에서 에러를 처리해야 합니다.

Q: Computed 내에서 signal에 쓸 수 있습니까?

불가능합니다. computed 내에서 .set()이나 .update()를 호출하려고 하면 에러가 발생합니다. Computed 함수는 순수해야 합니다. 쓰기 가능한 파생 값이 필요하면 linkedSignal을 사용합니다.

Q: Effect는 조건문 내의 signal 읽기를 어떻게 처리합니까?

typescript
// Dynamic dependency tracking example
const showDetails = signal(false);
const userDetails = signal({ name: 'Alice', email: 'alice@example.com' });

effect(() => {
  if (showDetails()) {
    // userDetails is only tracked when showDetails is true
    console.log('Details:', userDetails().email);
  }
});

Effect는 가장 최근 실행을 기반으로 의존성을 추적합니다. showDetails가 false이면 userDetails의 변경은 effect를 트리거하지 않습니다.

Q: Signal의 동등성은 어떻게 작동합니까?

기본적으로 signals는 동등성에 Object.is를 사용합니다. 객체의 경우 이는 참조 동등성을 의미합니다. 커스텀 동등성을 제공할 수 있습니다:

typescript
const user = signal(
  { id: 1, name: 'Alice' },
  { equal: (a, b) => a.id === b.id }
);
면접 인사이트

Computed signals가 메모이제이션되고 지연 평가된다고 언급하는 후보자는 기본 API 사용법 이상의 이해를 보여줍니다. linkedSignal과 computed의 사용 시점을 논의하는 것은 Angular 19+ 패턴에 익숙함을 보여줍니다.

변경 감지: Signals와 Zoneless Angular

Angular 22에서는 새 프로젝트에 zoneless 변경 감지가 기본값이 됩니다. Signals는 이것의 핵심입니다: 상태가 언제 변경되는지 Angular에 정확히 알려주어 Zone.js가 비동기 API를 패치할 필요성을 제거합니다.

zoneless-counter.component.tstypescript
import { Component, signal, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-zoneless-counter',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>Count: {{ count() }}</p>
    <button (click)="increment()">+1</button>
  `
})
export class ZonelessCounterComponent {
  count = signal(0);
  
  increment(): void {
    // Signal update triggers change detection
    // No Zone.js required
    this.count.update(c => c + 1);
  }
}

Signals로 Angular는 어떤 컴포넌트가 어떤 상태에 의존하는지 정확히 추적합니다. Signal 업데이트는 전체 트리가 아닌 영향받는 컴포넌트만 변경 감지를 스케줄링합니다.

Resource API: Signals로서의 비동기 데이터

Angular 20의 Resource API(resource()rxResource())는 비동기 데이터를 선언적으로 signals에 로드하여 수동 구독 패턴을 대체합니다.

user-profile.component.tstypescript
import { Component, signal, computed } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

interface User {
  id: number;
  name: string;
  email: string;
}

@Component({
  selector: 'app-user-profile',
  standalone: true,
  template: `
    @if (userResource.isLoading()) {
      <p>Loading...</p>
    }
    @if (userResource.error()) {
      <p>Error: {{ userResource.error() }}</p>
    }
    @if (userResource.value(); as user) {
      <h2>{{ user.name }}</h2>
      <p>{{ user.email }}</p>
    }
  `
})
export class UserProfileComponent {
  private http = inject(HttpClient);
  
  userId = signal(1);
  
  // Declarative async resource
  // Refetches when userId changes
  userResource = rxResource({
    request: () => this.userId(),
    loader: ({ request: id }) => 
      this.http.get<User>(`/api/users/${id}`)
  });
  
  loadUser(id: number): void {
    this.userId.set(id);
  }
}

Resource는 로딩 상태, 에러, 해결된 값을 자동으로 추적합니다. 요청 signal이 변경되면 다시 fetch합니다.

Signal 아키텍처 모범 사례

대규모 애플리케이션에서 signals를 구조화하려면 규율이 필요합니다. 이러한 패턴은 컴포넌트에서 서비스까지 확장됩니다.

관련 signals를 함께 배치하기: 함께 변경되는 signals를 그룹화합니다. 폼 컴포넌트는 서비스 전체에 흩어지지 않고 필드 signals를 함께 유지합니다.

공유 상태를 서비스로 올리기: 여러 컴포넌트가 동일한 상태가 필요한 경우 signal을 주입 가능한 서비스로 이동합니다:

cart.service.tstypescript
import { Injectable, signal, computed } from '@angular/core';

interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

@Injectable({ providedIn: 'root' })
export class CartService {
  private items = signal<CartItem[]>([]);
  
  // Expose read-only signal to consumers
  readonly cartItems = this.items.asReadonly();
  
  readonly totalPrice = computed(() =>
    this.items().reduce((sum, item) => sum + item.price * item.quantity, 0)
  );
  
  readonly itemCount = computed(() =>
    this.items().reduce((sum, item) => sum + item.quantity, 0)
  );
  
  addItem(item: Omit<CartItem, 'quantity'>): void {
    this.items.update(items => {
      const existing = items.find(i => i.id === item.id);
      if (existing) {
        return items.map(i => 
          i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
        );
      }
      return [...items, { ...item, quantity: 1 }];
    });
  }
}

effect보다 computed 선호하기: 값을 파생할 수 있으면 computed를 사용합니다. Effect는 반응형 그래프를 떠나는 부수 효과를 위한 것입니다: DOM 조작, localStorage, 분석, 네트워크 요청.

RxJS에서 Signals로 마이그레이션

레거시 Angular 코드베이스는 점진적으로 마이그레이션할 수 있습니다. RxJS 상호 운용 유틸리티를 통해 signals와 observables가 공존할 수 있습니다.

typescript
// Before: RxJS-based
@Component({ ... })
export class LegacyComponent {
  private userSubject = new BehaviorSubject<User | null>(null);
  user$ = this.userSubject.asObservable();
  
  userName$ = this.user$.pipe(
    map(u => u?.name ?? 'Guest')
  );
}

// After: Signal-based
@Component({ ... })
export class ModernComponent {
  user = signal<User | null>(null);
  
  userName = computed(() => this.user()?.name ?? 'Guest');
}

비동기 작업의 경우 파이프라인에는 RxJS를 유지하고 경계에서 변환합니다:

typescript
// Hybrid approach
private search$ = toObservable(this.searchQuery).pipe(
  debounceTime(300),
  switchMap(q => this.api.search(q))
);

results = toSignal(this.search$, { initialValue: [] });

연습을 시작하세요!

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

Angular Signals 면접 준비 요약

  • Signals는 동기 프리미티브이고, RxJS는 비동기 스트림을 처리합니다
  • Computed signals는 지연 평가되고 메모이제이션되며 의존성이 변경될 때만 재계산됩니다
  • Effect는 변경 감지 중 비동기로 실행되며 즉시 실행되지 않습니다
  • linkedSignal은 effect 없이 쓰기 가능한 파생 상태 문제를 해결합니다
  • Signal inputs(input(), model())는 데코레이터를 반응형 대안으로 대체합니다
  • toSignaltoObservable은 점진적 마이그레이션을 위해 signals와 RxJS를 연결합니다
  • Zoneless Angular는 세밀한 변경 감지를 위해 signals에 의존합니다
  • Resource API(rxResource)는 비동기 상태를 선언적으로 관리합니다
  • 공식 Angular signals 가이드는 API 세부 사항과 모범 사례에 대한 권위 있는 참조로 남아 있습니다
오늘의 챌린지

Angular 코드의 버그를 찾을 수 있나요

실제 코드 한 조각, 숨은 버그 하나, 하루 한 번. 계정 없이 바로 도전할 수 있습니다.

Anthony Fillion-Maillet

작성자

Anthony Fillion-Maillet

SharpSkill 창업자

10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.

2026년 9월 17일 업데이트

태그

#angular
#signals
#computed
#reactivity
#interview

공유

관련 기사