앵귤러 기초 강좌 #4 Directive와 Pipe

9 분 소요

지난 시간에는 데이터 바인딩과 이벤트 처리를 다루면서 컴포넌트와 템플릿이 어떻게 서로 데이터를 주고받는지 살펴봤습니다. 이번 시간에는 템플릿을 더 표현력 있게 만들어주는 두 가지 도구, DirectivePipe를 정리해보겠습니다. 조건에 따라 화면을 분기하고, 배열을 반복해서 그리고, 표시할 값을 보기 좋은 형태로 변환하는 — 거의 모든 화면에서 쓰는 기능들입니다.

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를 씁니다.

src/app/greeting.component.html
@if (user) {
  <p>안녕하세요, {{ user.name }}님!</p>
} @else {
  <p>로그인이 필요합니다.</p>
}

else if로 여러 갈래를 만들 수도 있습니다.

src/app/status.component.html
@if (status === 'loading') {
  <p>불러오는 중...</p>
} @else if (status === 'error') {
  <p class="error">에러가 발생했습니다.</p>
} @else {
  <p>완료!</p>
}

자바스크립트의 if/else if/else 문법과 거의 동일해서 직관적입니다.

@for — 반복 렌더링 #

배열을 반복해서 그리려면 @for를 씁니다.

src/app/post-list.component.html
<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 — 짝/홀수 인덱스인지 여부
src/app/post-list.component.html
@for (post of posts; track post.id; let i = $index, isFirst = $first) {
  <li [class.first]="isFirst">{{ i + 1 }}. {{ post.title }}</li>
}

@switch — 다중 분기 #

값에 따라 여러 갈래로 나뉘는 경우는 @switch가 적합합니다.

src/app/role-badge.component.html
@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가 실행됩니다.

새 제어 흐름 문법은 import가 필요 없습니다. 기존 *ngIf, *ngForCommonModule이나 개별 디렉티브를 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이 다시 그려져 성능 문제가 생기곤 했습니다. 새 @fortrack필수 문법으로 만든 이유이기도 합니다. 기존 코드를 만나면 읽을 줄은 알아야 하지만, 새로 쓸 때는 새 제어 흐름을 권장합니다. 공식 마이그레이션 도구(ng generate @angular/core:control-flow)도 제공됩니다.

속성 디렉티브 — ngClass, ngStyle #

요소의 모양을 조건에 따라 바꿀 때는 속성 디렉티브를 씁니다. 가장 자주 쓰는 두 가지가 ngClassngStyle입니다.

src/app/menu.component.html
<a
  [ngClass]="{ active: isActive, disabled: isDisabled }"
  [ngStyle]="{ color: textColor, 'font-size.px': fontSize }">
  메뉴
</a>

[ngClass]에 객체를 넘기면 키는 클래스 이름, 값은 적용 여부(true/false)입니다. 위 예에서 isActivetrueactive 클래스가 붙고, false면 빠집니다.

사실 단순한 케이스라면 [class.클래스명]이나 [style.속성] 바인딩이 더 간결합니다.

src/app/menu.component.html
<a [class.active]="isActive" [style.color]="textColor">메뉴</a>

조건이 단순하면 [class.X]/[style.X], 여러 클래스를 한 번에 다루면 [ngClass]/[ngStyle]을 쓰는 식으로 골라 씁니다. 이 두 디렉티브를 쓰려면 CommonModule을 컴포넌트의 imports에 추가해줘야 합니다.

커스텀 Directive 만들기 #

직접 디렉티브를 만들어 요소에 행동을 부여할 수도 있습니다. CLI로 뼈대를 만듭니다.

터미널
ng generate directive highlight

생성된 디렉티브에 마우스 hover 시 배경색을 바꾸는 로직을 넣어보겠습니다.

src/app/highlight.directive.ts
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()으로 값을 받을 수 있습니다.

사용은 일반 속성처럼 합니다.

src/app/app.component.html
<p appHighlight>마우스를 올려보세요 (기본 노란색)</p>
<p appHighlight color="#cce5ff">이쪽은 파란 배경</p>

물론 이 정도는 CSS의 :hover로도 충분하지만, “조건에 따라 hover 색을 바꾼다"거나 “특정 권한일 때만 hover 효과를 준다” 같은 동적인 로직이 들어가면 디렉티브가 빛을 발합니다.

Pipe란 #

**Pipe(파이프)**는 템플릿 안에서 데이터를 표시용으로 변환해주는 작은 함수입니다. 사용 문법은 유닉스 셸의 파이프와 닮아 있습니다.

src/app/profile.component.html
{{ value | pipeName }}
{{ value | pipeName:arg1:arg2 }}
{{ value | pipeA | pipeB }}

| 왼쪽의 값을 오른쪽 파이프에 넘기고, 결과를 또 다음 파이프로 넘기는 식입니다. 컴포넌트의 데이터 자체는 건드리지 않고 화면에 보일 모습만 바꾼다는 점이 핵심입니다.

빌트인 Pipe #

앵귤러가 기본 제공하는 파이프 중 자주 쓰는 것들을 정리해보겠습니다.

src/app/profile.component.html
<!-- 날짜 포맷 -->
<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 파이프입니다.

src/app/posts.component.html
@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 truncate
src/app/truncate.pipe.ts
import { 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() 메서드입니다. 첫 번째 매개변수가 | 왼쪽 값이고, 그 뒤가 :로 넘기는 인자들입니다.

사용은 다음과 같습니다.

src/app/post-list.component.html
@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)**는 변화 감지 사이클마다 매번 다시 실행됩니다. 배열 내부의 항목이 바뀌어도(참조는 그대로) 새 결과를 반영해야 하는 등의 특수한 경우에 쓰지만, 그만큼 성능 비용이 있습니다. 가능하면 입력 자체를 새 참조로 바꿔주고 순수 파이프를 쓰는 쪽이 안전합니다.

노트
“순수"라는 단어가 리액트의 useMemo나 함수형 프로그래밍의 순수 함수와 비슷한 개념입니다. 같은 입력에는 같은 출력을 보장하니까 캐시할 수 있는 것입니다. 실무에서는 거의 모든 파이프가 기본값(pure: true)이면 충분하고, 비순수가 필요한 순간은 드뭅니다.

마무리 #

이번 글에서는 템플릿의 표현력을 넓혀주는 두 도구, DirectivePipe를 정리했습니다. 핵심을 다시 묶으면:

  • 새 제어 흐름 @if / @for / @switch가 분기와 반복의 표준 (Angular 17+)
  • @for에서는 track 표현식이 필수
  • 단순 클래스/스타일은 [class.X] / [style.X], 여러 개를 다룰 때는 [ngClass] / [ngStyle]
  • 커스텀 디렉티브는 @Directive + ElementRef + @HostListener로 호스트 요소에 행동을 추가
  • 파이프는 value | pipe:arg로 템플릿 안에서 데이터를 변환
  • async 파이프는 Observable/Promise를 자동 구독,해제까지 해주는 가장 유용한 빌트인
  • 커스텀 파이프는 PipeTransformtransform() 메서드로 만든다

여기까지 오시면 컴포넌트 안에서 화면을 그리는 데 필요한 도구는 거의 다 갖춘 셈입니다. 하지만 컴포넌트가 직접 데이터를 들고 있고, API 호출도 직접 하고, 비즈니스 로직까지 끌어안기 시작하면 금방 비대해집니다. 이런 책임을 분리해 담아두는 도구가 바로 **서비스(Service)**입니다.

다음 글인 “앵귤러 기초 강좌 #5 Service와 의존성 주입"에서는 서비스 클래스를 만드는 법, 그리고 앵귤러의 가장 강력한 무기 중 하나인 **의존성 주입(Dependency Injection)**으로 그 서비스를 컴포넌트에 연결하는 방법을 다뤄보겠습니다.

X