포스트

[Angular 마스터하기] Day 16 - Signal 고급, Effect와 Computed 마스터

[Angular 마스터하기] Day 16 - Signal 고급, Effect와 Computed 마스터

이제와서 시작하는 Angular 마스터하기 - Day 16 “Signal의 고급 기능으로 더 강력한 앱을 만들어봅시다! ⚡”

오늘 배울 내용

  • Effect 심화 활용
  • asReadonly()로 상태 보호
  • untracked() 패턴
  • 실전: 다크모드, LocalStorage 연동

1. Effect - 부수 효과 처리

Angular의 effect()는 signal을 읽고, 그 값이 바뀔 때마다 부수 효과를 실행합니다. 공식 문서 기준으로 effect는 로깅, LocalStorage 동기화, 외부 API나 DOM처럼 signal이 아닌 세계와 연결할 때 쓰는 도구에 가깝습니다.

반대로 어떤 signal 값을 다른 signal 값으로 복사하기 위해 effect를 쓰는 것은 피하는 편이 좋습니다. 파생 값은 computed()로 만들고, 수동 변경도 필요한 파생 상태는 별도 상태 모델을 고려하세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Component, signal, effect } from '@angular/core';

@Component({
  selector: 'app-logger',
  template: `
    <button (click)="count.set(count() + 1)">클릭: {{ count() }}</button>
  `
})

export class LoggerComponent {
  count = signal(0);

  constructor() {
    // Effect: count가 변경될 때마다 실행
    effect(() => {
      console.log(`카운트: ${this.count()}`);

      // LocalStorage에 저장
      localStorage.setItem('count', this.count().toString());
    });
  }
}

effect()는 기본적으로 injection context 안에서 만들어야 합니다. 그래서 컴포넌트, 디렉티브, 서비스의 constructor 안에서 등록하는 예시가 가장 흔합니다.

Effect Cleanup

1
2
3
4
5
6
7
8
9
10
11
12
effect((onCleanup) => {
  const value = this.searchTerm();

  // API 호출
  const controller = new AbortController();
  fetch(`/api/search?q=${value}`, { signal: controller.signal });

  // Cleanup: 다음 실행 전 정리
  onCleanup(() => {
    controller.abort();
  });
});

cleanup은 “다음 effect 실행 전” 또는 “effect가 파괴될 때” 호출됩니다. 검색어가 바뀔 때 이전 요청을 취소하거나, 타이머를 정리하거나, 외부 구독을 해제할 때 꼭 필요합니다.


2. asReadonly() - 상태 보호

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Injectable({ providedIn: 'root' })
export class ThemeService {
  private _isDark = signal(false);

  // Public: 읽기 전용
isDark = this._isDark.asReadonly();

  toggle() {
    this._isDark.update(v => !v);
    this.applyTheme();
  }

  private applyTheme() {
    document.body.classList.toggle('dark', this._isDark());
  }
}

// 사용

@Component({
  template: `
    <button (click)="themeService.toggle()">
      {{ themeService.isDark() ? '🌙' : '☀️' }}
    </button>
  `
})

export class AppComponent {
  themeService = inject(ThemeService);
}

asReadonly()는 외부에서 .set()이나 .update()를 호출하지 못하게 막아줍니다. 서비스 내부에서만 상태를 바꾸고, 컴포넌트는 읽기만 하게 만들 때 유용합니다.

주의할 점도 있습니다. 읽기 전용 signal은 값 자체의 깊은 변경까지 막아주지는 않습니다. 객체나 배열을 담고 있다면 외부에서 내부 속성을 직접 바꾸지 못하도록 새 객체로 교체하는 패턴을 유지하세요.

1
2
3
4
5
6
private _todos = signal<Todo[]>([]);
todos = this._todos.asReadonly();

addTodo(title: string) {
  this._todos.update(todos => [...todos, { id: crypto.randomUUID(), title }]);
}

3. untracked() - 의존성 제외

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import { untracked } from '@angular/core';

@Component({
  template: `
    <input
      [ngModel]="searchTerm()"
      (ngModelChange)="searchTerm.set($event)"
      placeholder="검색">
    <p>결과: {{ results().length }}개</p>
  `
})

export class SearchComponent {
  searchTerm = signal('');
  results = signal<any[]>([]);
  searchCount = signal(0);

  constructor() {
    effect(() => {
      const term = this.searchTerm();

      // untracked: searchCount 변경해도 effect 재실행 안 됨
      untracked(() => {
        this.searchCount.update(c => c + 1);
        console.log(`검색 횟수: ${this.searchCount()}`);
      });

      // API 호출
      this.search(term);
    });
  }

  search(term: string) {
    // 검색 로직
  }
}

untracked()는 “읽기는 하지만 의존성으로 등록하고 싶지 않은 값”에 씁니다. 예를 들어 검색어가 바뀔 때만 effect를 실행하고 싶은데, 로그 서비스나 카운터가 내부적으로 다른 signal을 읽는다면 그 읽기는 추적 대상에서 빼는 편이 안전합니다.

남용하면 반응성이 예상과 다르게 끊어질 수 있으니, 의존성을 숨기기 위한 도구가 아니라 부수적인 읽기를 분리하는 도구로만 사용하세요.


4. 실전 예제: 다크모드 + LocalStorage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Injectable({ providedIn: 'root' })
export class ThemeService {
  private _isDark = signal(false);
  isDark = this._isDark.asReadonly();

  constructor() {
    // 저장된 테마 불러오기
    this.loadTheme();

    // 테마 변경 시 자동 저장
    effect(() => {
      const dark = this._isDark();
      localStorage.setItem('theme', dark ? 'dark' : 'light');
      document.body.classList.toggle('dark', dark);
    });
  }

  toggle() {
    this._isDark.update(v => !v);
  }

  private loadTheme() {
    const saved = localStorage.getItem('theme');
    this._isDark.set(saved === 'dark');
  }
}

이 예제는 effect의 좋은 사용처입니다. 테마 signal은 Angular 상태이고, localStoragedocument.body.classList는 Angular 바깥의 명령형 API입니다. 이런 경계를 동기화할 때 effect가 잘 맞습니다.

SSR 또는 prerendering을 고려하는 앱이라면 localStoragedocument가 브라우저에서만 존재한다는 점도 확인해야 합니다. 서버에서 실행될 수 있는 코드라면 플랫폼 체크나 클라이언트 전용 초기화 지점을 별도로 두는 편이 안전합니다.


5. 실전 판단 기준

Signals 고급 기능을 고를 때는 아래 기준으로 판단하면 됩니다.

상황 추천 API
다른 signal에서 계산되는 읽기 전용 값 computed()
외부 저장소, 로그, DOM API와 동기화 effect()
외부에 읽기만 허용하고 내부에서만 변경 asReadonly()
effect 안에서 부수적으로만 읽는 값 untracked()
요청, 타이머, 구독 정리 onCleanup()

가장 흔한 실수는 effect로 상태를 계속 복사하는 것입니다.

1
2
3
4
5
6
7
// 피하기
effect(() => {
  this.fullName.set(`${this.firstName()} ${this.lastName()}`);
});

// 선호
fullName = computed(() => `${this.firstName()} ${this.lastName()}`);

상태를 설명할 수 있으면 computed(), 외부 세계와 연결해야 하면 effect()라고 기억하면 대부분의 선택이 쉬워집니다.


📝 정리

Effect 사용 시기

사용처 예시
LocalStorage 자동 저장
로깅 상태 변화 추적
DOM 조작 다크모드 적용
외부 API 동기화
요청 정리 onCleanup()으로 취소

체크리스트

  • Effect를 활용할 수 있나요?
  • asReadonly()로 상태를 보호할 수 있나요?
  • untracked()를 사용할 수 있나요?
  • 파생 값은 effect가 아니라 computed로 만들 수 있나요?
  • cleanup이 필요한 부수 효과를 구분할 수 있나요?

📚 다음 학습


“Signal 고급 기능으로 더 강력한 앱을 만드세요!” ⚡

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.