출시·고도화 중
Angular 안내서 · 4/6
대부분의 Angular 앱은 HTTP로 데이터를 불러오고, 일부 상태를 클라이언트에 두며, 폼으로 입력을 받습니다. 이 장에서는 HttpClient, 시그널 기반 상태, Angular 코드에서 자주 만나는 RxJS 기초, 반응형 폼을 다룹니다.
app.config.ts에서 HttpClient를 한 번 등록합니다. withFetch()를 쓰면 브라우저의 Fetch API로 요청하며, 서버 사이드 렌더링을 쓸 때 권장되는 설정입니다.
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withFetch())],
};HTTP 호출은 서비스에 모아 둡니다. HttpClient 메서드는 Observable을 돌려줍니다. Observable은 누군가 구독할 때 요청을 보내고, 응답 JSON을 파싱한 값을 내보냅니다.
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
export interface Todo {
id: number;
title: string;
completed: boolean;
}
@Injectable({ providedIn: 'root' })
export class TodoApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = 'https://jsonplaceholder.typicode.com/todos';
list(limit = 10): Observable<Todo[]> {
const params = new HttpParams().set('_limit', limit);
return this.http.get<Todo[]>(this.baseUrl, { params });
}
create(title: string): Observable<Todo> {
return this.http.post<Todo>(this.baseUrl, { title, completed: false });
}
}get<Todo[]>의 타입 인자는 TypeScript에 기대하는 모양을 알려 줄 뿐, 실행 중에 응답을 검증하지는 않습니다.
toSignal()은 Observable을 구독해 가장 최근 값을 시그널로 내놓습니다. 컴포넌트가 사라지면 구독도 자동으로 해제됩니다.
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TodoApi } from './todo-api';
@Component({
selector: 'app-todo-list',
template: `
@for (todo of todos(); track todo.id) {
<p>{{ todo.title }}</p>
} @empty {
<p>Loading...</p>
}
`,
})
export class TodoList {
private readonly api = inject(TodoApi);
protected readonly todos = toSignal(this.api.list(), { initialValue: [] });
}장바구니처럼 여러 컴포넌트가 함께 쓰는 상태는 시그널을 가진 서비스 하나로 충분한 경우가 많습니다. 컴포넌트는 공개된 시그널을 읽기만 하고, 값은 서비스만 바꿉니다.
import { Injectable, computed, signal } from '@angular/core';
export interface CartItem { id: number; price: number; qty: number; }
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly items = signal<CartItem[]>([]);
readonly all = this.items.asReadonly();
readonly total = computed(() => this.items().reduce((sum, item) => sum + item.price * item.qty, 0));
add(item: CartItem) {
this.items.update((list) => [...list, item]);
}
}규모가 큰 앱에서는 NgRx 같은 상태 관리 라이브러리를 쓰기도 하지만, 그 전까지는 시그널 서비스로 상당 부분을 해결할 수 있습니다.
RxJS는 지금도 HttpClient, 라우터, 폼의 중심에 있습니다. debounceTime은 입력이 잠시 멈출 때까지 기다리고, distinctUntilChanged는 같은 값의 반복을 건너뛰며, switchMap은 새 값이 오면 이전 요청을 취소하고, catchError는 실패를 대체 값으로 바꿉니다. 직접 구독할 때는 takeUntilDestroyed()로 컴포넌트가 사라질 때 구독을 끝냅니다.
import { Component, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { catchError, debounceTime, distinctUntilChanged, of, switchMap } from 'rxjs';
@Component({
selector: 'app-search',
imports: [ReactiveFormsModule],
template: `
<input [formControl]="query" placeholder="Search" />
@for (name of results(); track name) {
<p>{{ name }}</p>
}
`,
})
export class Search {
private readonly http = inject(HttpClient);
protected readonly query = new FormControl('', { nonNullable: true });
protected readonly results = signal<string[]>([]);
constructor() {
this.query.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q) => this.http.get<string[]>('/api/search', { params: { q } }).pipe(catchError(() => of([])))),
takeUntilDestroyed(),
).subscribe((list) => this.results.set(list));
}
}반응형 폼은 폼 모델을 TypeScript로 정의하며, 컨트롤에 타입과 검증기가 붙습니다. 템플릿은 formGroup과 formControlName으로 모델에 연결합니다.
import { Component, inject } from '@angular/core';
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-signup',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<input type="email" formControlName="email" />
@if (form.controls.email.invalid && form.controls.email.touched) {
<small>Enter a valid email address.</small>
}
<input type="password" formControlName="password" />
<button type="submit" [disabled]="form.invalid">Sign up</button>
</form>
`,
})
export class Signup {
private readonly fb = inject(NonNullableFormBuilder);
protected readonly form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
submit() {
if (this.form.valid) console.log(this.form.getRawValue());
}
}provideHttpClient()를 한 번 등록하고, Observable을 돌려주는 서비스에서 API를 호출합니다.toSignal()로 Observable을 템플릿에서 쓰기 좋은 시그널로 바꿉니다.computed() 값을 가진 서비스는 간단한 공유 저장소가 됩니다.switchMap, debounceTime, catchError, takeUntilDestroyed()로 흔한 비동기 상황을 처리합니다.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.
첫 댓글을 남겨 보세요.