포스트

[Angular 마스터하기] Day 11 - 폼 다루기, 사용자 입력 받기

[Angular 마스터하기] Day 11 - 폼 다루기, 사용자 입력 받기

이제와서 시작하는 Angular 마스터하기 - Day 11 “사용자의 입력을 받고, 유효성을 검사하는 폼을 만들어봅시다! 📝”

오늘 배울 내용

  • FormsModule과 ngModel
  • 양방향 데이터 바인딩
  • 폼 유효성 검사
  • 실습: 회원가입 폼

1. FormsModule 시작하기

FormsModule 임포트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-simple-form',
  standalone: true,
  imports: [FormsModule],  // ← FormsModule 추가
  template: `
    <div class="form">
      <input [(ngModel)]="name" placeholder="이름을 입력하세요">
      <p>입력한 이름: {{ name }}</p>
    </div>
  `
})
export class SimpleFormComponent {
  name = '';
}

[(ngModel)] - 양방향 바인딩

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component({
  template: `
    <div class="demo">
      <!-- 양방향 바인딩 -->
      <input [(ngModel)]="message" placeholder="메시지 입력">

      <!-- 실시간 반영 -->
      <p>입력: {{ message }}</p>
      <p>길이: {{ message.length }}자</p>
      <p>대문자: {{ message.toUpperCase() }}</p>

      <button (click)="message = ''">초기화</button>
    </div>
  `
})
export class TwoWayBindingComponent {
  message = '';
}

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
@Component({
  selector: 'app-text-inputs',
  standalone: true,
  imports: [FormsModule],
  template: `
    <div class="form-group">
      <!-- 일반 텍스트 -->
      <label>이름</label>
      <input type="text" [(ngModel)]="user.name">

      <!-- 이메일 -->
      <label>이메일</label>
      <input type="email" [(ngModel)]="user.email">

      <!-- 비밀번호 -->
      <label>비밀번호</label>
      <input type="password" [(ngModel)]="user.password">

      <!-- 숫자 -->
      <label>나이</label>
      <input type="number" [(ngModel)]="user.age">

      <!-- 여러 줄 -->
      <label>자기소개</label>
      <textarea [(ngModel)]="user.bio" rows="5"></textarea>
    </div>
  `
})
export class TextInputsComponent {
  user = {
    name: '',
    email: '',
    password: '',
    age: 0,
    bio: ''
  };
}

선택 요소

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
@Component({
  selector: 'app-select-inputs',
  standalone: true,
  imports: [FormsModule, CommonModule],
  template: `
    <div class="form">
      <!-- 체크박스 -->
      <label>
        <input type="checkbox" [(ngModel)]="agreed">
        약관에 동의합니다
      </label>

      <!-- 라디오 버튼 -->
      <div>
        <label>
          <input type="radio" [(ngModel)]="gender" value="male">
          남성
        </label>
        <label>
          <input type="radio" [(ngModel)]="gender" value="female">
          여성
        </label>
      </div>

      <!-- 드롭다운 -->
      <select [(ngModel)]="selectedCity">
        <option value="">선택하세요</option>
        <option value="seoul">서울</option>
        <option value="busan">부산</option>
        <option value="jeju">제주</option>
      </select>

      <!-- 다중 선택 -->
      <div>
        <label *ngFor="let hobby of hobbies">
          <input
            type="checkbox"
            [value]="hobby"
            (change)="onHobbyChange($event, hobby)">
          {{ hobby }}
        </label>
      </div>

      <!-- 결과 -->
      <div class="result">
        <p>약관 동의: {{ agreed }}</p>
        <p>성별: {{ gender }}</p>
        <p>도시: {{ selectedCity }}</p>
        <p>취미: {{ selectedHobbies.join(', ') }}</p>
      </div>
    </div>
  `,
  styles: [`
    .form {
      max-width: 500px;
      padding: 30px;
    }

    label {
      display: block;
      margin: 10px 0;
      cursor: pointer;
    }

    select {
      width: 100%;
      padding: 10px;
      margin: 10px 0;
      border: 2px solid #ddd;
      border-radius: 8px;
    }

    .result {
      margin-top: 30px;
      padding: 20px;
      background: #f5f5f5;
      border-radius: 10px;
    }
  `]
})
export class SelectInputsComponent {
  agreed = false;
  gender = '';
  selectedCity = '';
  hobbies = ['독서', '운동', '영화', '게임'];
  selectedHobbies: string[] = [];

  onHobbyChange(event: Event, hobby: string) {
    const checked = (event.target as HTMLInputElement).checked;

    if (checked) {
      this.selectedHobbies.push(hobby);
    } else {
      this.selectedHobbies = this.selectedHobbies.filter(h => h !== hobby);
    }
  }
}

3. 폼 유효성 검사

기본 유효성 검사

{% raw %}

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
@Component({
  selector: 'app-validation-form',
  standalone: true,
  imports: [FormsModule, CommonModule],
  template: `
    <form #loginForm="ngForm" (ngSubmit)="onSubmit(loginForm)">
      <div class="form-group">
        <label>이메일</label>
        <input
          type="email"
          name="email"
          [(ngModel)]="email"
          #emailInput="ngModel"
          required
          email>

        @if (emailInput.invalid && emailInput.touched) {
          <div class="error">
            @if (emailInput.errors?.['required']) {
              <span>이메일은 필수입니다</span>
            }
            @if (emailInput.errors?.['email']) {
              <span>올바른 이메일 형식이 아닙니다</span>
            }
          </div>
        }
      </div>

      <div class="form-group">
        <label>비밀번호</label>
        <input
          type="password"
          name="password"
          [(ngModel)]="password"
          #passwordInput="ngModel"
          required
          minlength="8">

        @if (passwordInput.invalid && passwordInput.touched) {
          <div class="error">
            @if (passwordInput.errors?.['required']) {
              <span>비밀번호는 필수입니다</span>
            }
            @if (passwordInput.errors?.['minlength']) {
              <span>비밀번호는 최소 8자 이상이어야 합니다</span>
            }
          </div>
        }
      </div>

      <button
        type="submit"
        [disabled]="loginForm.invalid">
        로그인
      </button>

      <div class="form-status">
        <p>폼 유효성: {{ loginForm.valid ? '✅ 유효함' : '❌ 유효하지 않음' }}</p>
      </div>
    </form>
  `,
  styles: [`

    form {
      max-width: 400px;
      margin: 0 auto;
      padding: 30px;
      background: white;
      border-radius: 15px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.1);
    }

    .form-group {
      margin-bottom: 20px;
    }

    label {
      display: block;
      margin-bottom: 5px;
      font-weight: bold;
      color: #333;
    }

    input {
      width: 100%;
      padding: 12px;
      border: 2px solid #ddd;
      border-radius: 8px;
      font-size: 1em;
    }

    input.ng-invalid.ng-touched {
      border-color: #f44336;
    }

    input.ng-valid.ng-touched {
      border-color: #4CAF50;
    }

    .error {
      color: #f44336;
      font-size: 0.9em;
      margin-top: 5px;
    }

    button {
      width: 100%;
      padding: 15px;
      background: #667eea;
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 1.1em;
      font-weight: bold;
      cursor: pointer;
    }

    button:disabled {
      background: #ccc;
      cursor: not-allowed;
    }

    .form-status {
      margin-top: 20px;
      text-align: center;
      color: #666;
    }
  `]
})
export class ValidationFormComponent {
  email = '';
  password = '';

  onSubmit(form: any) {
    if (form.valid) {
      console.log('제출:', { email: this.email, password: this.password });
      alert('로그인 성공!');
    }
  }
}

4. 실전 예제: 회원가입 폼

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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';

@Component({
  selector: 'app-register-form',
  standalone: true,
  imports: [FormsModule, CommonModule],
  template: `
    <div class="register-page">
      <h1>🎉 회원가입</h1>

      <form #registerForm="ngForm" (ngSubmit)="onSubmit()">
        <!-- 이름 -->
        <div class="form-group">
          <label>이름 *</label>
          <input
            type="text"
            name="name"
            [(ngModel)]="formData.name"
            #nameInput="ngModel"
            required
            minlength="2"
            placeholder="홍길동">

          @if (nameInput.invalid && nameInput.touched) {
            <div class="error">
              @if (nameInput.errors?.['required']) {
                이름은 필수입니다
              }
              @if (nameInput.errors?.['minlength']) {
                이름은 최소 2자 이상이어야 합니다
              }
            </div>
          }
        </div>

        <!-- 이메일 -->
        <div class="form-group">
          <label>이메일 *</label>
          <input
            type="email"
            name="email"
            [(ngModel)]="formData.email"
            #emailInput="ngModel"
            required
            email
            placeholder="example@email.com">

          @if (emailInput.invalid && emailInput.touched) {
            <div class="error">
              @if (emailInput.errors?.['required']) {
                이메일은 필수입니다
              }
              @if (emailInput.errors?.['email']) {
                올바른 이메일 형식이 아닙니다
              }
            </div>
          }
        </div>

        <!-- 비밀번호 -->
        <div class="form-group">
          <label>비밀번호 *</label>
          <input
            type="password"
            name="password"
            [(ngModel)]="formData.password"
            #passwordInput="ngModel"
            required
            minlength="8"
            placeholder="8자 이상">

          @if (passwordInput.invalid && passwordInput.touched) {
            <div class="error">
              @if (passwordInput.errors?.['required']) {
                비밀번호는 필수입니다
              }
              @if (passwordInput.errors?.['minlength']) {
                비밀번호는 최소 8자 이상이어야 합니다
              }
            </div>
          }
        </div>

        <!-- 비밀번호 확인 -->
        <div class="form-group">
          <label>비밀번호 확인 *</label>
          <input
            type="password"
            name="confirmPassword"
            [(ngModel)]="formData.confirmPassword"
            #confirmInput="ngModel"
            required>

          @if (formData.password !== formData.confirmPassword && confirmInput.touched) {
            <div class="error">
              비밀번호가 일치하지 않습니다
            </div>
          }
        </div>

        <!-- 전화번호 -->
        <div class="form-group">
          <label>전화번호</label>
          <input
            type="tel"
            name="phone"
            [(ngModel)]="formData.phone"
            pattern="[0-9]{10,11}"
            #phoneInput="ngModel"
            placeholder="01012345678">

          @if (phoneInput.invalid && phoneInput.touched) {
            <div class="error">
              올바른 전화번호 형식이 아닙니다 (숫자 10-11자)
            </div>
          }
        </div>

        <!-- 성별 -->
        <div class="form-group">
          <label>성별</label>
          <div class="radio-group">
            <label>
              <input
                type="radio"
                name="gender"
                [(ngModel)]="formData.gender"
                value="male">
              남성
            </label>
            <label>
              <input
                type="radio"
                name="gender"
                [(ngModel)]="formData.gender"
                value="female">
              여성
            </label>
            <label>
              <input
                type="radio"
                name="gender"
                [(ngModel)]="formData.gender"
                value="other">
              기타
            </label>
          </div>
        </div>

        <!-- 약관 동의 -->
        <div class="form-group">
          <label class="checkbox-label">
            <input
              type="checkbox"
              name="agreeTerms"
              [(ngModel)]="formData.agreeTerms"
              #termsInput="ngModel"
              required>
            <span>이용약관 및 개인정보처리방침에 동의합니다 *</span>
          </label>

          @if (termsInput.invalid && termsInput.touched) {
            <div class="error">
              약관 동의는 필수입니다
            </div>
          }
        </div>

        <!-- 제출 버튼 -->
        <button
          type="submit"
          [disabled]="registerForm.invalid || !passwordsMatch()">
          회원가입
        </button>

        <!-- 폼 상태 -->
        <div class="form-debug">
          <h3>폼 상태</h3>
          <p>유효성: {{ registerForm.valid ? '✅ 유효' : '❌ 유효하지 않음' }}</p>
          <p>터치됨: {{ registerForm.touched ? '예' : '아니오' }}</p>
          <p>수정됨: {{ registerForm.dirty ? '예' : '아니오' }}</p>
        </div>
      </form>
    </div>
  `,
  styles: [`

    .register-page {
      max-width: 500px;
      margin: 50px auto;
      padding: 40px;
      background: white;
      border-radius: 20px;
      box-shadow: 0 10px 40px rgba(0,0,0,0.1);
    }

    h1 {
      text-align: center;
      color: #333;
      margin-bottom: 30px;
    }

    .form-group {
      margin-bottom: 25px;
    }

    label {
      display: block;
      margin-bottom: 8px;
      font-weight: bold;
      color: #555;
    }

    input[type="text"],
    input[type="email"],
    input[type="password"],
    input[type="tel"] {
      width: 100%;
      padding: 12px;
      border: 2px solid #ddd;
      border-radius: 8px;
      font-size: 1em;
      transition: border-color 0.3s;
    }

    input:focus {
      outline: none;
      border-color: #667eea;
    }

    input.ng-invalid.ng-touched {
      border-color: #f44336;
    }

    input.ng-valid.ng-touched {
      border-color: #4CAF50;
    }

    .radio-group {
      display: flex;
      gap: 20px;
    }

    .radio-group label {
      font-weight: normal;
      cursor: pointer;
    }

    .checkbox-label {
      display: flex;
      align-items: center;
      gap: 10px;
      cursor: pointer;
      font-weight: normal;
    }

    .checkbox-label input {
      width: auto;
    }

    .error {
      color: #f44336;
      font-size: 0.9em;
      margin-top: 5px;
    }

    button {
      width: 100%;
      padding: 15px;
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      color: white;
      border: none;
      border-radius: 8px;
      font-size: 1.1em;
      font-weight: bold;
      cursor: pointer;
      transition: transform 0.2s;
    }

    button:hover:not(:disabled) {
      transform: translateY(-2px);
    }

    button:disabled {
      background: #ccc;
      cursor: not-allowed;
      transform: none;
    }

    .form-debug {
      margin-top: 30px;
      padding: 20px;
      background: #f5f5f5;
      border-radius: 10px;
    }

    .form-debug h3 {
      margin-top: 0;
      color: #666;
    }

    .form-debug p {
      margin: 5px 0;
      color: #666;
    }
  `]
})
export class RegisterFormComponent {
  formData = {
    name: '',
    email: '',
    password: '',
    confirmPassword: '',
    phone: '',
    gender: '',
    agreeTerms: false
  };

  passwordsMatch(): boolean {
    return this.formData.password === this.formData.confirmPassword;
  }

  onSubmit() {
    if (!this.passwordsMatch()) {
      alert('비밀번호가 일치하지 않습니다');
      return;
    }

    console.log('제출된 데이터:', this.formData);
    alert('회원가입 완료!');

    // 폼 초기화 (실제로는 서버로 전송)
    this.formData = {
      name: '',
      email: '',
      password: '',
      confirmPassword: '',
      phone: '',
      gender: '',
      agreeTerms: false
    };
  }
}

🧪 직접 해보기

실습: 설문조사 폼

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
@Component({
  template: `
    <form #surveyForm="ngForm">
      <h2>고객 만족도 설문</h2>

      <!-- 이름 -->
      <input
        type="text"
        name="name"
        [(ngModel)]="survey.name"
        required
        placeholder="이름">

      <!-- 만족도 (1-5) -->
      <label>만족도</label>
      <input
        type="range"
        name="satisfaction"
        [(ngModel)]="survey.satisfaction"
        min="1"
        max="5">
      <span>{{ survey.satisfaction }}</span>

      <!-- 추천 의향 -->
      <label>
        <input
          type="checkbox"
          name="recommend"
          [(ngModel)]="survey.recommend">
        친구에게 추천하시겠습니까?
      </label>

      <!-- 의견 -->
      <textarea
        name="feedback"
        [(ngModel)]="survey.feedback"
        placeholder="의견을 남겨주세요"></textarea>

      <button
        type="submit"
        [disabled]="surveyForm.invalid">
        제출
      </button>
    </form>
  `
})

export class SurveyFormComponent {
  survey = {
    name: '',
    satisfaction: 3,
    recommend: false,
    feedback: ''
  };
}

💡 자주 하는 실수

❌ 실수 1: FormsModule 임포트 안 함

1
2
3
4
5
6
7
8
9
10
11
// ❌ FormsModule 없음
@Component({
  imports: [],  // ngModel 사용 불가!
  template: `<input [(ngModel)]="name">`
})

// ✅ FormsModule 추가
@Component({
  imports: [FormsModule],
  template: `<input [(ngModel)]="name">`
})

❌ 실수 2: name 속성 빼먹기

1
2
3
4
5
<!-- ❌ name 속성 없음 -->
<input [(ngModel)]="email" required>

<!-- ✅ name 속성 필수 -->
<input name="email" [(ngModel)]="email" required>

📝 정리

유효성 검사 종류

검증 설명 예시
required 필수 입력 <input required>
minlength 최소 길이 <input minlength="8">
maxlength 최대 길이 <input maxlength="20">
pattern 정규식 <input pattern="[0-9]+">
email 이메일 형식 <input type="email">

체크리스트

  • ngModel을 사용할 수 있나요?
  • 폼 유효성 검사를 할 수 있나요?
  • 다양한 입력 요소를 다룰 수 있나요?
  • 회원가입 폼을 만들어봤나요?

📚 다음 학습

다음 시간에는 Reactive Forms를 배웁니다!

더 강력하고 복잡한 폼을 다루는 방법을 배웁니다:


“좋은 폼은 좋은 사용자 경험의 시작입니다!” 📝

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