본문으로 건너뛰기

컴포넌트 설계

본 문서는 슬라이스 내부에서 컴포넌트를 나누는 기준, 상태의 소유 위치, 컴포넌트 간 입출력 계약을 정의합니다.

슬라이스 사이의 배치는 패키지 배치와 참조 규칙이 원본입니다. 본 문서는 "어느 폴더로 가는가"가 아니라 "슬라이스 안에서 어떻게 쪼개는가"를 다룹니다.

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는 금지합니다
@fortrack 필수입니다. 안정된 식별자를 지정하고 인덱스는 목록이 재정렬되지 않는 경우에만 사용합니다
빈 상태 @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 유틸리티로 해결합니다.

스타일 파일이 생겼다면 디자인 토큰으로 표현할 수 없는 것이 있다는 뜻이므로, 디자인 시스템과 토큰의 토큰 추가를 먼저 검토합니다.

© 2026 dev.goraebap