Component 완벽 마스터
Component의 핵심 개념과 실전 활용법
학습 항목
이미지 로딩 중...
Angular 실전 프로젝트 완벽 가이드
Angular로 실무 프로젝트를 처음 시작하는 개발자를 위한 완벽 가이드입니다. 컴포넌트 설계부터 상태 관리, 라우팅, HTTP 통신, 폼 처리까지 실전에서 꼭 필요한 핵심 개념들을 체계적으로 다룹니다. 각 주제마다 실제 작동하는 코드와 함께 실무 노하우를 제공합니다.
목차
1. 컴포넌트_기반_아키텍처
시작하며
여러분이 대규모 웹 애플리케이션을 개발할 때 코드가 점점 복잡해지고, 비슷한 UI 패턴을 반복해서 작성하느라 시간을 낭비한 경험이 있나요? 예를 들어 사용자 목록, 제품 목록, 주문 목록 등 비슷한 테이블 구조를 매번 새로 만들어야 했던 상황 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 코드 중복이 늘어나면 유지보수가 어려워지고, 하나의 버그를 고치려면 여러 곳을 수정해야 하는 악순환이 반복됩니다.
또한 팀원 간 협업도 어려워지죠. 바로 이럴 때 필요한 것이 컴포넌트 기반 아키텍처입니다.
Angular의 컴포넌트는 UI를 독립적이고 재사용 가능한 작은 블록으로 나누어, 마치 레고 블록처럼 조립해서 완성된 애플리케이션을 만들 수 있게 해줍니다.
개요
간단히 말해서, 컴포넌트는 화면의 특정 부분을 담당하는 독립적인 UI 단위입니다. 각 컴포넌트는 자체적인 템플릿(HTML), 스타일(CSS), 그리고 로직(TypeScript)을 가지고 있어서 완전히 독립적으로 동작할 수 있습니다.
예를 들어, 사용자 프로필 카드, 상품 카탈로그 아이템, 내비게이션 바 같은 UI 요소들을 각각 별도의 컴포넌트로 만들 수 있습니다. 기존의 전통적인 웹 개발에서는 하나의 거대한 HTML 파일에 모든 것을 작성했다면, 이제는 기능별로 작은 컴포넌트로 나누어 관리할 수 있습니다.
컴포넌트는 재사용성, 테스트 용이성, 명확한 책임 분리라는 핵심 특징을 가집니다. 이러한 특징들이 대규모 애플리케이션을 체계적으로 관리하는 데 필수적이죠.
코드 예제
// user-card.component.ts
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-user-card',
template: `
<div class="user-card">
<img [src]="user.avatar" [alt]="user.name">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<button (click)="onViewProfile()">프로필 보기</button>
</div>
`,
styles: [`
.user-card {
border: 1px solid #ddd;
padding: 20px;
border-radius: 8px;
}
`]
})
export class UserCardComponent {
// 부모 컴포넌트로부터 데이터를 받습니다
@Input() user!: { name: string; email: string; avatar: string };
// 사용자 프로필 보기 이벤트를 처리합니다
onViewProfile(): void {
console.log('프로필 보기:', this.user.name);
}
}
설명
이것이 하는 일: 이 컴포넌트는 사용자 정보를 카드 형태로 표시하는 재사용 가능한 UI 블록입니다. 어디서든 필요할 때마다 <app-user-card [user]="userData"></app-user-card> 형태로 사용할 수 있죠.
첫 번째로, @Component 데코레이터가 이 클래스를 Angular 컴포넌트로 정의합니다. selector는 HTML에서 이 컴포넌트를 사용할 때의 태그 이름이고, template은 실제 화면에 보여질 HTML 구조를 정의합니다.
이렇게 하면 컴포넌트의 모든 정보가 한 파일에 모여 있어 관리가 쉬워집니다. 그 다음으로, @Input() 데코레이터가 붙은 user 속성이 실행되면서 부모 컴포넌트로부터 데이터를 받아옵니다.
이것이 Angular의 데이터 바인딩 시스템이며, 부모가 전달한 사용자 정보가 자동으로 이 속성에 할당됩니다. 느낌표(!)는 TypeScript에게 "이 값은 반드시 초기화될 것"이라고 알려주는 표시입니다.
템플릿 내부에서는 이중 중괄호 {{ }}를 사용한 보간법과 대괄호 []를 사용한 속성 바인딩이 동작합니다. user.name 같은 표현식이 자동으로 평가되어 실제 값으로 치환되죠.
버튼의 (click) 이벤트 바인딩은 사용자 클릭을 감지하여 onViewProfile 메서드를 실행합니다. 여러분이 이 코드를 사용하면 사용자 목록 페이지에서 각 사용자마다 동일한 스타일의 카드를 쉽게 표시할 수 있습니다.
한 번만 작성하고 여러 곳에서 재사용할 수 있어 개발 시간이 크게 단축되고, 디자인 일관성도 자동으로 유지됩니다.
실전 팁
💡 컴포넌트는 가능한 한 작게 만드세요. 하나의 컴포넌트가 하나의 명확한 책임만 가지도록 설계하면 재사용성과 테스트 가능성이 높아집니다.
💡 @Input으로 받은 데이터를 컴포넌트 내부에서 직접 수정하지 마세요. 이는 단방향 데이터 흐름 원칙을 위반하며 예측 불가능한 버그를 만듭니다. 대신 @Output 이벤트를 사용해 부모에게 변경을 요청하세요.
💡 인라인 템플릿은 간단한 경우만 사용하고, 복잡한 UI는 별도의 HTML 파일로 분리하세요. templateUrl을 사용하면 가독성이 크게 향상됩니다.
💡 OnInit, OnDestroy 같은 라이프사이클 훅을 활용하세요. 컴포넌트 생성 시 데이터를 로드하거나, 파괴 시 리소스를 정리하는 등 적절한 시점에 작업을 수행할 수 있습니다.
2. 의존성_주입_시스템
시작하며
여러분이 여러 컴포넌트에서 사용자 데이터를 관리해야 하는데, 각 컴포넌트마다 동일한 로직을 반복해서 작성하고 있나요? 로그인 컴포넌트, 프로필 컴포넌트, 설정 컴포넌트에서 모두 사용자 정보를 가져오는 코드를 중복으로 작성하는 상황 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 코드 중복뿐만 아니라, 나중에 로직을 수정해야 할 때 모든 곳을 찾아다니며 고쳐야 하는 번거로움이 생깁니다.
또한 단위 테스트를 작성할 때도 실제 서비스를 모킹(mocking)하기 어려워집니다. 바로 이럴 때 필요한 것이 의존성 주입(Dependency Injection) 시스템입니다.
Angular의 DI는 공통 로직을 서비스로 분리하고, 필요한 곳에 자동으로 제공해주어 코드의 재사용성과 테스트 가능성을 극대적으로 높여줍니다.
개요
간단히 말해서, 의존성 주입은 클래스가 필요로 하는 객체를 외부에서 자동으로 제공받는 디자인 패턴입니다. 컴포넌트가 직접 서비스 인스턴스를 생성하는 대신, Angular가 알아서 만들어서 주입해줍니다.
예를 들어, 여러 컴포넌트에서 사용하는 인증 서비스나 데이터 서비스를 한 번만 정의하고, 필요한 모든 곳에서 동일한 인스턴스를 공유할 수 있습니다. 기존에는 const service = new UserService()처럼 직접 인스턴스를 생성했다면, 이제는 생성자에 타입만 명시하면 Angular가 자동으로 주입해줍니다.
DI의 핵심 특징은 느슨한 결합, 싱글톤 패턴 지원, 테스트 용이성입니다. 이러한 특징들이 대규모 애플리케이션의 아키텍처를 견고하게 만들어주죠.
코드 예제
// user.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
// 애플리케이션 전체에서 하나의 인스턴스만 생성됩니다
@Injectable({
providedIn: 'root'
})
export class UserService {
// 사용자 데이터를 반응형으로 관리합니다
private currentUserSubject = new BehaviorSubject<any>(null);
public currentUser$: Observable<any> = this.currentUserSubject.asObservable();
// 사용자 로그인 처리
login(email: string, password: string): void {
// API 호출 로직 (예시)
const user = { id: 1, name: '홍길동', email };
this.currentUserSubject.next(user);
}
// 사용자 로그아웃 처리
logout(): void {
this.currentUserSubject.next(null);
}
// 현재 로그인 상태 확인
isLoggedIn(): boolean {
return this.currentUserSubject.value !== null;
}
}
설명
이것이 하는 일: 이 서비스는 애플리케이션 전체에서 사용자 인증 상태를 관리하는 중앙 집중식 로직을 제공합니다. 어떤 컴포넌트에서든 동일한 사용자 정보에 접근할 수 있죠.
첫 번째로, @Injectable 데코레이터가 이 클래스를 주입 가능한 서비스로 표시합니다. providedIn: 'root' 옵션은 이 서비스가 애플리케이션 루트 레벨에서 제공된다는 의미이며, 이렇게 하면 앱 전체에서 단 하나의 인스턴스만 생성됩니다.
이것이 싱글톤 패턴의 구현입니다. 그 다음으로, BehaviorSubject를 사용해 사용자 데이터를 반응형으로 관리합니다.
BehaviorSubject는 현재 값을 저장하고, 구독자들에게 변경사항을 자동으로 알려주는 RxJS의 강력한 도구입니다. currentUser$는 외부에 노출되는 Observable이며, 컴포넌트들은 이것을 구독해서 실시간으로 사용자 상태 변화를 감지할 수 있습니다.
login, logout 같은 메서드들이 실행되면서 currentUserSubject.next()를 호출하여 상태를 업데이트합니다. 이 순간 구독하고 있던 모든 컴포넌트가 자동으로 새로운 값을 받아 화면을 갱신하죠.
isLoggedIn() 메서드는 현재 로그인 상태를 즉시 확인할 수 있는 헬퍼 함수입니다. 여러분이 이 코드를 사용하면 컴포넌트에서 constructor(private userService: UserService) {}만 선언하면 자동으로 서비스가 주입됩니다.
모든 컴포넌트가 동일한 UserService 인스턴스를 공유하므로, 한 곳에서 로그인하면 다른 모든 곳에서도 즉시 반영됩니다. 테스트할 때는 가짜 UserService를 주입해서 쉽게 모킹할 수 있습니다.
실전 팁
💡 providedIn: 'root' 대신 특정 모듈에만 제공하고 싶다면 해당 모듈의 providers 배열에 추가하세요. 이렇게 하면 서비스의 스코프를 제한할 수 있습니다.
💡 서비스에서 HTTP 호출 같은 외부 의존성을 사용할 때도 생성자 주입을 활용하세요. constructor(private http: HttpClient) {}처럼 선언하면 HttpClient도 자동으로 주입됩니다.
💡 순환 의존성을 조심하세요. ServiceA가 ServiceB를 주입받고, ServiceB가 다시 ServiceA를 주입받는 구조는 런타임 에러를 발생시킵니다. 공통 로직을 별도 서비스로 분리하세요.
💡 서비스는 상태 관리뿐만 아니라 비즈니스 로직, 데이터 변환, 외부 API 통신 등 다양한 용도로 활용할 수 있습니다. 컴포넌트는 가능한 한 가볍게 유지하고 무거운 로직은 서비스로 분리하세요.
3. RxJS_옵저버블
시작하며
여러분이 실시간 검색 기능을 구현할 때, 사용자가 타이핑할 때마다 서버에 요청을 보내다 보니 서버가 과부하되거나 불필요한 네트워크 트래픽이 발생하는 문제를 겪어본 적 있나요? 예를 들어 "Angular"를 검색하려는데 "A", "An", "Ang" 등 매 글자마다 API를 호출하는 상황 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 비동기 이벤트를 효율적으로 처리하지 못하면 성능 저하, 불필요한 리소스 낭비, 복잡한 콜백 지옥에 빠지게 됩니다.
또한 여러 비동기 작업을 조합하거나 에러를 처리하는 것도 어려워집니다. 바로 이럴 때 필요한 것이 RxJS 옵저버블입니다.
옵저버블은 시간에 따라 발생하는 데이터 스트림을 선언적으로 처리할 수 있게 해주며, debounce, throttle, filter 같은 강력한 연산자들로 복잡한 비동기 로직을 우아하게 해결합니다.
개요
간단히 말해서, 옵저버블은 시간의 흐름에 따라 여러 값을 방출하는 데이터 스트림입니다. Promise가 하나의 비동기 값만 다룬다면, Observable은 0개 이상의 값을 지속적으로 전달할 수 있습니다.
예를 들어, 사용자의 키보드 입력, 마우스 이동, HTTP 응답, WebSocket 메시지 등 모든 종류의 비동기 이벤트를 통일된 방식으로 처리할 수 있습니다. 기존에는 콜백 함수나 Promise 체이닝으로 복잡한 비동기 로직을 작성했다면, 이제는 map, filter, debounceTime 같은 연산자를 체이닝해서 선언적으로 표현할 수 있습니다.
옵저버블의 핵심 특징은 지연 실행(구독하기 전까지 실행되지 않음), 취소 가능성(unsubscribe로 언제든 중단 가능), 풍부한 연산자입니다. 이러한 특징들이 복잡한 비동기 시나리오를 간결하게 만들어주죠.
코드 예제
// search.component.ts
import { Component, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';
@Component({
selector: 'app-search',
template: `<input type="text" [formControl]="searchControl">`
})
export class SearchComponent implements OnInit {
searchControl = new FormControl('');
constructor(private searchService: SearchService) {}
ngOnInit(): void {
// 검색어 입력을 옵저버블 스트림으로 처리합니다
this.searchControl.valueChanges.pipe(
// 사용자가 타이핑을 멈춘 후 300ms 대기
debounceTime(300),
// 이전 값과 동일하면 무시
distinctUntilChanged(),
// 이전 검색 요청을 취소하고 새 요청 실행
switchMap(term => this.searchService.search(term))
).subscribe(results => {
console.log('검색 결과:', results);
});
}
}
설명
이것이 하는 일: 이 컴포넌트는 사용자의 검색어 입력을 실시간으로 감지하면서도, 불필요한 API 호출을 최소화하는 스마트한 검색 기능을 구현합니다. 첫 번째로, FormControl의 valueChanges 속성이 옵저버블을 반환합니다.
이것은 사용자가 입력 필드에 타이핑할 때마다 새로운 값을 방출하는 스트림이죠. 이 스트림을 그냥 구독하면 매 글자마다 이벤트가 발생하지만, 우리는 pipe()를 사용해 중간에 여러 연산자를 거치게 합니다.
그 다음으로, debounceTime(300) 연산자가 실행되면서 사용자가 타이핑을 멈춘 후 300밀리초를 기다립니다. 만약 300ms 이내에 또 다른 입력이 들어오면 타이머가 리셋되죠.
이렇게 하면 "Angular"를 입력할 때 매 글자가 아닌 최종 단어만 다음 단계로 전달됩니다. distinctUntilChanged()는 연속으로 동일한 값이 오면 중복을 제거합니다.
switchMap 연산자가 가장 중요한 역할을 합니다. 이것은 검색어를 받아 searchService.search()를 호출하는데, 만약 이전 검색이 아직 진행 중이면 자동으로 취소하고 새 검색을 시작합니다.
예를 들어 "Angular"를 검색하다가 빠르게 "React"로 바꾸면, Angular 검색 결과는 무시되고 React 결과만 받게 됩니다. 여러분이 이 코드를 사용하면 사용자 경험이 크게 향상됩니다.
API 호출이 90% 이상 줄어들고, 서버 부하도 감소하며, 항상 최신 검색어에 대한 결과만 표시됩니다. 코드도 선언적이고 읽기 쉬워서 유지보수가 편합니다.
실전 팁
💡 컴포넌트가 파괴될 때 반드시 unsubscribe()를 호출하거나 takeUntil 연산자를 사용해 구독을 해제하세요. 메모리 누수의 가장 흔한 원인입니다.
💡 mergeMap, switchMap, concatMap, exhaustMap의 차이를 이해하세요. 각각 동시 실행, 이전 취소, 순차 실행, 진행 중 무시라는 다른 동작을 합니다. 상황에 맞는 연산자를 선택하는 것이 중요합니다.
💡 catchError 연산자로 에러를 우아하게 처리하세요. 에러가 발생하면 옵저버블 스트림이 완전히 종료되므로, catchError로 에러를 잡아 기본값을 반환하거나 재시도 로직을 구현할 수 있습니다.
💡 async 파이프를 템플릿에서 사용하면 자동으로 구독/구독 해제를 처리해줍니다. {{ searchResults$ | async }}처럼 사용하면 수동 구독 관리가 필요 없어집니다.
💡 cold observable과 hot observable의 차이를 이해하세요. HTTP 요청은 cold(구독마다 새로 실행), Subject는 hot(구독과 무관하게 값 방출)입니다. share() 연산자로 cold를 hot으로 변환할 수 있습니다.
4. 라우팅_및_네비게이션
시작하며
여러분이 다중 페이지를 가진 웹 애플리케이션을 만들 때, 페이지 전환마다 전체 페이지가 새로고침되어 사용자 경험이 끊기고 느린 문제를 겪어본 적 있나요? 예를 들어 메인 페이지에서 상품 상세 페이지로 이동할 때마다 모든 리소스를 다시 로드하는 상황 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 전통적인 멀티 페이지 애플리케이션(MPA)은 서버 왕복이 많아 느리고, 상태 유지도 어렵습니다.
사용자가 폼에 입력하던 데이터가 페이지 전환 시 사라지는 것도 흔한 문제죠. 바로 이럴 때 필요한 것이 Angular의 라우팅 시스템입니다.
라우터는 URL을 변경하면서도 페이지 새로고침 없이 컴포넌트만 교체하는 Single Page Application(SPA)을 구현하게 해주며, 즉각적인 페이지 전환과 부드러운 사용자 경험을 제공합니다.
개요
간단히 말해서, 라우터는 URL 경로와 컴포넌트를 연결하여 SPA의 네비게이션을 관리하는 시스템입니다. 사용자가 /products/123 같은 URL로 이동하면, 라우터가 자동으로 ProductDetailComponent를 로드하고 123이라는 ID를 전달합니다.
예를 들어, 쇼핑몰 앱에서 홈, 상품 목록, 상품 상세, 장바구니, 결제 페이지 등을 각각 다른 URL로 매핑하고 브라우저의 뒤로가기/앞으로가기 버튼도 정상 동작하게 만들 수 있습니다. 기존에는 서버 측 라우팅으로 각 URL마다 새로운 HTML 페이지를 로드했다면, 이제는 클라이언트 측에서 컴포넌트만 동적으로 교체하여 훨씬 빠른 전환이 가능합니다.
라우터의 핵심 특징은 지연 로딩(필요할 때만 모듈 로드), 라우트 가드(권한 체크), 중첩 라우팅입니다. 이러한 특징들이 복잡한 다중 페이지 애플리케이션을 효율적으로 구성하게 해주죠.
코드 예제
// app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { ProductListComponent } from './products/product-list.component';
import { ProductDetailComponent } from './products/product-detail.component';
import { AuthGuard } from './guards/auth.guard';
// 라우트 경로와 컴포넌트를 매핑합니다
const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'products', component: ProductListComponent },
// URL 파라미터를 사용합니다
{ path: 'products/:id', component: ProductDetailComponent },
// 인증이 필요한 페이지는 가드를 적용합니다
{ path: 'checkout', component: CheckoutComponent, canActivate: [AuthGuard] },
// 잘못된 경로는 404 페이지로 리다이렉트
{ path: '**', redirectTo: '' }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
설명
이것이 하는 일: 이 라우팅 모듈은 애플리케이션의 모든 URL 경로를 정의하고, 각 경로에 어떤 컴포넌트를 표시할지 결정합니다. 첫 번째로, Routes 배열이 각 라우트 설정을 정의합니다.
path는 URL 경로이고 component는 그 경로에서 보여줄 컴포넌트입니다. 빈 문자열('')은 루트 경로(/)를 의미하며, 여기서는 HomeComponent를 보여줍니다.
이 설정만으로 사용자가 해당 URL로 이동하면 자동으로 컴포넌트가 렌더링됩니다. 그 다음으로, :id 같은 동적 세그먼트가 URL 파라미터를 정의합니다.
'products/:id' 경로는 /products/1, /products/123 등 모든 숫자 ID를 받아들이며, 컴포넌트에서 ActivatedRoute를 통해 이 값을 가져올 수 있습니다. 이것이 동적 라우팅의 핵심이죠.
canActivate: [AuthGuard] 부분이 실행되면서 라우트 가드가 작동합니다. 사용자가 /checkout으로 이동하려 하면 AuthGuard가 먼저 실행되어 로그인 여부를 확인하고, 로그인하지 않았다면 로그인 페이지로 리다이렉트합니다.
path: '**'는 와일드카드로 정의되지 않은 모든 경로를 잡아내며, 마지막에 위치해야 합니다. 여러분이 이 코드를 사용하면 템플릿에서 <router-outlet></router-outlet>만 추가하면 됩니다.
이 위치에 현재 URL에 맞는 컴포넌트가 동적으로 로드되죠. <a routerLink="/products">상품 보기</a> 같은 링크를 클릭하면 페이지 새로고침 없이 즉시 전환됩니다.
브라우저 뒤로가기, 북마크, URL 직접 입력 모두 완벽하게 동작합니다.
실전 팁
💡 지연 로딩을 활용해 초기 로딩 시간을 줄이세요. { path: 'admin', loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule) }처럼 설정하면 해당 경로에 접근할 때만 모듈을 로드합니다.
💡 라우트 파라미터는 snapshot으로 한 번만 읽거나, paramMap Observable로 구독해서 변경사항을 추적할 수 있습니다. 같은 컴포넌트 내에서 파라미터만 바뀔 때는 Observable 방식을 사용해야 합니다.
💡 RouterStateSnapshot을 사용해 복잡한 가드 로직을 구현하세요. 현재 URL, 쿼리 파라미터, 데이터 등 모든 라우팅 정보에 접근할 수 있습니다.
💡 Resolver를 사용해 컴포넌트 로드 전에 필요한 데이터를 미리 가져오세요. 이렇게 하면 사용자가 빈 화면을 보지 않고 데이터가 준비된 상태에서 페이지를 볼 수 있습니다.
5. 템플릿_기반_폼
시작하며
여러분이 사용자 등록 폼을 만들 때, 입력 검증, 에러 메시지 표시, 폼 상태 관리 등을 모두 수동으로 코딩하느라 시간을 많이 소비한 적 있나요? 예를 들어 이메일 형식이 올바른지, 비밀번호가 충분히 긴지 일일이 체크하고 에러 메시지를 보여주는 로직을 작성하는 상황 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 폼은 거의 모든 웹 애플리케이션에 필수적이지만, 직접 구현하면 보일러플레이트 코드가 많아지고 버그가 생기기 쉽습니다.
특히 복잡한 검증 규칙이나 동적 폼 필드를 다루기는 더욱 어렵죠. 바로 이럴 때 필요한 것이 Angular의 템플릿 기반 폼입니다.
FormsModule을 사용하면 HTML 템플릿에서 ngModel 디렉티브만으로 양방향 데이터 바인딩과 기본적인 검증을 쉽게 구현할 수 있으며, 간단한 폼을 빠르게 만들 수 있습니다.
개요
간단히 말해서, 템플릿 기반 폼은 HTML 템플릿 중심으로 폼을 정의하고 ngModel로 데이터를 바인딩하는 방식입니다. Angular가 자동으로 FormGroup과 FormControl을 생성해주므로, 개발자는 템플릿에서 디렉티브만 사용하면 됩니다.
예를 들어, 로그인 폼이나 간단한 설문조사처럼 복잡하지 않은 폼에 적합합니다. 기존에는 JavaScript로 각 입력 필드의 value를 읽고 검증 로직을 작성했다면, 이제는 ngModel과 HTML5 검증 속성(required, minlength 등)만으로 대부분을 처리할 수 있습니다.
템플릿 기반 폼의 핵심 특징은 간결한 문법, 양방향 바인딩, 자동 폼 상태 관리입니다. 이러한 특징들이 빠른 프로토타이핑과 간단한 폼 구현에 이상적이죠.
코드 예제
// login.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-login',
template: `
<form #loginForm="ngForm" (ngSubmit)="onSubmit(loginForm)">
<div>
<!-- ngModel로 양방향 바인딩을 설정합니다 -->
<input type="email" name="email" [(ngModel)]="user.email"
required email #emailInput="ngModel">
<!-- 검증 실패 시 에러 메시지를 표시합니다 -->
<div *ngIf="emailInput.invalid && emailInput.touched">
<span *ngIf="emailInput.errors?.['required']">이메일을 입력하세요</span>
<span *ngIf="emailInput.errors?.['email']">올바른 이메일 형식이 아닙니다</span>
</div>
</div>
<div>
<input type="password" name="password" [(ngModel)]="user.password"
required minlength="8" #passwordInput="ngModel">
<div *ngIf="passwordInput.invalid && passwordInput.touched">
<span *ngIf="passwordInput.errors?.['required']">비밀번호를 입력하세요</span>
<span *ngIf="passwordInput.errors?.['minlength']">최소 8자 이상 입력하세요</span>
</div>
</div>
<!-- 폼이 유효할 때만 버튼 활성화 -->
<button type="submit" [disabled]="loginForm.invalid">로그인</button>
</form>
`
})
export class LoginComponent {
user = { email: '', password: '' };
onSubmit(form: any): void {
if (form.valid) {
console.log('로그인 시도:', this.user);
}
}
}
설명
이것이 하는 일: 이 컴포넌트는 사용자 로그인 폼을 구현하며, 입력 검증과 에러 메시지 표시를 자동으로 처리합니다. 첫 번째로, #loginForm="ngForm" 구문이 템플릿 참조 변수를 생성합니다.
Angular가 자동으로 form 요소를 NgForm 디렉티브로 래핑하며, 이 변수를 통해 폼의 전체 상태(valid, invalid, touched 등)에 접근할 수 있습니다. (ngSubmit) 이벤트는 폼 제출을 가로채서 onSubmit 메서드를 실행하죠.
그 다음으로, [(ngModel)]="user.email" 구문이 양방향 데이터 바인딩을 설정합니다. 사용자가 입력 필드에 타이핑하면 user.email이 자동으로 업데이트되고, 반대로 컴포넌트에서 user.email을 변경하면 입력 필드도 업데이트됩니다.
이것이 템플릿 기반 폼의 가장 큰 장점입니다. name 속성은 반드시 필요하며, 이것으로 폼 컨트롤을 식별합니다.
required, email, minlength 같은 검증 속성들이 실행되면서 자동 검증이 이루어집니다. Angular가 이 속성들을 보고 내부적으로 Validator를 연결하며, 사용자 입력이 들어올 때마다 검증을 수행합니다.
#emailInput="ngModel"은 개별 필드의 상태를 참조하는 변수이며, emailInput.invalid, emailInput.errors 등으로 상세한 에러 정보를 얻을 수 있습니다. 여러분이 이 코드를 사용하면 FormsModule만 import하면 바로 작동합니다.
사용자가 잘못된 형식을 입력하면 즉시 에러 메시지가 표시되고, 모든 필드가 유효할 때만 제출 버튼이 활성화됩니다. 검증 로직을 직접 작성할 필요가 없어 개발 시간이 크게 단축됩니다.
실전 팁
💡 touched와 dirty 상태를 활용해 UX를 개선하세요. 사용자가 필드를 건드리기 전에는 에러를 보여주지 않는 것이 좋습니다. *ngIf="emailInput.invalid && (emailInput.dirty || emailInput.touched)"처럼 조합하세요.
💡 커스텀 validator가 필요하면 Directive를 만들어 적용할 수 있습니다. 예를 들어 비밀번호 확인 필드가 원본과 일치하는지 검증하는 로직을 재사용 가능한 디렉티브로 만들 수 있습니다.
💡 템플릿 기반 폼은 간단한 경우에만 사용하세요. 동적 필드 추가/제거, 복잡한 검증 로직, 크로스 필드 검증이 필요하면 반응형 폼이 더 적합합니다.
💡 ngModelOptions를 사용해 업데이트 타이밍을 조절할 수 있습니다. [ngModelOptions]="{updateOn: 'blur'}"를 설정하면 포커스를 잃을 때만 모델이 업데이트됩니다.
6. 반응형_폼
시작하며
여러분이 복잡한 주문 폼을 만들 때, 사용자가 선택한 상품 종류에 따라 동적으로 입력 필드를 추가하거나 제거해야 하는 상황을 겪어본 적 있나요? 예를 들어 배송 상품을 선택하면 주소 입력 필드가 나타나고, 디지털 상품을 선택하면 이메일 필드만 나타나야 하는 경우 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 템플릿 기반 폼으로는 동적 폼 구조, 복잡한 검증 로직, 프로그래매틱한 폼 조작이 어렵습니다.
또한 단위 테스트 작성도 템플릿에 의존하기 때문에 까다롭죠. 바로 이럴 때 필요한 것이 Angular의 반응형 폼입니다.
ReactiveFormsModule을 사용하면 TypeScript 코드에서 폼 구조를 명시적으로 정의하고, 런타임에 동적으로 조작할 수 있으며, 강력한 타입 안정성과 테스트 가능성을 얻을 수 있습니다.
개요
간단히 말해서, 반응형 폼은 TypeScript 코드에서 FormGroup과 FormControl을 직접 생성하여 폼을 제어하는 방식입니다. 템플릿은 단순히 뷰만 담당하고, 모든 로직과 상태는 컴포넌트 클래스에서 관리합니다.
예를 들어, 다단계 회원가입 폼, 설문조사 생성기, 동적 필터 같은 복잡한 폼에 적합합니다. 기존의 템플릿 기반 폼이 선언적이고 간단하다면, 반응형 폼은 명시적이고 강력합니다.
코드에서 폼 구조를 완전히 제어할 수 있습니다. 반응형 폼의 핵심 특징은 명시적 폼 모델, 동기 검증자, 비동기 검증자, FormArray를 통한 동적 필드 관리입니다.
이러한 특징들이 엔터프라이즈급 복잡한 폼을 구현하는 데 필수적이죠.
코드 예제
// registration.component.ts
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators, FormArray } from '@angular/forms';
@Component({
selector: 'app-registration',
templateUrl: './registration.component.html'
})
export class RegistrationComponent implements OnInit {
registrationForm!: FormGroup;
constructor(private fb: FormBuilder) {}
ngOnInit(): void {
// FormBuilder로 폼 구조를 정의합니다
this.registrationForm = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
// 중첩된 FormGroup으로 주소 정보를 그룹화합니다
address: this.fb.group({
street: ['', Validators.required],
city: ['', Validators.required],
zipCode: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]]
}),
// FormArray로 동적 필드를 관리합니다
phones: this.fb.array([this.createPhoneControl()])
});
// 폼 값 변경을 구독합니다
this.registrationForm.valueChanges.subscribe(value => {
console.log('폼 값 변경:', value);
});
}
createPhoneControl(): FormGroup {
return this.fb.group({
number: ['', Validators.required]
});
}
// 전화번호 필드를 동적으로 추가합니다
addPhone(): void {
(this.registrationForm.get('phones') as FormArray).push(this.createPhoneControl());
}
onSubmit(): void {
if (this.registrationForm.valid) {
console.log('제출:', this.registrationForm.value);
}
}
}
설명
이것이 하는 일: 이 컴포넌트는 복잡한 회원가입 폼을 구현하며, 중첩된 그룹, 동적 필드 추가, 세밀한 검증을 모두 TypeScript 코드로 관리합니다. 첫 번째로, FormBuilder 서비스가 주입되어 폼을 구성합니다.
fb.group()은 FormGroup을 생성하며, 각 필드는 [초기값, 검증자배열] 형태로 정의됩니다. email 필드는 required와 email 두 가지 검증자를 가지며, password는 required와 minLength를 가집니다.
이렇게 하면 폼 구조가 명시적으로 드러나 코드 이해가 쉬워집니다. 그 다음으로, address: this.fb.group({...}) 부분이 중첩된 FormGroup을 만듭니다.
주소 관련 필드들을 논리적으로 그룹화하여, 나중에 registrationForm.get('address.city') 같은 방식으로 접근할 수 있습니다. zipCode는 정규식 패턴 검증자를 사용해 5자리 숫자만 허용합니다.
phones: this.fb.array([...]) 부분이 FormArray를 생성합니다. 이것은 동적으로 크기가 변하는 필드 목록을 관리하는 데 완벽합니다.
사용자가 "전화번호 추가" 버튼을 클릭하면 addPhone() 메서드가 실행되어 새로운 FormGroup을 배열에 push합니다. 반대로 제거도 removeAt(index)로 간단히 할 수 있죠.
여러분이 이 코드를 사용하면 템플릿에서는 formGroup, formControlName, formArrayName 디렉티브만 사용하면 됩니다. 모든 로직이 TypeScript에 있어 단위 테스트가 쉽고, IDE의 자동완성과 타입 체크 혜택을 받을 수 있습니다.
valueChanges Observable을 구독해 실시간으로 폼 상태를 모니터링하거나, patchValue, setValue로 프로그래매틱하게 값을 설정할 수 있습니다.
실전 팁
💡 커스텀 검증자를 함수로 만들어 재사용하세요. 예를 들어 비밀번호 강도 검증, 중복 확인 등을 ValidatorFn 타입의 함수로 구현하면 여러 곳에서 사용할 수 있습니다.
💡 비동기 검증자를 사용해 서버 측 검증을 구현하세요. 사용자명 중복 확인 같은 경우 AsyncValidatorFn으로 만들어 세 번째 인자로 전달하면 됩니다. debounceTime을 적용해 불필요한 API 호출을 줄이세요.
💡 FormGroup의 valueChanges와 statusChanges를 구분하세요. valueChanges는 값이 바뀔 때, statusChanges는 검증 상태(VALID, INVALID, PENDING)가 바뀔 때 발생합니다.
💡 updateOn 옵션으로 검증 타이밍을 조절할 수 있습니다. this.fb.control('', { validators: [...], updateOn: 'blur' })처럼 설정하면 포커스를 잃을 때만 검증합니다.
💡 FormGroup을 중첩해서 복잡한 데이터 구조를 표현하세요. 백엔드 API의 JSON 구조와 1:1로 매핑되게 만들면 데이터 전송이 훨씬 간편합니다.
7. HTTP_클라이언트
시작하며
여러분이 백엔드 API에서 데이터를 가져와야 할 때, fetch API를 직접 사용하다가 에러 처리, 타입 안전성, 요청 취소 등의 문제로 어려움을 겪어본 적 있나요? 예를 들어 사용자 목록을 가져오는데 네트워크 에러가 발생했을 때 적절히 처리하지 못해 앱이 멈추는 상황 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 원시적인 HTTP 클라이언트는 보일러플레이트가 많고, 에러 처리가 번거로우며, RxJS와의 통합도 수동으로 해야 합니다.
또한 인증 토큰 추가, 공통 헤더 설정 같은 반복 작업도 매번 코딩해야 하죠. 바로 이럴 때 필요한 것이 Angular의 HttpClient입니다.
HttpClientModule을 사용하면 타입 안전한 HTTP 요청, Observable 기반 응답, 자동 JSON 파싱, 인터셉터를 통한 공통 로직 처리 등을 즉시 활용할 수 있습니다.
개요
간단히 말해서, HttpClient는 Angular에 내장된 강력하고 타입 안전한 HTTP 통신 클라이언트입니다. 모든 HTTP 메서드(GET, POST, PUT, DELETE 등)를 지원하며, Observable을 반환하므로 RxJS 연산자와 완벽하게 통합됩니다.
예를 들어, REST API에서 사용자 데이터를 가져오거나, 폼 데이터를 서버에 전송하는 모든 시나리오에 사용할 수 있습니다. 기존의 XMLHttpRequest나 fetch API를 직접 사용했다면, 이제는 HttpClient로 간결하고 강력한 코드를 작성할 수 있습니다.
HttpClient의 핵심 특징은 자동 타입 추론, Observable 반환, 인터셉터 지원, 테스트 유틸리티입니다. 이러한 특징들이 실무 API 통신을 안전하고 효율적으로 만들어주죠.
코드 예제
// user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, retry, map } from 'rxjs/operators';
interface User {
id: number;
name: string;
email: string;
}
@Injectable({ providedIn: 'root' })
export class UserService {
private apiUrl = 'https://api.example.com/users';
constructor(private http: HttpClient) {}
// GET 요청: 사용자 목록을 가져옵니다
getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiUrl).pipe(
// 네트워크 에러 시 최대 3번 재시도
retry(3),
// 에러를 우아하게 처리
catchError(this.handleError)
);
}
// GET 요청: 특정 사용자를 가져옵니다
getUserById(id: number): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/${id}`);
}
// POST 요청: 새 사용자를 생성합니다
createUser(user: Omit<User, 'id'>): Observable<User> {
return this.http.post<User>(this.apiUrl, user);
}
// PUT 요청: 사용자 정보를 업데이트합니다
updateUser(user: User): Observable<User> {
return this.http.put<User>(`${this.apiUrl}/${user.id}`, user);
}
// DELETE 요청: 사용자를 삭제합니다
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
private handleError(error: HttpErrorResponse): Observable<never> {
let errorMessage = '알 수 없는 오류가 발생했습니다';
if (error.error instanceof ErrorEvent) {
// 클라이언트 측 에러
errorMessage = `에러: ${error.error.message}`;
} else {
// 서버 측 에러
errorMessage = `서버 에러 ${error.status}: ${error.message}`;
}
console.error(errorMessage);
return throwError(() => new Error(errorMessage));
}
}
설명
이것이 하는 일: 이 서비스는 백엔드 API와 통신하여 사용자 데이터를 조회, 생성, 수정, 삭제하는 모든 CRUD 작업을 제공합니다. 첫 번째로, HttpClient가 생성자를 통해 주입됩니다.
이것은 의존성 주입 시스템의 일부이며, HttpClientModule을 app.module.ts에서 import해야 사용할 수 있습니다. get<User[]>처럼 제네릭 타입을 지정하면, 반환되는 Observable이 자동으로 User[] 타입을 가지며, 이후 코드에서 타입 안전성을 보장받습니다.
그 다음으로, this.http.get(url).pipe(...) 패턴이 실행되면서 요청을 보내고 응답을 처리합니다. 중요한 점은 실제 HTTP 요청은 subscribe()가 호출될 때까지 발생하지 않는다는 것입니다.
이것이 cold observable의 특성이죠. retry(3)는 네트워크 일시적 장애에 대비해 자동으로 3번까지 재시도하며, catchError는 최종적으로 실패하면 handleError 함수로 에러를 전달합니다.
POST, PUT 요청에서는 두 번째 인자로 body를 전달합니다. Angular가 자동으로 JavaScript 객체를 JSON으로 직렬화하고, Content-Type: application/json 헤더를 추가합니다.
응답도 자동으로 JSON에서 TypeScript 객체로 파싱되죠. handleError 함수는 에러의 종류(클라이언트 vs 서버)를 구분하여 적절한 메시지를 생성하고, throwError로 새로운 Observable 에러를 반환합니다.
여러분이 이 코드를 사용하면 컴포넌트에서 this.userService.getUsers().subscribe(users => {...})처럼 간단하게 데이터를 가져올 수 있습니다. 타입 안전성 덕분에 users.map(u => u.name) 같은 코드를 작성할 때 IDE가 자동완성을 제공하고, 잘못된 속성 접근을 컴파일 시점에 잡아줍니다.
에러 처리도 자동으로 되어 안정적인 애플리케이션을 만들 수 있습니다.
실전 팁
💡 HttpParams를 사용해 쿼리 파라미터를 안전하게 추가하세요. const params = new HttpParams().set('page', '1').set('limit', '10'); this.http.get(url, { params })처럼 사용하면 URL 인코딩도 자동으로 됩니다.
💡 HttpHeaders로 커스텀 헤더를 추가할 수 있습니다. 하지만 모든 요청에 공통으로 필요한 헤더(인증 토큰 등)는 인터셉터를 사용하는 것이 더 효율적입니다.
💡 shareReplay 연산자로 불필요한 중복 요청을 방지하세요. 여러 컴포넌트가 동시에 같은 데이터를 요청하면, shareReplay(1)을 추가해 한 번만 요청하고 결과를 공유할 수 있습니다.
💡 타임아웃을 설정해 무한 대기를 방지하세요. timeout(5000) 연산자를 추가하면 5초 후 자동으로 에러가 발생합니다.
💡 HttpClientTestingModule을 사용하면 HTTP 요청을 모킹해서 단위 테스트를 작성할 수 있습니다. 실제 서버 없이도 모든 시나리오를 테스트할 수 있죠.
8. 인터셉터_패턴
시작하며
여러분이 모든 API 요청에 인증 토큰을 추가하고, 응답에서 공통 에러를 처리하며, 로딩 상태를 표시해야 할 때, 각 서비스마다 동일한 코드를 반복해서 작성하고 있나요? 예를 들어 50개의 API 호출이 있는데, 각각에 토큰 추가 로직을 넣어야 하는 상황 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 공통 로직이 여러 곳에 흩어지면 유지보수가 어렵고, 나중에 토큰 형식이 바뀌면 모든 곳을 수정해야 합니다.
또한 로그, 캐싱, 에러 추적 같은 횡단 관심사(cross-cutting concerns)를 일관되게 적용하기도 어렵죠. 바로 이럴 때 필요한 것이 HTTP 인터셉터입니다.
HttpInterceptor를 구현하면 모든 HTTP 요청과 응답을 가로채서 공통 로직을 한 곳에서 처리할 수 있으며, DRY(Don't Repeat Yourself) 원칙을 완벽하게 지킬 수 있습니다.
개요
간단히 말해서, 인터셉터는 모든 HTTP 요청/응답을 가로채서 전처리나 후처리를 수행하는 미들웨어입니다. 요청이 서버로 가기 전에 헤더를 추가하거나, 응답이 도착한 후 데이터를 변환하는 등의 작업을 중앙 집중식으로 처리합니다.
예를 들어, JWT 토큰 자동 추가, 401 에러 시 자동 로그아웃, 전역 로딩 스피너 표시 등에 활용할 수 있습니다. 기존에는 각 서비스마다 동일한 로직을 복붙했다면, 이제는 인터셉터 한 곳에서 모든 HTTP 트래픽을 제어할 수 있습니다.
인터셉터의 핵심 특징은 AOP(Aspect-Oriented Programming) 스타일, 체이닝 가능, 요청/응답 변환입니다. 이러한 특징들이 대규모 애플리케이션의 HTTP 통신을 일관되게 관리하게 해주죠.
코드 예제
// auth.interceptor.ts
import { Injectable } from '@angular/core';
import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, finalize } from 'rxjs/operators';
import { AuthService } from './auth.service';
import { LoadingService } from './loading.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(
private authService: AuthService,
private loadingService: LoadingService
) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// 로딩 상태 시작
this.loadingService.show();
// 인증 토큰을 가져옵니다
const token = this.authService.getToken();
// 요청을 복제하고 헤더를 추가합니다 (원본 요청은 불변)
const authReq = token ? req.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
'X-Custom-Header': 'MyApp'
}
}) : req;
// 수정된 요청을 다음 핸들러로 전달합니다
return next.handle(authReq).pipe(
catchError((error: HttpErrorResponse) => {
// 401 에러 시 자동 로그아웃
if (error.status === 401) {
this.authService.logout();
}
// 에러를 상위로 전파
return throwError(() => error);
}),
finalize(() => {
// 성공/실패 여부와 관계없이 로딩 종료
this.loadingService.hide();
})
);
}
}
// app.module.ts에서 등록
// providers: [
// { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
// ]
설명
이것이 하는 일: 이 인터셉터는 모든 HTTP 요청에 자동으로 인증 토큰과 커스텀 헤더를 추가하고, 401 에러 발생 시 사용자를 로그아웃시키며, 로딩 상태를 관리합니다. 첫 번째로, intercept 메서드가 모든 HTTP 요청을 가로챕니다.
req는 현재 요청 객체이고, next는 다음 인터셉터(또는 최종적으로 실제 HTTP 호출)를 실행하는 핸들러입니다. 이 구조가 인터셉터 체인을 가능하게 하며, 여러 인터셉터를 순서대로 적용할 수 있습니다.
그 다음으로, req.clone() 메서드가 요청을 복제합니다. HttpRequest는 불변 객체이므로 직접 수정할 수 없고, 반드시 clone()으로 새 인스턴스를 만들어야 합니다.
setHeaders 옵션으로 Authorization 헤더에 Bearer 토큰을 추가하며, 이것이 모든 API 요청에 자동으로 적용됩니다. token이 없으면 원본 요청을 그대로 사용하죠.
next.handle(authReq)가 실행되면서 수정된 요청을 다음 단계로 전달합니다. 이것이 반환하는 Observable을 구독하면 실제 HTTP 호출이 발생합니다.
catchError에서 HttpErrorResponse를 검사하여, 상태 코드가 401이면 세션이 만료된 것으로 판단하고 자동으로 로그아웃 처리합니다. finalize는 성공/실패 여부와 관계없이 항상 실행되므로, 로딩 스피너를 확실히 제거할 수 있습니다.
여러분이 이 코드를 사용하면 서비스 코드가 극도로 간결해집니다. UserService에서 this.http.get(url) 만 호출하면, 자동으로 토큰이 추가되고, 에러가 처리되며, 로딩 상태가 관리됩니다.
나중에 토큰 형식을 변경하거나 새로운 헤더를 추가할 때도 인터셉터 한 곳만 수정하면 모든 요청에 즉시 반영됩니다. multi: true 옵션으로 여러 인터셉터를 등록할 수 있으며, 등록 순서대로 실행됩니다.
실전 팁
💡 인터셉터는 순서가 중요합니다. providers 배열에 등록한 순서대로 실행되므로, 인증 인터셉터를 로깅 인터셉터보다 먼저 등록하는 등 순서를 신중히 결정하세요.
💡 특정 요청만 인터셉터를 건너뛰고 싶다면 HttpContext를 사용하세요. 요청에 메타데이터를 추가해서 인터셉터에서 조건부로 처리할 수 있습니다.
💡 캐싱 인터셉터를 만들어 GET 요청 결과를 메모리에 저장하고, 동일한 요청이 오면 캐시에서 반환하여 성능을 향상시킬 수 있습니다. Map이나 RxJS shareReplay를 활용하세요.
💡 로그 인터셉터를 만들어 개발 환경에서만 모든 HTTP 요청/응답을 콘솔에 출력하면 디버깅이 훨씬 쉬워집니다. environment 변수로 프로덕션에서는 비활성화하세요.
💡 여러 인터셉터를 기능별로 분리하세요. 인증, 로깅, 에러 처리, 캐싱을 각각 별도 인터셉터로 만들면 단일 책임 원칙을 지킬 수 있고 테스트도 쉬워집니다.