앵귤러 실전 강좌 #3 폼 + API로 Product CRUD

10 분 소요

#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삭제

응답 모양은 단순합니다.

src/app/products/product.model.ts
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로 변환해 씁니다.

src/app/products/product.service.ts
@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로 그립니다.

src/app/pages/product-list.component.ts
@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. 이 셋을 템플릿에서 분기하면 됩니다.

src/app/pages/product-list.component.html
<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 패턴이 잘 어울립니다. 화면이 그려지는 순간부터 데이터가 손에 있어야 “데이터 없음” 분기를 둘 필요가 없기 때문입니다.

src/app/products/product.resolver.ts
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로 붙입니다.

컴포넌트는 ActivatedRoutedata에서 결과를 꺼냅니다.

src/app/pages/product-detail.component.ts
@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 패턴을 그대로 가져옵니다.

src/app/pages/product-create.component.ts
@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 시그널 — 비동기 작업의 진행 상태를 컴포넌트가 직접 들고 있습니다. 로딩 스피너와 에러 알림은 모두 이 두 시그널만 보고 그립니다.
src/app/pages/product-create.component.html
<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 }만 똑같이 걸어주면 됩니다.

src/app/pages/product-edit.component.ts
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 본문이 가벼워지고, 서버 입장에서도 변경된 필드를 정확히 알 수 있어 감사 로그,동시성 처리가 깔끔해집니다. 변경 사항이 아예 없으면 서버를 두드리지도 않고 바로 상세 화면으로 돌아갑니다.

노트
폼 전체 값을 그대로 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) → 목록 갱신” 순서입니다. 응답을 기다리는 시간이 사용자에게는 “버튼 눌렀는데 반응이 없네"로 느껴집니다. 낙관적 업데이트는 이 순서를 뒤집습니다 — 먼저 화면을 바꾸고, 나중에 서버에 알리고, 실패하면 되돌립니다.

src/app/pages/product-list.component.ts (낙관적 삭제)
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 화면을 처음부터 끝까지 만들면서 도구들을 한 곳에 모아봤습니다.

  • ProductServiceinject(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까지 — 어떤 규모에 무엇을 쓰면 되는지를 한 번에 정리하겠습니다. 이 강좌의 클라이언트 아키텍처 편이 본격적으로 시작되는 단계입니다.

X