서버 상태와 클라이언트 상태
본 문서는 화면이 다루는 데이터를 종류별로 나누고, 각각을 어느 계층이 소유하며 어떻게 갱신·무효화하는지를 정의합니다.
1. 상태의 세 종류
상태는 원본이 어디에 있는가로 구분합니다. 이 구분이 저장 위치와 갱신 방식을 결정합니다.
| 종류 | 원본 | 화면이 가진 것 | 저장 위치 |
|---|---|---|---|
| 서버 상태 | 서버 | 사본. 언제든 낡을 수 있습니다 | 조회 지점의 api 세그먼트 |
| 클라이언트 상태 | 화면 | 원본 | 해당 슬라이스의 model 세그먼트 |
| URL 상태 | 브라우저 히스토리 | 원본 | Angular Router |
서버 상태를 클라이언트 상태처럼 다루는 것이 가장 흔한 오류입니다. 조회 결과를 signal에 담아 두고 그것을 원본처럼 수정하면, 서버와 화면이 어긋난 사실을 아무도 알 수 없게 됩니다.
| 구분 | 준수 지침 (Do) | 금지 지침 (Don't) |
|---|---|---|
| 서버 상태 갱신 | 서버에 변경을 요청하고 조회를 다시 실행합니다 | 응답 사본을 직접 수정해 화면만 바꿉니다 |
| 파생 값 | computed로 계산합니다 |
별도 signal에 복사해 둡니다 |
| 필터 · 정렬 · 페이지 | URL 쿼리 파라미터에 둡니다 | 컴포넌트 signal에 둡니다 |
2. 서버 상태 조회
2.1 두 가지 수단
조회 수단은 화면 진입에 필수인가로 갈립니다. 이 판정 하나만 하면 됩니다.
| 조건 | 수단 | 대기 표현 |
|---|---|---|
| 화면 진입에 필수 | 라우트 리졸버 (ResolveFn) |
베일 또는 인디케이터. 라우터 층이 담당 |
| 필수가 아님 | httpResource |
해당 영역 안에서 표시 |
리졸버는 데이터를 받은 뒤 화면을 그리므로 반쯤 채워진 화면이 나타나지 않습니다. 세부 규칙과 층별 적용 경계는 로딩 전략이 원본입니다.
// 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 },
});// pages/assessment-detail/api/comments.ts — 접힌 패널을 펼칠 때 조회
export function injectComments(id: Signal<string>) {
return httpResource<CommentListResponse>(() => `/api/assessments/${id()}/comments`);
}외부 상태 라이브러리 도입은 5절의 조건을 충족할 때만 검토합니다.
2.2 리졸버 결과의 수신
withComponentInputBinding()을 활성화하면 리졸버 결과가 컴포넌트 입력 시그널로 들어옵니다.
export class AssessmentList {
readonly assessments = input.required<AssessmentListResponse>();
}ActivatedRoute를 주입해 data를 구독하는 방식보다 이쪽을 사용합니다. 시그널이므로 computed와 그대로 결합됩니다.
필터를 바꿔 리졸버가 다시 도는 동안 이전 데이터가 화면에 그대로 남아 있습니다. 라우터가 같은 라우트 설정으로 이동할 때 컴포넌트 인스턴스를 재사용하기 때문이며, 이전 목록을 보관하는 코드를 작성할 필요가 없습니다.
2.3 httpResource의 상태 해석
ResourceStatus는 여섯 값을 가집니다.
| 값 | 의미 | 화면 처리 |
|---|---|---|
idle |
요청이 아직 시작되지 않음 | 아무것도 표시하지 않습니다 |
loading |
최초 조회 또는 요청 파라미터 변경 | 해당 영역에 인디케이터 |
reloading |
reload() 호출로 재조회 중 |
이전 값을 유지한 채 갱신 표시만 겹칩니다 |
resolved |
조회 완료 | 값을 표시합니다 |
error |
조회 실패 | 에러 표시. 처리 규칙은 예외 · 에러 표시 · 로깅 |
local |
로컬에서 값을 설정함 |
loading과 reloading을 구분해서 다루어야 합니다. 둘을 같은 분기로 처리하면 재조회 때마다 영역이 비었다가 다시 그려집니다.
스켈레톤은 사용하지 않습니다. 근거는 ADR-0011에 있습니다.
2.4 조회의 소유 위치
조회 함수의 배치는 패키지 배치와 참조 규칙 3절의 판정 트리를 따릅니다. 결과만 옮기면 다음과 같습니다.
| 조회의 성격 | 위치 |
|---|---|
| 한 화면의 진입 필수 데이터 (리졸버) | pages/<화면>/api/. 슬라이스 공개 API로 내보냅니다 |
| 한 화면에서만 쓰는 보조 조회 | pages/<화면>/api/ |
| 두 화면 이상에서 동일한 형태로 사용 | entities/<도메인>/api/ |
| 도메인 무관한 CRUD 헬퍼 | shared/api/ |
리졸버는 app/app.routes.ts가 임포트하므로 슬라이스 공개 API에 포함됩니다. 다만 리졸버는 지연 로딩되지 않아 초기 번들에 들어가므로 HTTP 호출만 담고 변환 로직을 넣지 않습니다.
같은 화면 안의 컴포넌트 둘이 같은 데이터를 필요로 하면 각각 조회하지 않습니다. 리졸버가 받은 데이터를 입력으로 전달합니다. httpResource는 요청 중복 제거를 제공하지 않으므로 인스턴스 두 개는 요청 두 번을 뜻합니다.
3. 명령과 무효화
3.1 명령
데이터를 변경하는 요청은 httpResource를 쓰지 않습니다. httpResource는 읽기 전용이며 요청이 바뀔 때 진행 중인 작업을 중단하므로, 변경 요청에 사용하면 중도 취소될 수 있습니다.
변경은 HttpClient를 직접 사용하고, 완료 후 관련 조회를 무효화합니다.
// features/assessment-approve/api/approve.ts
export async function approveAssessment(id: string): Promise<void> {
await lastValueFrom(inject(HttpClient).post<void>(`/api/assessments/${id}/approve`, {}));
inject(InvalidationBus).emit('risk-assessment');
}3.2 무효화 신호
변경을 수행하는 곳(features)과 조회를 소유하는 곳(pages)은 계층이 달라 서로를 임포트할 수 없습니다. features가 pages를 참조하는 것은 상위 계층 참조이므로 금지입니다.
이를 해소하기 위해 무효화 신호를 shared에 둡니다. 신호는 문자열 키이므로 계층 참조가 발생하지 않습니다.
// shared/api/invalidation.ts
@Injectable({ providedIn: 'root' })
export class InvalidationBus {
private readonly ticks = signal(new Map<string, number>());
emit(key: string): void {
this.ticks.update((m) => new Map(m).set(key, (m.get(key) ?? 0) + 1));
}
tick(key: string): Signal<number> {
return computed(() => this.ticks().get(key) ?? 0);
}
}조회 측은 이 신호를 요청 계산식에 포함시켜 구독합니다.
const tick = inject(InvalidationBus).tick('risk-assessment');
const list = httpResource<AssessmentListResponse>(() => {
tick(); // 신호가 바뀌면 재요청합니다
return `/api/assessments?page=${page()}`;
});무효화 키는 도메인 단위로 정의하고 shared/api/invalidation-keys.ts에 상수로 모읍니다. 문자열을 호출부에 흩뿌리면 오타를 검출할 수 없습니다.
3.3 낙관적 업데이트
기본적으로 사용하지 않습니다. 서버 응답을 기다린 뒤 조회를 무효화하는 것이 기본 흐름입니다.
낙관적 업데이트는 실패 시 되돌리는 경로와 그 사이에 발생한 다른 변경을 조정하는 경로를 함께 만들어야 합니다. 그 복잡도를 감수할 근거가 측정으로 확인된 화면에서만 적용하며, 적용 시 ADR로 사유를 남깁니다.
4. 클라이언트 상태와 URL 상태
4.1 클라이언트 상태
화면에서만 의미를 갖는 값입니다. 선택된 탭, 열린 아코디언, 편집 중인 임시 입력값이 해당합니다.
그 값을 사용하는 최소 범위가 소유합니다. 컴포넌트 하나만 쓰면 그 컴포넌트의 signal이고, 슬라이스 여러 컴포넌트가 쓰면 슬라이스의 model 세그먼트입니다. 전역 스토어에 올리는 것은 금지합니다.
4.2 URL 상태
다음은 반드시 URL 쿼리 파라미터에 둡니다.
- 목록의 필터 조건
- 정렬 기준과 방향
- 페이지 번호와 크기
- 탭이나 단계 중 현재 위치가 공유·북마크되어야 하는 것
새로고침·뒤로가기·링크 공유가 별도 구현 없이 동작하며, 상태 복원 코드를 작성할 필요가 없습니다.
세부 규칙은 라우팅과 네비게이션이 원본입니다.
5. 외부 상태 라이브러리 도입 조건
TanStack Query 등 서버 상태 라이브러리는 다음이 측정으로 확인된 뒤에만 검토합니다.
| 조건 | 확인 방법 |
|---|---|
| 동일 데이터의 중복 요청이 실제로 발생함 | 네트워크 탭에서 같은 요청이 반복되는지 확인 |
| 화면 전환 시 재조회 지연이 사용자에게 체감됨 | 목록에서 상세로 갔다 돌아올 때의 지연 측정 |
| 무효화 대상이 도메인 키로 표현하기 어려울 만큼 복잡해짐 | 무효화 키가 열 개를 넘고 상호 의존이 생김 |
도입을 결정하면 ADR로 기록합니다. @tanstack/angular-query-experimental은 정식 명칭 패키지가 없는 실험 단계이며 마이너·패치에서도 파괴적 변경이 예고되어 있으므로, 도입 시 패치 수준까지 버전을 고정합니다.
6. 금지 사항
| 금지 | 사유 |
|---|---|
조회 결과를 signal에 복사해 직접 수정 |
서버와 화면의 불일치를 검출할 수 없습니다 |
httpResource로 변경 요청 수행 |
읽기 전용이며 진행 중 요청을 중단합니다 |
| 같은 화면에서 같은 데이터를 두 번 조회 | 요청 중복 제거가 없어 그대로 두 번 나갑니다 |
화면 진입 필수 데이터를 httpResource로 조회 |
반쯤 채워진 화면이 노출되고 레이아웃이 이동합니다 |
| 리졸버에 진입 필수가 아닌 데이터 포함 | 첫 표시가 가장 느린 요청에 묶입니다 |
| 리졸버에서 데이터 변환 수행 | 초기 번들에 계산 로직이 포함됩니다 |
| 무효화 키 문자열을 호출부에 직접 기재 | 오타를 검출할 수 없습니다 |
필터·페이지 상태를 컴포넌트 signal에 보관 |
새로고침과 뒤로가기에서 소실됩니다 |
| 전역 스토어에 화면 전용 상태 보관 | 화면을 떠나도 상태가 남습니다 |