[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 상태이고, localStorage와 document.body.classList는 Angular 바깥의 명령형 API입니다. 이런 경계를 동기화할 때 effect가 잘 맞습니다.
SSR 또는 prerendering을 고려하는 앱이라면 localStorage와 document가 브라우저에서만 존재한다는 점도 확인해야 합니다. 서버에서 실행될 수 있는 코드라면 플랫폼 체크나 클라이언트 전용 초기화 지점을 별도로 두는 편이 안전합니다.
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이 필요한 부수 효과를 구분할 수 있나요?
📚 다음 학습
- 이전: Day 15: 에러 처리
- 다음: Day 17: 성능 최적화
“Signal 고급 기능으로 더 강력한 앱을 만드세요!” ⚡
![[Angular 마스터하기] Day 16 - Signal 고급, Effect와 Computed 마스터](/assets/img/posts/angular/angular-day-16.png)