signal, computed, input 등의 시그널 API는 대부분 동기 방식으로 동작합니다.
그런데 애플리케이션은 데이터를 비동기로 다뤄야 하는 경우가 종종 있습니다.
이때 Resource를 활용하면 애플리케이션이 시그널 기반으로 동작하면서 비동기 데이터를 처리할 수 있습니다.
Resource를 활용하면 모든 종류의 비동기 작업을 다룰 수 있지만, 가장 많이 사용하는 경우는 역시 서버에서 데이터를 받아오는 것입니다.
아래 코드는 사용자 데이터를 받아오는 예제 코드입니다.
Resource는 resource 함수로 생성하는 것이 가장 간단합니다.
import {computed, resource, Signal} from '@angular/core';
const userId: Signal<string> = getUserId();
const userResource = resource({
// 반응형 연산 함수를 정의합니다.
// 이 계산에서 읽은 시그널의 값이 변경될 때마다 params 값을 다시 계산합니다.
params: () => ({id: userId()}),
// 데이터를 받아오는 비동기 로더 함수를 정의합니다.
// Resource는 `params` 값이 변경될 때마다 이 함수를 실행합니다.
loader: ({params}) => fetchUser(params),
});
// 리소스의 로더 함수의 실행 결과로 computed() 시그널을 생성합니다.
const firstName = computed(() => {
if (userResource.hasValue()) {
// `hasValue`는 두 가지 용도로 사용합니다:
// - `undefined` 타입을 걸러내는 타입 가드 역할을 합니다.
// - 자원이 에러 상태일 때 값을 읽는 것을 방지합니다.
return userResource.value().firstName;
}
// 자원이 `undefined`이거나 에러 상태일 때는 아래줄을 실행합니다.
return undefined;
});
resource 함수는 ResourceOptions 객체를 인자로 받는데, 이 객체에는 params와 loader 프로퍼티가 있습니다.
params 프로퍼티에는 인자값을 생성할 반응형 연산 함수를 지정합니다.
이 계산 함수에서 읽는 시그널이 변경될 때마다 Resource는 새로운 값을 생성합니다.
computed() 시그널과 비슷합니다.
loader 프로퍼티에는 어떤 값을 받아오는 비동기 함수인 ResourceLoader를 지정합니다.
리소스는 params 연산 함수가 새 값을 계산할 때마다 로더 함수를 실행하면서 그 값을 인자로 전달합니다.
자세한 내용은 Resource 로더 문서를 참고하세요.
Resource 객체에는 로더의 결과를 담는 value 시그널도 있습니다.
리소스 로더
리소스를 생성할 때 ResourceLoader를 지정합니다.
이 로더는 ResourceLoaderParams 객체를 인자로 받는 비동기 함수이며, 값을 하나 반환합니다.
ResourceLoaderParams 객체에는 params, previous, abortSignal 프로퍼티가 있습니다.
| 프로퍼티 | 설명 |
|---|---|
params |
리소스의 params 연산 함수가 반환한 값 |
previous |
status 프로퍼티와 이전 ResourceStatus 값이 담겨 전달되는 객체입니다. |
abortSignal |
AbortSignal에 대해 자세하게 알아보려면 요청 취소하기 섹션을 참고하세요. |
params 연산 함수가 undefined를 반환하면, 로더 함수가 실행되지 않으며 리소스의 상태는 'idle'로 유지됩니다.
스트리밍 리소스
일부 비동기 데이터 소스는 결과 하나를 반환하는 대신 시간에 따라 여러 값을 생성합니다. WebSocket, Server-Sent Events(SSE), Firestore의 onSnapshot 리스너가 여기에 해당합니다.
계속 갱신되는 데이터 소스에는 stream을 사용하세요. 요청마다 한 번 결과를 반환하는 loader와 달리, stream은 새 데이터가 도착할 때마다 값을 갱신할 수 있는 시그널을 반환합니다.
HTTP 엔드포인트에서 데이터를 가져오는 것처럼 한 번만 실행하는 비동기 작업에는 loader를 사용하세요.
다음 예제의 userUpdates는 WebSocket이나 SSE 연결 같은 외부 데이터 소스를 흉내 냅니다:
const userUpdates = signal({value: 'Alice'});
const userResource = resource({
stream: () => userUpdates,
});
// 외부 데이터 소스에서 새 값이 도착하는 상황을 흉내 냅니다:
userUpdates.set({value: 'Bob'});
요청 취소하기
리소스가 로드되는 동안 params 연산 함수가 변경되면 리소스는 완료되지 않은 로딩을 취소합니다.
그리고 ResourceLoaderParams의 abortSignal을 지정하면 중단된 요청에 응답할 수 있습니다.
네이티브 fetch 함수를 AbortSignal로 적용해 봅시다:
const userId: Signal<string> = getUserId();
const userResource = resource({
params: () => ({id: userId()}),
loader: ({params, abortSignal}): Promise<User> => {
// fetch는 `AbortSignal`을 통해 중단 요청이 들어올 때 미처리된 모든 `HTTP 요청을 취소합니다.
return fetch(`users/${params.id}`, {signal: abortSignal}).then((res) => res.json());
},
});
AbortSignal을 사용해서 요청을 취소하는 방법을 자세하게 알아보려면 MDN의 AbortSignal 문서를 참고하세요.
재시도
리소스 객체의 reload 메서드를 실행하면 loader 함수를 다시 실행할 수 있습니다.
const userId: Signal<string> = getUserId();
const userResource = resource({
params: () => ({id: userId()}),
loader: ({params}) => fetchUser(params),
});
// ...
userResource.reload();
리소스 상태
리소스 객체에는 비동기 로더의 상태를 읽을 수 있는 시그널 프로퍼티가 여러 개 있습니다.
| 프로퍼티 | 설명 |
|---|---|
value |
리소스의 가장 최근 값이며, 값을 받지 못했으면 기본값(defaultValue를 설정하지 않았다면 undefined)입니다. 상태가 'error'일 때 value()를 읽으면 예외가 발생합니다. |
hasValue |
리소스가 값을 갖고 있는지 여부 |
error |
리소스 로더를 실행하다가 발생한 가장 최근 오류, 오류가 발생하지 않았다면 undefined |
isLoading |
리소스 로더가 실행 중인지 여부 |
status |
리소스의 세부 ResourceStatus, 아래에서 설명 |
status 시그널은 리소스의 상태를 문자열 상수로 표현하는 ResourceStatus 값을 제공합니다.
| 상태 | value() |
설명 |
|---|---|---|
'idle' |
undefined |
리소스에 유효한 요청이 없으며, 로더가 실행되지 않았음 |
'error' |
오류 발생 | 로더 실행 중 오류가 발생했음 |
'loading' |
undefined |
params 값이 변경되어 로더가 실행 중 |
'reloading' |
이전 값 | 리소스의 reload 메서드가 실행되어 로더가 실행 중 |
'resolved' |
로더가 반환한 값 | 로더 실행 종료 |
'local' |
직접 설정된 값 | 리소스의 .set(), .update() 메서드로 값이 설정된 상태 |
이 상태 정보를 활용하면 로딩 인디케이터나 오류 메시지 같은 UI 엘리먼트를 조건에 따라 표시할 수 있습니다.
SSR에서 resource 데이터 캐싱하기
애플리케이션을 서버에서 렌더링하면 리소스 로더가 초기 HTML을 만들기 위해 한 번 실행됩니다. 하이드레이션 시에는 일반적으로 브라우저가 같은 로더를 다시 실행합니다.
서버의 결과를 재사용하려면 리소스에 id를 지정하세요. Angular는 서버에서 확정된 값을 TransferState에 저장하고, 클라이언트에서 이 값으로 리소스를 'resolved' 상태로 초기화합니다:
const userId: Signal<string> = getUserId();
const userResource = resource({
params: () => ({id: userId()}),
loader: ({params}) => fetchUser(params),
id: 'user-unique-id',
});
id는 애플리케이션 안에서 고유해야 하고 서버와 클라이언트에서 같아야 합니다. 그래야 Angular가 캐시 항목을 해당 리소스와 연결할 수 있습니다.
중요: 캐시된 값은 페이지 HTML에 직렬화됩니다. 서버 사이드 렌더링을 요청한 사용자에게만 해당하는 데이터를 불러오는 리소스에는 id를 설정하지 마세요. 특히 렌더링된 HTML을 캐시하거나 여러 사용자가 공유할 수 있을 때 주의하세요.
리소스 연결하기
리소스가 다른 리소스의 결과에 의존하는 경우, params 컨텍스트 객체의 chain 함수로 의존 관계를 표현할 수 있습니다:
import {resource} from '@angular/core';
const userResource = resource({
params: () => ({id: getUserId()}),
loader: ({params}) => fetchUser(params),
});
const companyResource = resource({
params: ({chain}) => chain(userResource)?.companyId,
loader: ({params: companyId}) => fetchCompany(companyId),
});
여기서 companyResource는 userResource 로드가 끝난 뒤에야 알 수 있는 사용자의 companyId에 의존합니다. chain(userResource)는 userResource의 값을 읽고 상태를 companyResource로 자동 전파합니다:
userResource가 idle이면companyResource도idle이 됩니다.userResource가 loading 또는 reloading이면companyResource는loading상태가 되고 로더를 실행하지 않습니다.reloading동안에는chain이 이전에 확정된 값을 반환하지 않습니다.userResource가 error 상태이면companyResource도error상태가 됩니다.userResource가 resolved 또는 local이면chain은 현재 값을 반환하고,companyResource는 이 값을 매개변수로 사용합니다.
chain이 userResource의 idle, loading, reloading, error 상태를 전파하면 params 함수의 실행은 더 이상 진행되지 않습니다. userResource가 resolved나 local이면 chain은 값을 반환하며, 값 자체가 undefined일 수도 있습니다. 예제에서는 chain(userResource)?.companyId로 처리하므로 값이 undefined이면 매개변수도 undefined가 되고 companyResource는 idle 상태가 됩니다.
참고: 연결된 값을 객체로 감싸지 말고 매개변수 값으로 직접 전달하세요. {companyId: undefined}도 정의된 값이므로 리소스가 idle이 되는 대신 companyId가 undefined인 상태로 로더를 실행합니다.
연결하기와 리소스 값을 직접 읽기의 차이
params 안에서 리소스 값을 직접 읽고 싶을 수도 있습니다:
reads value() directly without status propagation
const companyResource = resource({
params: () => {
const user = userResource.value(); // undefined일 수 있습니다.
return user ? {companyId: user.companyId} : undefined;
},
loader: ({params}) => fetchCompany(params.companyId),
});
이 방식도 동작하지만 params가 undefined를 반환하면 선행 리소스의 실제 상태를 반영하는 대신 리소스가 idle이 됩니다. loading과 error 상태를 정확히 반영하는 chain을 사용하는 것이 좋습니다.
후행 리소스가 선행 리소스의 값에 의존하는 비동기 작업을 직접 수행할 때만 chain을 사용하세요. 리소스에서 값을 동기적으로 파생하기만 하면 되는 경우에는 computed() 함수를 사용하세요.
반응형으로 데이터 불러오기: httpResource
httpResource는 요청 결과를 시그널로 보내는 HttpClient 래퍼(wrapper)입니다.
이 래퍼는 인터셉터를 포함하여 Angular HTTP 스택을 그대로 활용합니다.
resource 상태 확인하기
ResourceSnapshot을 확인하면 resource 시그널의 현재 상태를 참조할 수 있습니다.
resource 시그널은 모두 현재 상태를 확인할 수 있는 snapshot 프로퍼티를 제공합니다.
const userId: Signal<string> = getUserId();
const userResource = resource({
params: () => ({id: userId()}),
loader: ({params}) => fetchUser(params),
});
const userSnapshot = userResource.snapshot;
개별 스냅샷에는 status 프로퍼티가 존재하고, value나 error 프로퍼티 중 하나도 존재합니다.
스냅샷으로 resource 조합하기
IMPORTANT: resourceFromSnapshots는 실험적 기능입니다. 지금 사용해 볼 수 있지만, 안정화되기 전까지 변경될 수 있습니다.
resourceFromSnapshots를 사용하면 스냅샷으로 새로운 resource 시그널을 만들 수 있습니다.
computed() 함수나 linkedSignal도 사용할 수 있습니다.
import {
Component,
input,
linkedSignal,
resourceFromSnapshots,
Resource,
ResourceSnapshot,
} from '@angular/core';
import {httpResource} from '@angular/common/http';
function withPreviousValue<T>(input: Resource<T>): Resource<T> {
const derived = linkedSignal<ResourceSnapshot<T>, ResourceSnapshot<T>>({
source: input.snapshot,
computation: (snap, previous) => {
if (snap.status === 'loading' && previous && previous.value.status !== 'error') {
// 입력 시그널이 로딩 상태에 들어가면, 일단 이전 값을 유지합니다.
return {status: 'loading' as const, value: previous.value.value};
}
// 아니면 입력 리소스 시그널의 상태를 반환합니다.
return snap;
},
});
return resourceFromSnapshots(derived);
}
@Component({/*... */})
export class AwesomeProfile {
userId = input.required<number>();
user = withPreviousValue(httpResource(() => `/user/${this.userId()}`));
// userId 값이 변경되면 새 값이 로딩될 때까지 user.value()가 이전 값을 유지합니다.
}