앵귤러 실전 강좌 #3 폼 + API로 Product CRUD
#2에서는 로그인,로그아웃 흐름과 인증 가드까지 마쳤습니다. 토큰을 인터셉터에 실어 서버에 보내는 부분까지 설정해두었으니, 이제 진짜로 데이터를 다룰 시간입니다. 이번 글에서는 우리 앱의 핵심 도메인인 Product를 CRUD — 생성,조회,수정,삭제 — 할 수 있는 화면을 만들어봅니다.
이 글에서 회수하는 도구가 꽤 많습니다. 중급 #1 Reactive Forms의 폼 모델, 중급 #6 Resolver의 데이터 선로딩, 그리고 기초에서 다뤘던 HttpClient와 Signals까지 — 익혀둔 도구들을 한 화면에 모아 조립해보는 시간입니다.
백엔드 API 약속 #
코드를 짜기 전에 서버와 어떤 모양으로 주고받을지 합의부터 잡겠습니다. 실전 강좌에서는 백엔드를 직접 만들지 않으니, #1에서 띄워둔 mock 서버가 다음 다섯 엔드포인트를 제공한다고 가정합니다.
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | /api/products | 목록 |
| POST | /api/products | 생성 |
| GET | /api/products/:id | 단건 조회 |
| PATCH | /api/products/:id | 부분 수정 |
| DELETE | /api/products/:id | 삭제 |
응답 모양은 단순합니다.
export interface Product {
id: number;
name: string;
price: number;
category: 'food' | 'drink' | 'goods';
description: string;
createdAt: string;
}
export type NewProduct = Omit<Product, 'id' | 'createdAt'>;
export type ProductPatch = Partial<NewProduct>;Product는 서버가 돌려주는 완성된 모양, NewProduct는 생성 시 보내는 모양(서버가 id,createdAt을 붙입니다), ProductPatch는 수정 시 보내는 부분 객체입니다. 타입을 이렇게 셋으로 갈라두면 화면마다 무엇을 다루는지 헷갈릴 일이 줄어듭니다.
ProductService — 4가지 메서드 #
서비스 클래스부터 만듭니다. 모든 메서드는 Observable을 돌려주고, 호출하는 쪽에서 필요에 따라 subscribe하거나 toSignal로 변환해 씁니다.
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private base = '/api/products';
list() { return this.http.get<Product[]>(this.base); }
get(id: number) { return this.http.get<Product>(`${this.base}/${id}`); }
create(payload: NewProduct) { return this.http.post<Product>(this.base, payload); }
update(id: number, patch: ProductPatch) {
return this.http.patch<Product>(`${this.base}/${id}`, patch);
}
remove(id: number) { return this.http.delete<void>(`${this.base}/${id}`); }
}@Injectable({ providedIn: 'root' })로 앱 전체에서 단일 인스턴스로 동작합니다. 네트워크 호출 외 가공 로직은 일부러 두지 않았습니다 — 데이터 변형은 호출하는 컴포넌트가 자기 사정에 맞게 처리하도록 두는 편이 재사용성이 좋습니다.
Product 목록 화면 #
목록은 가장 단순한 패턴입니다. 컴포넌트가 떠오르는 즉시 list()를 부르고, 그 결과를 toSignal로 받아 @for로 그립니다.
@Component({
selector: 'app-product-list',
standalone: true,
imports: [RouterLink],
templateUrl: './product-list.component.html',
})
export class ProductListComponent {
private products = inject(ProductService);
protected readonly state = toSignal(
this.products.list().pipe(catchError(() => of(null))),
{ initialValue: 'loading' as const },
);
}toSignal은 Observable을 Signal로 바꿔주는 다리입니다. 초기값을 'loading' 문자열로 두고, 정상 응답이 오면 배열로, 에러가 나면 null로 갈아끼웁니다. 결과적으로 state()는 세 가지 값 중 하나가 됩니다 — 'loading' / Product[] / null. 이 셋을 템플릿에서 분기하면 됩니다.
<header>
<h1>상품 목록</h1>
<a routerLink="/products/new">+ 상품 추가</a>
</header>
@if (state() === 'loading') {
<p>불러오는 중...</p>
} @else if (state() === null) {
<p class="error">상품을 불러오지 못했습니다. 잠시 후 다시 시도해주세요.</p>
} @else if (state()!.length === 0) {
<p class="empty">아직 등록된 상품이 없습니다.</p>
} @else {
<ul class="grid">
@for (p of state(); track p.id) {
<li class="card">
<a [routerLink]="['/products', p.id]">
<h3>{{ p.name }}</h3>
<p>{{ p.price | number }}원</p>
</a>
</li>
}
</ul>
}로딩,에러,빈 상태,정상 — 네 가지 분기를 @if / @else if / @else 한 덩어리에 넣어 표현했습니다. 각 분기가 어떤 상태를 가리키는지 한눈에 들어옵니다. 빈 상태(empty state)를 따로 챙기는 건 사소해 보여도 사용성에 큰 영향을 줍니다 — “처음 들어왔는데 화면이 텅 비어 있다"는 인상을 사용자에게 주지 마세요.
'loading'으로 둔 이유는 첫 렌더 시점에 Signal이 비어 있지 않게 하기 위해서입니다. toSignal은 기본적으로 Observable이 첫 값을 내기 전까지 undefined를 돌려주는데, 그러면 템플릿에서 undefined도 분기에 끼워 넣어야 합니다. 명시적인 sentinel 값(여기서는 문자열 'loading')을 두면 분기가 깔끔해집니다.Product 상세 화면 — Resolver로 미리 받기 #
상세 화면은 Resolver 패턴이 잘 어울립니다. 화면이 그려지는 순간부터 데이터가 손에 있어야 “데이터 없음” 분기를 둘 필요가 없기 때문입니다.
export const productResolver: ResolveFn<Product | null> = (route) => {
const id = Number(route.paramMap.get('id'));
const router = inject(Router);
return inject(ProductService).get(id).pipe(
catchError(() => {
router.navigate(['/products']);
return of(null);
}),
);
};존재하지 않는 id로 들어오면 목록으로 돌려보내고 null을 흘립니다. 라우트에는 resolve: { product: productResolver }를 키-값으로 등록하고, 상세 컴포넌트는 lazy load로 붙입니다.
컴포넌트는 ActivatedRoute의 data에서 결과를 꺼냅니다.
@Component({
selector: 'app-product-detail',
standalone: true,
imports: [RouterLink],
templateUrl: './product-detail.component.html',
})
export class ProductDetailComponent {
private route = inject(ActivatedRoute);
protected readonly product = this.route.snapshot.data['product'] as Product;
}템플릿에서는 product.name, product.price 같은 필드를 그대로 바인딩합니다. product가 항상 채워져 있으니 옵셔널 체이닝(?.)이나 로딩 분기가 필요 없습니다. Resolver의 본질적인 매력이 여기서 드러납니다 — 컴포넌트가 데이터를 의심하지 않고 시작할 수 있다는 점입니다.
Product 추가 — Reactive Forms #
생성 화면은 빈 폼에서 시작합니다. 중급 #1에서 다룬 Reactive Forms 패턴을 그대로 가져옵니다.
@Component({
selector: 'app-product-create',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './product-create.component.html',
})
export class ProductCreateComponent {
private fb = inject(FormBuilder);
private products = inject(ProductService);
private router = inject(Router);
protected submitting = signal(false);
protected errorMessage = signal<string | null>(null);
protected form = this.fb.nonNullable.group({
name: ['', [Validators.required, Validators.maxLength(80)]],
price: [0, [Validators.required, Validators.min(0)]],
category: ['goods' as 'food' | 'drink' | 'goods', Validators.required],
description: ['', Validators.maxLength(500)],
});
onSubmit() {
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
this.submitting.set(true);
this.errorMessage.set(null);
this.products.create(this.form.getRawValue()).subscribe({
next: created => this.router.navigate(['/products', created.id]),
error: () => {
this.errorMessage.set('저장에 실패했습니다. 다시 시도해주세요.');
this.submitting.set(false);
},
});
}
}세 가지 포인트가 있습니다.
fb.nonNullable.group— 모든 컨트롤이null이 될 수 없게 강제합니다.getRawValue()의 타입이 깔끔해져서 서버 호출 페이로드로 그대로 넘길 수 있습니다.markAllAsTouched()— 제출 시점에 모든 필드를 touched 상태로 만들어 누락된 항목의 에러를 한 번에 보여줍니다.submitting/errorMessage시그널 — 비동기 작업의 진행 상태를 컴포넌트가 직접 들고 있습니다. 로딩 스피너와 에러 알림은 모두 이 두 시그널만 보고 그립니다.
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<label>
이름
<input formControlName="name" />
@if (form.controls.name.touched && form.controls.name.invalid) {
<small class="error">이름을 입력하세요 (80자 이내).</small>
}
</label>
<label>가격 <input type="number" formControlName="price" min="0" /></label>
<label>
카테고리
<select formControlName="category">
<option value="goods">잡화</option>
<option value="food">식품</option>
<option value="drink">음료</option>
</select>
</label>
<label>설명 <textarea formControlName="description" rows="4"></textarea></label>
@if (errorMessage()) { <p class="error">{{ errorMessage() }}</p> }
<button type="submit" [disabled]="submitting()">
{{ submitting() ? '저장 중...' : '저장' }}
</button>
</form>검증 메시지는 touched && invalid일 때만 보여주는 패턴 — 첫 렌더부터 빨간 메시지가 깔리지 않도록 하는 자연스러운 UX입니다.
Product 편집 — patchValue로 폼 채우기 #
편집 화면은 생성 화면과 거의 같지만, 이미 있는 데이터로 폼을 채워놓고 시작하고 서버에는 변경된 필드만 PATCH로 보낸다는 점이 다릅니다. 라우트와 Resolver는 상세 화면과 같은 것을 재사용합니다 — path: 'products/:id/edit'에 resolve: { product: productResolver }만 똑같이 걸어주면 됩니다.
export class ProductEditComponent {
private fb = inject(FormBuilder);
private route = inject(ActivatedRoute);
private router = inject(Router);
private products = inject(ProductService);
private original = this.route.snapshot.data['product'] as Product;
protected submitting = signal(false);
protected form = this.fb.nonNullable.group({
name: [this.original.name, [Validators.required, Validators.maxLength(80)]],
price: [this.original.price, [Validators.required, Validators.min(0)]],
category: [this.original.category, Validators.required],
description: [this.original.description, Validators.maxLength(500)],
});
onSubmit() {
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
const patch = this.diff();
if (Object.keys(patch).length === 0) {
this.router.navigate(['/products', this.original.id]);
return;
}
this.submitting.set(true);
this.products.update(this.original.id, patch).subscribe({
next: () => this.router.navigate(['/products', this.original.id]),
error: () => this.submitting.set(false),
});
}
private diff(): ProductPatch {
const current = this.form.getRawValue();
const result: ProductPatch = {};
(Object.keys(current) as (keyof typeof current)[]).forEach(key => {
if (current[key] !== this.original[key]) {
(result as Record<string, unknown>)[key] = current[key];
}
});
return result;
}
}핵심은 diff() 메서드입니다. 폼의 현재 값과 원본을 키별로 비교해 달라진 필드만 모은 객체를 만듭니다. PATCH 본문이 가벼워지고, 서버 입장에서도 변경된 필드를 정확히 알 수 있어 감사 로그,동시성 처리가 깔끔해집니다. 변경 사항이 아예 없으면 서버를 두드리지도 않고 바로 상세 화면으로 돌아갑니다.
템플릿은 생성 화면과 거의 같으니 생략합니다 — formControlName을 똑같이 매칭만 해주면 patchValue 없이도 초깃값이 그대로 들어가 있는 폼이 떠오릅니다.
삭제 — confirm 다이얼로그 #
삭제는 가장 간단하지만, 되돌릴 수 없는 작업이므로 한 번 더 묻는 단계가 필요합니다. 우선 브라우저 기본 confirm으로 시작했다가, 나중에 Material 다이얼로그로 바꿀 수 있습니다.
protected onDelete() {
if (!confirm(`'${this.product.name}'을(를) 삭제하시겠습니까?`)) return;
this.products.remove(this.product.id).subscribe({
next: () => this.router.navigate(['/products']),
error: () => alert('삭제에 실패했습니다.'),
});
}confirm은 가장 기초적인 형태입니다. 실제 프로젝트라면 MatDialog로 모달을 띄워 일관된 다이얼로그 컴포넌트를 만들어 재사용하는 편이 좋습니다 — UX 일관성과 자동화 테스트 양쪽 모두에 유리합니다.
낙관적 업데이트 (Optimistic Update) #
목록 화면에서 삭제하는 흐름을 떠올려봅시다. 보통은 “클릭 → DELETE → 응답 대기(수백 ms) → 목록 갱신” 순서입니다. 응답을 기다리는 시간이 사용자에게는 “버튼 눌렀는데 반응이 없네"로 느껴집니다. 낙관적 업데이트는 이 순서를 뒤집습니다 — 먼저 화면을 바꾸고, 나중에 서버에 알리고, 실패하면 되돌립니다.
export class ProductListComponent {
private products = inject(ProductService);
protected items = signal<Product[]>([]);
// ...초기 로드 로직 생략 (toSignal 또는 effect)
onDelete(target: Product) {
if (!confirm(`'${target.name}'을(를) 삭제하시겠습니까?`)) return;
const snapshot = this.items();
// 1. 화면을 먼저 바꾼다
this.items.update(list => list.filter(p => p.id !== target.id));
// 2. 서버에 알린다
this.products.remove(target.id).subscribe({
error: () => {
// 3. 실패 시 롤백
this.items.set(snapshot);
alert('삭제에 실패했습니다. 화면을 되돌렸습니다.');
},
});
}
}핵심은 snapshot 변수입니다. 변경 직전의 상태를 손에 쥐고 있다가, 서버가 거절하면 그대로 되돌립니다. 사용자 입장에서 99%는 즉시 반응하는 빠른 앱처럼 느껴지고, 1%의 실패에서만 짧은 깜박임과 함께 원래대로 돌아옵니다.
낙관적 업데이트는 모든 작업에 어울리지는 않습니다 — 결제처럼 실패의 비용이 큰 작업에는 부적절합니다. 하지만 좋아요,삭제,간단한 토글 같은 가벼운 작업에서는 사용자가 체감하는 속도를 크게 끌어올립니다.
마무리 #
이번 글에서는 Product CRUD 화면을 처음부터 끝까지 만들면서 도구들을 한 곳에 모아봤습니다.
ProductService—inject(HttpClient)로 4가지 메서드. 모두 Observable 반환- 목록 —
toSignal로 Observable을 Signal로 변환, 로딩,에러,빈 상태,정상의 4분기 - 상세 —
productResolver로 데이터를 미리 받아 첫 렌더부터 손에 쥐고 시작 - 생성 —
fb.nonNullable.group으로 폼 모델,markAllAsTouched로 제출 시점 검증 - 편집 — Resolver로 받은 원본을 폼 초깃값으로,
diff()로 변경된 필드만 PATCH - 삭제 —
confirm→ DELETE → 목록 갱신 - 낙관적 업데이트 — 화면을 먼저 바꾸고, 실패 시 snapshot으로 롤백
여기까지 만들면 한 가지 어색함이 남습니다. 목록,상세,편집 화면이 각자 자기 데이터를 들고 있다 보니, 한 화면에서 수정한 값이 다른 화면에 자동으로 반영되지 않습니다. 편집 후 목록으로 돌아가면 다시 fetch하거나 수동 갱신을 해야 합니다. 이걸 깔끔하게 푸는 길이 상태 관리입니다.
다음 글 “앵귤러 실전 강좌 #4 상태 관리 — Signals Store와 NgRx"에서는 Signals 기반 가벼운 스토어부터 시작해, 본격적인 NgRx까지 — 어떤 규모에 무엇을 쓰면 되는지를 한 번에 정리하겠습니다. 이 강좌의 클라이언트 아키텍처 편이 본격적으로 시작되는 단계입니다.