Angular 앱에 폼 추가하기

이번 실습에서는 사용자 데이터를 수집하는 폼을 Angular 앱에 추가하는 방법을 알아봅니다. 이미 동작하는 Angular 앱에 폼을 추가하는 과정으로 진행합니다.

폼에서 수집한 데이터는 앱의 서비스로만 전달하며 서비스는 이 데이터를 브라우저 콘솔에 출력합니다. REST API로 폼 데이터를 주고받는 방법은 이번 실습에서 다루지 않습니다.

IMPORTANT: 이 실습은 로컬 환경에서 진행하는 것을 권장합니다.

학습 목표

  • 사용자가 입력한 데이터를 앱의 서비스로 전달하는 폼을 추가합니다.
  • 서비스에서 폼 데이터를 브라우저 콘솔에 출력합니다.
  1. 폼 데이터를 전달하는 메서드 추가하기

    이 단계에서는 폼 데이터를 받아 전달할 메서드를 앱의 서비스에 추가합니다. 이 예제에서는 폼 데이터를 브라우저 콘솔에 출력합니다.

    IDE의 편집 창에서 다음 작업을 수행하세요:

    1. src/app/housing.service.ts의 HousingService 클래스 정의 맨 아래에 다음 메서드를 붙여 넣으세요.

      src/app/housing.service.ts의 제출 메서드

      import {Service} from '@angular/core';
      import {HousingLocationInfo} from './housinglocation';
      
      @Service()
      export class HousingService {
        readonly baseUrl = 'https://angular.dev/assets/images/tutorials/common';
      
        protected housingLocationList: HousingLocationInfo[] = [
          {
            id: 0,
            name: 'Acme Fresh Start Housing',
            city: 'Chicago',
            state: 'IL',
            photo: `${this.baseUrl}/bernard-hermant-CLKGGwIBTaY-unsplash.jpg`,
            availableUnits: 4,
            wifi: true,
            laundry: true,
          },
          {
            id: 1,
            name: 'A113 Transitional Housing',
            city: 'Santa Monica',
            state: 'CA',
            photo: `${this.baseUrl}/brandon-griggs-wR11KBaB86U-unsplash.jpg`,
            availableUnits: 0,
            wifi: false,
            laundry: true,
          },
          {
            id: 2,
            name: 'Warm Beds Housing Support',
            city: 'Juneau',
            state: 'AK',
            photo: `${this.baseUrl}/i-do-nothing-but-love-lAyXdl1-Wmc-unsplash.jpg`,
            availableUnits: 1,
            wifi: false,
            laundry: false,
          },
          {
            id: 3,
            name: 'Homesteady Housing',
            city: 'Chicago',
            state: 'IL',
            photo: `${this.baseUrl}/ian-macdonald-W8z6aiwfi1E-unsplash.jpg`,
            availableUnits: 1,
            wifi: true,
            laundry: false,
          },
          {
            id: 4,
            name: 'Happy Homes Group',
            city: 'Gary',
            state: 'IN',
            photo: `${this.baseUrl}/krzysztof-hepner-978RAXoXnH4-unsplash.jpg`,
            availableUnits: 1,
            wifi: true,
            laundry: false,
          },
          {
            id: 5,
            name: 'Hopeful Apartment Group',
            city: 'Oakland',
            state: 'CA',
            photo: `${this.baseUrl}/r-architecture-JvQ0Q5IkeMM-unsplash.jpg`,
            availableUnits: 2,
            wifi: true,
            laundry: true,
          },
          {
            id: 6,
            name: 'Seriously Safe Towns',
            city: 'Oakland',
            state: 'CA',
            photo: `${this.baseUrl}/phil-hearing-IYfp2Ixe9nM-unsplash.jpg`,
            availableUnits: 5,
            wifi: true,
            laundry: true,
          },
          {
            id: 7,
            name: 'Hopeful Housing Solutions',
            city: 'Oakland',
            state: 'CA',
            photo: `${this.baseUrl}/r-architecture-GGupkreKwxA-unsplash.jpg`,
            availableUnits: 2,
            wifi: true,
            laundry: true,
          },
          {
            id: 8,
            name: 'Seriously Safe Towns',
            city: 'Oakland',
            state: 'CA',
            photo: `${this.baseUrl}/saru-robert-9rP3mxf8qWI-unsplash.jpg`,
            availableUnits: 10,
            wifi: false,
            laundry: false,
          },
          {
            id: 9,
            name: 'Capital Safe Towns',
            city: 'Portland',
            state: 'OR',
            photo: `${this.baseUrl}/webaliser-_TPTXZd9mOo-unsplash.jpg`,
            availableUnits: 6,
            wifi: true,
            laundry: true,
          },
        ];
      
        getAllHousingLocations(): HousingLocationInfo[] {
          return this.housingLocationList;
        }
      
        getHousingLocationById(id: number): HousingLocationInfo | undefined {
          return this.housingLocationList.find((housingLocation) => housingLocation.id === id);
        }
        submitApplication(firstName: string, lastName: string, email: string) {
          console.log(
            `Homes application received: firstName: ${firstName}, lastName: ${lastName}, email: ${email}.`,
          );
        }
      }
    2. 앱이 오류 없이 빌드되는지 확인하세요. 오류가 있다면 수정한 후 다음 단계로 진행하세요.

  2. 상세 페이지에 폼 기능 추가하기

    이 단계에서는 폼과의 상호작용을 처리하는 코드를 상세 페이지에 추가합니다.

    IDE의 편집 창에서 src/app/details/details.ts를 수정하세요:

    1. 파일 위쪽의 import 구문 뒤에 Angular 폼 클래스를 불러오는 다음 코드를 추가하세요.

    src/app/details/details.ts에서 폼 관련 항목 불러오기

    import {Component, inject} from '@angular/core';
    import {ActivatedRoute} from '@angular/router';
    import {HousingService} from '../housing.service';
    import {HousingLocationInfo} from '../housinglocation';
    import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
    
    @Component({
      selector: 'app-details',
      imports: [ReactiveFormsModule],
      template: `
        <article>
          <img
            class="listing-photo"
            [src]="housingLocation?.photo"
            alt="Exterior photo of {{ housingLocation?.name }}"
            crossorigin
          />
          <section class="listing-description">
            <h2 class="listing-heading">{{ housingLocation?.name }}</h2>
            <p class="listing-location">{{ housingLocation?.city }}, {{ housingLocation?.state }}</p>
          </section>
          <section class="listing-features">
            <h2 class="section-heading">About this housing location</h2>
            <ul>
              <li>Units available: {{ housingLocation?.availableUnits }}</li>
              <li>Does this location have wifi: {{ housingLocation?.wifi }}</li>
              <li>Does this location have laundry: {{ housingLocation?.laundry }}</li>
            </ul>
          </section>
          <section class="listing-apply">
            <h2 class="section-heading">Apply now to live here</h2>
            <form [formGroup]="applyForm" (submit)="submitApplication()">
              <label for="first-name">First Name</label>
              <input id="first-name" type="text" formControlName="firstName" />
    
              <label for="last-name">Last Name</label>
              <input id="last-name" type="text" formControlName="lastName" />
    
              <label for="email">Email</label>
              <input id="email" type="email" formControlName="email" />
              <button type="submit" class="primary">Apply now</button>
            </form>
          </section>
        </article>
      `,
      styleUrls: ['./details.css'],
    })
    export class Details {
      route: ActivatedRoute = inject(ActivatedRoute);
      housingService = inject(HousingService);
      housingLocation: HousingLocationInfo | undefined;
      applyForm = new FormGroup({
        firstName: new FormControl(''),
        lastName: new FormControl(''),
        email: new FormControl(''),
      });
    
      constructor() {
        const housingLocationId = parseInt(this.route.snapshot.params['id'], 10);
        this.housingLocation = this.housingService.getHousingLocationById(housingLocationId);
      }
      submitApplication() {
        this.housingService.submitApplication(
          this.applyForm.value.firstName ?? '',
          this.applyForm.value.lastName ?? '',
          this.applyForm.value.email ?? '',
        );
      }
    }
    1. Details 데코레이터 메타데이터의 imports 프로퍼티를 다음 코드로 수정하세요:

    src/app/details/details.ts의 imports 프로퍼티

    import {Component, inject} from '@angular/core';
    import {ActivatedRoute} from '@angular/router';
    import {HousingService} from '../housing.service';
    import {HousingLocationInfo} from '../housinglocation';
    import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
    
    @Component({
      selector: 'app-details',
      imports: [ReactiveFormsModule],
      template: `
        <article>
          <img
            class="listing-photo"
            [src]="housingLocation?.photo"
            alt="Exterior photo of {{ housingLocation?.name }}"
            crossorigin
          />
          <section class="listing-description">
            <h2 class="listing-heading">{{ housingLocation?.name }}</h2>
            <p class="listing-location">{{ housingLocation?.city }}, {{ housingLocation?.state }}</p>
          </section>
          <section class="listing-features">
            <h2 class="section-heading">About this housing location</h2>
            <ul>
              <li>Units available: {{ housingLocation?.availableUnits }}</li>
              <li>Does this location have wifi: {{ housingLocation?.wifi }}</li>
              <li>Does this location have laundry: {{ housingLocation?.laundry }}</li>
            </ul>
          </section>
          <section class="listing-apply">
            <h2 class="section-heading">Apply now to live here</h2>
            <form [formGroup]="applyForm" (submit)="submitApplication()">
              <label for="first-name">First Name</label>
              <input id="first-name" type="text" formControlName="firstName" />
    
              <label for="last-name">Last Name</label>
              <input id="last-name" type="text" formControlName="lastName" />
    
              <label for="email">Email</label>
              <input id="email" type="email" formControlName="email" />
              <button type="submit" class="primary">Apply now</button>
            </form>
          </section>
        </article>
      `,
      styleUrls: ['./details.css'],
    })
    export class Details {
      route: ActivatedRoute = inject(ActivatedRoute);
      housingService = inject(HousingService);
      housingLocation: HousingLocationInfo | undefined;
      applyForm = new FormGroup({
        firstName: new FormControl(''),
        lastName: new FormControl(''),
        email: new FormControl(''),
      });
    
      constructor() {
        const housingLocationId = parseInt(this.route.snapshot.params['id'], 10);
        this.housingLocation = this.housingService.getHousingLocationById(housingLocationId);
      }
      submitApplication() {
        this.housingService.submitApplication(
          this.applyForm.value.firstName ?? '',
          this.applyForm.value.lastName ?? '',
          this.applyForm.value.email ?? '',
        );
      }
    }
    1. Details 클래스의 constructor() 앞에 다음 코드를 추가해 폼 객체를 만드세요.

      src/app/details/details.ts의 폼 객체

      import {Component, inject} from '@angular/core';
      import {ActivatedRoute} from '@angular/router';
      import {HousingService} from '../housing.service';
      import {HousingLocationInfo} from '../housinglocation';
      import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
      
      @Component({
        selector: 'app-details',
        imports: [ReactiveFormsModule],
        template: `
          <article>
            <img
              class="listing-photo"
              [src]="housingLocation?.photo"
              alt="Exterior photo of {{ housingLocation?.name }}"
              crossorigin
            />
            <section class="listing-description">
              <h2 class="listing-heading">{{ housingLocation?.name }}</h2>
              <p class="listing-location">{{ housingLocation?.city }}, {{ housingLocation?.state }}</p>
            </section>
            <section class="listing-features">
              <h2 class="section-heading">About this housing location</h2>
              <ul>
                <li>Units available: {{ housingLocation?.availableUnits }}</li>
                <li>Does this location have wifi: {{ housingLocation?.wifi }}</li>
                <li>Does this location have laundry: {{ housingLocation?.laundry }}</li>
              </ul>
            </section>
            <section class="listing-apply">
              <h2 class="section-heading">Apply now to live here</h2>
              <form [formGroup]="applyForm" (submit)="submitApplication()">
                <label for="first-name">First Name</label>
                <input id="first-name" type="text" formControlName="firstName" />
      
                <label for="last-name">Last Name</label>
                <input id="last-name" type="text" formControlName="lastName" />
      
                <label for="email">Email</label>
                <input id="email" type="email" formControlName="email" />
                <button type="submit" class="primary">Apply now</button>
              </form>
            </section>
          </article>
        `,
        styleUrls: ['./details.css'],
      })
      export class Details {
        route: ActivatedRoute = inject(ActivatedRoute);
        housingService = inject(HousingService);
        housingLocation: HousingLocationInfo | undefined;
        applyForm = new FormGroup({
          firstName: new FormControl(''),
          lastName: new FormControl(''),
          email: new FormControl(''),
        });
      
        constructor() {
          const housingLocationId = parseInt(this.route.snapshot.params['id'], 10);
          this.housingLocation = this.housingService.getHousingLocationById(housingLocationId);
        }
        submitApplication() {
          this.housingService.submitApplication(
            this.applyForm.value.firstName ?? '',
            this.applyForm.value.lastName ?? '',
            this.applyForm.value.email ?? '',
          );
        }
      }

      Angular의 FormGroup과 FormControl은 폼을 구성하는 타입입니다. FormControl에는 기본값을 지정하고 폼 데이터의 타입을 정의할 수 있습니다. 이 예제에서 firstName은 문자열 값을 사용하며 기본값은 빈 문자열입니다.

    2. Details 클래스의 constructor() 뒤에 Apply now 버튼 클릭을 처리하는 다음 코드를 추가하세요.

      src/app/details/details.ts의 제출 처리 메서드

      import {Component, inject} from '@angular/core';
      import {ActivatedRoute} from '@angular/router';
      import {HousingService} from '../housing.service';
      import {HousingLocationInfo} from '../housinglocation';
      import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
      
      @Component({
        selector: 'app-details',
        imports: [ReactiveFormsModule],
        template: `
          <article>
            <img
              class="listing-photo"
              [src]="housingLocation?.photo"
              alt="Exterior photo of {{ housingLocation?.name }}"
              crossorigin
            />
            <section class="listing-description">
              <h2 class="listing-heading">{{ housingLocation?.name }}</h2>
              <p class="listing-location">{{ housingLocation?.city }}, {{ housingLocation?.state }}</p>
            </section>
            <section class="listing-features">
              <h2 class="section-heading">About this housing location</h2>
              <ul>
                <li>Units available: {{ housingLocation?.availableUnits }}</li>
                <li>Does this location have wifi: {{ housingLocation?.wifi }}</li>
                <li>Does this location have laundry: {{ housingLocation?.laundry }}</li>
              </ul>
            </section>
            <section class="listing-apply">
              <h2 class="section-heading">Apply now to live here</h2>
              <form [formGroup]="applyForm" (submit)="submitApplication()">
                <label for="first-name">First Name</label>
                <input id="first-name" type="text" formControlName="firstName" />
      
                <label for="last-name">Last Name</label>
                <input id="last-name" type="text" formControlName="lastName" />
      
                <label for="email">Email</label>
                <input id="email" type="email" formControlName="email" />
                <button type="submit" class="primary">Apply now</button>
              </form>
            </section>
          </article>
        `,
        styleUrls: ['./details.css'],
      })
      export class Details {
        route: ActivatedRoute = inject(ActivatedRoute);
        housingService = inject(HousingService);
        housingLocation: HousingLocationInfo | undefined;
        applyForm = new FormGroup({
          firstName: new FormControl(''),
          lastName: new FormControl(''),
          email: new FormControl(''),
        });
      
        constructor() {
          const housingLocationId = parseInt(this.route.snapshot.params['id'], 10);
          this.housingLocation = this.housingService.getHousingLocationById(housingLocationId);
        }
        submitApplication() {
          this.housingService.submitApplication(
            this.applyForm.value.firstName ?? '',
            this.applyForm.value.lastName ?? '',
            this.applyForm.value.email ?? '',
          );
        }
      }

      이 버튼은 아직 없으며 다음 단계에서 추가합니다. 위 코드의 FormControl은 null을 반환할 수 있습니다. 널 병합 연산자로 값이 null일 때 빈 문자열을 사용하도록 합니다.

    3. 앱이 오류 없이 빌드되는지 확인하세요. 오류가 있다면 수정한 후 다음 단계로 진행하세요.

  3. 상세 페이지에 폼 마크업 추가하기

    이 단계에서는 폼을 표시하는 마크업을 상세 페이지에 추가합니다.

    IDE의 편집 창에서 src/app/details/details.ts를 수정하세요:

    1. Details 데코레이터 메타데이터의 template HTML을 다음 코드로 수정해 폼 마크업을 추가하세요.

      src/app/details/details.ts의 template 프로퍼티

      import {Component, inject} from '@angular/core';
      import {ActivatedRoute} from '@angular/router';
      import {HousingService} from '../housing.service';
      import {HousingLocationInfo} from '../housinglocation';
      import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
      
      @Component({
        selector: 'app-details',
        imports: [ReactiveFormsModule],
        template: `
          <article>
            <img
              class="listing-photo"
              [src]="housingLocation?.photo"
              alt="Exterior photo of {{ housingLocation?.name }}"
              crossorigin
            />
            <section class="listing-description">
              <h2 class="listing-heading">{{ housingLocation?.name }}</h2>
              <p class="listing-location">{{ housingLocation?.city }}, {{ housingLocation?.state }}</p>
            </section>
            <section class="listing-features">
              <h2 class="section-heading">About this housing location</h2>
              <ul>
                <li>Units available: {{ housingLocation?.availableUnits }}</li>
                <li>Does this location have wifi: {{ housingLocation?.wifi }}</li>
                <li>Does this location have laundry: {{ housingLocation?.laundry }}</li>
              </ul>
            </section>
            <section class="listing-apply">
              <h2 class="section-heading">Apply now to live here</h2>
              <form [formGroup]="applyForm" (submit)="submitApplication()">
                <label for="first-name">First Name</label>
                <input id="first-name" type="text" formControlName="firstName" />
      
                <label for="last-name">Last Name</label>
                <input id="last-name" type="text" formControlName="lastName" />
      
                <label for="email">Email</label>
                <input id="email" type="email" formControlName="email" />
                <button type="submit" class="primary">Apply now</button>
              </form>
            </section>
          </article>
        `,
        styleUrls: ['./details.css'],
      })
      export class Details {
        route: ActivatedRoute = inject(ActivatedRoute);
        housingService = inject(HousingService);
        housingLocation: HousingLocationInfo | undefined;
        applyForm = new FormGroup({
          firstName: new FormControl(''),
          lastName: new FormControl(''),
          email: new FormControl(''),
        });
      
        constructor() {
          const housingLocationId = parseInt(this.route.snapshot.params['id'], 10);
          this.housingLocation = this.housingService.getHousingLocationById(housingLocationId);
        }
        submitApplication() {
          this.housingService.submitApplication(
            this.applyForm.value.firstName ?? '',
            this.applyForm.value.lastName ?? '',
            this.applyForm.value.email ?? '',
          );
        }
      }

      이제 템플릿에 (submit)="submitApplication()" 이벤트 핸들러가 포함됩니다. Angular 템플릿에서는 이벤트 이름을 괄호로 감싸 이벤트를 바인딩합니다. 등호 오른쪽 코드는 해당 이벤트가 발생했을 때 실행됩니다. 브라우저 이벤트와 커스텀 이벤트 모두 바인딩할 수 있습니다.

    2. 앱이 오류 없이 빌드되는지 확인하세요. 오류가 있다면 수정한 후 다음 단계로 진행하세요.

      입주 신청 폼이 표시된 상세 페이지
  4. 새 폼 테스트하기

    이 단계에서는 새 폼에서 데이터를 제출했을 때 브라우저 콘솔에 해당 데이터가 표시되는지 테스트합니다.

    1. IDE의 터미널 창에서 ng serve가 실행 중이 아니라면 실행하세요.
    2. 브라우저에서 http://localhost:4200을 열어 앱에 접속하세요.
    3. 브라우저의 앱 화면에서 마우스 오른쪽 버튼을 클릭하고 메뉴에서 검사를 선택하세요.
    4. 개발자 도구 창에서 콘솔(Console) 탭을 선택하세요. 다음 단계를 진행하는 동안 개발자 도구 창이 보이도록 두세요.
    5. 앱에서 다음 작업을 수행하세요:
      1. 주택을 선택하고 Learn more를 클릭해 상세 정보를 여세요.
      2. 상세 페이지 아래쪽으로 스크롤해 새 폼을 찾으세요.
      3. 폼의 입력 필드에 원하는 데이터를 입력하세요.
      4. Apply now를 클릭해 데이터를 제출하세요.
    6. 개발자 도구 창의 로그에 폼 데이터가 출력되는지 확인하세요.
  5. SUMMARY: 이번 실습에서는 Angular의 폼 기능으로 앱에 폼을 추가하고 이벤트 핸들러를 통해 입력한 데이터를 컴포넌트에 연결했습니다.

    이 실습에서 다룬 내용을 더 알아보려면 다음 문서를 참고하세요: