AI와 대규모 언어 모델(LLM) API를 사용하면 비동기 작업 관리, 스트리밍 데이터 처리, 느리거나 불안정할 수 있는 네트워크 요청에 반응하는 사용자 경험 설계와 같은 고유한 과제를 마주하게 됩니다.
Angular 시그널과 resource API는 이러한 문제를 간결하게 해결할 수 있는 강력한 도구입니다.
시그널로 요청 실행하기
사용자가 입력하는 프롬프트를 다룰 때는 실시간 입력값과 API 호출을 실행하는 제출값을 분리하는 패턴을 자주 사용합니다.
- 사용자가 입력하는 원래 값을 하나의 시그널에 저장합니다.
- 사용자가 버튼을 클릭하는 등의 방법으로 제출하면 첫 번째 시그널의 값을 두 번째 시그널에 반영합니다.
- 두 번째 시그널을
resource의params필드에 사용합니다.
이렇게 구성하면 사용자가 키를 누를 때마다 실행하는 대신, 명시적으로 프롬프트를 제출할 때만 리소스의 loader 함수를 실행할 수 있습니다.
loader 필드에서는 sessionId나 userId와 같은 추가 시그널 매개변수를 사용할 수 있습니다(지속적인 LLM 세션을 만드는 데 유용합니다).
그러면 loader 필드에 정의한 비동기 함수를 다시 실행하지 않으면서 요청에 항상 이 매개변수의 현재 값을 사용할 수 있습니다.
여러 AI SDK는 API 호출을 위한 헬퍼 메서드를 제공합니다.
예를 들어 Genkit 클라이언트 라이브러리는 Genkit 플로를 호출하는 runFlow 메서드를 제공하며, 이 메서드는 리소스의 loader에서 호출할 수 있습니다.
다른 API에는 httpResource를 사용할 수 있습니다.
다음 예제는 AI가 생성한 이야기의 일부를 가져오는 resource입니다.
loader는 storyInput 시그널이 변경될 때만 실행됩니다.
// AI가 생성한 이야기의 세 부분을 가져오는 리소스입니다.
storyResource = resource({
// 첫 요청 전이나 오류가 발생했을 때 사용할 기본값입니다.
defaultValue: DEFAULT_STORY,
// 이 시그널이 변경되면 로더를 다시 실행합니다.
params: () => this.storyInput(),
// 데이터를 가져오는 비동기 함수입니다.
loader: ({params}): Promise<StoryData> => {
// params 값은 storyInput 시그널의 현재 값입니다.
const url = this.endpoint();
return runFlow({
url,
input: {
userInput: params,
sessionId: this.storyService.sessionId(), // 다른 시그널에서 값을 읽습니다.
},
});
},
});
템플릿에 사용할 LLM 데이터 준비하기
LLM API가 구조화된 데이터를 반환하도록 설정할 수 있습니다.
LLM의 예상 출력에 맞게 resource의 타입을 명확하게 지정하면 타입 안전성과 에디터의 자동 완성 기능을 개선할 수 있습니다.
리소스로부터 파생된 상태를 관리하려면 computed() 시그널이나 linkedSignal을 사용하세요.
linkedSignal은 이전 값에 접근할 수 있기 때문에 다음과 같은 다양한 AI 활용 사례에 사용할 수 있습니다:
- 채팅 기록 구성하기
- LLM이 콘텐츠를 생성하는 동안 템플릿에 표시할 데이터를 유지하거나 변경하기
다음 예제의 storyParts는 storyResource가 반환한 최신 이야기 부분을 기존 이야기 배열에 추가하는 linkedSignal입니다.
storyParts = linkedSignal<string[], string[]>({
// 연산을 실행하는 원본 시그널입니다.
source: () => this.storyResource.value().storyParts,
// 연산 함수입니다.
computation: (newStoryParts, previous) => {
// 이 linkedSignal의 이전 값이나 빈 배열을 가져옵니다.
const existingStoryParts = previous?.value || [];
// 기존 부분과 새 부분을 합친 새 배열을 반환합니다.
return [...existingStoryParts, ...newStoryParts];
},
});
성능과 사용자 경험
LLM API는 결과가 비교적 예측 가능한 일반적인 API보다 느리고 오류가 발생하기 쉽습니다. Angular의 여러 기능을 활용하면 성능이 좋고 사용하기 편한 인터페이스를 만들 수 있습니다.
- 로딩 범위 제한: 데이터를 직접 사용하는 컴포넌트에
resource를 배치하세요. 이렇게 하면 특히 존리스 애플리케이션에서 변경 감지 사이클의 범위를 제한하고, 애플리케이션의 다른 부분이 대기하는 것을 방지할 수 있습니다. 여러 컴포넌트에서 데이터를 공유해야 한다면 서비스에서resource를 제공하세요. - SSR과 하이드레이션: 점진적 하이드레이션과 서버 사이드 렌더링(SSR)을 함께 사용하면 초기 페이지 콘텐츠를 빠르게 렌더링할 수 있습니다. AI가 생성할 콘텐츠 대신 플레이스홀더를 표시하고, 클라이언트에서 컴포넌트가 하이드레이션될 때까지 데이터 요청을 미룰 수 있습니다.
- 로딩 상태: 요청이 진행 중인 동안
resource의LOADING상태를 사용해 스피너와 같은 표시기를 보여주세요. 이 상태는 최초 로딩과 다시 로딩하는 경우를 모두 포함합니다. - 오류 처리와 재시도:
resource의reload()메서드를 사용하면 사용자가 실패한 요청을 간단하게 재시도하도록 할 수 있습니다. AI가 생성하는 콘텐츠를 사용하는 경우에는 요청 실패가 더 자주 발생할 수 있습니다.
다음 예제는 로딩과 재시도 기능을 제공하면서 AI가 생성한 이미지를 동적으로 표시하는 반응성 있는 UI를 만드는 방법입니다.
<!-- LLM이 이미지를 생성하는 동안 로딩 스피너를 표시합니다. -->
@if (imgResource.isLoading()) {
<div class="img-placeholder">
<mat-spinner [diameter]="50" />
</div>
<!-- 생성한 이미지 URL을 src 어트리뷰트에 동적으로 지정합니다. -->
} @else if (imgResource.hasValue()) {
<img [src]="imgResource.value()" />
<!-- 요청이 실패하면 재시도할 수 있도록 합니다. -->
} @else {
<div class="img-placeholder" (click)="imgResource.reload()">
<mat-icon fontIcon="refresh" />
<p>Failed to load image. Click to retry.</p>
</div>
}
AI 패턴 활용: 채팅 응답 스트리밍
LLM 기반 API를 사용하는 인터페이스는 응답 데이터가 도착하는 대로 결과의 일부를 점진적으로 표시하는 경우가 많습니다.
Angular의 리소스 API는 이러한 패턴을 지원하도록 응답을 스트리밍할 수 있습니다.
resource의 stream 프로퍼티는 시간에 따라 시그널 값을 갱신하는 비동기 함수를 받습니다.
갱신하는 시그널은 스트리밍 중인 데이터를 나타냅니다.
characters = resource({
stream: async () => {
const data = signal<ResourceStreamItem<string>>({value: ''});
// Genkit 클라이언트 SDK가 제공하는 streamFlow 메서드로
// Genkit 스트리밍 플로를 호출합니다.
const response = streamFlow({
url: '/streamCharacters',
input: 10,
});
(async () => {
for await (const chunk of response.stream) {
data.update((prev) => {
if ('value' in prev) {
return {value: `${prev.value} ${chunk}`};
} else {
return {error: chunk as unknown as Error};
}
});
}
})();
return data;
},
});
characters 멤버는 비동기로 갱신되며 템플릿에 표시할 수 있습니다.
@if (characters.isLoading()) {
<p>Loading...</p>
} @else if (characters.hasValue()) {
<p>{{ characters.value() }}</p>
} @else {
<p>{{ characters.error() }}</p>
}
서버 측에서는 server.ts 등에 정의한 엔드포인트가 클라이언트로 스트리밍할 데이터를 전송합니다.
다음 코드는 Genkit 프레임워크와 Gemini를 사용하지만, 이 기법은 LLM 응답 스트리밍을 지원하는 다른 API에도 적용할 수 있습니다:
import {startFlowServer} from '@genkit-ai/express';
import {genkit} from 'genkit/beta';
import {googleAI, gemini20Flash} from '@genkit-ai/googleai';
const ai = genkit({plugins: [googleAI()]});
export const streamCharacters = ai.defineFlow(
{
name: 'streamCharacters',
inputSchema: z.number(),
outputSchema: z.string(),
streamSchema: z.string(),
},
async (count, {sendChunk}) => {
const {response, stream} = ai.generateStream({
model: gemini20Flash,
config: {
temperature: 1,
},
prompt: `Generate ${count} different RPG game characters.`,
});
(async () => {
for await (const chunk of stream) {
sendChunk(chunk.content[0].text!);
}
})();
return (await response).text;
},
);
startFlowServer({
flows: [streamCharacters],
});