포스트

[Angular 마스터하기] Day 10 - HTTP 통신, 실제 데이터 가져오기

[Angular 마스터하기] Day 10 - HTTP 통신, 실제 데이터 가져오기

이제와서 시작하는 Angular 마스터하기 - Day 10 “실제 API와 통신하여 데이터를 주고받아봅시다! 🌐”

오늘 배울 내용

  • HttpClient 사용법
  • GET/POST/PUT/DELETE 요청
  • toSignal()로 Observable → Signal 변환
  • 실습: JSONPlaceholder API 연동

1. HttpClient 설정하기

HttpClient 제공하기

1
2
3
4
5
6
7
8
9
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient()  // ← HTTP 기능 활성화
  ]
};

기본 사용법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-data-fetcher',
  standalone: true,
  template: `
    <div>
      <button (click)="fetchData()">데이터 가져오기</button>
      <pre>{{ data | json }}</pre>
    </div>
  `
})
export class DataFetcherComponent {
  private http = inject(HttpClient);
  data: any = null;

  fetchData() {
    this.http.get('https://jsonplaceholder.typicode.com/posts/1')
      .subscribe(response => {
        this.data = response;
      });
  }
}

2. Signal과 함께 사용하기

toSignal() - Observable을 Signal로 변환

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
import { Component, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';

interface Post {
  id: number;
  title: string;
  body: string;
  userId: number;
}

@Component({
  selector: 'app-post-list',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="post-list">
      <h1>📝 게시글 목록</h1>

      <button (click)="loadPosts()">새로고침</button>

      @if (isLoading()) {
        <div class="loading">⏳ 로딩 중...</div>
      } @else if (error()) {
        <div class="error">❌ 에러: {{ error() }}</div>
      } @else {
        <div class="posts">
          @for (post of posts(); track post.id) {
            <div class="post-card">
              <h2>{{ post.title }}</h2>
              <p>{{ post.body }}</p>
            </div>
          }
        </div>
      }
    </div>
  `,
  styles: [`
    .post-list {
      max-width: 800px;
      margin: 0 auto;
      padding: 40px 20px;
    }

    .loading, .error {
      text-align: center;
      padding: 40px;
      font-size: 1.2em;
    }

    .error {
      color: #f44336;
    }

    .posts {
      display: grid;
      gap: 20px;
      margin-top: 20px;
    }

    .post-card {
      padding: 20px;
      background: white;
      border-radius: 10px;
      box-shadow: 0 2px 10px rgba(0,0,0,0.1);
    }

    .post-card h2 {
      color: #333;
      margin-bottom: 10px;
    }

    .post-card p {
      color: #666;
      line-height: 1.6;
    }

    button {
      padding: 12px 24px;
      background: #667eea;
      color: white;
      border: none;
      border-radius: 8px;
      cursor: pointer;
      margin-bottom: 20px;
    }
  `]
})
export class PostListComponent {
  private http = inject(HttpClient);

  posts = signal<Post[]>([]);
  isLoading = signal(false);
  error = signal<string | null>(null);

  ngOnInit() {
    this.loadPosts();
  }

  loadPosts() {
    this.isLoading.set(true);
    this.error.set(null);

    this.http.get<Post[]>('https://jsonplaceholder.typicode.com/posts')
      .subscribe({
        next: (data) => {
          this.posts.set(data.slice(0, 10)); // 처음 10개만
          this.isLoading.set(false);
        },
        error: (err) => {
          this.error.set(err.message);
          this.isLoading.set(false);
        }
      });
  }
}

3. REST API 메서드

GET - 데이터 조회

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Injectable({ providedIn: 'root' })
export class PostService {
  private http = inject(HttpClient);
  private baseUrl = 'https://jsonplaceholder.typicode.com';

  // 전체 목록 조회
  getPosts() {
    return this.http.get<Post[]>(`${this.baseUrl}/posts`);
  }

  // 특정 항목 조회
  getPost(id: number) {
    return this.http.get<Post>(`${this.baseUrl}/posts/${id}`);
  }

  // 쿼리 파라미터 사용
  searchPosts(userId: number) {
    return this.http.get<Post[]>(`${this.baseUrl}/posts`, {
      params: { userId: userId.toString() }
    });
  }
}

POST - 데이터 생성

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
@Component({
  selector: 'app-create-post',
  standalone: true,
  imports: [FormsModule],
  template: `
    <div class="create-form">
      <h2>새 게시글 작성</h2>

      <input
        type="text"
        [(ngModel)]="title"
        placeholder="제목">

      <textarea
        [(ngModel)]="body"
        placeholder="내용"></textarea>

      <button (click)="createPost()" [disabled]="isSubmitting()">
        {{ isSubmitting() ? '등록 중...' : '등록' }}
      </button>

      @if (result()) {
        <div class="result">
          ✅ 등록 완료! ID: {{ result()?.id }}
        </div>
      }
    </div>
  `,
  styles: [`
    .create-form {
      max-width: 600px;
      margin: 0 auto;
      padding: 30px;
      background: white;
      border-radius: 15px;
    }

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

    textarea {
      min-height: 150px;
      resize: vertical;
    }

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

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

    .result {
      margin-top: 20px;
      padding: 15px;
      background: #e8f5e9;
      border-radius: 8px;
      color: #4CAF50;
    }
  `]
})
export class CreatePostComponent {
  private http = inject(HttpClient);

  title = '';
  body = '';
  isSubmitting = signal(false);
  result = signal<any>(null);

  createPost() {
    if (!this.title || !this.body) {
      alert('제목과 내용을 입력하세요');
      return;
    }

    this.isSubmitting.set(true);

    const newPost = {
      title: this.title,
      body: this.body,
      userId: 1
    };

    this.http.post('https://jsonplaceholder.typicode.com/posts', newPost)
      .subscribe({
        next: (response) => {
          this.result.set(response);
          this.isSubmitting.set(false);
          this.title = '';
          this.body = '';
        },
        error: (err) => {
          alert('등록 실패: ' + err.message);
          this.isSubmitting.set(false);
        }
      });
  }
}

PUT - 데이터 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
updatePost(id: number, post: Partial<Post>) {
  return this.http.put<Post>(
    `${this.baseUrl}/posts/${id}`,
    post
  );
}

// 사용 예
editPost() {
  this.postService.updatePost(1, {
    title: '수정된 제목',
    body: '수정된 내용'
  }).subscribe(response => {
    console.log('수정 완료:', response);
  });
}

DELETE - 데이터 삭제

1
2
3
4
5
6
7
8
9
10
11
12
13
deletePost(id: number) {
  return this.http.delete(`${this.baseUrl}/posts/${id}`);
}

// 사용 예
removePost(id: number) {
  if (confirm('정말 삭제하시겠습니까?')) {
    this.postService.deletePost(id).subscribe(() => {
      this.posts.update(posts => posts.filter(p => p.id !== id));
      alert('삭제 완료');
    });
  }
}

4. 실전 예제: CRUD 앱

완전한 서비스

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
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';

export interface User {
  id: number;
  name: string;
  email: string;
  phone: string;
}

@Injectable({
  providedIn: 'root'
})
export class UserService {
  private http = inject(HttpClient);
  private baseUrl = 'https://jsonplaceholder.typicode.com/users';

  // State
  private usersSignal = signal<User[]>([]);
  private loadingSignal = signal(false);
  private errorSignal = signal<string | null>(null);

  // Public readonly
  users = this.usersSignal.asReadonly();
  loading = this.loadingSignal.asReadonly();
  error = this.errorSignal.asReadonly();

  // CRUD Operations
  loadUsers() {
    this.loadingSignal.set(true);
    this.errorSignal.set(null);

    this.http.get<User[]>(this.baseUrl).subscribe({
      next: (users) => {
        this.usersSignal.set(users);
        this.loadingSignal.set(false);
      },
      error: (err) => {
        this.errorSignal.set(err.message);
        this.loadingSignal.set(false);
      }
    });
  }

  addUser(user: Omit<User, 'id'>) {
    return this.http.post<User>(this.baseUrl, user).subscribe({
      next: (newUser) => {
        this.usersSignal.update(users => [...users, newUser]);
      }
    });
  }

  updateUser(id: number, user: Partial<User>) {
    return this.http.put<User>(`${this.baseUrl}/${id}`, user).subscribe({
      next: (updated) => {
        this.usersSignal.update(users =>
          users.map(u => u.id === id ? updated : u)
        );
      }
    });
  }

  deleteUser(id: number) {
    return this.http.delete(`${this.baseUrl}/${id}`).subscribe({
      next: () => {
        this.usersSignal.update(users => users.filter(u => u.id !== id));
      }
    });
  }
}

CRUD 컴포넌트

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
@Component({
  selector: 'app-user-manager',
  standalone: true,
  imports: [CommonModule, FormsModule],
  template: `
    <div class="user-manager">
      <h1>👥 사용자 관리</h1>

      <!-- 로딩/에러 -->
      @if (userService.loading()) {
        <div class="loading">⏳ 로딩 중...</div>
      } @else if (userService.error()) {
        <div class="error">❌ {{ userService.error() }}</div>
      }

      <!-- 추가 폼 -->
      <div class="add-form">
        <h2>새 사용자 추가</h2>
        <input [(ngModel)]="newUser.name" placeholder="이름">
        <input [(ngModel)]="newUser.email" placeholder="이메일">
        <input [(ngModel)]="newUser.phone" placeholder="전화번호">
        <button (click)="addUser()">추가</button>
      </div>

      <!-- 사용자 목록 -->
      <div class="user-list">
        @for (user of userService.users(); track user.id) {
          <div class="user-card">
            @if (editingId() === user.id) {
              <!-- 수정 모드 -->
              <input [(ngModel)]="editForm.name" placeholder="이름">
              <input [(ngModel)]="editForm.email" placeholder="이메일">
              <input [(ngModel)]="editForm.phone" placeholder="전화번호">
              <button (click)="saveEdit(user.id)">저장</button>
              <button (click)="cancelEdit()">취소</button>
            } @else {
              <!-- 보기 모드 -->
              <div class="user-info">
                <h3>{{ user.name }}</h3>
                <p>📧 {{ user.email }}</p>
                <p>📱 {{ user.phone }}</p>
              </div>
              <div class="actions">
                <button (click)="startEdit(user)">수정</button>
                <button (click)="deleteUser(user.id)" class="delete">삭제</button>
              </div>
            }
          </div>
        }
      </div>
    </div>
  `,
  styles: [`
    .user-manager {
      max-width: 800px;
      margin: 0 auto;
      padding: 40px 20px;
    }

    .loading, .error {
      text-align: center;
      padding: 20px;
      margin: 20px 0;
    }

    .error {
      color: #f44336;
      background: #ffebee;
      border-radius: 8px;
    }

    .add-form {
      background: white;
      padding: 30px;
      border-radius: 15px;
      margin-bottom: 30px;
      box-shadow: 0 2px 10px rgba(0,0,0,0.1);
    }

    .add-form h2 {
      margin-top: 0;
    }

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

    button {
      padding: 10px 20px;
      margin: 5px;
      border: none;
      border-radius: 8px;
      cursor: pointer;
      font-weight: bold;
    }

    .add-form button {
      background: #667eea;
      color: white;
      width: 100%;
      padding: 15px;
      margin-top: 10px;
    }

    .user-list {
      display: grid;
      gap: 20px;
    }

    .user-card {
      background: white;
      padding: 20px;
      border-radius: 15px;
      box-shadow: 0 2px 10px rgba(0,0,0,0.1);
    }

    .user-info h3 {
      margin: 0 0 10px 0;
      color: #333;
    }

    .user-info p {
      margin: 5px 0;
      color: #666;
    }

    .actions {
      margin-top: 15px;
      display: flex;
      gap: 10px;
    }

    .actions button {
      flex: 1;
      background: #667eea;
      color: white;
    }

    .actions button.delete {
      background: #f44336;
    }
  `]
})
export class UserManagerComponent {
  userService = inject(UserService);

  newUser = { name: '', email: '', phone: '' };
  editingId = signal<number | null>(null);
  editForm = { name: '', email: '', phone: '' };

  ngOnInit() {
    this.userService.loadUsers();
  }

  addUser() {
    if (!this.newUser.name || !this.newUser.email) {
      alert('이름과 이메일을 입력하세요');
      return;
    }

    this.userService.addUser(this.newUser);
    this.newUser = { name: '', email: '', phone: '' };
  }

  startEdit(user: User) {
    this.editingId.set(user.id);
    this.editForm = { ...user };
  }

  saveEdit(id: number) {
    this.userService.updateUser(id, this.editForm);
    this.editingId.set(null);
  }

  cancelEdit() {
    this.editingId.set(null);
  }

  deleteUser(id: number) {
    if (confirm('정말 삭제하시겠습니까?')) {
      this.userService.deleteUser(id);
    }
  }
}

🧪 직접 해보기

실습: 할 일 API 연동

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Injectable({ providedIn: 'root' })
export class TodoApiService {
  private http = inject(HttpClient);
  private baseUrl = 'https://jsonplaceholder.typicode.com/todos';

  getTodos(userId: number) {
    return this.http.get<any[]>(this.baseUrl, {
      params: { userId: userId.toString() }
    });
  }

  addTodo(todo: { title: string; userId: number }) {
    return this.http.post(this.baseUrl, { ...todo, completed: false });
  }

  toggleTodo(id: number, completed: boolean) {
    return this.http.patch(`${this.baseUrl}/${id}`, { completed });
  }

  deleteTodo(id: number) {
    return this.http.delete(`${this.baseUrl}/${id}`);
  }
}

💡 자주 하는 실수

❌ 실수 1: HttpClient 제공 안 함

1
2
3
4
5
6
7
// ❌ app.config.ts에 provideHttpClient() 없음
// → HttpClient를 inject할 수 없음!

// ✅ 반드시 추가
export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient()]
};

❌ 실수 2: subscribe 빼먹기

1
2
3
4
5
6
7
// ❌ Observable은 subscribe해야 실행됨
this.http.get('/api/data');  // 아무 일도 안 일어남!

// ✅ subscribe 필수
this.http.get('/api/data').subscribe(data => {
  console.log(data);
});

📝 정리

HTTP 메서드

메서드 용도 예시
GET 조회 http.get('/posts')
POST 생성 http.post('/posts', data)
PUT 전체 수정 http.put('/posts/1', data)
PATCH 부분 수정 http.patch('/posts/1', data)
DELETE 삭제 http.delete('/posts/1')

Phase 2 완료! 🎉

축하합니다! Phase 2를 모두 완료했습니다:

  • ✅ Day 6: Signal 상태 관리
  • ✅ Day 7: 컴포넌트 간 통신
  • ✅ Day 8: 서비스와 의존성 주입
  • ✅ Day 9: 라우팅 기초
  • ✅ Day 10: HTTP 통신

체크리스트

  • HttpClient를 사용할 수 있나요?
  • GET/POST 요청을 보낼 수 있나요?
  • Signal과 함께 HTTP를 사용할 수 있나요?
  • CRUD 앱을 만들어봤나요?

📚 다음 학습

Phase 3 시작!

다음 시간부터는 실전 활용 단계입니다:


“실제 데이터와 통신할 수 있다면, 진짜 앱을 만들 수 있습니다!” 🌐

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