앵귤러 기초 강좌 #4 Directive와 Pipe
지난 시간에는 데이터 바인딩과 이벤트 처리를 다루면서 컴포넌트와 템플릿이 어떻게 서로 데이터를 주고받는지 살펴봤습니다. 이번 시간에는 템플릿을 더 표현력 있게 만들어주는 두 가지 도구, Directive와 Pipe를 정리해보겠습니다. 조건에 따라 화면을 분기하고, 배열을 반복해서 그리고, 표시할 값을 보기 좋은 형태로 변환하는 — 거의 모든 화면에서 쓰는 기능들입니다.
Directive란 #
**Directive(디렉티브)**는 DOM에 추가적인 행동을 부여하는 클래스입니다. “이 요소를 조건에 따라 보여줄지 말지 결정해라”, “이 배열을 반복해서 그려라”, “이 요소에 이런 스타일을 적용해라” 같은 지시(directive)를 템플릿 안에서 표현하는 도구입니다.
사실 우리가 #2에서 만든 컴포넌트도 Directive의 한 종류입니다. 정확히는 “템플릿을 가진 디렉티브"가 컴포넌트입니다. 그 외에 앵귤러는 두 가지 디렉티브를 더 제공합니다.
- 구조 디렉티브(Structural Directive) — DOM의 구조 자체를 바꾸는 디렉티브. 요소를 추가하거나 제거하거나 반복합니다.
- 속성 디렉티브(Attribute Directive) — 요소의 모양이나 행동만 바꾸는 디렉티브. 클래스를 토글하거나 스타일을 입힙니다.
먼저 구조 디렉티브부터 보겠습니다. Angular 17부터 도입된 새 제어 흐름이 핵심입니다.
새 제어 흐름 — @if, @for, @switch #
오랫동안 앵귤러는 *ngIf, *ngFor, *ngSwitch 같은 구조 디렉티브로 분기와 반복을 처리해왔습니다. Angular 17부터는 이를 대체하는 빌트인 제어 흐름(Built-in Control Flow) 문법이 도입되었고, 새 프로젝트에서는 이쪽을 표준으로 쓰는 것이 좋습니다. 더 빠르고, 타입 추론이 잘 되며, 별도 import도 필요 없습니다.
@if — 조건부 렌더링 #
조건에 따라 요소를 보였다 안 보였다 하려면 @if를 씁니다.
@if (user) {
<p>안녕하세요, {{ user.name }}님!</p>
} @else {
<p>로그인이 필요합니다.</p>
}else if로 여러 갈래를 만들 수도 있습니다.
@if (status === 'loading') {
<p>불러오는 중...</p>
} @else if (status === 'error') {
<p class="error">에러가 발생했습니다.</p>
} @else {
<p>완료!</p>
}자바스크립트의 if/else if/else 문법과 거의 동일해서 직관적입니다.
@for — 반복 렌더링 #
배열을 반복해서 그리려면 @for를 씁니다.
<ul>
@for (post of posts; track post.id) {
<li>{{ post.title }}</li>
} @empty {
<li>게시글이 없습니다.</li>
}
</ul>@for에서 가장 중요한 부분은 track 표현식입니다. 앵귤러는 배열이 변할 때 어떤 항목이 추가/제거/이동되었는지 식별하기 위해 track에 지정된 값을 사용합니다. 보통은 post.id처럼 고유한 식별자를 넘기고, 식별자가 없으면 track $index를 쓸 수도 있지만 가능하면 고유 ID 쪽이 좋습니다.
@empty 블록은 배열이 비어 있을 때 보여줄 내용입니다. 이전에는 별도의 *ngIf로 처리해야 했던 부분이 한 곳에 깔끔하게 담깁니다.
@for 안에서는 다음과 같은 컨텍스트 변수도 사용할 수 있습니다.
$index— 현재 인덱스 (0부터)$first,$last— 첫/마지막 항목인지 여부$even,$odd— 짝/홀수 인덱스인지 여부
@for (post of posts; track post.id; let i = $index, isFirst = $first) {
<li [class.first]="isFirst">{{ i + 1 }}. {{ post.title }}</li>
}@switch — 다중 분기 #
값에 따라 여러 갈래로 나뉘는 경우는 @switch가 적합합니다.
@switch (role) {
@case ('admin') {
<span class="badge admin">관리자</span>
}
@case ('editor') {
<span class="badge editor">편집자</span>
}
@default {
<span class="badge">일반 사용자</span>
}
}자바스크립트의 switch와 다르게 break가 필요 없고, 일치하는 @case만 실행되며 일치하는 게 없으면 @default가 실행됩니다.
*ngIf, *ngFor는 CommonModule이나 개별 디렉티브를 import해야 했지만, @if/@for/@switch는 템플릿 컴파일러가 직접 처리하므로 imports 배열에 아무것도 추가하지 않아도 바로 동작합니다. 보일러플레이트가 한 단계 줄었습니다.옛 구조 디렉티브 — *ngIf, *ngFor #
새 프로젝트에서는 새 제어 흐름을 쓰지만, 기존 프로젝트나 오래된 예제에서는 여전히 별표(*)가 붙은 옛 디렉티브를 자주 보게 됩니다.
<p *ngIf="user; else login">{{ user.name }}님 환영합니다</p>
<ng-template #login>
<p>로그인이 필요합니다.</p>
</ng-template>
<ul>
<li *ngFor="let post of posts; trackBy: trackById">{{ post.title }}</li>
</ul>*ngFor에는 trackBy로 추적 함수를 따로 정의해야 했고, 이를 깜빡 잊으면 매 렌더마다 모든 DOM이 다시 그려져 성능 문제가 생기곤 했습니다. 새 @for가 track을 필수 문법으로 만든 이유이기도 합니다. 기존 코드를 만나면 읽을 줄은 알아야 하지만, 새로 쓸 때는 새 제어 흐름을 권장합니다. 공식 마이그레이션 도구(ng generate @angular/core:control-flow)도 제공됩니다.
속성 디렉티브 — ngClass, ngStyle #
요소의 모양을 조건에 따라 바꿀 때는 속성 디렉티브를 씁니다. 가장 자주 쓰는 두 가지가 ngClass와 ngStyle입니다.
<a
[ngClass]="{ active: isActive, disabled: isDisabled }"
[ngStyle]="{ color: textColor, 'font-size.px': fontSize }">
메뉴
</a>[ngClass]에 객체를 넘기면 키는 클래스 이름, 값은 적용 여부(true/false)입니다. 위 예에서 isActive가 true면 active 클래스가 붙고, false면 빠집니다.
사실 단순한 케이스라면 [class.클래스명]이나 [style.속성] 바인딩이 더 간결합니다.
<a [class.active]="isActive" [style.color]="textColor">메뉴</a>조건이 단순하면 [class.X]/[style.X], 여러 클래스를 한 번에 다루면 [ngClass]/[ngStyle]을 쓰는 식으로 골라 씁니다. 이 두 디렉티브를 쓰려면 CommonModule을 컴포넌트의 imports에 추가해줘야 합니다.
커스텀 Directive 만들기 #
직접 디렉티브를 만들어 요소에 행동을 부여할 수도 있습니다. CLI로 뼈대를 만듭니다.
ng generate directive highlight생성된 디렉티브에 마우스 hover 시 배경색을 바꾸는 로직을 넣어보겠습니다.
import { Directive, ElementRef, HostListener, input } from '@angular/core';
@Directive({
selector: '[appHighlight]',
standalone: true,
})
export class HighlightDirective {
color = input<string>('#fffbcc');
constructor(private el: ElementRef<HTMLElement>) {}
@HostListener('mouseenter')
onMouseEnter() {
this.el.nativeElement.style.backgroundColor = this.color();
}
@HostListener('mouseleave')
onMouseLeave() {
this.el.nativeElement.style.backgroundColor = '';
}
}핵심을 정리하면:
selector: '[appHighlight]'— 대괄호로 감싼 셀렉터는 속성 셀렉터입니다.<p appHighlight>처럼 속성으로 사용된다는 뜻입니다.ElementRef— 디렉티브가 붙은 호스트 요소에 접근하는 통로.nativeElement로 실제 DOM 요소를 다룰 수 있습니다.@HostListener— 호스트 요소의 이벤트를 구독하는 데코레이터입니다.input<string>('#fffbcc')— 디렉티브에도 컴포넌트와 똑같이input()으로 값을 받을 수 있습니다.
사용은 일반 속성처럼 합니다.
<p appHighlight>마우스를 올려보세요 (기본 노란색)</p>
<p appHighlight color="#cce5ff">이쪽은 파란 배경</p>물론 이 정도는 CSS의 :hover로도 충분하지만, “조건에 따라 hover 색을 바꾼다"거나 “특정 권한일 때만 hover 효과를 준다” 같은 동적인 로직이 들어가면 디렉티브가 빛을 발합니다.
Pipe란 #
**Pipe(파이프)**는 템플릿 안에서 데이터를 표시용으로 변환해주는 작은 함수입니다. 사용 문법은 유닉스 셸의 파이프와 닮아 있습니다.
{{ value | pipeName }}
{{ value | pipeName:arg1:arg2 }}
{{ value | pipeA | pipeB }}| 왼쪽의 값을 오른쪽 파이프에 넘기고, 결과를 또 다음 파이프로 넘기는 식입니다. 컴포넌트의 데이터 자체는 건드리지 않고 화면에 보일 모습만 바꾼다는 점이 핵심입니다.
빌트인 Pipe #
앵귤러가 기본 제공하는 파이프 중 자주 쓰는 것들을 정리해보겠습니다.
<!-- 날짜 포맷 -->
<p>{{ today | date:'yyyy-MM-dd HH:mm' }}</p>
<!-- 통화 포맷 -->
<p>{{ price | currency:'KRW':'symbol':'1.0-0' }}</p>
<!-- 백분율 -->
<p>{{ ratio | percent:'1.0-1' }}</p>
<!-- 대소문자 -->
<p>{{ name | uppercase }}</p>
<p>{{ name | lowercase }}</p>
<!-- 객체를 JSON 문자열로 (디버깅용) -->
<pre>{{ user | json }}</pre>
<!-- 소수 자릿수 -->
<p>{{ score | number:'1.2-2' }}</p>
<!-- 슬라이스 -->
<p>{{ longText | slice:0:50 }}...</p>이 중 특히 알아둘 만한 것이 async 파이프입니다.
@for (post of posts$ | async; track post.id) {
<li>{{ post.title }}</li>
}async 파이프는 RxJS의 Observable이나 Promise를 받아 자동으로 구독하고, 값이 도착하면 화면에 표시해주며, 컴포넌트가 사라질 때 자동으로 구독 해제까지 해줍니다. 수동으로 subscribe() / unsubscribe()를 관리하지 않아도 되어서 #1에서 살짝 언급한 RxJS 흐름을 템플릿에서 안전하게 다루는 표준 패턴입니다.
date, currency, percent, number, slice 같은 파이프는 CommonModule에 들어있고, async도 마찬가지입니다.
커스텀 Pipe 만들기 #
직접 파이프를 만들어보겠습니다. 긴 문자열을 잘라서 말줄임표를 붙이는 truncate 파이프입니다.
ng generate pipe truncateimport { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'truncate',
standalone: true,
})
export class TruncatePipe implements PipeTransform {
transform(value: string, limit = 30, ellipsis = '...'): string {
if (!value) return '';
return value.length > limit ? value.slice(0, limit) + ellipsis : value;
}
}핵심은 PipeTransform 인터페이스의 transform() 메서드입니다. 첫 번째 매개변수가 | 왼쪽 값이고, 그 뒤가 :로 넘기는 인자들입니다.
사용은 다음과 같습니다.
@for (post of posts; track post.id) {
<li>
<h3>{{ post.title | truncate:20 }}</h3>
<p>{{ post.body | truncate:100:' ...(더 보기)' }}</p>
</li>
}물론 컴포넌트에서도 imports: [TruncatePipe]로 추가해줘야 합니다.
순수 vs 비순수 Pipe #
@Pipe 데코레이터에는 pure라는 옵션이 있고 기본값은 true입니다. **순수 파이프(pure pipe)**는 입력값이 바뀌었을 때만 다시 계산하므로 매우 효율적입니다. 같은 입력에는 캐시된 결과를 그대로 씁니다.
pure: false로 설정한 **비순수 파이프(impure pipe)**는 변화 감지 사이클마다 매번 다시 실행됩니다. 배열 내부의 항목이 바뀌어도(참조는 그대로) 새 결과를 반영해야 하는 등의 특수한 경우에 쓰지만, 그만큼 성능 비용이 있습니다. 가능하면 입력 자체를 새 참조로 바꿔주고 순수 파이프를 쓰는 쪽이 안전합니다.
pure: true)이면 충분하고, 비순수가 필요한 순간은 드뭅니다.마무리 #
이번 글에서는 템플릿의 표현력을 넓혀주는 두 도구, Directive와 Pipe를 정리했습니다. 핵심을 다시 묶으면:
- 새 제어 흐름
@if/@for/@switch가 분기와 반복의 표준 (Angular 17+) @for에서는track표현식이 필수- 단순 클래스/스타일은
[class.X]/[style.X], 여러 개를 다룰 때는[ngClass]/[ngStyle] - 커스텀 디렉티브는
@Directive+ElementRef+@HostListener로 호스트 요소에 행동을 추가 - 파이프는
value | pipe:arg로 템플릿 안에서 데이터를 변환 async파이프는 Observable/Promise를 자동 구독,해제까지 해주는 가장 유용한 빌트인- 커스텀 파이프는
PipeTransform의transform()메서드로 만든다
여기까지 오시면 컴포넌트 안에서 화면을 그리는 데 필요한 도구는 거의 다 갖춘 셈입니다. 하지만 컴포넌트가 직접 데이터를 들고 있고, API 호출도 직접 하고, 비즈니스 로직까지 끌어안기 시작하면 금방 비대해집니다. 이런 책임을 분리해 담아두는 도구가 바로 **서비스(Service)**입니다.
다음 글인 “앵귤러 기초 강좌 #5 Service와 의존성 주입"에서는 서비스 클래스를 만드는 법, 그리고 앵귤러의 가장 강력한 무기 중 하나인 **의존성 주입(Dependency Injection)**으로 그 서비스를 컴포넌트에 연결하는 방법을 다뤄보겠습니다.