본문으로 건너뛰기
Advertisement

16.3 컴포넌트 & 템플릿 — 데코레이터, 데이터 바인딩 4종, 디렉티브

컴포넌트 기초

Angular에서 **컴포넌트(Component)**는 UI의 기본 단위입니다. 하나의 컴포넌트는 세 가지 요소로 구성됩니다:

  1. 클래스 (TypeScript) — 데이터와 로직
  2. 템플릿 (HTML) — UI 구조
  3. 스타일 (CSS/SCSS) — 스타일링

@Component 데코레이터

import { Component, OnInit, input, output, signal } from '@angular/core';

@Component({
// HTML에서 사용할 태그명
selector: 'app-user-card',

// Standalone 컴포넌트 (Angular 17+ 기본)
standalone: true,

// 이 컴포넌트에서 필요한 모듈/컴포넌트
imports: [],

// 인라인 템플릿 (소규모 컴포넌트에 적합)
template: `<p>{{ name() }}</p>`,

// 또는 외부 파일 참조
// templateUrl: './user-card.component.html',

// 인라인 스타일
styles: [`p { color: blue; }`],

// 또는 외부 스타일
// styleUrl: './user-card.component.scss',

// 변경 감지 전략 (성능 최적화)
// changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent implements OnInit {
name = input.required<string>(); // 필수 입력

ngOnInit() {
console.log('컴포넌트 초기화:', this.name());
}
}

데이터 바인딩 4종

Angular의 핵심은 데이터 바인딩입니다. 클래스의 데이터와 템플릿을 연결하는 4가지 방법이 있습니다.

1. 인터폴레이션 {{ }}

클래스의 값을 텍스트로 표시합니다.

@Component({
standalone: true,
template: `
<h1>{{ title }}</h1>
<p>가격: {{ price | currency:'KRW' }}</p>
<p>날짜: {{ today | date:'yyyy-MM-dd' }}</p>
<p>계산: {{ 1 + 2 }}</p>
<p>메서드: {{ getGreeting() }}</p>
`
})
export class DemoComponent {
title = 'Angular 바인딩';
price = 29900;
today = new Date();

getGreeting(): string {
return '안녕하세요!';
}
}

2. 프로퍼티 바인딩 [property]="value"

DOM 프로퍼티나 컴포넌트 입력에 값을 바인딩합니다.

@Component({
standalone: true,
template: `
<!-- DOM 프로퍼티 바인딩 -->
<img [src]="imageUrl" [alt]="imageAlt">
<input [value]="username" [disabled]="isDisabled">
<button [disabled]="isLoading">
{{ isLoading ? '처리 중...' : '제출' }}
</button>

<!-- 컴포넌트 프로퍼티 바인딩 -->
<app-user-card [userId]="selectedUserId" [showAvatar]="true" />

<!-- class 바인딩 -->
<div [class.active]="isActive" [class.error]="hasError">상태</div>

<!-- style 바인딩 -->
<div [style.color]="textColor" [style.fontSize.px]="fontSize">
스타일 바인딩
</div>

<!-- attr 바인딩 (DOM 프로퍼티가 없는 경우) -->
<td [attr.colspan]="columnSpan">합계</td>
`
})
export class PropertyBindingComponent {
imageUrl = 'https://example.com/avatar.jpg';
imageAlt = '사용자 아바타';
username = 'alice';
isDisabled = false;
isLoading = false;
selectedUserId = 42;
isActive = true;
hasError = false;
textColor = 'blue';
fontSize = 16;
columnSpan = 3;
}

3. 이벤트 바인딩 (event)="handler()"

사용자 이벤트를 클래스 메서드와 연결합니다.

@Component({
standalone: true,
template: `
<!-- 기본 클릭 이벤트 -->
<button (click)="onClick()">클릭</button>

<!-- 이벤트 객체 전달 -->
<button (click)="onClickWithEvent($event)">이벤트 포함</button>

<!-- 입력 이벤트 -->
<input (input)="onInput($event)" (keyup.enter)="onEnter()">

<!-- 마우스 이벤트 -->
<div
(mouseenter)="onMouseEnter()"
(mouseleave)="onMouseLeave()"
[class.hovered]="isHovered"
>
마우스를 올려보세요
</div>

<!-- 폼 submit 이벤트 -->
<form (submit)="onSubmit($event)">
<input [(ngModel)]="formValue">
<button type="submit">제출</button>
</form>
`
})
export class EventBindingComponent {
isHovered = false;
formValue = '';

onClick() {
console.log('버튼 클릭!');
}

onClickWithEvent(event: MouseEvent) {
console.log('클릭 위치:', event.clientX, event.clientY);
}

onInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
console.log('입력값:', value);
}

onEnter() {
console.log('Enter 키 입력!');
}

onMouseEnter() { this.isHovered = true; }
onMouseLeave() { this.isHovered = false; }

onSubmit(event: Event) {
event.preventDefault();
console.log('폼 제출:', this.formValue);
}
}

4. 양방향 바인딩 [(ngModel)]

입력 필드와 클래스 속성을 양방향으로 동기화합니다.

import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms'; // ngModel 사용을 위해 필요

@Component({
standalone: true,
imports: [FormsModule],
template: `
<input [(ngModel)]="username" placeholder="사용자 이름">
<p>입력한 이름: {{ username }}</p>

<!-- 양방향 바인딩은 아래의 단축 표현 -->
<!-- 실제로는 프로퍼티 + 이벤트 바인딩의 조합 -->
<input
[ngModel]="email"
(ngModelChange)="email = $event"
placeholder="이메일"
>
`
})
export class TwoWayBindingComponent {
username = '';
email = '';
}

내장 제어 흐름 문법 (Angular 17+)

Angular 17부터 *ngIf, *ngFor 대신 새로운 블록 문법을 사용합니다.

@if — 조건부 렌더링

@Component({
standalone: true,
template: `
<!-- 기본 @if -->
@if (isLoggedIn) {
<p>로그인 상태입니다.</p>
}

<!-- @if / @else if / @else -->
@if (userRole === 'admin') {
<p>관리자 패널</p>
} @else if (userRole === 'manager') {
<p>매니저 대시보드</p>
} @else {
<p>일반 사용자 홈</p>
}

<!-- null 체크와 함께 -->
@if (user; as u) {
<p>{{ u.name }} ({{ u.email }})</p>
} @else {
<p>사용자 정보 없음</p>
}
`
})
export class IfDirectiveComponent {
isLoggedIn = true;
userRole: 'admin' | 'manager' | 'user' = 'admin';
user: { name: string; email: string } | null = {
name: 'Alice',
email: 'alice@example.com'
};
}

@for — 반복 렌더링

@Component({
standalone: true,
template: `
<!-- 기본 @for — track은 필수 -->
<ul>
@for (item of items; track item.id) {
<li>{{ item.name }}</li>
}
</ul>

<!-- $index, $first, $last 등 내장 변수 활용 -->
<ul>
@for (item of items; track item.id; let i = $index; let first = $first; let last = $last) {
<li [class.first]="first" [class.last]="last">
{{ i + 1 }}. {{ item.name }}
</li>
} @empty {
<li>항목이 없습니다.</li>
}
</ul>
`
})
export class ForDirectiveComponent {
items = [
{ id: 1, name: '사과' },
{ id: 2, name: '바나나' },
{ id: 3, name: '오렌지' },
];
}

@for의 내장 변수:

변수타입설명
$indexnumber현재 인덱스 (0부터 시작)
$countnumber전체 아이템 수
$firstboolean첫 번째 아이템 여부
$lastboolean마지막 아이템 여부
$evenboolean짝수 인덱스 여부
$oddboolean홀수 인덱스 여부

@switch — 다중 조건 렌더링

@Component({
standalone: true,
template: `
@switch (status) {
@case ('loading') {
<div class="spinner">로딩 중...</div>
}
@case ('success') {
<div class="success">완료!</div>
}
@case ('error') {
<div class="error">오류 발생</div>
}
@default {
<div>알 수 없는 상태</div>
}
}
`
})
export class SwitchDirectiveComponent {
status: 'loading' | 'success' | 'error' | 'idle' = 'loading';
}

속성 디렉티브 — NgClass, NgStyle

import { Component } from '@angular/core';
import { NgClass, NgStyle } from '@angular/common';

@Component({
standalone: true,
imports: [NgClass, NgStyle],
template: `
<!-- NgClass: 여러 클래스를 조건부로 적용 -->
<div [ngClass]="{
'active': isActive,
'disabled': isDisabled,
'highlighted': isHighlighted
}">
NgClass 예제
</div>

<!-- 배열로도 가능 -->
<div [ngClass]="['btn', isActive ? 'btn-primary' : 'btn-secondary']">
버튼
</div>

<!-- NgStyle: 여러 스타일을 동적으로 적용 -->
<div [ngStyle]="{
'color': textColor,
'font-size': fontSize + 'px',
'background-color': bgColor
}">
NgStyle 예제
</div>
`
})
export class DirectivesComponent {
isActive = true;
isDisabled = false;
isHighlighted = true;
textColor = '#333';
fontSize = 16;
bgColor = '#f0f0f0';
}

커스텀 디렉티브

// directives/highlight.directive.ts
import { Directive, ElementRef, HostListener, input } from '@angular/core';

@Directive({
selector: '[appHighlight]',
standalone: true
})
export class HighlightDirective {
// 입력으로 하이라이트 색상 받기
appHighlight = input('yellow');

constructor(private el: ElementRef) {}

@HostListener('mouseenter')
onMouseEnter() {
this.el.nativeElement.style.backgroundColor = this.appHighlight();
}

@HostListener('mouseleave')
onMouseLeave() {
this.el.nativeElement.style.backgroundColor = '';
}
}
// 사용 예
@Component({
standalone: true,
imports: [HighlightDirective],
template: `
<p appHighlight>기본 노란색 하이라이트</p>
<p [appHighlight]="'lightblue'">파란색 하이라이트</p>
<p [appHighlight]="'#ffcccc'">빨간색 하이라이트</p>
`
})
export class DemoComponent {}

컴포넌트 생명주기 훅

import {
Component, OnInit, OnChanges, DoCheck,
AfterContentInit, AfterContentChecked,
AfterViewInit, AfterViewChecked,
OnDestroy, SimpleChanges, input
} from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

@Component({
selector: 'app-lifecycle-demo',
standalone: true,
template: `<p>생명주기 데모: {{ data() }}</p>`
})
export class LifecycleDemoComponent
implements OnInit, OnChanges, OnDestroy, AfterViewInit {

data = input<string>('');

private destroy$ = new Subject<void>();

// 1. 컴포넌트 생성 직후 (Input 값 초기화 전)
// constructor는 DI만 사용, 로직 금지

// 2. Input 값 변경 시 (ngOnInit 전에도 호출됨)
ngOnChanges(changes: SimpleChanges) {
console.log('입력 변경:', changes);
// changes['data'].currentValue — 새 값
// changes['data'].previousValue — 이전 값
// changes['data'].firstChange — 첫 변경 여부
}

// 3. 컴포넌트 초기화 (한 번만 실행) — 주요 초기화 로직
ngOnInit() {
console.log('컴포넌트 초기화');
// HTTP 요청, 구독 설정 등은 여기서
}

// 4. 뷰 초기화 완료 (DOM 접근 가능)
ngAfterViewInit() {
console.log('뷰 초기화 완료 — DOM 접근 가능');
}

// 5. 컴포넌트 소멸 (메모리 누수 방지)
ngOnDestroy() {
console.log('컴포넌트 소멸');
this.destroy$.next();
this.destroy$.complete();
}
}

생명주기 훅 순서:

constructor → ngOnChanges → ngOnInit → ngDoCheck
→ ngAfterContentInit → ngAfterContentChecked
→ ngAfterViewInit → ngAfterViewChecked
→ (변경 감지마다 ngDoCheck, ngAfterContentChecked, ngAfterViewChecked 반복)
→ ngOnDestroy

Input / Output 데코레이터

Angular 17.1+에서는 input(), output() 함수 방식을 권장합니다.

최신 방식 (Angular 17.1+)

// child.component.ts
import { Component, input, output } from '@angular/core';

interface Product {
id: number;
name: string;
price: number;
}

@Component({
selector: 'app-product-card',
standalone: true,
template: `
<div class="card">
<h3>{{ product().name }}</h3>
<p>{{ product().price | currency:'KRW' }}</p>
<button (click)="addToCart()">장바구니 추가</button>
<button (click)="onFavoriteToggle()">
{{ isFavorite() ? '♥' : '♡' }}
</button>
</div>
`
})
export class ProductCardComponent {
// 필수 입력
product = input.required<Product>();

// 선택적 입력 (기본값 제공)
isFavorite = input(false);

// 출력 이벤트
addedToCart = output<Product>();
favoriteToggled = output<{ id: number; favorite: boolean }>();

addToCart() {
this.addedToCart.emit(this.product());
}

onFavoriteToggle() {
this.favoriteToggled.emit({
id: this.product().id,
favorite: !this.isFavorite()
});
}
}
// parent.component.ts
import { Component, signal } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { ProductCardComponent } from './product-card.component';

@Component({
standalone: true,
imports: [ProductCardComponent, CurrencyPipe],
template: `
<div class="product-grid">
@for (product of products(); track product.id) {
<app-product-card
[product]="product"
[isFavorite]="favorites().has(product.id)"
(addedToCart)="onAddToCart($event)"
(favoriteToggled)="onFavoriteToggle($event)"
/>
}
</div>

<div class="cart-summary">
장바구니: {{ cartItems().length }}개
</div>
`
})
export class ParentComponent {
products = signal([
{ id: 1, name: '노트북', price: 1200000 },
{ id: 2, name: '마우스', price: 35000 },
]);

cartItems = signal<{ id: number; name: string; price: number }[]>([]);
favorites = signal(new Set<number>());

onAddToCart(product: { id: number; name: string; price: number }) {
this.cartItems.update(items => [...items, product]);
}

onFavoriteToggle(event: { id: number; favorite: boolean }) {
this.favorites.update(favs => {
const newFavs = new Set(favs);
if (event.favorite) {
newFavs.add(event.id);
} else {
newFavs.delete(event.id);
}
return newFavs;
});
}
}

실전 예제 — Todo 목록 컴포넌트

// todo/todo.component.ts
import { Component, signal, computed } from '@angular/core';
import { FormsModule } from '@angular/forms';

interface Todo {
id: number;
text: string;
completed: boolean;
createdAt: Date;
}

type FilterType = 'all' | 'active' | 'completed';

@Component({
selector: 'app-todo',
standalone: true,
imports: [FormsModule],
template: `
<div class="todo-app">
<h1>할 일 목록</h1>

<!-- 입력 폼 -->
<div class="add-todo">
<input
[(ngModel)]="newTodoText"
(keyup.enter)="addTodo()"
placeholder="새 할 일 입력..."
[disabled]="isAdding()"
>
<button (click)="addTodo()" [disabled]="!newTodoText.trim()">
추가
</button>
</div>

<!-- 필터 버튼 -->
<div class="filters">
@for (filter of filters; track filter) {
<button
[class.active]="currentFilter() === filter"
(click)="setFilter(filter)"
>
{{ filterLabels[filter] }}
</button>
}
</div>

<!-- 통계 -->
<p class="stats">
전체 {{ totalCount() }}개 | 완료 {{ completedCount() }}개 | 남은 할 일 {{ activeCount() }}개
</p>

<!-- Todo 목록 -->
<ul class="todo-list">
@for (todo of filteredTodos(); track todo.id) {
<li [class.completed]="todo.completed">
<input
type="checkbox"
[checked]="todo.completed"
(change)="toggleTodo(todo.id)"
>
<span>{{ todo.text }}</span>
<small>{{ todo.createdAt | date:'HH:mm' }}</small>
<button (click)="deleteTodo(todo.id)" class="delete-btn">✕</button>
</li>
} @empty {
<li class="empty">할 일이 없습니다! 🎉</li>
}
</ul>

<!-- 전체 완료/초기화 -->
@if (totalCount() > 0) {
<div class="actions">
<button (click)="toggleAll()">
{{ allCompleted() ? '모두 미완료' : '모두 완료' }}
</button>
@if (completedCount() > 0) {
<button (click)="clearCompleted()" class="danger">
완료 항목 삭제 ({{ completedCount() }})
</button>
}
</div>
}
</div>
`,
styles: [`
.todo-app { max-width: 500px; margin: 2rem auto; padding: 1.5rem; }
.add-todo { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.add-todo input { flex: 1; padding: 0.5rem; }
.filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.filters button.active { background: #007bff; color: white; }
.todo-list { list-style: none; padding: 0; }
.todo-list li { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; border-bottom: 1px solid #eee; }
.todo-list li.completed span { text-decoration: line-through; color: #999; }
.todo-list li span { flex: 1; }
.delete-btn { color: red; background: none; border: none; cursor: pointer; }
.danger { color: red; }
`]
})
export class TodoComponent {
newTodoText = '';
isAdding = signal(false);
currentFilter = signal<FilterType>('all');

private nextId = signal(1);
private todos = signal<Todo[]>([
{ id: 1, text: 'Angular 기초 학습', completed: true, createdAt: new Date() },
{ id: 2, text: '컴포넌트 만들기', completed: false, createdAt: new Date() },
{ id: 3, text: '서비스 패턴 이해하기', completed: false, createdAt: new Date() },
]);

// Computed signals
totalCount = computed(() => this.todos().length);
completedCount = computed(() => this.todos().filter(t => t.completed).length);
activeCount = computed(() => this.todos().filter(t => !t.completed).length);
allCompleted = computed(() => this.totalCount() > 0 && this.activeCount() === 0);

filteredTodos = computed(() => {
const filter = this.currentFilter();
const todos = this.todos();
switch (filter) {
case 'active': return todos.filter(t => !t.completed);
case 'completed': return todos.filter(t => t.completed);
default: return todos;
}
});

filters: FilterType[] = ['all', 'active', 'completed'];
filterLabels: Record<FilterType, string> = {
all: '전체',
active: '진행 중',
completed: '완료'
};

addTodo() {
const text = this.newTodoText.trim();
if (!text) return;

const newTodo: Todo = {
id: this.nextId(),
text,
completed: false,
createdAt: new Date()
};

this.todos.update(todos => [...todos, newTodo]);
this.nextId.update(id => id + 1);
this.newTodoText = '';
}

toggleTodo(id: number) {
this.todos.update(todos =>
todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t)
);
}

deleteTodo(id: number) {
this.todos.update(todos => todos.filter(t => t.id !== id));
}

toggleAll() {
const shouldComplete = !this.allCompleted();
this.todos.update(todos =>
todos.map(t => ({ ...t, completed: shouldComplete }))
);
}

clearCompleted() {
this.todos.update(todos => todos.filter(t => !t.completed));
}

setFilter(filter: FilterType) {
this.currentFilter.set(filter);
}
}

고수 팁

팁 1: track 최적화

@fortrack은 Angular가 어떤 항목이 변경되었는지 식별하는 데 사용합니다. 고유 ID를 사용하면 성능이 크게 향상됩니다.

<!-- 좋음: 고유 ID로 추적 -->
@for (item of items; track item.id) { ... }

<!-- 나쁨: 인덱스 추적 (목록 순서 변경 시 비효율) -->
@for (item of items; track $index) { ... }

팁 2: 컴포넌트 분리 원칙

단일 책임 원칙을 지키세요:

  • 컴포넌트는 UI 표시와 이벤트 처리만
  • 비즈니스 로직은 서비스로 분리
  • 컴포넌트가 100줄이 넘으면 분리 고려

팁 3: OnPush 변경 감지

import { ChangeDetectionStrategy } from '@angular/core';

@Component({
changeDetection: ChangeDetectionStrategy.OnPush
// Input 참조가 변경되거나 Signal이 변경될 때만 재렌더링
// 성능 최적화에 핵심
})

팁 4: @defer로 지연 로딩

<!-- 뷰포트에 들어왔을 때만 로드 -->
@defer (on viewport) {
<app-heavy-chart />
} @placeholder {
<div class="skeleton">차트 로딩 중...</div>
}
Advertisement