라떼군 이야기


Angular 19 Signals: linkedSignal을 computed 대신 사용하는 이유와 활용법

Problem

Angular의 Signals 시스템을 사용하여 상태 관리를 할 때, 다른 상태에 의존하는 파생 상태를 만들기 위해 주로 computed를 사용합니다. 예를 들어, ‘배송 옵션 목록’에 의존하는 ‘선택된 배송 옵션’ 상태를 만들 때 computed를 사용하는 것이 자연스러워 보입니다. 하지만 사용자가 UI를 통해 ‘선택된 옵션’을 직접 변경해야 하는 상황이 오면 문제가 발생합니다. computed로 생성된 시그널은 읽기 전용(Read-only)이기 때문에 값을 직접 업데이트할 수 없어, 개발자들은 상태 동기화를 위해 복잡한 우회 방법을 찾아야 하는 어려움을 겪습니다.

Background

Angular Signals에서 기본적으로 제공하는 signal()은 값을 직접 읽고 쓸 수 있는(Writable) 상태를 만듭니다. 반면 computed()는 하나 이상의 다른 시그널에 의존하여 값을 계산하며, 의존하는 값이 변경될 때만 자동으로 업데이트되는 읽기 전용 상태입니다. 과거에는 부모 컴포넌트로부터 받은 input() 값이나 다른 시그널의 변경에 반응하면서도, 컴포넌트 내부에서 독립적으로 값을 변경할 수 있는 상태를 만들려면 effect()를 사용해 수동으로 상태를 동기화해야 했습니다. 이러한 패턴은 코드를 복잡하게 만들고 예기치 않은 버그를 유발하기 쉬웠습니다. 이를 근본적으로 해결하기 위해 Angular 19에서는 파생 상태이면서 동시에 쓰기가 가능한 linkedSignal이 도입되었습니다.

Solution

linkedSignalcomputedWritableSignal의 하이브리드 형태라고 볼 수 있습니다. 상황에 따라 어떻게 다르게 적용되는지 코드로 살펴보겠습니다.

1. computed의 한계 (읽기 전용)

단순히 리스트의 첫 번째 항목을 보여주기만 한다면 computed로 충분합니다. 하지만 값을 변경할 수는 없습니다.

import { signal, computed } from '@angular/core';

const shippingOptions = signal(['Ground', 'Air', 'Sea']);
// 의존성이 변경될 때만 업데이트되는 읽기 전용 시그널
const selectedOption = computed(() => shippingOptions()[0]);

// 에러 발생! computed는 값을 직접 쓸 수 없습니다.
// selectedOption.set('Air'); 

2. linkedSignal의 기본 사용법 (파생 + 쓰기 가능)

linkedSignal을 사용하면 원본 시그널에 의존하여 초기/파생 값을 가지면서도, 로컬에서 값을 직접 덮어쓸 수 있습니다.

import { signal, linkedSignal } from '@angular/core';

const shippingOptions = signal(['Ground', 'Air', 'Sea']);
// 원본 시그널에 반응하면서도 쓰기가 가능한 시그널 생성
const selectedOption = linkedSignal(() => shippingOptions()[0]);

console.log(selectedOption()); // 출력: 'Ground'

// WritableSignal이므로 로컬에서 값을 자유롭게 변경 가능합니다.
selectedOption.set('Air');
console.log(selectedOption()); // 출력: 'Air'

// 원본 시그널이 변경되면 linkedSignal의 계산 로직이 다시 실행되어 값이 초기화됩니다.
shippingOptions.set(['Express', 'Standard']);
console.log(selectedOption()); // 출력: 'Express'

3. 실전 활용: 리스트 변경 시 안전한 상태 유지

가장 유용한 시나리오는 동적인 리스트에서 특정 항목을 선택하는 경우입니다. 선택된 항목이 리스트에서 삭제되었을 때, 이전 선택값을 유지하면 버그가 발생합니다. linkedSignal의 고급 문법을 사용하면 이전 값(previousValue)을 참조하여 스마트하게 상태를 복구할 수 있습니다.

import { signal, linkedSignal } from '@angular/core';

export class AnimalSelectorComponent {
  // 동적으로 변할 수 있는 동물 리스트
  animals = signal(['Cat', 'Dog', 'Pig']);
  
  // 고급 객체 문법을 사용한 linkedSignal
  selectedAnimal = linkedSignal({
    source: this.animals, // 감시할 원본 시그널
    computation: (list, prev) => {
      // 1. 이전 선택값이 없으면 리스트의 첫 번째 항목 반환
      if (!prev) return list[0];
      
      // 2. 새로운 리스트에 이전에 선택한 항목이 여전히 존재하면 그 값을 유지
      if (list.includes(prev.value)) return prev.value;
      
      // 3. 이전에 선택한 항목이 리스트에서 삭제되었다면 첫 번째 항목으로 안전하게 초기화
      return list[0];
    }
  });

  removeCat() {
    // 'Cat'이 삭제되면 computation 로직에 의해 selectedAnimal은 자동으로 'Dog'가 됩니다.
    this.animals.update(list => list.filter(a => a !== 'Cat'));
  }
}

Deep Dive

linkedSignal은 컴포넌트의 input()과 결합할 때 매우 강력합니다. 부모 컴포넌트로부터 초기 상태를 전달받아 자식 컴포넌트에서 로컬 상태로 사용해야 하지만, 그 변경 사항을 부모에게 양방향 바인딩으로 노출하고 싶지 않을 때(model()의 동작 방식과 다르게) 완벽한 해결책이 됩니다. 또한 computation 함수 내에서 복잡한 로직을 수행할 때는 성능을 고려해야 합니다. 원본 source가 변경될 때마다 computation이 동기적으로 실행되므로, 이 내부에서 무거운 연산을 수행하면 렌더링 성능에 영향을 줄 수 있습니다. 따라서 상태 복구 로직은 최대한 가볍고 순수 함수 형태로 유지하는 것이 베스트 프랙티스입니다.

Conclusion

Angular Signals에서 상태가 다른 상태에 의존하면서 읽기 전용이어야 한다면 computed를 사용하세요. 하지만 파생된 상태이면서 동시에 사용자의 상호작용에 의해 로컬에서 값이 업데이트되어야 한다면 linkedSignal이 올바른 선택입니다. 이를 통해 불필요한 effect() 사용을 줄이고, 데이터 변경에 따른 상태 불일치 버그를 우아하게 예방할 수 있습니다.

References

프리랜서로 제품 기획과 개발을 맡길 파트너가 필요하신가요? 개인, 팀, 기업 누구나 의뢰할 수 있으며 문제 정의부터 출시까지 함께합니다.