포스트

[Angular 마스터하기] Day 4 - 이벤트 처리, 클릭하면 반응하는 앱

[Angular 마스터하기] Day 4 - 이벤트 처리, 클릭하면 반응하는 앱

이제와서 시작하는 Angular 마스터하기 - Day 4 “사용자와 대화하는 앱을 만들어봅시다! 클릭, 입력, 키보드… 모든 것에 반응해요! 🖱️”

오늘 배울 내용

  • 이벤트 바인딩: (event)="handler()"
  • 다양한 이벤트 처리 (click, input, keyup 등)
  • $event 객체 활용하기
  • 실습: 카운터 앱, 좋아요 버튼, 입력 폼

1. 이벤트 바인딩 기초

이전 시간의 바인딩은 컴포넌트 값을 화면으로 보냈습니다. 이번에는 클릭이나 입력이 먼저 발생하고, 핸들러가 값을 바꾸면 바인딩이 새 값을 표시합니다. 앞의 작은 예제는 한 번에 하나씩 기존 앱에 연결해 실행하세요. 여러 App 역할의 컴포넌트를 한 파일에 이어 붙이는 실습은 아닙니다.

(event)=”handler()” 문법

이벤트 바인딩은 사용자 동작에 반응하는 방법입니다.

graph LR
    A[사용자 액션] -->|클릭, 입력 등| B["(event)='handler()'"]
    B --> C[컴포넌트 메서드 실행]
    C --> D[데이터 변경]
    D --> E[화면 자동 업데이트]

    style A fill:#4CAF50,stroke:#fff,color:#fff
    style E fill:#dd0031,stroke:#fff,color:#fff

가장 기본: 클릭 이벤트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { Component } from '@angular/core';

@Component({
  selector: 'app-clicker',
  standalone: true,
  template: `
    <div class="container">
      <h2>클릭 횟수: {{ count }}</h2>
      <button (click)="increment()">클릭!</button>
    </div>
  `,
  styles: [`
    .container {
      text-align: center;
      padding: 50px;
    }
    button {
      font-size: 1.5em;
      padding: 15px 40px;
      cursor: pointer;
    }
  `]
})
export class ClickerComponent {
  count = 0;

  increment() {
    this.count++;
    console.log('현재 카운트:', this.count);
  }
}

작동 방식:

  1. 사용자가 버튼 클릭
  2. (click) 이벤트 발생
  3. increment() 메서드 실행
  4. count 값 증가
  5. 화면 자동 업데이트! ✨

2. 다양한 이벤트

클릭 이벤트 변형

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Component({
  selector: 'app-mouse-events',
  standalone: true,
  template: `
    <div class="event-box"
      (click)="onClick()"
      (dblclick)="onDoubleClick()"
      (mouseenter)="onMouseEnter()"
      (mouseleave)="onMouseLeave()">

      <p>{{ message }}</p>
      <p class="hint">마우스를 올려보거나 클릭해보세요!</p>
    </div>
  `,
  styles: [`
    .event-box {
      padding: 50px;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      color: white;
      border-radius: 15px;
      text-align: center;
      cursor: pointer;
      transition: transform 0.3s;
    }
    .event-box:hover {
      transform: scale(1.05);
    }
    .hint {
      font-size: 0.9em;
      opacity: 0.8;
    }
  `]
})
export class MouseEventsComponent {
  message = '이벤트를 기다리는 중...';

  onClick() {
    this.message = '✅ 클릭됨!';
  }

  onDoubleClick() {
    this.message = '✅✅ 더블 클릭됨!';
  }

  onMouseEnter() {
    this.message = '👋 마우스가 들어왔어요!';
  }

  onMouseLeave() {
    this.message = '👋 마우스가 나갔어요!';
  }
}

입력 이벤트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@Component({
  selector: 'app-input-demo',
  standalone: true,
  template: `
    <div class="input-container">
      <h3>실시간 입력 감지</h3>

      <input
        type="text"
        placeholder="여기에 입력하세요..."
        (input)="onInput($event)"
        (focus)="onFocus()"
        (blur)="onBlur()">

      <p>입력한 내용: <strong>{{ inputValue }}</strong></p>
      <p>글자 수: {{ inputValue.length }}</p>
      <p class="status">{{ status }}</p>
    </div>
  `,
  styles: [`
    .input-container {
      padding: 30px;
      max-width: 500px;
      margin: 0 auto;
    }
    input {
      width: 100%;
      padding: 15px;
      font-size: 1.1em;
      border: 2px solid #ddd;
      border-radius: 8px;
      margin-bottom: 15px;
    }
    input:focus {
      outline: none;
      border-color: #667eea;
    }
    .status {
      color: #666;
      font-style: italic;
    }
  `]
})
export class InputDemoComponent {
  inputValue = '';
  status = '입력 대기 중...';

  onInput(event: Event) {
    const target = event.target as HTMLInputElement;
    this.inputValue = target.value;
  }

  onFocus() {
    this.status = '⌨️ 입력 중...';
  }

  onBlur() {
    this.status = '✅ 입력 완료!';
  }
}

키보드 이벤트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@Component({
  selector: 'app-keyboard-demo',
  standalone: true,
  template: `
    <div class="keyboard-container">
      <h3>키보드 이벤트</h3>

      <input
        type="text"
        placeholder="Enter를 눌러보세요..."
        (keyup.enter)="onEnter()"
        (keyup.escape)="onEscape()"
        (keyup)="onKeyUp($event)">

      <p>{{ message }}</p>
      <p class="key-info">마지막 키: {{ lastKey }}</p>

      <!-- 특수 키 조합 -->
      <input
        type="text"
        placeholder="Ctrl+S로 저장해보세요"
        (keydown.control.s)="onSave($event)">
    </div>
  `,
  styles: [`
    .keyboard-container {
      padding: 30px;
    }
    input {
      display: block;
      width: 100%;
      padding: 15px;
      font-size: 1.1em;
      border: 2px solid #ddd;
      border-radius: 8px;
      margin-bottom: 15px;
    }
    .key-info {
      background: #f5f5f5;
      padding: 10px;
      border-radius: 5px;
      font-family: monospace;
    }
  `]
})
export class KeyboardDemoComponent {
  message = '';
  lastKey = '';

  onEnter() {
    this.message = '✅ Enter 키가 눌렸습니다!';
  }

  onEscape() {
    this.message = '❌ Escape 키가 눌렸습니다!';
  }

  onKeyUp(event: KeyboardEvent) {
    this.lastKey = event.key;
  }

  onSave(event: KeyboardEvent) {
    event.preventDefault();  // 브라우저 기본 동작 방지
    this.message = '💾 저장되었습니다!';
  }
}

3. $event 객체 활용하기

$event란?

$event는 이벤트에 대한 상세 정보를 담고 있는 객체입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
@Component({
  selector: 'app-event-object',
  standalone: true,
  template: `
    <div class="demo">
      <!-- 마우스 위치 추적 -->
      <div
        class="track-area"
        (mousemove)="onMouseMove($event)">
        <p>마우스 위치: X={{ mouseX }}, Y={{ mouseY }}</p>
      </div>

      <!-- 체크박스 -->
      <label>
        <input
          type="checkbox"
          (change)="onCheckboxChange($event)">
        {{ checkboxText }}
      </label>

      <!-- 드롭다운 -->
      <select (change)="onSelectChange($event)">
        <option value="">선택하세요</option>
        <option value="apple">사과</option>
        <option value="banana">바나나</option>
        <option value="orange">오렌지</option>
      </select>
      <p>선택한 과일: {{ selectedFruit }}</p>
    </div>
  `,
  styles: [`
    .track-area {
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      color: white;
      height: 200px;
      display: flex;
      align-items: center;
      justify-content: center;
      border-radius: 10px;
      margin-bottom: 20px;
      font-size: 1.2em;
    }
    select {
      width: 100%;
      padding: 10px;
      font-size: 1em;
      border-radius: 5px;
      margin: 10px 0;
    }
  `]
})
export class EventObjectComponent {
  mouseX = 0;
  mouseY = 0;
  checkboxText = '동의하지 않음';
  selectedFruit = '';

  onMouseMove(event: MouseEvent) {
    this.mouseX = event.clientX;
    this.mouseY = event.clientY;
  }

  onCheckboxChange(event: Event) {
    const target = event.target as HTMLInputElement;
    this.checkboxText = target.checked ? '동의함 ✅' : '동의하지 않음';
  }

  onSelectChange(event: Event) {
    const target = event.target as HTMLSelectElement;
    this.selectedFruit = target.value;
  }
}

이벤트 전파 제어

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Component({
  selector: 'app-event-propagation',
  standalone: true,
  template: `
    <div
      class="outer"
      (click)="onOuterClick()">
      <p>외부 영역</p>

      <div
        class="inner"
        (click)="onInnerClick($event)">
        <p>내부 영역 (전파 중단)</p>
      </div>
    </div>

    <p>{{ message }}</p>
  `,
  styles: [`
    .outer {
      padding: 50px;
      background: #f0f0f0;
      border: 3px solid #ddd;
      cursor: pointer;
    }
    .inner {
      padding: 30px;
      background: #667eea;
      color: white;
      border: 3px solid #764ba2;
    }
  `]
})
export class EventPropagationComponent {
  message = '';

  onOuterClick() {
    this.message = '외부 영역 클릭됨';
  }

  onInnerClick(event: Event) {
    event.stopPropagation();  // 이벤트 전파 중단!
    this.message = '내부 영역 클릭됨 (외부 이벤트 실행 안 됨)';
  }
}

4. 실전 예제

예제 1: 완전한 카운터 앱

긴 코드에서는 맨 아래 count·step·history와 메서드를 먼저 읽고, 템플릿의 (click)이 어느 메서드를 부르는지 거슬러 올라가세요. 가운데 styles는 배치와 색상을 위한 부분입니다. +1 한 번, 단계값 변경 후 증가, 리셋 순서로 눌러 숫자와 이력이 같은 동작을 설명하는지 확인합니다.

이력은 unshift()로 최근 항목을 앞에 넣고 10개만 남깁니다. 11번 동작한 뒤 가장 오래된 기록이 사라지는 이유를 addHistory()에서 찾아보세요. 이 예제에는 저장소 연동이 없어서 새로고침하면 상태가 초기화됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
import { Component } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <div class="counter-app">
      <h1>카운터 앱</h1>

      <div class="display">
        <span class="count" [class.negative]="count < 0" [class.positive]="count > 0">
          {{ count }}
        </span>
      </div>

      <div class="controls">
        <button (click)="decrement()" class="btn btn-danger">-1</button>
        <button (click)="reset()" class="btn btn-secondary">리셋</button>
        <button (click)="increment()" class="btn btn-success">+1</button>
      </div>

      <div class="advanced-controls">
        <input
          type="number"
          [(ngModel)]="step"
          min="1"
          max="100"
          placeholder="증감 단위">

        <button (click)="incrementBy(step)" class="btn">+{{ step }}</button>
        <button (click)="decrementBy(step)" class="btn">-{{ step }}</button>
      </div>

      <div class="history">
        <h3>히스토리</h3>
        <div class="history-items">
          @for (item of history; track item) {
            <div class="history-item">
              {{ item }}
            </div>
          }
        </div>
      </div>
    </div>
  `,
  styles: [`
    .counter-app {
      max-width: 500px;
      margin: 50px auto;
      padding: 40px;
      background: white;
      border-radius: 20px;
      box-shadow: 0 10px 40px rgba(0,0,0,0.1);
      text-align: center;
    }

    .display {
      margin: 30px 0;
    }

    .count {
      font-size: 5em;
      font-weight: bold;
      color: #333;
      transition: color 0.3s;
    }

    .count.positive {
      color: #4CAF50;
    }

    .count.negative {
      color: #f44336;
    }

    .controls, .advanced-controls {
      display: flex;
      gap: 10px;
      justify-content: center;
      margin-bottom: 20px;
    }

    .btn {
      padding: 15px 30px;
      font-size: 1.1em;
      font-weight: bold;
      border: none;
      border-radius: 10px;
      cursor: pointer;
      transition: transform 0.2s;
    }

    .btn:hover {
      transform: scale(1.05);
    }

    .btn-success {
      background: #4CAF50;
      color: white;
    }

    .btn-danger {
      background: #f44336;
      color: white;
    }

    .btn-secondary {
      background: #9e9e9e;
      color: white;
    }

    input[type="number"] {
      padding: 15px;
      font-size: 1em;
      border: 2px solid #ddd;
      border-radius: 10px;
      width: 100px;
    }

    .history {
      margin-top: 30px;
      padding-top: 20px;
      border-top: 2px solid #eee;
    }

    .history-items {
      max-height: 200px;
      overflow-y: auto;
    }

    .history-item {
      padding: 8px;
      background: #f5f5f5;
      margin: 5px 0;
      border-radius: 5px;
      font-size: 0.9em;
    }
  `]
})
export class CounterComponent {
  count = 0;
  step = 10;
  history: string[] = [];

  increment() {
    this.count++;
    this.addHistory(`+1 → ${this.count}`);
  }

  decrement() {
    this.count--;
    this.addHistory(`-1 → ${this.count}`);
  }

  incrementBy(value: number) {
    this.count += value;
    this.addHistory(`+${value} → ${this.count}`);
  }

  decrementBy(value: number) {
    this.count -= value;
    this.addHistory(`-${value} → ${this.count}`);
  }

  reset() {
    this.count = 0;
    this.addHistory('리셋 → 0');
  }

  private addHistory(action: string) {
    const timestamp = new Date().toLocaleTimeString('ko-KR');
    this.history.unshift(`[${timestamp}] ${action}`);

    // 최대 10개까지만 보관
    if (this.history.length > 10) {
      this.history = this.history.slice(0, 10);
    }
  }
}

예제 2: 좋아요 버튼

isLiked는 현재 선택 여부, likes는 표시할 숫자, isAnimating은 잠깐 적용할 시각 효과입니다. 한 번 누르면 숫자가 1 증가하고 다시 누르면 원래대로 돌아오는지 확인하세요. 애니메이션 타이머는 좋아요 데이터를 서버에 저장하는 작업이 아닙니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@Component({
  selector: 'app-like-button',
  standalone: true,
  template: `
    <button
      class="like-button"
      [class.liked]="isLiked"
      [class.animating]="isAnimating"
      (click)="toggleLike()">

      <span class="icon">{{ isLiked ? '❤️' : '🤍' }}</span>
      <span class="count">{{ likes }}</span>
    </button>
  `,
  styles: [`
    .like-button {
      background: white;
      border: 3px solid #e0e0e0;
      border-radius: 50px;
      padding: 15px 30px;
      font-size: 1.5em;
      cursor: pointer;
      display: inline-flex;
      align-items: center;
      gap: 10px;
      transition: all 0.3s;
    }

    .like-button:hover {
      transform: scale(1.1);
    }

    .like-button.liked {
      background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
      border-color: #f5576c;
      color: white;
    }

    .like-button.animating .icon {
      animation: heartbeat 0.6s;
    }

    @keyframes heartbeat {
      0%, 100% { transform: scale(1); }
      25% { transform: scale(1.3); }
      50% { transform: scale(1.1); }
      75% { transform: scale(1.2); }
    }

    .count {
      font-weight: bold;
      min-width: 30px;
      text-align: left;
    }
  `]
})
export class LikeButtonComponent {
  likes = 0;
  isLiked = false;
  isAnimating = false;

  toggleLike() {
    if (this.isLiked) {
      this.likes--;
      this.isLiked = false;
    } else {
      this.likes++;
      this.isLiked = true;
      this.animate();
    }
  }

  private animate() {
    this.isAnimating = true;
    setTimeout(() => {
      this.isAnimating = false;
    }, 600);
  }
}

🧪 직접 해보기

실습 1: 간단한 계산기

미션: +, -, ×, ÷ 계산기를 만드세요!

1
ng g c calculator

힌트:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export class CalculatorComponent {
  num1 = 0;
  num2 = 0;
  result = 0;

  add() {
    this.result = this.num1 + this.num2;
  }

  subtract() {
    this.result = this.num1 - this.num2;
  }

  multiply() {
    this.result = this.num1 * this.num2;
  }

  divide() {
    if (this.num2 !== 0) {
      this.result = this.num1 / this.num2;
    } else {
      alert('0으로 나눌 수 없습니다!');
    }
  }
}

실습 2: 할 일 추가 폼

미션: Enter를 누르면 할 일이 추가되는 폼을 만드세요!

1
2
3
4
5
6
7
8
9
10
11
export class TodoFormComponent {
  newTodo = '';
  todos: string[] = [];

  addTodo() {
    if (this.newTodo.trim()) {
      this.todos.push(this.newTodo);
      this.newTodo = '';  // 입력 초기화
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<div class="todo-form">
  <input
    type="text"
    [(ngModel)]="newTodo"
    (keyup.enter)="addTodo()"
    placeholder="할 일을 입력하고 Enter를 누르세요">

  <button (click)="addTodo()">추가</button>

  <ul>
    @for (todo of todos; track todo) {
      <li>{{ todo }}</li>
    }
  </ul>
</div>

💡 자주 하는 실수

❌ 실수 1: 함수 호출 시 괄호 빼먹기

1
2
3
4
5
<!-- ❌ 틀린 예 -->
<button (click)="increment">클릭</button>

<!-- ✅ 올바른 예 -->
<button (click)="increment()">클릭</button>

❌ 실수 2: 이벤트명 오타

1
2
3
4
5
<!-- ❌ 틀린 예 -->
<button (onclick)="doSomething()">클릭</button>

<!-- ✅ 올바른 예 -->
<button (click)="doSomething()">클릭</button>

❌ 실수 3: $event 타입 미지정

1
2
3
4
5
6
7
8
9
10
// ❌ 타입 없음
onInput(event) {
  console.log(event.target.value);  // 에러 가능성
}

// ✅ 타입 지정
onInput(event: Event) {
  const target = event.target as HTMLInputElement;
  console.log(target.value);
}

❌ 실수 4: preventDefault 빠뜨리기

1
2
3
4
5
// Form submit 시 페이지 새로고침 방지
onSubmit(event: Event) {
  event.preventDefault();  // ← 필수!
  // 폼 처리 로직
}

📝 정리

주요 이벤트 목록

이벤트 발생 시점 예시
(click) 클릭 시 (click)="onClick()"
(dblclick) 더블 클릭 (dblclick)="onDblClick()"
(input) 입력값 변경 (input)="onInput($event)"
(change) 값 변경 완료 (change)="onChange($event)"
(keyup) 키 놓을 때 (keyup.enter)="onEnter()"
(keydown) 키 누를 때 (keydown)="onKeyDown($event)"
(mouseenter) 마우스 진입 (mouseenter)="onEnter()"
(mouseleave) 마우스 떠남 (mouseleave)="onLeave()"
(focus) 포커스 획득 (focus)="onFocus()"
(blur) 포커스 잃음 (blur)="onBlur()"

핵심 개념

mindmap
  root((이벤트 처리))
    기본 문법
      click 이벤트
      input 이벤트
      keyboard 이벤트
    $event 객체
      이벤트 정보
      타입 캐스팅
      preventDefault
      stopPropagation
    실전 활용
      카운터
      좋아요 버튼
      폼 처리

체크리스트

  • (click) 이벤트를 사용할 수 있나요?
  • 키보드 이벤트를 처리할 수 있나요?
  • $event 객체를 활용할 수 있나요?
  • 카운터 앱을 만들어봤나요?

📚 다음 학습

다음 시간에는 조건과 반복을 배웁니다!

지금까지는 고정된 화면이었다면, 이제 조건에 따라 보이고 안 보이고, 리스트를 반복해서 표시하는 방법을 배웁니다:


💬 마무리하며

“이벤트는 사용자와의 대화입니다. 클릭, 입력, 키보드… 모든 동작에 귀 기울이세요! 👂”

오늘은 이벤트 처리의 기초를 배웠습니다. 이제 사용자와 상호작용하는 앱을 만들 수 있어요!

내일은 조건과 반복으로 더욱 동적인 화면을 만들어봅니다! 🚀

실습하다가 궁금한 점이 있으면 언제든 댓글로 질문해주세요! 💬


“작은 클릭 하나가 큰 변화를 만듭니다!” ✨

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.