포스트

[Angular] Signals Effect 베스트 프랙티스: React 개발자를 위한 가이드

[Angular] Signals Effect 베스트 프랙티스: React 개발자를 위한 가이드

들어가며

React의 useEffect에 익숙한 개발자라면 Angular의 effect가 조금 다르게 동작한다는 것을 알아차렸을 겁니다. 이 글에서는 Angular Signals의 effect를 효과적으로 사용하는 방법을 체계적으로 알아보겠습니다.

목차

  1. React vs Angular: 핵심 차이점
  2. Effect 실행 타이밍 이해하기
  3. 베스트 프랙티스: Signal 선언 패턴
  4. 클로저 변수로 상태 관리하기
  5. queueMicrotask 완벽 이해
  6. 실전 예제: 채팅 앱 구현
  7. 정리 및 체크리스트

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는 다음과 같은 경우에 실행됩니다:

  1. 컴포넌트 생성 시: 초기 실행
  2. 새 메시지 도착: messages 배열 변경
  3. 채팅방 전환: conversationId 변경
  4. 로딩 상태 변화: 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: 무한 루프 가능성은 없나?
  // (클래스 멤버 변수 변경 시 주의)
});

핵심 요약

  1. Angular effect는 내부의 signal을 자동 추적합니다
  2. 사용할 signal은 effect 맨 위에 선언하세요
  3. 조건부로 signal을 읽지 마세요
  4. 필요시 untracked를 활용하세요
  5. 내부 상태는 클로저 변수로 관리하세요
  6. DOM 조작은 queueMicrotask로 지연시키세요
  7. 디버깅을 위해 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에 대해 더 궁금하신가요? 댓글로 질문해주세요!

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