들어가며
React의 useEffect에 익숙한 개발자라면 Angular의 effect가 조금 다르게 동작한다는 것을 알아차렸을 겁니다. 이 글에서는 Angular Signals의 effect를 효과적으로 사용하는 방법을 체계적으로 알아보겠습니다.
목차
- React vs Angular: 핵심 차이점
- Effect 실행 타이밍 이해하기
- 베스트 프랙티스: Signal 선언 패턴
- 클로저 변수로 상태 관리하기
- queueMicrotask 완벽 이해
- 실전 예제: 채팅 앱 구현
- 정리 및 체크리스트
1. React vs Angular: 핵심 차이점
React의 명시적 의존성
1
2
3
4
5
| // React - 의존성을 명시적으로 선언
useEffect(() => {
console.log('Effect 실행!');
// 로직...
}, [messages, isLoading, conversationId]); // 👈 의존성 배열
|
Angular의 자동 추적
1
2
3
4
5
6
7
| // Angular - 내부의 signal을 자동으로 추적
effect(() => {
const messages = this.state().messages; // 👈 자동 추적
const isLoading = this.state().isLoading; // 👈 자동 추적
console.log('Effect 실행!');
});
|
주요 차이점 한눈에 보기
| 항목 | React useEffect | Angular effect |
| 의존성 지정 | 명시적 배열 | 자동 추적 |
| 첫 실행 | 마운트 시 | constructor에서 |
| 정리 함수 | return으로 반환 | DestroyRef 사용 |
| 조건부 실행 | 의존성 배열로 제어 | untracked 사용 |
| 타이밍 제어 | useLayoutEffect 등 | queueMicrotask 등 |
| 내부 상태 | useState/useRef | 클로저 변수 |
2. Effect 실행 타이밍 이해하기
언제 Effect가 실행될까?
flowchart TB
A[컴포넌트 생성] --> B[constructor 실행]
B --> C[effect 첫 실행]
D[Signal 값 변경] --> E{effect 내부에서<br/>해당 Signal 사용?}
E -->|Yes| F[effect 재실행]
E -->|No| G[effect 실행 안 함]
style C fill:#bbf,stroke:#333,stroke-width:2px
style F fill:#bbf,stroke:#333,stroke-width:2px
실제 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| export class ChatComponent {
private state = inject(ChatService).state;
constructor() {
effect(() => {
// 🔍 이 signal들이 변경되면 effect가 재실행됩니다
const messages = this.state().messages;
const isLoading = this.state().isLoading;
const conversationId = this.state().conversationId;
console.log('Effect 실행됨!');
});
}
}
|
Effect는 다음과 같은 경우에 실행됩니다:
- 컴포넌트 생성 시: 초기 실행
- 새 메시지 도착:
messages 배열 변경 - 채팅방 전환:
conversationId 변경 - 로딩 상태 변화:
isLoading 변경
3. 베스트 프랙티스: Signal 선언 패턴
✅ 권장 패턴: Signal을 맨 위에 선언
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| effect(() => {
// 1️⃣ 사용할 signal들을 맨 위에 선언
const messages = this.state().messages;
const isLoading = this.state().isLoading;
const conversationId = this.state().conversationId;
// 2️⃣ 이제 로직 시작
if (isLoading) {
return; // 로딩 중이면 처리 안 함
}
if (messages.length > 0) {
// 메시지 처리...
}
});
|
❌ 피해야 할 패턴
1
2
3
4
5
6
7
8
9
10
11
| effect(() => {
// ❌ BAD: 조건문 안에서 signal 접근
if (someCondition) {
const messages = this.state().messages; // 조건부 추적 위험!
}
// ❌ BAD: 중간중간 signal 접근
doSomething();
const isLoading = this.state().isLoading; // 가독성 떨어짐
doSomethingElse();
});
|
이 패턴의 장점
1. 명확한 의존성 파악
1
2
3
4
5
6
7
8
| effect(() => {
// 한눈에 보이는 의존성들 ✨
const messages = this.state().messages;
const user = this.state().currentUser;
const settings = this.state().settings;
// "아, 이 effect는 messages, user, settings가 바뀔 때 실행되는구나!"
});
|
2. 디버깅 용이성
1
2
3
4
5
6
7
8
9
| effect(() => {
const messages = this.state().messages;
console.log('[Effect] Messages changed:', messages.length);
const isLoading = this.state().isLoading;
console.log('[Effect] Loading state:', isLoading);
// 어떤 signal 때문에 effect가 실행됐는지 쉽게 파악!
});
|
3. 성능 최적화
1
2
3
4
5
6
7
8
9
10
11
12
| effect(() => {
// signal 값을 한 번만 읽음 (캐싱 효과)
const messages = this.state().messages;
// 여러 곳에서 재사용
if (messages.length === 0) return;
const lastMessage = messages[messages.length - 1];
const unreadCount = messages.filter(m => !m.read).length;
// this.state().messages를 여러 번 호출하지 않아도 됨!
});
|
4. 클로저 변수로 상태 관리하기
클로저 변수 vs 클래스 멤버 변수
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| export class ChatComponent {
// ❌ 클래스 멤버 변수 - 위험!
private previousCount = 0; // effect에서 변경하면 무한 루프 위험!
constructor() {
// ✅ 클로저 변수 - 안전!
let previousMessageCount = 0; // constructor 스코프 내에서만 존재
effect(() => {
const messages = this.state().messages;
// 클로저 변수에 안전하게 접근 & 수정
if (messages.length !== previousMessageCount) {
console.log(`메시지 수 변경: ${previousMessageCount} → ${messages.length}`);
previousMessageCount = messages.length; // ✅ 안전!
}
});
}
someMethod() {
// ❌ 여기서는 previousMessageCount에 접근 불가능!
}
}
|
왜 클로저 변수를 사용해야 할까?
flowchart LR
A[클래스 멤버 변수] -->|변경 시| B[Angular 변경 감지]
B -->|감지됨| C[컴포넌트 재렌더링]
C -->|가능성| D[무한 루프!]
E[클로저 변수] -->|변경 시| F[변경 감지 없음]
F --> G[안전한 상태 관리]
style D fill:#f99,stroke:#333,stroke-width:2px
style G fill:#9f9,stroke:#333,stroke-width:2px
5. queueMicrotask 완벽 이해
queueMicrotask란?
queueMicrotask는 JavaScript 표준 내장 함수로, 현재 실행 중인 태스크가 끝난 후 즉시 실행될 마이크로태스크를 예약합니다.
1
2
3
4
5
6
7
| // 별도 import 없이 전역 함수로 사용 가능
queueMicrotask(() => {
console.log('이것은 microtask입니다');
});
// 브라우저 지원: 모든 모던 브라우저
// Node.js 지원: v11.0.0 이상
|
실행 순서 이해하기
단일 Effect 내에서의 실행 순서
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| effect(() => {
console.log('1️⃣ effect 시작');
const messages = this.state().messages;
console.log('2️⃣ queueMicrotask 호출 (예약만 함)');
queueMicrotask(() => {
console.log('4️⃣ microtask 실행됨!'); // 나중에 실행
this.scrollToBottom();
});
console.log('3️⃣ effect 끝');
});
// 콘솔 출력:
// 1️⃣ effect 시작
// 2️⃣ queueMicrotask 호출 (예약만 함)
// 3️⃣ effect 끝
// 4️⃣ microtask 실행됨!
|
JavaScript 이벤트 루프 시각화
sequenceDiagram
participant S as Signal 변경
participant E as Effect 실행
participant M as Microtask Queue
participant D as DOM 업데이트
participant MT as Microtask 실행
S->>E: Signal 값 변경됨
E->>E: effect 내부 코드 실행
E->>M: queueMicrotask() 호출
Note over M: Microtask 대기열에 추가
E->>D: Angular DOM 업데이트
D->>MT: 현재 태스크 완료
MT->>MT: Microtask 실행 (스크롤 등)
왜 queueMicrotask를 사용해야 할까?
Angular의 변경 감지 사이클과의 충돌을 방지하기 위해서입니다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // ❌ BAD: 직접 DOM 조작
effect(() => {
const messages = this.state().messages;
// Angular가 아직 DOM을 업데이트하지 않았을 수 있음!
this.scrollContainer.nativeElement.scrollTop = 999999; // 위험!
});
// ✅ GOOD: microtask로 지연
effect(() => {
const messages = this.state().messages;
queueMicrotask(() => {
// Angular가 DOM 업데이트를 완료한 후 실행됨
this.scrollContainer.nativeElement.scrollTop = 999999; // 안전!
});
});
|
여러 개의 queueMicrotask 사용하기
FIFO(선입선출) 실행 순서
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| effect(() => {
console.log('1️⃣ effect 시작');
queueMicrotask(() => {
console.log('3️⃣ 첫 번째 microtask');
});
queueMicrotask(() => {
console.log('4️⃣ 두 번째 microtask');
});
queueMicrotask(() => {
console.log('5️⃣ 세 번째 microtask');
});
console.log('2️⃣ effect 끝');
});
// 실행 순서:
// 1️⃣ effect 시작
// 2️⃣ effect 끝
// 3️⃣ 첫 번째 microtask
// 4️⃣ 두 번째 microtask
// 5️⃣ 세 번째 microtask
|
중첩된 queueMicrotask
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| effect(() => {
console.log('1. effect 시작');
queueMicrotask(() => {
console.log('2. 첫 번째 microtask');
// 중첩된 microtask
queueMicrotask(() => {
console.log('4. 중첩된 microtask');
});
});
queueMicrotask(() => {
console.log('3. 두 번째 microtask');
});
});
// 실행 순서:
// 1. effect 시작
// 2. 첫 번째 microtask
// 3. 두 번째 microtask
// 4. 중첩된 microtask (다음 사이클)
|
하나로 통합 vs 개별 분리
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
| // ✅ 관련 작업은 하나로 통합
effect(() => {
const messages = this.state().messages;
queueMicrotask(() => {
this.updateScroll();
this.updateUI();
this.logMetrics();
});
});
// ✅ 독립적인 작업은 논리적으로 분리
effect(() => {
const messages = this.state().messages;
const conversationId = this.state().conversationId;
// DOM 관련 작업
queueMicrotask(() => {
this.updateScroll();
this.highlightNewMessages();
});
// 데이터 관련 작업 (독립적)
if (conversationId !== this.previousId) {
queueMicrotask(() => {
this.loadConversationSettings();
this.previousId = conversationId;
});
}
});
|
다른 비동기 메커니즘과의 비교
flowchart LR
A[실행 스택] --> B[Microtask Queue]
B --> C[Task Queue]
C --> D[다음 이벤트 루프]
E[queueMicrotask] -.-> B
F[Promise.then] -.-> B
G[setTimeout] -.-> C
style B fill:#bbf,stroke:#333,stroke-width:2px
style C fill:#fbf,stroke:#333,stroke-width:2px
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
| // 실행 순서 예제
console.log('1. 시작');
// Microtask (가장 빠름)
queueMicrotask(() => {
console.log('2. queueMicrotask');
});
// Promise (같은 microtask queue)
Promise.resolve().then(() => {
console.log('3. Promise.then');
});
// Macrotask (task queue - 더 느림)
setTimeout(() => {
console.log('4. setTimeout');
}, 0);
console.log('5. 끝');
// 출력 순서:
// 1. 시작
// 5. 끝
// 2. queueMicrotask ├ Microtask Queue
// 3. Promise.then ┘
// 4. setTimeout ← Task Queue (나중에)
|
6. 실전 예제: 채팅 앱 구현
모든 개념을 종합한 완전한 예제
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
37
38
39
40
41
42
| export class ChatMessagesComponent {
private state = inject(ChatService).state;
@ViewChild('scrollContainer') scrollContainer!: ElementRef;
constructor() {
// 🔍 클로저 변수 선언
let previousMessageCount = 0;
let previousConversationId: string | null = null;
effect(() => {
// 1️⃣ Signal 선언부 (자동 추적)
const messages = this.state().messages;
const conversationId = this.state().conversationId;
const isLoading = this.state().isLoading;
// 2️⃣ 로딩 중이면 처리 안 함
if (isLoading) return;
// 3️⃣ 대화방이 바뀌면 상태 초기화
if (conversationId !== previousConversationId) {
previousMessageCount = 0;
previousConversationId = conversationId;
}
// 4️⃣ 새 메시지가 추가되면 스크롤
if (messages.length > previousMessageCount) {
// 5️⃣ DOM 업데이트 후 스크롤하기 위해 microtask 사용
queueMicrotask(() => {
this.scrollToBottom();
});
// 6️⃣ 클로저 변수 업데이트
previousMessageCount = messages.length;
}
});
}
private scrollToBottom(): void {
const element = this.scrollContainer.nativeElement;
element.scrollTop = element.scrollHeight;
}
}
|
코드 흐름 분석
| 단계 | 동작 | 설명 |
| 1 | Signal 읽기 | messages, conversationId, isLoading 자동 추적 |
| 2 | 조건 확인 | 로딩 중이면 early return |
| 3 | 대화방 변경 감지 | 클로저 변수로 이전 상태 비교 |
| 4 | 메시지 수 변화 감지 | 새 메시지 추가 여부 확인 |
| 5 | Microtask 예약 | DOM 업데이트 완료 후 실행 보장 |
| 6 | 상태 업데이트 | 다음 비교를 위해 클로저 변수 갱신 |
고급 예제: 여러 작업 처리
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
37
38
39
| export class ChatComponent {
constructor() {
let previousMessageCount = 0;
let previousConversationId: string | null = null;
effect(() => {
const messages = this.state().messages;
const conversationId = this.state().conversationId;
const isLoading = this.state().isLoading;
// 작업 1: 대화방 변경 감지
queueMicrotask(() => {
if (conversationId !== previousConversationId) {
console.log('✅ 대화방 변경 처리');
this.resetScrollPosition();
previousConversationId = conversationId;
previousMessageCount = 0;
}
});
// 작업 2: 새 메시지 스크롤
queueMicrotask(() => {
if (messages.length > previousMessageCount && !isLoading) {
console.log('✅ 스크롤 처리');
this.scrollToBottom();
previousMessageCount = messages.length;
}
});
// 작업 3: UI 상태 업데이트
queueMicrotask(() => {
if (messages.length > 100) {
console.log('✅ 경고 표시');
this.showPerformanceWarning = true;
}
});
});
}
}
|
7. 정리 및 체크리스트
핵심 개념 정리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| constructor() {
// 1️⃣ 클로저 변수: effect 내부에서만 사용할 상태
let previousState = 0;
effect(() => {
// 2️⃣ Signal 선언: 추적할 reactive 값들
const currentState = this.signal();
// 3️⃣ 동기적 로직: 즉시 실행되는 코드
if (currentState !== previousState) {
// 4️⃣ 비동기 처리: DOM 조작 등은 microtask로
queueMicrotask(() => {
this.updateDOM();
});
// 5️⃣ 상태 업데이트: 다음 실행을 위해
previousState = currentState;
}
});
}
|
Effect 디버깅 체크리스트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| effect(() => {
// ✅ 체크 1: Signal들을 맨 위에 선언했나?
const signal1 = this.signal1();
const signal2 = this.signal2();
// ✅ 체크 2: 클로저 변수를 사용하고 있나?
// (constructor 스코프에 선언)
// ✅ 체크 3: DOM 조작은 queueMicrotask로 감쌌나?
queueMicrotask(() => {
// DOM 업데이트
});
// ✅ 체크 4: 무한 루프 가능성은 없나?
// (클래스 멤버 변수 변경 시 주의)
});
|
핵심 요약
- Angular effect는 내부의 signal을 자동 추적합니다
- 사용할 signal은 effect 맨 위에 선언하세요
- 조건부로 signal을 읽지 마세요
- 필요시 untracked를 활용하세요
- 내부 상태는 클로저 변수로 관리하세요
- DOM 조작은 queueMicrotask로 지연시키세요
- 디버깅을 위해 console.log를 활용하세요
고급 팁: untracked 사용
특정 signal의 변경을 무시하고 싶을 때:
1
2
3
4
5
6
7
8
9
10
11
12
13
| import { effect, untracked } from '@angular/core';
effect(() => {
// 이것들은 추적됨 (effect 트리거)
const messages = this.state().messages;
const userId = this.state().userId;
// 이건 추적 안 됨 (effect 트리거 안 함)
const theme = untracked(() => this.themeService.currentTheme());
// 메시지나 userId가 바뀔 때만 실행됨
// theme이 바뀌어도 실행 안 됨!
});
|
마치며
Angular의 effect는 React의 useEffect보다 더 “마법같이” 동작하지만, 그만큼 주의해서 사용해야 합니다. 이 글에서 소개한 패턴들을 따르면 더 안전하고 효율적인 reactive 코드를 작성할 수 있을 것입니다.
이제 여러분도 Angular Signals의 effect를 프로처럼 사용할 수 있을 겁니다! 🚀
Angular Signals에 대해 더 궁금하신가요? 댓글로 질문해주세요!