레이아웃
본 문서는 화면 골격의 종류와 구조, 스크롤 컨테이너의 위치, 그리고 상태 화면이 놓이는 자리를 정의합니다.
레이아웃은 app 계층이 소유합니다. pages 슬라이스는 자신이 어느 레이아웃 안에 놓이는지 알지 못합니다.
1. 레이아웃 종류
| 레이아웃 | 용도 | 구조 | 골격 축 |
|---|---|---|---|
| 랜딩 | 공개 소개·마케팅 화면 | 헤더 + 전폭 섹션 + 푸터 | 없음 |
| 인증 | 로그인, 회원가입, 비밀번호 재설정 | 중앙 정렬 단일 열, 최소 크롬 | 없음 |
| 앱셸 | 인증 후 업무 화면 | 네비게이션 + 콘텐츠 영역 | 있음 |
| 문서형 | 문서, 도움말, 블로그 | 목차 + 읽기 폭 본문 | 있음 |
랜딩과 인증은 네비게이션 구조가 없거나 최소이므로 골격 축을 갖지 않습니다. "인증 화면의 골격은 무엇인가" 같은 질문이 성립하지 않습니다.
2. 골격과 표면
앱셸과 문서형만 두 축을 가집니다.
2.1 골격 (frame)
| 골격 | 구조 | 스크롤 컨테이너 |
|---|---|---|
| topbar | 헤더가 전폭을 차지하고 그 아래에 네비게이션과 본문이 놓입니다 | 문서 전체. 스크롤바가 뷰포트 오른쪽 끝에 붙습니다 |
| sidebar | 사이드바가 전고를 차지하고 헤더가 콘텐츠 열 안에 놓입니다 | 콘텐츠 박스. 스크롤바가 콘텐츠 영역 안쪽에 생깁니다 |
골격의 실질적 차이는 스크롤 컨테이너의 위치입니다. 시각적 배치가 아니라 이 차이가 골격을 두 개로 나누는 근거입니다.
문서형은 topbar, 앱셸은 sidebar가 관례적 기본값이나 강제하지 않습니다.
2.2 표면 (surface)
| 표면 | 표현 |
|---|---|
| bordered | 콘텐츠와 네비게이션이 경계선으로 구분됩니다 |
| inset | 콘텐츠가 여백 안쪽에 놓여 배경이 둘레로 보입니다 |
topbar 골격은 bordered로 고정합니다. 문서 전체가 스크롤되는 구조에서 inset을 적용하면 콘텐츠 둘레의 배경이 스크롤과 함께 움직여 표면 경계가 스크롤 영역과 어긋납니다.
표면 선택은 sidebar 골격에서만 유효합니다.
3. 구조 분기와 페인트 분기
변형을 마크업으로 나눌지 CSS 변수로 처리할지의 판정 기준입니다.
스크롤 컨테이너의 위치가 달라지는가?
예 → 구조. 템플릿 분기
아니오 → 색·경계·여백만 달라지는가?
예 → 페인트. 루트 data 속성 + CSS 변수| 축 | 구현 |
|---|---|
| 골격 | 레이아웃 컴포넌트의 템플릿 분기 |
| 표면 | 루트 data-surface 속성 + CSS 변수 |
스크롤 containment는 어느 요소가 overflow를 갖는가의 문제이므로 CSS 변수로 전환할 수 없습니다. 반대로 색과 경계는 마크업을 바꾸지 않고 전환하는 편이 저렴합니다.
분기하는 것은 골격이지 내용물이 아닙니다. 네비게이션 항목, 헤더 컨트롤, 본문 슬롯은 두 템플릿이 공유합니다.
3.1 호출부는 골격을 모릅니다
라우트는 레이아웃 컴포넌트 하나만 참조하며 그것이 어느 골격으로 렌더링되는지 알지 못합니다.
app/layout/
├── app-shell.ts
├── app-shell-topbar.ts
└── app-shell-sidebar.tsapp-shell.ts만 공개하며 내부에서 골격을 판정해 나머지 둘로 분기합니다. app-shell-topbar.ts와 app-shell-sidebar.ts는 비공개입니다.
적응형 UI의 패턴 B와 동일한 구조입니다. 두 문서가 같은 원리를 공유하므로 규칙을 따로 익힐 필요가 없습니다.
3.1.1 화면에 고정되는 조작
프레임이 자기 이동 수단을 화면에 고정해야 하는 경우가 있습니다. 문서 영역의 목록 열기가 그렇습니다. 본문 안에 두면 아래로 스크롤한 뒤 화면 밖으로 나가 이동 수단이 사라집니다.
띄우는 요소의 위치는 그것을 소유한 프레임이 정합니다. 셸이 경로를 보고 그리면 셸이 어느 화면인지 알게 되어 3.1절이 뒤집힙니다.
고정 막대 위에 놓는 경우 막대의 높이를 다시 적지 않습니다. 막대 자신과 본문의 아래 여백, 그리고 그 위에 놓이는 요소가 같은 토큰을 읽습니다. 세 곳에 숫자를 적어 두면 하나를 고칠 때 나머지가 어긋나며, 그 어긋남은 그 폭에서만 드러나 발견이 늦습니다.
3.2 골격 선택의 저장
사용자가 골격이나 표면을 전환할 수 있게 한다면 선택값을 localStorage에 저장하고 첫 페인트 전에 복원합니다. 복원이 늦으면 기본 골격이 잠시 보였다가 바뀝니다.
정적 생성 경로에서는 서버가 사용자 선택을 알 수 없습니다. 해당 경로에서는 전환 기능을 제공하지 않거나, 기본값으로 렌더링한 뒤 하이드레이션 이후에 적용합니다. 상세는 렌더링 전략에 있습니다.
4. 스크롤
4.1 좌우 흔들림 방지
화면마다 스크롤 유무가 달라 콘텐츠 폭이 변하면 페이지 전환 시 레이아웃이 좌우로 흔들립니다.
html { scrollbar-gutter: stable; }스크롤 여부와 무관하게 스크롤바 자리를 확보합니다. 내부 스크롤 컨테이너에도 동일하게 적용합니다.
overflow-y: scroll로 스크롤바를 상시 표시하는 방식은 사용하지 않습니다. 스크롤이 필요 없는 화면에도 빈 트랙이 보이고, 오버레이 스크롤바를 쓰는 환경에서는 효과가 없습니다.
4.2 flex 콘텐츠 열의 붕괴
sidebar 골격에서 콘텐츠 열이 flex 자식이면 내용이 넘칠 때 열이 밀려 나갑니다. flex 자식의 기본 min-width가 auto라 콘텐츠보다 작아지지 못하기 때문입니다.
.content-column { min-width: 0; }콘텐츠 열에 min-width: 0을 필수로 지정합니다. grid 레이아웃에서는 minmax(0, 1fr)을 사용합니다.
이 지정을 빠뜨리면 긴 표나 코드 블록이 있는 화면에서만 열이 밀리므로 발견이 늦습니다.
4.3 스크롤 위치 복원
provideRouter(
routes,
withInMemoryScrolling({ scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled' }),
)주의
이 설정은 문서 스크롤만 대상으로 합니다
sidebar 골격처럼 내부 박스가 스크롤되는 구조에서는 라우터의 위치 복원이 동작하지 않습니다. 사용자가 목록을 스크롤해 상세로 들어갔다 돌아오면 맨 위로 올라갑니다. 내부 스크롤을 선택한 레이아웃은 스크롤 위치 저장과 복원을 직접 구현해야 하며, 이 비용은 골격 선택의 대가입니다.
sticky 헤더가 있으면 ViewportScroller.setOffset()을 함께 지정합니다. 라우터의 앵커 이동은 scroll-margin-top을 따르지 않습니다. ViewportScroller가 scrollIntoView() 대신 좌표를 계산해 scrollTo()를 호출하므로 CSS 가 닿지 않는 경로입니다. 지정하지 않으면 앵커로 이동했을 때 대상 제목이 헤더에 가립니다.
// 기준 배율이 뷰포트 폭에 따라 달라지므로 고정값이 아니라 함수로 넘깁니다.
inject(ViewportScroller).setOffset(() => [0, headerOffsetInPx()]);scroll-margin-top 도 함께 유지합니다. 주소창에 프래그먼트를 직접 입력해 새로 여는 경우는 브라우저 기본 동작이며 그쪽은 CSS 를 따릅니다.
4.4 스크롤 잠금
오버레이가 열릴 때 배경 스크롤을 막습니다. CDK의 BlockScrollStrategy를 사용합니다.
body { overflow: hidden }을 직접 설정하는 것을 금지합니다. 스크롤바가 사라지며 레이아웃이 흔들리고, 오버레이가 중첩될 때 복원 시점이 어긋나 스크롤이 잠긴 채로 남습니다.
같은 어긋남은 직접 잠그지 않아도 재현됩니다. BlockScrollStrategy는 문서를 고정할 때 위치를 저장하고 닫을 때 되돌리므로, 오버레이 안의 링크로 다른 화면에 가면 라우터가 상단으로 보낸 뒤 오버레이가 닫히면서 이전 위치로 복원되어 새 화면이 중간부터 보입니다. 오버레이 안에 화면 이동 수단을 두는 경우 이동 전에 오버레이를 먼저 닫습니다.
해제 시점을 앞지르려는 시도는 취약합니다. 오버레이가 닫힘 애니메이션을 마친 뒤 정리되므로 지연을 얼마로 잡아도 경쟁이 남습니다. 이동 수단을 품는 오버레이는 스크롤 전략을
reposition으로 지정해 잠금 자체를 두지 않습니다. 배경이 스크롤되는 대가를 받아들이는 것이며, 화면 대부분을 덮는 시트에서는 체감이 작습니다.
4.5 sticky 요소
position: sticky는 가장 가까운 스크롤 컨테이너를 기준으로 동작합니다. 골격에 따라 기준이 달라지므로 같은 컴포넌트가 topbar에서는 고정되고 sidebar에서는 고정되지 않는 상황이 발생합니다.
sticky를 사용할 때 그 요소가 어느 스크롤 컨테이너 안에 있는지 확인합니다. 두 골격을 모두 지원해야 하면 각 골격 템플릿에서 sticky 대상을 명시합니다.
4.6 오버레이 호스트의 배치
오버레이 컴포넌트는 내용과 호스트의 위치가 다릅니다. 내용은 CDK 가 body 최상위에 렌더하지만 <hlm-sheet> 같은 호스트 태그는 템플릿에 쓴 자리에 남습니다.
호스트를 flex 나 grid 컨테이너 안에 두는 것을 금지합니다. 너비가 0이어도 아이템으로 계산되어 gap 이 하나 더 생기고 옆 요소가 밀립니다. 트리거 버튼만 컨테이너 안에 두고 오버레이 호스트는 밖에 배치합니다.
@defer 와 함께 쓰면 발견이 늦습니다. 로드 전에는 호스트가 없다가 한 번 열린 뒤부터 밀리므로, 처음에는 정상으로 보이고 새로고침하면 다시 정상이 됩니다.
4.7 현재 위치 표시
목차처럼 읽고 있는 절을 표시하는 장치는 헤딩의 위치를 기준선과 비교해 판정합니다. 기준선을 지난 것 중 가장 아래를 고릅니다.
IntersectionObserver 로 헤딩만 관찰하는 방식은 두 경우에 실패합니다.
| 실패 조건 | 결과 |
|---|---|
| 절 사이의 내용이 화면보다 김 | 어떤 헤딩도 교차하지 않아 표시가 사라집니다 |
| 마지막 절 | 문서 끝에서는 화면 상단까지 올라올 수 없어 영영 선택되지 않습니다 |
두 번째는 위치 비교로도 풀리지 않으므로 문서 끝에 닿았는지를 별도 조건으로 판정해 마지막 절을 선택합니다.
목차를 눌렀을 때는 위치 계산을 기다리지 않고 프래그먼트를 그대로 표시에 반영합니다. 앵커로 이동하면 헤딩이 기준선과 같은 위치에 놓이는데 서브픽셀 반올림으로 몇 px 어긋나며, 그대로 두면 방금 이동한 절이 아니라 그 앞 절이 선택됩니다. 위치 비교에도 몇 px 의 여유를 둡니다.
5. 상태 화면
5.1 전체 화면 에러
401 · 403 · 404 · 500은 전체 화면 에러로 통일합니다. 앱셸의 네비게이션을 유지하지 않습니다.
셸 유지 여부를 상황마다 판정하지 않는 이유는 판정할 수 없는 경우가 있기 때문입니다. 401 세션 만료나 부트스트랩 실패에서는 네비게이션 항목을 조회할 수단 자체가 없습니다. 유지 가능한 경우와 불가능한 경우를 나누면 에러마다 판정이 들어가고, 판정이 들어가는 규칙은 배치 예측 가능성을 떨어뜨립니다.
전체 화면 에러는 다음을 필수로 갖습니다.
| 요소 | 내용 |
|---|---|
| 상황 설명 | 무엇이 일어났는지. 상태 코드를 그대로 노출하지 않습니다 |
| 복구 액션 | 홈으로 이동, 이전 화면으로, 재시도 중 상황에 맞는 것 |
| 추적 식별자 | 문의 시 로그를 찾을 수 있는 값 |
복구 액션이 없으면 사용자에게 남는 수단은 브라우저 뒤로가기뿐입니다. 셸을 유지하지 않기로 한 결정은 화면 안에 복구 경로를 두는 것을 전제로 성립합니다.
메시지 문구와 에러 분류는 예외 · 에러 표시 · 로깅이 원본입니다.
5.2 화면을 대체하지 않는 알림
다음은 에러 화면으로 전환하지 않고 현재 화면 위에 배너로 표시합니다.
| 상황 | 이유 |
|---|---|
| 네트워크 연결 끊김 | 화면의 기존 내용은 여전히 유효합니다 |
| 세션 곧 만료 | 작업 중인 내용을 잃지 않게 해야 합니다 |
| 점검 예정 공지 | 아직 사용할 수 있습니다 |
배너는 레이아웃의 고정 슬롯을 차지하며 콘텐츠를 밀어냅니다. 콘텐츠 위에 겹치면 하단 내용이 가려집니다.
5.3 점검 중
애플리케이션이 뜨지 않는 상태이므로 레이아웃 컴포넌트를 거치지 않습니다. 정적 HTML로 제공하며 인프라 계층에서 응답합니다.
프론트엔드 애플리케이션 안에 점검 화면 라우트를 두면, 정작 애플리케이션이 배포되지 않은 상황에서 그 화면을 보여줄 수 없습니다.
5.4 빈 상태
빈 상태는 에러가 아닙니다. 정상 응답이며 콘텐츠 영역 안에 표시합니다.
| 구분 | 빈 상태 | 에러 |
|---|---|---|
| 의미 | 데이터가 없음 | 데이터를 가져오지 못함 |
| 위치 | 콘텐츠 영역 내부 | 전체 화면 |
| 다음 행동 | 데이터를 만드는 동작 안내 | 복구 액션 |
조회 실패를 빈 상태로 표시하는 것을 금지합니다. 사용자가 데이터가 없는 것으로 오해합니다.
빈 상태 삽화는 public/ 의 단색 선화를 CSS 마스크로 얹고 색을 토큰에서 가져갑니다. <img> 로 넣으면 파일에 박힌 색이 그대로 나와 모드마다 사본을 두어야 하고, 인라인으로 넣으면 경로 수백 개가 DOM 에 들어갑니다. 마스크는 알파 채널만 읽으므로 파일은 한 벌이면 됩니다.
/* 색은 배경색 유틸리티가 정하므로 라이트와 다크가 자동으로 갈립니다. */
.empty-art {
display: block;
-webkit-mask-image: url('/empty.svg');
mask-image: url('/empty.svg');
-webkit-mask-size: contain;
mask-size: contain;
}삽화 파일은 width 와 height 를 갖지 않고 viewBox 만 둡니다. 고유 크기가 있으면 사용처의 크기 지정과 어긋납니다.
5.5 로딩
| 상황 | 표시 위치 |
|---|---|
| 초기 부트스트랩 | index.html의 정적 마크업. 앱이 뜨기 전이므로 컴포넌트를 쓸 수 없습니다 |
| 라우트 전환 | 레이아웃의 콘텐츠 슬롯 |
| 화면 내 영역 조회 | 해당 영역 |
6. 인쇄
업무 시스템은 화면을 지면으로 출력하는 요구가 발생합니다. 화면 골격을 그대로 인쇄하면 네비게이션이 지면을 차지하고 내부 스크롤 영역이 잘립니다.
@media print {
.app-nav, .app-header, .app-actions { display: none; }
.content-column { overflow: visible; height: auto; }
}| 규칙 | 내용 |
|---|---|
| 크롬 제거 | 네비게이션, 헤더, 조작 버튼을 숨깁니다 |
| 스크롤 해제 | 내부 스크롤 컨테이너의 overflow와 높이 제한을 해제합니다. 해제하지 않으면 첫 화면 분량만 인쇄됩니다 |
| 페이지 나눔 | 표의 행이나 카드가 잘리지 않도록 break-inside: avoid를 지정합니다 |
| 배경 의존 금지 | 배경색·배경 이미지는 기본적으로 인쇄되지 않습니다. 의미를 배경으로만 전달하지 않습니다 |
| 링크 | 필요하면 a[href]::after로 URL을 노출합니다 |
인쇄 스타일은 레이아웃 컴포넌트가 소유합니다. 개별 화면이 각자 정의하면 규칙이 흩어집니다.
7. 적응형 전환
화면이 좁거나 터치 환경일 때 앱셸의 네비게이션 형태가 바뀝니다.
| 상호작용 모드 | 네비게이션 |
|---|---|
pointer |
사이드바 상시 표시 또는 접힘 |
touch |
드로어 또는 하단 탭 |
판정 축과 구현 패턴은 적응형 UI가 원본입니다. 레이아웃 전환은 DOM 구조가 달라지므로 패턴 B(표현 컴포넌트 교체)에 해당합니다.
정적 생성 경로에서는 적응형 전환을 사용할 수 없으므로, 랜딩과 문서형이 정적 생성 대상이면 CSS 미디어 쿼리로 해결되는 반응형만 사용합니다.
7.1 좁은 화면의 이동 수단
본 사이트는 전 경로가 정적 생성 대상이므로 기기 판정을 쓸 수 없습니다. 상단과 하단을 모두 렌더하고 md 를 경계로 CSS 가 한쪽만 표시합니다. DOM 이 같고 배치만 달라지므로 반응형입니다.
| 자리 | md 미만 |
md 이상 |
|---|---|---|
| 상단 바 | 로고와 테마 전환만 표시합니다 | 로고 · 메뉴 · 검색 · 테마 전환 |
| 하단 막대 | 메뉴와 검색을 균등한 칸으로 표시합니다 | 표시하지 않습니다 |
두 자리가 같은 shared/config 의 메뉴 정의를 읽습니다. 각자 배열을 가지면 한쪽에만 항목을 추가했을 때 그 기기에서 갈 길이 사라집니다.
하단 막대는 고정 요소이므로 셸이 막대 높이와 env(safe-area-inset-bottom) 을 더한 값을 아래 여백으로 확보합니다. 확보하지 않으면 문서 끝과 푸터가 막대 아래로 들어갑니다.
띄우는 버튼(FAB)을 두지 않습니다. 스크롤 중 본문을 가리고 막대 높이를 두 곳에서 맞춰야 하며, 기각 사유는 ADR-0017에 있습니다.
8. FSD 배치
app/
├── layout/
│ ├── app-shell.ts
│ ├── app-shell-topbar.ts
│ ├── app-shell-sidebar.ts
│ ├── landing-layout.ts
│ ├── auth-layout.ts
│ ├── docs-layout.ts
│ └── error-page.ts
└── app.routes.ts| 파일 | 공개 여부 | 역할 |
|---|---|---|
app-shell.ts |
공개 | 골격을 판정해 분기합니다 |
app-shell-topbar.ts |
비공개 | topbar 골격의 구현 |
app-shell-sidebar.ts |
비공개 | sidebar 골격의 구현 |
error-page.ts |
공개 | 전체 화면 에러 |
app/ui/ 폴더명은 Steiger의 no-ui-in-app 규칙이 차단하므로 app/layout/을 사용합니다.
라우트는 부모에 레이아웃을, 자식에 화면을 배치합니다.
{
path: '',
component: AppShell,
children: [
{ path: 'assessments', loadComponent: () => import('@/pages/assessment-list').then((m) => m.AssessmentList) },
],
}레이아웃 컴포넌트는 pages를 직접 임포트하지 않습니다. 라우터가 router-outlet으로 주입하므로 레이아웃과 화면 사이에 의존이 생기지 않습니다.
9. 금지 사항
| 금지 | 사유 |
|---|---|
pages 슬라이스가 자신의 레이아웃을 결정 |
레이아웃 소유가 app에서 흩어집니다 |
| 라우트가 골격을 직접 지정 | 호출부가 구조를 알게 되어 골격 변경 시 라우트를 전부 수정합니다 |
overflow-y: scroll로 흔들림 방지 |
불필요한 빈 트랙이 보이고 오버레이 스크롤바 환경에서 무효합니다 |
flex·grid 콘텐츠 열에 min-width: 0 누락 |
긴 내용이 있는 화면에서만 열이 밀려 발견이 늦습니다 |
body { overflow: hidden } 직접 설정 |
레이아웃이 흔들리고 중첩 오버레이에서 복원이 어긋납니다 |
| 에러 화면에 복구 액션 미제공 | 셸을 유지하지 않는 결정의 전제가 무너집니다 |
| 조회 실패를 빈 상태로 표시 | 사용자가 데이터 없음으로 오해합니다 |
| 점검 화면을 애플리케이션 라우트로 구현 | 애플리케이션이 뜨지 않는 상황에서 보여줄 수 없습니다 |
| 개별 화면이 인쇄 스타일을 정의 | 규칙이 흩어져 일관성이 깨집니다 |
| 인쇄 시 내부 스크롤 해제 누락 | 첫 화면 분량만 인쇄됩니다 |