포스트

[Angular 마스터하기] Day 15 - 에러 처리와 로딩 상태

[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은 보통 status0이고, 서버가 에러 응답을 반환한 경우에는 실제 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 시작!


“안정적인 앱은 에러 처리에서 시작됩니다!” 🛡️

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