로딩 전략
본 문서는 데이터가 준비되지 않은 동안 무엇을 보여줄지, 그리고 대기가 발생하는 층마다 어떤 표현을 쓰는지를 정의합니다.
1. 원칙
완성된 화면만 노출합니다. 반쯤 채워진 화면과 순차적 레이아웃 이동을 허용하지 않습니다.
이를 위해 데이터를 화면 진입 전에 받습니다. 컴포넌트를 먼저 그린 뒤 조회하는 방식(render-then-fetch)이 아니라, 조회를 마친 뒤 그리는 방식(fetch-then-render)입니다.
이는 새로운 방식이 아니라 브라우저가 원래 갖고 있던 계약의 복원입니다. 여러 문서를 오가는 전통적인 웹 탐색에서 브라우저는 다음 문서가 준비될 때까지 이전 문서를 유지하다가 완성본으로 교체합니다. 단일 페이지 애플리케이션이 깨뜨린 그 계약을 리졸버와 베일이 되살립니다.
스켈레톤은 사용하지 않습니다. 근거는 ADR-0011에 있습니다.
2. 층별 적용 경계
대기가 발생하는 층마다 성격이 다릅니다. 하나의 정책을 모든 층에 적용하면 각 층에서 오답이 됩니다.
| 층 | 상황 | 데이터 로딩 | 대기 표현 |
|---|---|---|---|
| 정적 첫 진입 | 공개 경로 | 빌드 시점에 완성 | 없음 |
| 앱 부트스트랩 | 최초 로드 | 해당 없음 | index.html의 정적 표시 |
| 화면 전환 | 다른 화면으로 이동 | 리졸버 | 베일 |
| 화면 내 갱신 | 필터·검색·정렬·페이지 | 리졸버 재실행 | 이전 내용 유지 + 인디케이터 |
| 오버레이 | 모달·시트·팝오버 | 오버레이 내부 조회 | 크기 트랜지션. 베일 비대상 |
| 덧붙는 로딩 | 무한 스크롤, 추가 조회 | 추가 요청 | 하단 인라인 인디케이터 |
| 명령 실행 | 저장·승인·삭제 | 명령 요청 | 버튼 내 진행 표시 |
각 층이 다른 정책을 갖는 이유는 "이전 상태를 유지할 수 있는가" 가 층마다 다르기 때문입니다.
- 정적 첫 진입은 서버가 이미 대기를 흡수했습니다
- 오버레이는 여는 행위 자체가 즉각 피드백입니다
- 덧붙는 로딩과 화면 내 갱신은 기존 콘텐츠가 맥락을 유지합니다
- 명령 실행은 화면이 아니라 그 동작에 대한 피드백이 필요합니다
3. 베일과 인디케이터의 판정
3.1 판정 기준
경로가 바뀌면 베일, 쿼리만 바뀌면 인디케이터입니다.
| 이동 | 판정 | 표현 |
|---|---|---|
/assessments → /assessments/1 |
경로 변경 | 베일 |
/assessments/1 → /assessments/2 |
경로 변경 | 베일 |
/assessments?page=1 → ?page=2 |
쿼리만 변경 | 인디케이터 |
/assessments?keyword=a → ?keyword=b |
쿼리만 변경 | 인디케이터 |
이 기준은 NavigationStart 시점에 현재 URL과 대상 URL의 경로 세그먼트를 비교해 계산합니다. 사람이 "이건 전환인가 갱신인가"를 판단하지 않습니다.
필터·검색·정렬·페이지를 쿼리 파라미터에 두기로 한 결정(라우팅과 네비게이션 3절)이 이 판정을 성립시킵니다. 필터를 컴포넌트 상태에 두면 라우터가 관여하지 않아 이 규칙이 적용되지 않습니다.
3.2 화면 내 갱신에 베일을 씌우지 않는 이유
| 이유 | 내용 |
|---|---|
| 조작 대상이 가려집니다 | 사용자는 방금 자신이 누른 컨트롤을 보고 있습니다. 덮으면 무엇을 눌렀는지 확인할 수 없습니다 |
| 연속 조작이 막힙니다 | 체크박스를 여러 개 연달아 누르는 조작이 불가능해집니다 |
| 골격이 그대로입니다 | 헤더·필터·표 열 구조는 유지되고 본문 행만 바뀝니다. 덮을 대상이 없습니다 |
3.3 이전 내용 유지는 자동으로 됩니다
같은 라우트 설정으로 이동하면 Angular 라우터가 컴포넌트 인스턴스를 재사용합니다. 리졸버가 도는 동안 이전 데이터가 화면에 그대로 남아 있고, 완료되면 입력 시그널만 갱신되어 내용이 교체됩니다.
이전 목록을 보관하는 코드를 작성할 필요가 없습니다. 라우터가 그 일을 대신합니다.
3.4 화면이 바뀌는 순간
교체 자체에는 효과를 두지 않습니다. 베일이 걷히면 다음 화면이 그 자리에 그대로 나타납니다.
대기를 다루는 것과 교체를 다루는 것은 목적이 다릅니다. 베일은 이전 화면을 유지해 맥락을 지키는 장치이며, 그 역할이 끝나는 시점에 덧붙는 효과는 완성된 화면이 드러나는 시각을 뒤로 미룹니다. 1절의 "완성된 화면만 노출합니다"에 더해지는 것이 아니라 그 시각을 늦추는 쪽으로 작동합니다.
교체 구간에 애니메이션을 두려면 이 문서가 정하지 않은 값을 화면마다 새로 정해야 합니다. 무엇이 움직이고 무엇이 제자리에 남는지, 크롬이 늘 때 그것을 어느 쪽에 넣는지가 그때마다 판단 대상이 되며, 빠뜨린 요소는 오류 없이 그 요소만 다르게 움직입니다. 기본값을 "효과 없음"으로 두면 그 판단이 발생하지 않습니다.
4. 시간 정책
시간 정책은 개별 컴포넌트가 아니라 라우터 층에 겁니다. 화면마다 다른 값을 쓰면 같은 대기가 화면에 따라 다르게 보입니다.
| 값 | 기본 | 역할 |
|---|---|---|
--wait-delay |
200ms | 이 시간 안에 끝나면 베일을 띄우지 않습니다 |
--wait-min |
400ms | 한 번 띄운 베일은 최소 이 시간 유지합니다 |
빠른 응답이 베일 없이 지나가고, 띄운 베일은 즉시 사라지지 않아 깜빡임이 발생하지 않습니다.
--wait-delay가 200ms인 근거는 짧은 대기에 표시자를 띄우면 오히려 체감이 나빠진다는 점입니다. 사용자 인지 연구는 500ms 미만 구간에서 표시자 없는 빈 상태가 표시자보다 빠르게 느껴진다고 보고합니다.
에러에는 최소 유지 시간을 적용하지 않습니다. 즉시 베일을 걷고 에러 표현으로 전환합니다. 실패를 400ms 동안 더 기다리게 할 이유가 없습니다.
5. 리졸버
5.1 배치와 등록
리졸버는 조회이므로 해당 슬라이스의 api 세그먼트에 두고 공개 API로 내보냅니다.
// pages/assessment-list/api/assessment-list-resolver.ts
export const assessmentListResolver: ResolveFn<AssessmentListResponse> = (route) =>
inject(HttpClient).get<AssessmentListResponse>(ENDPOINTS.assessments, {
params: { page: route.queryParams['page'] ?? 1 },
});// app/app.routes.ts
{
path: 'assessments',
loadComponent: () => import('@/pages/assessment-list').then((m) => m.AssessmentList),
resolve: { assessments: assessmentListResolver },
runGuardsAndResolvers: 'paramsOrQueryParamsChange',
}중요
runGuardsAndResolvers 지정이 필수입니다
기본값은 'paramsChange'이며 쿼리 파라미터 변경에는 리졸버가 다시 실행되지 않습니다. 필터·검색·정렬·페이지를 쿼리에 두기로 했으므로 'paramsOrQueryParamsChange'를 지정하지 않으면 필터를 바꿔도 데이터가 갱신되지 않습니다. 이 누락은 조용히 실패하므로 발견이 늦습니다.
5.2 데이터 수신
withComponentInputBinding()을 활성화하면 리졸버 결과가 컴포넌트 입력으로 들어옵니다.
export class AssessmentList {
readonly assessments = input.required<AssessmentListResponse>();
}시그널 입력으로 받으므로 computed와 그대로 결합됩니다. ActivatedRoute를 주입해 data를 구독하는 방식보다 이쪽을 사용합니다.
5.3 리졸버에 넣는 것과 넣지 않는 것
| 넣습니다 | 넣지 않습니다 |
|---|---|
| 화면의 주요 내용을 구성하는 데이터 | 화면 일부의 보조 정보 |
| 없으면 화면이 의미를 갖지 못하는 것 | 사용자가 열어야 보이는 것 |
| 폴링이나 실시간 갱신이 필요한 것 |
리졸버가 무거워지면 첫 표시가 가장 느린 요청에 묶입니다. 화면 진입에 필수인 것만 넣습니다.
리졸버 안에서 데이터 변환을 하지 않습니다. 리졸버는 HTTP 호출만 수행하고, 계산은 컴포넌트의 model 세그먼트가 담당합니다.
5.4 번들 고려
리졸버는 지연 로딩되지 않습니다. loadComponent는 지연이지만 resolve는 라우트 정의 시점에 참조되므로 초기 번들에 포함됩니다. 실측으로 확인한 사항입니다.
같은 슬라이스 공개 API에서 리졸버와 컴포넌트를 함께 내보내도 각각 초기 청크와 지연 청크로 분리됩니다. 번들러가 배럴이 아니라 모듈 단위로 분할하기 때문이며, 리졸버를 별도 파일로 빼기 위해 공개 API를 나눌 필요가 없습니다.
리졸버를 가볍게 유지하고, 라우트 그룹 전체를 loadChildren으로 지연 로드하면 리졸버도 함께 지연됩니다. 화면 수가 늘어나면 이 방식으로 전환합니다.
5.5 실패 처리
리졸버가 실패하면 네비게이션이 취소되고 이전 화면에 머무릅니다. 사용자에게는 아무 일도 일어나지 않은 것처럼 보이므로 실패를 명시적으로 처리해야 합니다.
리졸버는 실패 시 RedirectCommand로 에러 화면 경로를 반환합니다.
return http.get<Assessment>(url).pipe(
catchError(() => of(new RedirectCommand(router.parseUrl(ROUTES.error())))),
);전역 처리가 필요하면 provideRouter에 withNavigationErrorHandler를 등록합니다. 에러 화면 규칙은 레이아웃 5절이 원본입니다.
5.6 연속 네비게이션
사용자가 검색어를 빠르게 입력하면 네비게이션이 연달아 발생합니다. Angular 라우터는 새 네비게이션이 시작되면 이전 것을 취소하고 진행 중이던 리졸버 구독을 해제합니다.
HttpClient가 Observable을 반환하므로 구독 해제가 곧 요청 취소로 이어집니다. 별도 처리가 필요 없으나, 리졸버가 Promise를 반환하면 취소되지 않으므로 Observable 반환을 사용합니다.
6. httpResource의 역할
리졸버를 기본으로 채택했지만 httpResource가 사라지지 않습니다. 다음 자리에서 사용합니다.
| 대상 | 이유 |
|---|---|
| 오버레이 내부 조회 | 오버레이는 라우트가 아니므로 리졸버가 관여하지 않습니다 |
| 화면 일부의 보조 정보 | 주요 내용과 함께 기다리게 할 필요가 없습니다 |
| 사용자 동작으로 여는 영역 | 접힌 패널을 펼칠 때 조회합니다 |
| 폴링과 주기적 갱신 | 라우트 전환과 무관합니다 |
판정 기준은 하나입니다. 화면 진입에 필수인가. 필수면 리졸버, 아니면 httpResource입니다.
7. 층별 구현
7.1 베일
베일은 app 계층이 소유하며 라우터 이벤트를 구독해 제어합니다. 개별 화면이 베일을 띄우거나 걷는 것을 금지합니다.
베일은 콘텐츠 영역을 덮되 전역 네비게이션은 덮지 않습니다. 사용자가 대기 중에도 다른 화면으로 이동할 수 있어야 합니다.
첫 진입은 대상이 아닙니다
베일은 화면이 이미 있는 상태에서 다음 화면으로 바뀔 때만 관여합니다. 첫 진입과 새로고침에는 서버가 완성된 문서를 보내므로 덮을 이전 화면이 없습니다.
전 경로를 정적 생성하는 구성에서는 이 구분이 저절로 성립합니다. 클라이언트 렌더링 경로를 추가한다면 첫 진입의 빈 화면을 무엇으로 채울지 별도로 정해야 하며, 그것은 베일이 아닙니다.
덮는 범위는 프레임이 정합니다
"콘텐츠 영역"은 골격마다 다릅니다. 무엇이 제외인지 프레임이 알고 있으므로 베일의 자리도 프레임이 정합니다.
| 골격 | 덮는 곳 | 덮지 않는 곳 |
|---|---|---|
| 셸 직속 화면 | 셸의 본문 영역 | 상단 바, 푸터, 하단 네비 |
| 사이드바를 가진 프레임 | 프레임의 콘텐츠 열 | 위의 셋과 사이드바 |
사이드바를 제외하는 이유는 그것이 그 영역의 이동 수단이기 때문입니다. 대기 중에 이동할 수 있어야 한다는 규칙이 문서 영역에서는 사이드바를 덮지 않는다는 뜻이 됩니다.
주의: 화면 전체를 덮는 요소로 구현하지 않습니다
position: fixed로 뷰포트 전체를 덮으면 콘텐츠 영역을 알 수 없어 상단 바와 사이드바까지 가려집니다. z-index 로 크롬을 하나씩 위로 빼내는 방식도 금지합니다. 크롬이 늘 때마다 함께 고쳐야 하는 값이 생기고, 빠뜨리면 조용히 가려집니다. 베일은position: absolute로 두고 놓는 쪽이 기준 상자를 지정합니다.
기준 상자는 눈에 보이는 콘텐츠 영역과 정확히 같아야 합니다. 좌우 여백을 부모 컨테이너에 두면 자식의 상자가 그만큼 좁아지고, 베일이 양옆에 덮이지 않는 띠를 남깁니다. 여백은 기준 상자가 되는 요소가 직접 갖습니다.
셸과 프레임의 자리가 겹치면 바깥 것이 사이드바까지 덮습니다. 안쪽 프레임이 자리를 가져간 동안 셸은 물러납니다. 이때 셸이 경로를 보고 판정하는 것을 금지합니다. 셸이 어느 화면인지 알게 되어 호출부가 골격을 모른다는 원칙(레이아웃 3.1절)이 뒤집힙니다. 셸은 안쪽이 자리를 가져갔는지만 묻습니다.
7.2 오버레이
오버레이에는 베일을 씌우지 않습니다. 여는 행위 자체가 이미 피드백이며, 오버레이 위에 다시 덮개를 올리면 이중이 됩니다.
내용이 도착하며 크기가 변하는 것은 높이 트랜지션으로 흡수합니다.
7.3 덧붙는 로딩
무한 스크롤과 목록 추가 조회는 기존 콘텐츠를 유지한 채 하단에 인라인 인디케이터를 둡니다. 베일과 스켈레톤 모두 대상이 아닙니다.
7.4 명령 실행
저장·승인·삭제 같은 명령은 누른 버튼 안에서 진행을 표시합니다. 화면 전체를 덮지 않습니다.
진행 중에는 같은 버튼을 다시 누를 수 없도록 비활성화합니다. 완료 후 조회를 무효화하면 화면 내 갱신 규칙이 적용됩니다.
8. 금지 사항
| 금지 | 사유 |
|---|---|
| 스켈레톤 사용 | 최종 치수를 알 수 없는 화면에서 성립하지 않으며 예약 자체가 유지보수 대상이 됩니다 |
| 화면 내 갱신에 베일 적용 | 조작 대상이 가려지고 연속 조작이 막힙니다 |
| 오버레이에 베일 적용 | 덮개가 이중이 됩니다 |
| 덧붙는 로딩에 베일·스켈레톤 적용 | 기존 콘텐츠를 가립니다 |
| 명령 실행에 전체 화면 덮개 | 피드백 범위가 실제 영향 범위와 어긋납니다 |
| 개별 화면이 베일을 직접 제어 | 시간 정책이 화면마다 달라집니다 |
| 베일을 화면 전체에 적용 | 상단 바와 사이드바가 가려져 대기 중에 이동할 수 없습니다 |
| 화면 교체 구간에 애니메이션 적용 | 완성된 화면이 드러나는 시각을 늦추며, 무엇을 움직일지가 화면마다 판단 대상이 됩니다 |
| 셸이 경로를 보고 베일 자리를 판정 | 셸이 어느 화면인지 알게 되어 골격 소유가 뒤집힙니다 |
| 컴포넌트별로 다른 대기 시간 사용 | 같은 대기가 화면마다 다르게 보입니다 |
| 에러에 최소 유지 시간 적용 | 실패를 더 기다리게 합니다 |
runGuardsAndResolvers 미지정 |
필터를 바꿔도 데이터가 갱신되지 않으며 조용히 실패합니다 |
| 리졸버에서 Promise 반환 | 네비게이션 취소 시 요청이 취소되지 않습니다 |
| 리졸버에서 데이터 변환 수행 | 초기 번들에 계산 로직이 포함됩니다 |