심화 가이드
시그널

linkedSignal을 활용한 의존 상태 관리

Angular 코드에서 어떤 상태를 저장하려면 signal 함수를 사용하면 됩니다. 때로는 다른 상태에 종속된 상태를 관리해야 할 수도 있습니다. 사용자가 배송 방식을 선택하는 컴포넌트를 예로 들어 봅시다:

@Component({/* ... */})
export class ShippingMethodPicker {
  shippingOptions: Signal<ShippingMethod[]> = getShippingOptions();

  // 기본 배송 방법을 지정합니다.
  selectedOption = signal(this.shippingOptions()[0]);

  changeShipping(newOptionIndex: number) {
    this.selectedOption.set(this.shippingOptions()[newOptionIndex]);
  }
}

이 예제에서 selectedOption은 기본값이 설정되어 있으며, 사용자의 선택에 따라 값이 변경됩니다. 그런데 shippingOptions는 시그널이기 때문에 값이 변경될 수 있습니다! shippingOptions 값이 변경되면 selectedOption 값은 더 이상 유효하지 않습니다.

이때 linkedSignal 함수를 사용하면, 다른 상태와 직접 연결되는 시그널을 생성할 수 있습니다. 위 예제 코드에서 signal을 linkedSignal로 바꿔 보세요:

@Component({/* ... */})
export class ShippingMethodPicker {
  shippingOptions: Signal<ShippingMethod[]> = getShippingOptions();

  // 기본 배송방법을 지정합니다.
  selectedOption = linkedSignal(() => this.shippingOptions()[0]);

  changeShipping(index: number) {
    this.selectedOption.set(this.shippingOptions()[index]);
  }
}

linkedSignal은 signal과 비슷하지만 다른 점이 하나 있습니다. linkedSignal은 기본값을 직접 받지 않고 computed() 함수처럼 계산 함수 를 통해 전달받습니다. 그리고 계산 값이 변경되면 linkedSignal의 값도 새로 계산된 값으로 변경됩니다. 이제 linkedSignal의 값은 언제나 유효한 값을 유지합니다.

아래 예제를 보며 linkedSignal 값이 연결 상태에 따라 어떻게 변경되는지 확인해 보세요:

const shippingOptions = signal(['Ground', 'Air', 'Sea']);
const selectedOption = linkedSignal(() => shippingOptions()[0]);
console.log(selectedOption()); // 'Ground'

selectedOption.set(shippingOptions()[2]);
console.log(selectedOption()); // 'Sea'

shippingOptions.set(['Email', 'Will Call', 'Postal service']);
console.log(selectedOption()); // 'Email'

이전 상태 참조하기

경우에 따라 linkedSignal이 이전 값을 참조해야 하는 경우가 있습니다.

위 예제에서 shippingOptions 목록이 변경되면 selectedOption의 값은 언제나 첫번째 항목으로 변경됩니다. 하지만 사용자가 선택한 옵션이 새 목록에 남아 있다면 선택을 유지하고 싶을 수 있습니다. 그러면 linkedSignal을 생성할 때 소스(source) 와 연산(computation) 을 구분하는 방식을 사용하면 됩니다:

interface ShippingMethod {
  id: number;
  name: string;
}

@Component({/* ... */})
export class ShippingMethodPicker {
  constructor() {
    this.changeShipping(2);
    this.changeShippingOptions();
    console.log(this.selectedOption()); // {"id":2,"name":"Postal Service"}
  }

  shippingOptions = signal<ShippingMethod[]>([
    {id: 0, name: 'Ground'},
    {id: 1, name: 'Air'},
    {id: 2, name: 'Sea'},
  ]);

  selectedOption = linkedSignal<ShippingMethod[], ShippingMethod>({
    // `selectedOption`의 값은 `source`가 바뀌었을 때 `computation` 결과 값으로 할당됩니다.
    source: this.shippingOptions,
    computation: (newOptions, previous) => {
      // newOptions에 이전 값이 전달되면 이전 값을 유지합니다.
      // 아니면 첫번째 옵션 값을 선택합니다.
      return newOptions.find((opt) => opt.id === previous?.value.id) ?? newOptions[0];
    },
  });

  changeShipping(index: number) {
    this.selectedOption.set(this.shippingOptions()[index]);
  }

  changeShippingOptions() {
    this.shippingOptions.set([
      {id: 0, name: 'Email'},
      {id: 1, name: 'Sea'},
      {id: 2, name: 'Postal Service'},
    ]);
  }
}

linkedSignal을 생성할 때 연산 함수를 전달하는 대신, 객체를 전달하면서 이 객체의 프로퍼티로 source와 computation 옵션을 전달할 수 있습니다.

source에는 computed() 시그널이나 컴포넌트 input 등 어떤 시그널이든 사용할 수 있습니다. source가 변경되거나 computation에서 참조한 시그널이 변경되면, linkedSignal은 제공된 computation의 실행 결과로 값을 갱신합니다.

그리고 computation은 source에서 새 값을 받으면서 previous 객체를 받는 함수입니다. 이때 previous 객체에는 source 프로퍼티와 value 프로퍼티가 있습니다. previous.source 프로퍼티에는 이전 source 값이 전달되며, previous.value에는 linkedSignal의 이전 값이 전달됩니다. 연산 함수는 이 값들을 활용해서 새 값을 만들어 내면 됩니다.

도움말: previous 인자를 사용할 때는 linkedSignal의 제네릭 타입을 명시적으로 지정해야 합니다. 제네릭의 첫 번째 타입은 source로 전달되는 타입이며, 두 번째 타입은 computation 연산 결과의 타입입니다.

동일성 평가 함수 커스터마이징

linkedSignal은 다른 시그널과 마찬가지로 커스텀 동일성 평가 함수를 지정할 수 있습니다. 이 함수는 linkedSignal 값이 변경되었는지 판단하는 용도로 사용됩니다:

const activeUser = signal({id: 123, name: 'Morgan', isAdmin: true});

const activeUserEditCopy = linkedSignal(() => activeUser(), {
  // 사용자의 id가 같으면 같은 사용자로 판단합니다.
  equal: (a, b) => a.id === b.id,
});

// `source`와 `computation`을 각각 지정합니다.
const activeUserEditCopy = linkedSignal({
  source: activeUser,
  computation: (user) => user,
  equal: (a, b) => a.id === b.id,
});

set() 커스터마이징하기

linkedSignal의 set 메서드나 update 메서드의 동작이 linkedSignal 자체의 값을 변경하지 않고 원본 데이터를 변경하는 경우가 있다고 합시다. 이런 경우에는 linkedSignal을 생성할 때 옵션 객체의 set 프로퍼티로 커스텀 함수를 전달해서 동작을 커스터마이징할 수 있습니다.

커스텀 set 함수는 인자를 2개 받습니다:

  1. 변경할 새 값
  2. rawSet 함수 - linkedSignal 내부 상태를 직접 갱신하는 함수

참고: rawSet 함수를 사용하면 linkedSignal 내부값을 직접 갱신할 수 있습니다. 이 방식은 계산이 복잡하거나 이미 결과를 알고 있는 경우를 건너뛰는 방식으로 유용합니다.

원본 시그널의 값을 변경하기

화면에 사용하는 시그널은 화씨 시그널이지만, 원본 데이터는 섭씨 시그널인 경우를 생각해 봅시다:

const tempC = signal(0);
const tempF = linkedSignal(() => (tempC() * 9) / 5 + 32, {
  set: (valF) => tempC.set(((valF - 32) * 5) / 9),
});

console.log(tempF()); // 32

// 화씨 값을 변경하면 원래 시그널인 섭씨 시그널 값을 변경합니다.
tempF.set(212);
console.log(tempC()); // 100
console.log(tempF()); // 212

부모 객체의 프로퍼티 값 변경하기

부모 객체의 프로퍼티 값을 변경하는 경우도 고려해봅시다. 대상 객체는 원본 시그널에 있습니다:

interface Order {
  id: number;
  shippingMethod: string;
}

const order = signal<Order>({
  id: 42,
  shippingMethod: 'Ground',
});

const shippingMethod = linkedSignal(() => order().shippingMethod, {
  set: (newMethod) => {
    // 객체 참조를 유지하면서 내부 값을 변경합니다.
    order.update((currentOrder) => ({
      ...currentOrder,
      shippingMethod: newMethod,
    }));
  },
});

console.log(shippingMethod()); // 'Ground'

// shippingMethod 값을 변경하면서 부모 객체의 프로퍼티 값을 갱신합니다.
shippingMethod.set('Air');
console.log(order()); // { id: 42, shippingMethod: 'Air' }
console.log(shippingMethod()); // 'Air'