[Angular 마스터하기] Day 15 - 에러 처리와 로딩 상태
이제와서 시작하는 Angular 마스터하기 - Day 15 “안정적인 앱을 위한 에러 처리와 로딩 상태 관리! 🛡️”
오늘 배울 내용
- HTTP 에러 처리
- 로딩 상태 관리
- try-catch와 catchError
- 실전: 안전한 데이터 페칭
1. 로딩 상태 관리
에러 처리는 단순히 console.error를 찍는 일이 아닙니다. 사용자가 지금 무엇을 할 수 있는지 알려주고, 앱이 다음 상태로 안전하게 이동하도록 만드는 일입니다.
HTTP 요청 화면은 보통 네 가지 상태를 가집니다.
| 상태 | 의미 | UI |
|---|---|---|
| idle | 아직 요청 전 | “데이터 로드” 버튼 |
| loading | 요청 진행 중 | 스피너, 버튼 비활성화 |
| success | 데이터 있음 | 결과 목록 |
| error | 실패 | 원인 메시지와 다시 시도 버튼 |
이 네 상태를 명시적으로 분리하면 “로딩 중인데 에러도 보이는” 어색한 UI를 줄일 수 있습니다.
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
import { Component, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-data-loader',
template: `
<div class="container">
<button (click)="loadData()">데이터 로드</button>
@if (isLoading()) {
<div class="loading">⏳ 로딩 중...</div>
} @else if (error()) {
<div class="error">❌ 에러: {{ error() }}</div>
} @else if (data()) {
<div class="success">✅ 데이터: {{ data() | json }}</div>
}
</div>
`,
styles: [`
.loading { color: #2196F3; }
.error { color: #f44336; }
.success { color: #4CAF50; }
`]
})
export class DataLoaderComponent {
private http = inject(HttpClient);
data = signal<any>(null);
isLoading = signal(false);
error = signal<string | null>(null);
loadData() {
this.isLoading.set(true);
this.error.set(null);
this.http.get('https://jsonplaceholder.typicode.com/posts/1')
.subscribe({
next: (response) => {
this.data.set(response);
this.isLoading.set(false);
},
error: (err) => {
this.error.set(err.message);
this.isLoading.set(false);
}
});
}
}
위 예제는 개념을 보여주기에는 충분하지만, 실무에서는 isLoading.set(false)가 여러 곳에 반복됩니다. 요청 성공과 실패 모두에서 마지막 정리를 해야 한다면 RxJS의 finalize를 쓰면 더 안전합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { catchError, finalize, of } from 'rxjs';
loadData() {
this.isLoading.set(true);
this.error.set(null);
this.http.get<Post>('/api/posts/1').pipe(
catchError((err: HttpErrorResponse) => {
this.error.set(this.toUserMessage(err));
return of(null);
}),
finalize(() => this.isLoading.set(false))
).subscribe(response => {
this.data.set(response);
});
}
2. catchError 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { catchError, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class SafeDataService {
private http = inject(HttpClient);
getData() {
return this.http.get('https://api.example.com/data').pipe(
catchError(error => {
console.error('에러 발생:', error);
return of({ error: true, message: error.message });
})
);
}
}
Angular의 HttpClient는 실패를 HttpErrorResponse로 전달합니다. 공식 문서 기준으로 네트워크 오류나 timeout은 보통 status가 0이고, 서버가 에러 응답을 반환한 경우에는 실제 HTTP 상태 코드가 들어옵니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private toUserMessage(error: HttpErrorResponse): string {
if (error.status === 0) {
return '네트워크 연결을 확인해주세요.';
}
if (error.status === 401) {
return '로그인이 필요합니다.';
}
if (error.status >= 500) {
return '서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
return '요청을 처리하지 못했습니다.';
}
개발자에게 필요한 상세 로그와 사용자에게 보여줄 메시지는 분리하는 것이 좋습니다. 내부 에러 메시지를 그대로 화면에 노출하면 보안상 좋지 않고, 사용자도 해결 방법을 알기 어렵습니다.
3. 재시도 로직
1
2
3
4
5
6
7
8
9
10
import { retry, delay } from 'rxjs';
loadDataWithRetry() {
this.http.get('https://api.example.com/data').pipe(
retry({ count: 3, delay: 1000 }) // 3번 재시도, 1초 대기
).subscribe({
next: (data) => console.log('성공:', data),
error: (err) => console.error('3번 시도 후 실패:', err)
});
}
재시도는 모든 요청에 붙이면 안 됩니다. 일시적인 네트워크 실패에는 도움이 되지만, 잘못된 비밀번호나 권한 없음 같은 4xx 오류를 반복 요청해도 해결되지 않습니다.
추천 기준:
- GET 조회 요청: 짧은 재시도 가능
- POST 결제/주문/삭제 요청: 자동 재시도 주의
- 401/403: 재시도보다 로그인/권한 안내
- 500/503: 사용자 메시지와 재시도 버튼 제공
공통 로깅, 인증 헤더, timeout, 일부 retry 정책은 Angular의 functional interceptor로 분리할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
export function errorLoggingInterceptor(
req: HttpRequest<unknown>,
next: HttpHandlerFn
) {
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
console.error('[HTTP]', req.method, req.url, error.status);
return throwError(() => error);
})
);
}
인터셉터는 공통 관심사를 처리하는 곳이고, “이 화면에서 어떤 메시지를 보여줄지”는 호출 지점에서 결정하는 편이 맥락을 잃지 않습니다.
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
@Component({
selector: 'app-user-list',
template: `
<div class="user-list">
<h1>사용자 목록</h1>
<button (click)="loadUsers()" [disabled]="isLoading()">
{{ isLoading() ? '로딩 중...' : '새로고침' }}
</button>
@if (isLoading()) {
<div class="spinner">
<div class="loader"></div>
<p>데이터를 불러오는 중...</p>
</div>
} @else if (error()) {
<div class="error-box">
<h3>😢 오류가 발생했습니다</h3>
<p>{{ error() }}</p>
<button (click)="loadUsers()">다시 시도</button>
</div>
} @else {
<div class="users">
@for (user of users(); track user.id) {
<div class="user-card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
}
</div>
}
</div>
`,
styles: [`
.spinner {
text-align: center;
padding: 40px;
}
.loader {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-box {
background: #ffebee;
padding: 30px;
border-radius: 10px;
text-align: center;
}
`]
})
export class UserListComponent {
private http = inject(HttpClient);
users = signal<any[]>([]);
isLoading = signal(false);
error = signal<string | null>(null);
ngOnInit() {
this.loadUsers();
}
loadUsers() {
this.isLoading.set(true);
this.error.set(null);
this.http.get<any[]>('https://jsonplaceholder.typicode.com/users')
.pipe(
retry({ count: 2, delay: 1000 }),
catchError(err => {
this.error.set(err.message || '알 수 없는 오류');
this.isLoading.set(false);
return of([]);
})
)
.subscribe(data => {
this.users.set(data);
this.isLoading.set(false);
});
}
}
이 예제도 finalize를 적용하면 로딩 상태를 더 일관되게 정리할 수 있습니다. 또한 성공 시 이전 에러를 지우고, 실패 시 기존 데이터를 유지할지 비울지 정책을 정해야 합니다.
사용자 목록처럼 이미 표시 중인 데이터가 있다면 실패했다고 목록을 바로 비우지 않는 편이 더 친절할 수 있습니다.
1
2
3
4
catchError(err => {
this.error.set(this.toUserMessage(err));
return EMPTY;
})
반대로 검색 결과처럼 실패한 요청의 결과를 보여주면 혼란스러운 화면에서는 빈 목록으로 바꾸는 편이 나을 수 있습니다. 정답은 없고, 사용자 맥락에 맞춰 결정해야 합니다.
5. 테스트해야 할 에러 상황
에러 처리는 테스트하지 않으면 금방 깨집니다. Angular HTTP 테스트에서는 서버 오류와 네트워크 오류를 따로 검증할 수 있습니다.
1
2
3
4
5
6
7
8
it('shows a server error message', () => {
component.loadUsers();
const req = httpTesting.expectOne('/api/users');
req.flush('Failed', { status: 500, statusText: 'Server Error' });
expect(component.error()).toContain('서버 오류');
});
네트워크 오류는 ProgressEvent로 흉내낼 수 있습니다.
1
2
3
4
5
6
7
8
it('shows a network error message', () => {
component.loadUsers();
const req = httpTesting.expectOne('/api/users');
req.error(new ProgressEvent('network error'));
expect(component.error()).toContain('네트워크');
});
최소한 로딩 종료, 에러 메시지, 다시 시도 버튼은 테스트해두면 회귀를 줄일 수 있습니다.
📝 정리
Phase 3 완료! 🎉
축하합니다! Phase 3를 모두 완료했습니다:
- ✅ Day 11: 폼 다루기
- ✅ Day 12: Reactive Forms
- ✅ Day 13: 파이프
- ✅ Day 14: 컴포넌트 생명주기
- ✅ Day 15: 에러 처리
체크리스트
- 로딩 상태를 관리할 수 있나요?
- 에러를 처리할 수 있나요?
- 재시도 로직을 구현할 수 있나요?
HttpErrorResponse.status에 따라 메시지를 분기할 수 있나요?finalize로 로딩 상태를 일관되게 정리할 수 있나요?- 서버 오류와 네트워크 오류 테스트를 구분할 수 있나요?
📚 다음 학습
Phase 4 시작!
- 이전: Day 14: 컴포넌트 생명주기
- 다음: Day 16: Signal 고급
“안정적인 앱은 에러 처리에서 시작됩니다!” 🛡️
![[Angular 마스터하기] Day 15 - 에러 처리와 로딩 상태](/assets/img/posts/angular/angular-day-15.png)