컴포넌트 설계
본 문서는 슬라이스 내부에서 컴포넌트를 나누는 기준, 상태의 소유 위치, 컴포넌트 간 입출력 계약을 정의합니다.
슬라이스 사이의 배치는 패키지 배치와 참조 규칙이 원본입니다. 본 문서는 "어느 폴더로 가는가"가 아니라 "슬라이스 안에서 어떻게 쪼개는가"를 다룹니다.
1. 상태의 소유 위치
그 상태를 사용하는 최소 범위가 소유합니다.
| 사용 범위 | 소유 위치 |
|---|---|
| 컴포넌트 하나 | 그 컴포넌트의 signal |
| 한 슬라이스의 여러 컴포넌트 | 슬라이스의 model 세그먼트 |
| 여러 슬라이스 | 상태가 아니라 서버 상태이거나 URL 상태입니다. 4절을 확인합니다 |
상태를 필요 이상 위로 올리면 그 상태와 무관한 컴포넌트까지 다시 그려집니다. 아래에 두면 형제 컴포넌트가 값을 공유하지 못해 중복 조회가 발생합니다.
파생 값은 computed로 계산하고 별도 signal에 복사하지 않습니다. 복사본은 원본이 바뀔 때 갱신을 잊을 수 있는 지점을 만듭니다.
2. 컴포넌트를 나누는 기준
한 컴포넌트가 커지면 나눕니다. 나누는 기준은 파일 길이가 아니라 변경 이유입니다.
| 나눕니다 | 나누지 않습니다 |
|---|---|
| 서로 다른 이유로 변경되는 영역이 한 파일에 있습니다 | 단순히 템플릿이 깁니다 |
| 같은 표현이 화면 안에서 반복됩니다 | 한 번만 쓰이는데 구조가 복잡합니다 |
| 조건부로 큰 영역이 통째로 교체됩니다 | 조건부 표시가 몇 줄입니다 |
나눈 결과는 같은 슬라이스의 ui/ 세그먼트에 둡니다. 재사용을 예상해 shared/ui로 올리는 것은 금지합니다. 실제로 다른 슬라이스가 쓰기 시작할 때 올립니다.
2.1 표현 컴포넌트의 공개 여부
슬라이스 내부를 위해 만든 컴포넌트는 index.ts로 내보내지 않습니다.
// pages/assessment-list/index.ts
export { AssessmentList } from './ui/assessment-list';
// AssessmentFilter, AssessmentRow 는 내보내지 않습니다내보내면 다른 슬라이스가 사용하기 시작하고, 그 순간 이 컴포넌트는 마음대로 바꿀 수 없게 됩니다. 공개 API는 약속이며 약속은 최소로 유지합니다.
3. 입출력 계약
3.1 입력
readonly assessment = input.required<AssessmentResponse>();
readonly compact = input(false, { transform: booleanAttribute });| 규칙 | 내용 |
|---|---|
| 시그널 입력 사용 | @Input() 데코레이터 대신 input()을 사용합니다 |
필수는 required |
기본값이 의미를 갖지 않는 입력은 input.required로 선언합니다 |
| 입력 개수 상한 | 다섯 개를 넘으면 객체 하나로 묶거나 컴포넌트 분할을 검토합니다 |
| 입력 변형 금지 | 입력값을 컴포넌트 안에서 수정하지 않습니다. 부모가 원본을 소유합니다 |
3.2 출력
readonly approved = output<string>();출력 이름은 일어난 사실을 과거형 또는 명사로 표현합니다. 부모가 무엇을 해야 하는지를 이름에 담지 않습니다.
| 구분 | 준수 지침 (Do) | 금지 지침 (Don't) |
|---|---|---|
| 출력 이름 | approved, selectionChange, dismissed |
onApproveClick, doRefresh, handleClose |
onApproveClick은 부모가 승인 처리를 해야 한다고 지시하는 이름입니다. 자식이 부모의 동작을 규정하면 재사용 가능한 범위가 좁아집니다.
3.3 양방향 바인딩
model()은 값 자체가 컴포넌트의 정체성인 입력 컴포넌트에만 사용합니다.
readonly value = model<string>(''); // 입력 컴포넌트목록이나 화면 단위 컴포넌트에는 사용하지 않습니다. 상태 흐름이 양방향이 되면 어느 쪽이 원본인지 판별할 수 없습니다.
4. 컴포넌트가 하지 않는 것
| 하지 않는 것 | 대신 |
|---|---|
| 서버 상태를 직접 수정 | 변경을 요청하고 조회를 무효화합니다. 서버 상태와 클라이언트 상태 |
| 필터·페이지 상태 보관 | URL 쿼리 파라미터. 라우팅과 네비게이션 |
| 전역 상태 생성 | providedIn: 'root'는 shared·app에서만 허용합니다 |
| DOM 직접 조작 | 템플릿 바인딩. 불가피하면 afterNextRender 안에서 |
| 미디어 쿼리 직접 판정 | shared/lib/adaptive의 의미 신호. 적응형 UI |
shared/ui의 컴포넌트는 추가로 다음을 금지합니다.
- 서버 데이터 조회
- 도메인 규칙 계산
- 라우터 직접 사용
shared는 비즈니스 로직이 없는 인프라이기 때문입니다. shared/ui의 컴포넌트가 조회를 하면 그 순간 특정 도메인에 묶여 다른 프로젝트로 옮길 수 없게 됩니다.
5. 변경 감지
전역 프로바이더에 provideZonelessChangeDetection()을 등록하고 시그널 기반으로 작성합니다.
| 규칙 | 내용 |
|---|---|
| 상태는 시그널로 | 시그널이 아닌 필드를 템플릿에서 읽으면 갱신되지 않을 수 있습니다 |
| 템플릿에서 함수 호출 금지 | {{ calculate() }} 대신 computed를 바인딩합니다 |
ChangeDetectorRef 수동 호출 금지 |
필요하다면 상태가 시그널이 아니라는 뜻입니다 |
RxJS를 사용하는 코드와 경계가 생기면 toSignal로 변환해 컴포넌트 안쪽은 시그널로 통일합니다.
6. 템플릿
| 규칙 | 내용 |
|---|---|
| 제어 흐름 | @if, @for, @switch를 사용합니다. *ngIf, *ngFor는 금지합니다 |
@for의 track |
필수입니다. 안정된 식별자를 지정하고 인덱스는 목록이 재정렬되지 않는 경우에만 사용합니다 |
| 빈 상태 | @empty 블록으로 명시합니다 |
| 템플릿 길이 | 화면 하나가 200줄을 넘으면 2절의 기준으로 분할을 검토합니다 |
| 로직 배치 | 조건식이 두 항을 넘으면 computed로 옮기고 템플릿에는 이름을 둡니다 |
@for (item of items(); track item.id) {
<app-assessment-row [assessment]="item" />
} @empty {
<p>등록된 평가표가 없습니다.</p>
}7. 컴포넌트 파일 구성
템플릿과 스타일은 별도 파일로 분리합니다. 인라인 템플릿은 20줄 이하일 때만 허용합니다.
ui/
├── assessment-list.ts
├── assessment-list.html
└── assessment-list.css.css 파일은 필요할 때만 만듭니다. 대부분 Tailwind 유틸리티로 해결합니다.
스타일 파일이 생겼다면 디자인 토큰으로 표현할 수 없는 것이 있다는 뜻이므로, 디자인 시스템과 토큰의 토큰 추가를 먼저 검토합니다.