앵귤러 고급 강좌 #2 Signals 깊이 — computed, effect, model

10 분 소요

기초 #3에서는 signal()로 카운터를 만들어봤고, 중급 #3에서는 RxJS를 정리하면서 toSignal로 두 세계를 잇는 그림까지 그려봤습니다. 그동안 시그널은 “값을 함수처럼 호출해서 꺼내는 반응형 변수” 정도로만 다뤄왔습니다. 이번 글에서는 미뤄뒀던 Signals의 안쪽을 본격적으로 들여다보겠습니다. computed, effect, 그리고 컴포넌트 API의 새 얼굴인 input(), output(), model(), 더 나아가 linkedSignal까지 한곳에 정리합니다.

Signals가 어디에 서 있는가 #

Signals는 Angular 16에 미리보기로 들어왔다가 17에서 안정화됐고, 지금은 모던 앵귤러의 표준 반응형 모델로 자리 잡았습니다. 이건 앵귤러만의 흐름이 아닙니다. SolidJS가 먼저 길을 냈고, Vue 3의 ref/computed도 같은 모양이며, Svelte 5의 $state/$derived 룬도 같은 계열입니다. 프레임워크들이 약속이라도 한 듯 같은 방향으로 모이고 있습니다.

이유는 단순합니다. 시그널은 “무엇이 무엇에 의존하는지"를 런타임이 정확히 안다는 점에서, 변경 감지를 정밀하게 만들고 코드를 추적하기 쉽게 만들어줍니다. 앵귤러 입장에선 오랫동안 떠받쳐온 Zone.js 기반 변경 감지의 부담을 덜어내고, 미래의 Zoneless 모드로 가는 다리이기도 합니다.

signal() 다시 보기 — set, update, asReadonly #

기초에서 본 그대로지만, 한 단계만 더 깊이 들여다보겠습니다.

src/app/counter.component.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <p>현재: {{ count() }}</p>
    <button (click)="inc()">+1</button>
  `,
})
export class CounterComponent {
  private _count = signal(0);
  count = this._count.asReadonly();

  inc() {
    this._count.update(v => v + 1);
  }
}

핵심은 세 가지입니다.

  • set(value)로 값을 통째로 바꾸고, update(prev => next)로 이전 값을 받아 새 값을 만듭니다.
  • asReadonly()외부에 읽기 전용 시그널만 노출할 수 있습니다. RxJS의 Subject/asObservable() 패턴과 정확히 같은 발상입니다.
  • 시그널의 변화 추적 단위는 **참조(reference)**입니다. 객체 안의 필드를 직접 수정해도 시그널은 모릅니다. 새 객체를 set하거나 update(prev => ({ ...prev, ... }))로 갈아끼워야 합니다.

마지막 항목은 익숙하지 않으면 자주 빠지기 쉬운 부분입니다. 시그널은 “변했다"를 기본적으로 ===로 판단하기 때문에, 같은 참조를 다시 넣으면 변화로 보지 않습니다.

computed() — 파생값과 메모이제이션 #

computed다른 시그널에 의존하는 파생값입니다. 의존하는 시그널이 바뀔 때만 자동으로 재계산되고, 그렇지 않으면 캐시된 값을 그대로 돌려줍니다.

src/app/cart.component.ts
import { Component, computed, signal } from '@angular/core';

interface Item { name: string; price: number; qty: number; }

@Component({
  selector: 'app-cart',
  standalone: true,
  template: `
    <p>합계: {{ total() }}원 ({{ count() }}개)</p>
    <p>배송비 포함: {{ totalWithShipping() }}원</p>
  `,
})
export class CartComponent {
  items = signal<Item[]>([
    { name: '사과', price: 1000, qty: 3 },
    { name: '배', price: 2000, qty: 2 },
  ]);

  count = computed(() => this.items().reduce((s, i) => s + i.qty, 0));
  total = computed(() => this.items().reduce((s, i) => s + i.price * i.qty, 0));
  totalWithShipping = computed(() => this.total() + (this.total() >= 10000 ? 0 : 3000));
}

totalWithShippingtotal을 통해 결국 items에만 의존합니다. items가 그대로면 템플릿에서 몇 번을 호출해도 계산은 한 번만 일어납니다. 의존성은 함수 본문 안에서 시그널을 읽는 순간에 자동으로 등록됩니다. 별도로 “이걸 추적하라"고 적어줄 일이 없습니다.

규칙 하나만 기억하시면 됩니다 — computed 안에서는 부수 효과를 일으키지 마세요. HTTP 호출, 콘솔 로그, 다른 시그널의 set 같은 건 절대 금지입니다. 메모이제이션 때문에 호출이 생략되거나 여러 번 실행되어도 이상하지 않은 영역이기 때문입니다.

effect() — 시그널 변화에 부수 효과 걸기 #

부수 효과를 일으켜야 할 때는 effect를 씁니다. computed가 “값을 만든다"면 effect는 “일을 한다"입니다.

src/app/preferences.component.ts
import { Component, effect, signal } from '@angular/core';

@Component({ /* ... */ })
export class PreferencesComponent {
  theme = signal<'light' | 'dark'>('light');

  constructor() {
    effect(() => {
      const t = this.theme();
      document.documentElement.dataset['theme'] = t;
      localStorage.setItem('theme', t);
    });
  }
}

effect는 등록 시점에 한 번 실행되고, 그 후에는 본문에서 읽은 시그널이 바뀔 때마다 다시 실행됩니다. 의존성 추적은 computed와 똑같이 자동입니다.

effect는 기본적으로 컴포넌트(혹은 디렉티브, 서비스)의 주입 컨텍스트에 묶여 있어야 합니다. 그래서 보통 constructor나 필드 초기화에서 호출합니다. 컴포넌트가 사라지면 알아서 정리되니, RxJS처럼 unsubscribe를 챙길 일이 없습니다.

cleanup이 필요한 경우엔 콜백의 인자로 받습니다.

cleanup이 있는 effect
constructor() {
  effect(onCleanup => {
    const id = setInterval(() => console.log(this.count()), 1000);
    onCleanup(() => clearInterval(id));
  });
}

onCleanup에 등록한 함수는 다음 실행 직전과 컴포넌트 파괴 시점에 호출됩니다. setInterval/addEventListener처럼 직접 손대는 자원이 있을 때만 신경 쓰면 됩니다.

노트
effect는 라이프사이클 훅이 아닙니다. ngOnInit처럼 “한 번 초기화” 용도로 쓰지 마세요. effect의 영역은 어디까지나 시그널 → 외부 세계입니다. localStorage 동기화, 로깅, 외부 라이브러리 동기화 같은 것들이 좋은 예입니다.

Signal 기반 컴포넌트 API — input, output, viewChild #

Angular 17.1부터는 데코레이터 대신 함수로 컴포넌트의 입출력을 선언하는 새 API가 들어왔습니다. 시그널과 자연스럽게 어울리는 모양입니다.

src/app/user-card.component.ts
import { Component, input, output, computed } from '@angular/core';

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `
    <h3>{{ greeting() }}</h3>
    <button (click)="select.emit(name())">선택</button>
  `,
})
export class UserCardComponent {
  name = input.required<string>();
  prefix = input<string>('님');

  select = output<string>();

  greeting = computed(() => `${this.name()}${this.prefix()}, 어서오세요!`);
}

비교해보면 차이가 또렷합니다.

데코레이터 방식시그널 기반
입력@Input() name!: string;name = input.required<string>();
출력@Output() select = new EventEmitter<string>();select = output<string>();
자식 참조@ViewChild('x') x!: ElementRef;x = viewChild<ElementRef>('x');
사용this.namethis.name()

새 API의 가장 큰 이득은 입력이 곧 시그널이라는 점입니다. computed로 자연스럽게 파생값을 만들 수 있고, 부모가 바꾼 입력값이 자식의 effect까지 그대로 흘러갑니다. 변경 감지가 시그널 그래프 위에서 일관되게 동작합니다.

input.required<T>()는 부모가 반드시 값을 넘겨야 한다는 뜻으로, 빠뜨리면 컴파일 에러로 잡아줍니다. 옛날 데코레이터 시절엔 !로 타입을 속여야 했던 부분이 깔끔해졌습니다.

model() — 양방향 바인딩이 standalone에서 깔끔해진다 #

[(ngModel)]은 폼에 갇혀 있었지만, 일반 컴포넌트끼리도 양방향이 필요할 때가 있습니다. 예전에는 @Input value + @Output valueChange로 손수 짝을 맞춰야 했는데, model()이 이걸 한 줄로 정리해줍니다.

src/app/toggle.component.ts
import { Component, model } from '@angular/core';

@Component({
  selector: 'app-toggle',
  standalone: true,
  template: `
    <button (click)="flip()">{{ checked() ? '켜짐' : '꺼짐' }}</button>
  `,
})
export class ToggleComponent {
  checked = model.required<boolean>();

  flip() {
    this.checked.update(v => !v);
  }
}

부모는 [(checked)] 한 줄로 양방향을 잇습니다.

parent.component.html
<app-toggle [(checked)]="isOn" />

model은 내부적으로 input + output + update 가능 시그널이 합쳐진 모양입니다. 자식이 update/set으로 값을 바꾸면 부모 쪽 시그널도 함께 갱신됩니다. FormsModule도, ngModel도 필요하지 않습니다.

linkedSignal — 입력에 연결된 로컬 가공 시그널 (Angular 19+) #

부모의 입력값을 기본으로 시작하되 사용자가 편집할 수 있어야 하고, 부모가 새 값을 내려보내면 다시 그 값으로 리셋되어야 하는 — 예전에는 ngOnChanges로 손수 동기화하던 부분을 linkedSignal이 한 줄로 끝냅니다.

src/app/editable-name.component.ts
import { Component, input, linkedSignal } from '@angular/core';

@Component({ /* ... */ })
export class EditableNameComponent {
  initialName = input.required<string>();

  draft = linkedSignal(() => this.initialName());

  reset() { this.draft.set(this.initialName()); }
}

draft는 평범한 쓰기 가능한 시그널처럼 동작하지만, initialName이 바뀌면 자동으로 그 값으로 다시 동기화됩니다. computed처럼 의존성을 추적하면서 signal처럼 쓸 수도 있는 하이브리드입니다.

Signal과 RxJS의 협업 — toSignal / toObservable #

중급 #3에서 말씀드린 그림이 여기서 완성됩니다. 두 세계는 적이 아니라 각자 잘하는 영역이 다를 뿐입니다.

src/app/users.component.ts
import { Component, inject, signal, computed } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { switchMap } from 'rxjs';
import { UserService } from './user.service';

@Component({ /* ... */ })
export class UsersComponent {
  private users = inject(UserService);

  query = signal('');
  // 시그널 → Observable → HTTP → 시그널
  results = toSignal(
    toObservable(this.query).pipe(
      switchMap(q => this.users.search(q)),
    ),
    { initialValue: [] },
  );

  count = computed(() => this.results().length);
}

흐름을 따라 읽으면 단순합니다.

  1. query는 시그널입니다. 입력이 바뀌면 즉시 반응합니다.
  2. toObservable이 시그널의 변화를 Observable 스트림으로 흘려보냅니다.
  3. switchMap이 비동기 검색을 RxJS의 영역에서 처리합니다 — 이전 요청 취소까지 한 줄에.
  4. 결과는 toSignal로 다시 시그널이 되어 템플릿과 computed에 흘러갑니다.

가이드는 단순합니다 — UI 상태와 파생값은 시그널, 비동기 스트림과 operator 조합은 RxJS. 경계에서 두 변환 함수가 다리를 놔줍니다.

untracked와 equality — 정밀 제어 #

가끔은 읽기는 하지만 의존하고 싶지 않은 시그널이 있습니다. effect 안에서 다른 시그널을 참고만 하고 싶은데, 그 시그널이 바뀔 때마다 effect가 재실행되면 곤란합니다. 이럴 때 untracked를 씁니다.

untracked로 의존성 끊기
import { effect, untracked } from '@angular/core';

constructor() {
  effect(() => {
    const id = this.userId();           // 추적
    const region = untracked(() => this.region()); // 읽기만
    this.fetchProfile(id, region);
  });
}

이렇게 하면 userId가 바뀔 때만 effect가 다시 돌고, region은 그 순간의 최신값을 그냥 빌려옵니다.

비교 함수를 바꾸고 싶은 경우엔 equal 옵션을 씁니다.

객체 비교를 깊게
import { signal } from '@angular/core';
import { isEqual } from 'lodash-es';

const filter = signal({ q: '', tag: null }, { equal: isEqual });

기본 === 대신 깊은 비교를 쓰면, 같은 모양의 새 객체를 set해도 변화로 보지 않습니다. 다운스트림 computed/effect의 불필요한 재실행을 줄일 수 있습니다. 다만 모든 시그널에 깊은 비교를 거는 건 과합니다 — 재실행이 실제로 거슬리는 부분에만 선택적으로 씁니다.

흔히 만나는 실수들 #

처음 시그널을 본격적으로 쓸 때 자주 걸리는 세 가지 함정입니다.

1. effect 안에서 시그널을 set합니다. 기본적으로 막혀 있습니다. 정말 필요하면 { allowSignalWrites: true }로 풀 수 있지만, 99%의 경우엔 그게 필요한 게 아니라 computed가 필요한 것입니다.

2. computed 안에서 부수 효과를 냅니다. computed는 메모이제이션 때문에 실행 횟수를 보장하지 않습니다. console.log도, 다른 시그널의 set도 절대 금지입니다. 부수 효과는 effect에 두세요.

3. 객체를 직접 수정합니다.

안 좋은 예 vs 좋은 예
this.user().name = '새 이름'; // ❌ 시그널이 모름
this.user.update(u => ({ ...u, name: '새 이름' })); // ✅ 새 참조

참조가 바뀌어야 시그널이 변화로 인식합니다. 불변 갱신이 답입니다.

새로 시그널을 쓰기 시작하면 “값을 어떻게 바꾸지?“가 아니라 “이 부분은 signal인가, computed인가, effect인가?“를 먼저 묻는 습관을 들이세요. 셋 중 하나로 정확히 떨어지면 코드가 깔끔해집니다. 어디 둬야 할지 헷갈린다면 거의 항상 computed가 정답입니다.

마무리 #

이번 글에서는 Signals를 본격적으로 들여다봤습니다.

  • signal은 변화 추적 단위. set/update/asReadonly
  • computed는 자동 추적되는 파생값. 부수 효과 금지, 메모이제이션됨
  • effect는 시그널 → 외부 세계. cleanup 콜백 지원, 라이프사이클 훅 아님
  • input()/output()/model()/viewChild()로 컴포넌트 API가 시그널 위에 통합
  • model()로 standalone 컴포넌트 양방향 바인딩이 깔끔
  • linkedSignal로 입력에 연결된 로컬 편집 가능 시그널 (Angular 19+)
  • toSignal/toObservable로 RxJS와 자유롭게 오감
  • untracked로 의존성 끊기, equal로 비교 커스텀

다음 글은 **“앵귤러 고급 강좌 #3 동적 컴포넌트와 Portal”**입니다. 런타임에 컴포넌트를 만들어 띄우고, 모달,툴팁,오버레이를 직접 구성하는 동적 컴포넌트 API와 Angular CDK의 Portal,Overlay를 다뤄보겠습니다.

X