이제와서 시작하는 Angular 마스터하기 - Day 10 “실제 API와 통신하여 데이터를 주고받아봅시다! 🌐”
오늘 배울 내용
- HttpClient 사용법
- GET/POST/PUT/DELETE 요청
toSignal()로 Observable → Signal 변환 - 실습: JSONPlaceholder API 연동
1. HttpClient 설정하기
서비스 주입과 Signal을 먼저 익힌 뒤 진행하세요. 첫 단계는 provideHttpClient() 등록이고, 그다음 Network 탭에서 요청을 확인하면서 GET 하나를 실행합니다. 초기 예제의 json 파이프를 쓰려면 해당 standalone 컴포넌트에 JsonPipe 또는 CommonModule도 등록해야 합니다. 아래 조각은 HTTP 연결 부분을 중심으로 보여줍니다.
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 앱
서비스의 loadUsers()가 loading을 켜고 응답을 usersSignal에 넣는 흐름을 먼저 읽으세요. 그다음 추가·수정·삭제의 next 처리에서 배열을 어떻게 바꾸는지 보고, 마지막에 컴포넌트가 그 읽기용 Signal을 표시하는 부분을 연결합니다. 처음 로딩 표시 뒤 사용자 목록이 나타나고, 삭제 응답 후 해당 행이 사라지는지를 확인합니다.
JSONPlaceholder 공식 안내에 따르면 쓰기 요청은 성공 응답을 흉내 내며 서버 데이터를 실제로 보존하지 않습니다. 화면의 추가·수정은 이 서비스가 로컬 배열을 갱신한 결과이므로, 다시 조회하면 원래 데이터가 돌아올 수 있습니다.
또한 이 서비스의 쓰기 요청에는 실패용 error 처리가 없고 컴포넌트는 응답 전에 입력·편집 모드를 초기화합니다. 네트워크 실패 시 폼 복구까지 완성된 구현으로 보지 말고, 성공 경로와 로딩·조회 오류 경로를 구분해 읽으세요.
완전한 서비스
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 시작!
다음 시간부터는 실전 활용 단계입니다:
“실제 데이터와 통신할 수 있다면, 진짜 앱을 만들 수 있습니다!” 🌐