앵귤러 기초 강좌 #3 데이터 바인딩과 이벤트
지난 시간에는 앵귤러 컴포넌트의 구조와 템플릿 문법의 기본을 살펴봤습니다. 클래스에 선언한 값을 {{ }}로 화면에 출력해 보기도 했습니다. 그런데 실제 앱은 단순히 값을 보여주는 데서 끝나지 않습니다. 사용자가 버튼을 누르고, 입력란에 글자를 치고, 그에 따라 화면이 갱신되어야 합니다.
이번 시간에는 앵귤러가 컴포넌트 클래스와 템플릿 사이에서 데이터를 주고받는 방법인 데이터 바인딩의 4가지 방식과, 모던 앵귤러의 반응형 상태 도구인 Signals를 함께 살펴보도록 하겠습니다.
4가지 바인딩 방식 한눈에 보기 #
앵귤러의 데이터 바인딩은 “어느 방향으로 흐르는가?“를 기준으로 4가지로 나뉩니다.
| 종류 | 문법 | 흐름 |
|---|---|---|
| Interpolation | {{ value }} | 클래스 → 템플릿 |
| Property binding | [prop]="value" | 클래스 → 템플릿 (DOM 속성) |
| Event binding | (event)="handler()" | 템플릿 → 클래스 |
| Two-way binding | [(ngModel)]="value" | 양방향 |
문법에 사용된 괄호 모양이 바로 흐름을 의미합니다. [ ]는 들어오는 화살표(왼쪽 ←), ( )는 나가는 화살표(오른쪽 →), 그리고 두 개를 합친 [( )]는 양방향(↔)입니다. 모양만 봐도 어느 쪽으로 데이터가 흐르는지 직관적으로 알 수 있도록 만들어졌습니다.
이제 하나씩 자세히 살펴보겠습니다.
보간법(Interpolation) 복습 #
가장 기본적인 바인딩 방식입니다. 컴포넌트 클래스의 값을 템플릿에 그대로 끼워 넣습니다.
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
standalone: true,
template: `
<h1>안녕하세요, {{ name }}님!</h1>
<p>10년 후에는 {{ age + 10 }}세가 됩니다.</p>
`,
})
export class AppComponent {
name = '철수';
age = 30;
}{{ }} 안에는 표현식(expression)이라면 무엇이든 들어갈 수 있습니다. 산술 연산도, 메소드 호출도, 삼항 연산자도 가능합니다. 다만 할당문(=)이나 if 같은 제어 흐름 문은 넣을 수 없습니다.
보간법은 결국 텍스트로 변환되어 화면에 출력됩니다. HTML 속성에 값을 넣을 때도 사용할 수는 있지만, 속성을 다룰 때는 다음에 나오는 Property binding이 더 적합한 경우가 많습니다.
Property binding — 대괄호로 DOM 속성에 값 전달 #
[속성명]="표현식" 형태로 DOM 속성(property)에 값을 바인딩합니다.
@Component({
selector: 'app-root',
standalone: true,
template: `
<img [src]="imageUrl" [alt]="imageAlt" />
<button [disabled]="isLoading">제출</button>
<input [value]="defaultName" />
`,
})
export class AppComponent {
imageUrl = '/images/logo.svg';
imageAlt = '로고 이미지';
isLoading = true;
defaultName = '철수';
}[src]="imageUrl"은 imageUrl 변수의 값을 평가해서 src 속성으로 전달합니다. 만약 따옴표 안에 대괄호 없이 src="{{ imageUrl }}"라고 써도 비슷하게 동작하긴 하지만, 두 방식에는 미묘한 차이가 있습니다.
<!-- Interpolation: 결과가 항상 문자열이 됨 -->
<button disabled="{{ isLoading }}">제출</button>
<!-- Property binding: 표현식 결과가 그대로 전달됨 -->
<button [disabled]="isLoading">제출</button>disabled처럼 불리언, 숫자, 객체 등 문자열이 아닌 값을 다룰 때는 반드시 property binding([disabled])을 사용해야 합니다. 보간법은 모든 값을 문자열로 바꾸기 때문에 disabled="false"라는 문자열이 들어가버려서 의도와 다르게 버튼이 비활성화되는 식의 버그가 생기기 쉽습니다.
Event binding — 소괄호로 사용자 입력 받기 #
이제 반대 방향, 즉 사용자의 동작을 컴포넌트 클래스로 받아오는 방법입니다. (이벤트명)="핸들러()" 형태로 사용합니다.
@Component({
selector: 'app-root',
standalone: true,
template: `
<p>현재 카운트: {{ count }}</p>
<button (click)="increment()">+1</button>
<button (click)="reset()">리셋</button>
`,
})
export class AppComponent {
count = 0;
increment() {
this.count = this.count + 1;
}
reset() {
this.count = 0;
}
}(click)="increment()"는 버튼이 클릭될 때마다 컴포넌트 클래스의 increment() 메소드를 호출하라는 의미입니다. click, input, keyup, submit 등 표준 DOM 이벤트는 모두 같은 방식으로 사용할 수 있습니다.
이벤트 객체가 필요하면 $event라는 특수 변수로 접근합니다.
@Component({
selector: 'app-root',
standalone: true,
template: `
<input (input)="onInput($event)" placeholder="이름을 입력하세요" />
<p>입력값: {{ name }}</p>
`,
})
export class AppComponent {
name = '';
onInput(event: Event) {
const input = event.target as HTMLInputElement;
this.name = input.value;
}
}event.target을 HTMLInputElement로 캐스팅한 뒤 .value를 꺼내 쓰는 패턴입니다. 매번 이렇게 쓰는 게 번거롭게 느껴지신다면 잘 보신 겁니다 — 다음에 나오는 양방향 바인딩이 바로 이 번거로움을 덜어줍니다.
Two-way binding — 양방향으로 한 번에 #
입력란에 사용자가 친 값이 변수에 들어가고, 변수의 값이 화면에도 그대로 보이는 — 이 두 흐름을 한 번에 묶어주는 것이 양방향 바인딩([(ngModel)])입니다. 대괄호와 소괄호를 합친 모양이 바로 “양방향"이라는 뜻을 시각적으로 보여줍니다. 이 모양 때문에 흔히 “바나나 박스(banana in a box)“라고 부르기도 합니다.
ngModel은 FormsModule에 포함되어 있어서, standalone 컴포넌트에서는 imports 배열에 추가해야 합니다.
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-root',
standalone: true,
imports: [FormsModule],
template: `
<input [(ngModel)]="name" placeholder="이름을 입력하세요" />
<p>안녕하세요, {{ name }}님!</p>
`,
})
export class AppComponent {
name = '';
}이전 예제와 비교해보면 (input) 핸들러도, event.target 캐스팅도 사라졌습니다. 입력란에 글자를 칠 때마다 name 값이 자동으로 갱신되고, 갱신된 값이 다시 <p> 태그에 반영됩니다.
[(ngModel)]="name"은 [ngModel]="name"(클래스 → 입력란)과 (ngModelChange)="name = $event"(입력란 → 클래스)를 동시에 적은 축약형입니다.시그널로 단순한 반응형 상태 관리 #
지금까지 본 예제에서는 일반 클래스 속성(count = 0)을 그대로 사용했습니다. 이것도 잘 동작합니다. 그런데 모던 앵귤러(v17+)에서는 단순한 반응형 상태에 대해 Signal이라는 더 명확한 도구를 제공합니다.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-root',
standalone: true,
template: `
<p>현재 카운트: {{ count() }}</p>
<button (click)="increment()">+1</button>
<button (click)="reset()">리셋</button>
`,
})
export class AppComponent {
count = signal(0);
increment() {
this.count.update(value => value + 1);
}
reset() {
this.count.set(0);
}
}세 가지 바뀐 점이 있습니다.
count = 0→count = signal(0): 시그널로 감싼 상태가 됩니다.- 템플릿에서
{{ count }}→{{ count() }}: 시그널은 함수처럼 호출해서 값을 꺼냅니다. - 값을 바꿀 때
this.count = ...대신set()이나update()를 사용합니다.
set(value)는 새 값으로 통째로 갈아끼우는 것이고, update(prev => ...)는 이전 값을 받아서 새 값을 만드는 함수형 갱신입니다. 카운터처럼 이전 값을 기반으로 다음 값을 계산할 때는 update()가 더 안전합니다.
시그널이 좋은 이유 #
“그냥 일반 속성을 써도 화면이 잘 갱신되는데 왜 시그널을 쓰나요?“라는 질문이 나올 만합니다. 시그널의 장점은 다음과 같습니다.
- 변경을 명시적으로 한다:
set()/update()로만 값을 바꿀 수 있으니, “어디서 누가 이 값을 바꿨지?“를 추적하기 쉬워집니다. - 변경 감지가 정밀해진다: 앵귤러는 어느 시그널이 어디에서 사용되는지 알기 때문에, 시그널이 바뀌면 그 시그널을 사용하는 부분만 정확히 다시 그릴 수 있습니다. 큰 앱에서 성능에 도움이 됩니다.
- 계산된 값(
computed)과 사이드 이펙트(effect)로 확장된다: 단순 상태에서 시작해 같은 모델 위에서 파생 값과 효과를 자연스럽게 표현할 수 있습니다.
새 프로젝트에서는 가능하면 처음부터 시그널로 시작하시기를 권합니다. 이 강좌의 다음 글들에서도 기본적으로 시그널을 사용하도록 하겠습니다.
클래스 바인딩과 스타일 바인딩 #
property binding의 응용으로 자주 쓰이는 두 가지를 더 보겠습니다. CSS 클래스를 조건부로 붙이거나, 인라인 스타일을 동적으로 바꾸는 패턴입니다.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-root',
standalone: true,
template: `
<p
[class.active]="isActive()"
[class.disabled]="isDisabled()"
[style.color]="textColor()"
[style.font-size.px]="fontSize()"
>
샘플 텍스트
</p>
<button (click)="toggle()">활성화 토글</button>
`,
styles: [`
.active { font-weight: bold; }
.disabled { opacity: 0.4; }
`],
})
export class AppComponent {
isActive = signal(false);
isDisabled = signal(false);
textColor = signal('tomato');
fontSize = signal(16);
toggle() {
this.isActive.update(v => !v);
}
}[class.active]="isActive()": 표현식이 truthy면active클래스를 추가, falsy면 제거합니다.[style.color]="textColor()":color스타일을 동적으로 바꿉니다.[style.font-size.px]="fontSize()": 단위(px)를 점으로 이어 적으면 숫자만 넘겨도 자동으로 단위가 붙습니다.
여러 클래스나 스타일을 한 번에 다루려면 객체 형태도 가능합니다.
<p [ngClass]="{ active: isActive(), disabled: isDisabled() }">텍스트</p>
<p [ngStyle]="{ color: textColor(), 'font-size': fontSize() + 'px' }">텍스트</p>[ngClass]/[ngStyle]은 CommonModule에 들어 있는 디렉티브라서, standalone 컴포넌트에서는 imports에 추가해야 합니다. 단순한 케이스에서는 [class.x]/[style.x]가 더 가볍고 읽기 쉬워서 자주 쓰입니다.
시그널 vs 일반 속성, 언제 무엇을 쓸까? #
기준을 단순화하면 다음과 같습니다.
- 시그널을 쓴다: 시간이 지남에 따라 바뀌고, 그 변화를 화면이 따라가야 하는 값. 카운트, 로딩 상태, 사용자 이름, 토글 상태 등.
- 일반 속성을 써도 충분하다: 컴포넌트 생성 후 변하지 않는 설정 값, 메소드, 상수.
처음에는 “변할 수 있는 모든 상태는 시그널"이라고 단순히 외워두셔도 좋습니다. 익숙해진 뒤에 더 정교하게 구분하시면 됩니다.
정리: 4가지 모양과 흐름 #
복습 차원에서 한 번 더 짚어 보겠습니다.
{{ value }} <!-- 클래스 → 템플릿 (텍스트) -->
<img [src]="value" /> <!-- 클래스 → 템플릿 (속성) -->
<button (click)="fn()" /> <!-- 템플릿 → 클래스 (이벤트) -->
<input [(ngModel)]="value" /> <!-- 양방향 -->괄호 모양이 곧 화살표 방향이라는 것만 기억하면, 처음 보는 코드도 어느 쪽으로 데이터가 흐르는지 한눈에 읽힐 것입니다.
마무리 #
이번 글에서는 앵귤러의 4가지 데이터 바인딩 방식과, 모던 앵귤러의 반응형 상태 도구인 시그널을 살펴봤습니다. 정리하자면:
- **
{{ }}**는 값을 텍스트로 출력 - **
[ ]**는 클래스 → 템플릿(속성, 클래스, 스타일) - **
( )**는 템플릿 → 클래스(이벤트) - **
[( )]**는 양방향(ngModel은FormsModule필요) - **
signal()/set()/update()**로 명시적인 반응형 상태 관리
다음 글인 “앵귤러 기초 강좌 #4 디렉티브와 파이프"에서는 템플릿의 표현력을 한 단계 끌어올리는 도구인 Directives(*ngIf, *ngFor, 그리고 신문법인 @if/@for)와, 화면에 보이기 직전에 값을 변환해주는 Pipes를 다뤄보도록 하겠습니다.