앵귤러 실전 강좌 #1 대시보드 골격 만들기
고급 #7의 마지막에서 예고했던 실전 시리즈입니다. 기초부터 고급까지 도구는 다 만져봤으니, 이제 한 제품의 맥락에 그 도구들을 모아 작은 SaaS 한 채를 처음부터 끝까지 지어봅니다.
이번 시리즈가 만들 것은 **관리자 대시보드(Admin Dashboard)**입니다. 흔하지만 다룰 거리가 풍부한 도메인입니다 — 인증, CRUD, 차트, 폼, 테이블, 상태 관리, 배포까지 한 곳에 모입니다. 6편으로 쪼갭니다.
- #1 대시보드 골격 ← 이번 글
- #2 인증 흐름 (로그인 / 토큰 / 가드 / 인터셉터)
- #3 Product CRUD (Reactive Forms + HttpClient + Resource API)
- #4 Orders 테이블과 차트
- #5 상태 관리 (시그널 store → NgRx Signal Store)
- #6 테스트와 배포
이번 글은 골격입니다. 화면 한 픽셀도 그리기 전에 프로젝트를 만들고, Angular Material을 얹고, 폴더 구조를 정리하고, 메인 레이아웃과 라우팅을 잡고, 첫 화면에 placeholder stat 카드 4개를 띄우는 데까지 갑니다.
만들 앱의 모습 #
전형적인 어드민 구조 — 상단 toolbar, 좌측 sidebar, 우측 메인 영역. 페이지는 다음 다섯입니다.
| 라우트 | 화면 | 시리즈 등장 |
|---|---|---|
/dashboard | 통계 카드 + 차트 | #1 (placeholder) → #4 (차트) |
/products | 상품 목록,생성,수정 | #3 |
/orders | 주문 테이블 | #4 |
/settings | 사용자 / 환경 설정 | #5 |
/login | 로그인 페이지 | #2 |
이번 글에서는 라우트와 빈 컴포넌트만 마련해두고, 살은 다음 글부터 붙입니다.
프로젝트 만들기 #
기초 #2에서 했던 것처럼 ng new로 시작합니다. 다만 옵션을 미리 지정해 질문 화면을 건너뜁니다.
ng new admin-dashboard --routing --style=scss --ssr=false --standalone
cd admin-dashboard플래그의 의미 — --routing은 app.routes.ts를 미리 만들고, --style=scss는 변수,중첩이 가능한 SCSS, --ssr=false는 닫힌 사용자용 어드민이라 SSR이 필요 없으니 꺼두고, --standalone은 Angular 17+의 기본값이지만 명시해둡니다.
Angular Material 셋업 #
UI 라이브러리는 Angular Material을 씁니다. 어드민은 디자인 자체보다 “기능을 빠르게 깔고 일관되게"가 우선이라, 검증된 컴포넌트 모음이 잘 어울립니다.
ng add @angular/materialng add는 npm install과 다른 단계 — schematic을 함께 실행해 셋업까지 알아서 해줍니다. 받게 되는 질문은 셋:
- Choose a prebuilt theme name, or “custom”: —
Azure/Blue같은 무난한 prebuilt 테마. 시리즈 후반에 custom으로 갈아탑니다. - Set up global Angular Material typography styles? —
Yes. - Include the Angular animations module? —
Include and enable animations.
설치가 끝나면 src/styles.scss에 테마 import가, app.config.ts에 provideAnimations()가 자동으로 들어가 있습니다.
폴더 구조 #
빈 프로젝트에 폴더부터 그려둡니다. core / shared / features / layouts 4분할이 어드민에 잘 맞는 표준 구조입니다.
src/app/
├── core/ ← 싱글톤 (auth, api, guards, interceptors)
├── shared/ ← stateless 재사용 조각 (StatCard, dialogs, pipes)
├── features/ ← 도메인별 페이지 묶음
│ ├── dashboard/ products/ orders/ settings/ auth/
├── layouts/ ← MainLayout, AuthLayout
├── app.component.ts
├── app.config.ts
└── app.routes.ts세 폴더의 차이를 한 줄로:
core/— “한 군데서만 만들어져야 하는” 것들. 인증 서비스, API 클라이언트, 인터셉터shared/— 어디서든 가져다 쓰는 stateless 조각. 버튼, 카드, 다이얼로그, 파이프features/— 실제 화면. 한 도메인이 한 폴더, 그 안에 컴포넌트,서비스,모델이 모임
mkdir -p src/app/{core/{services,guards,interceptors},shared/components,features/{dashboard,products,orders,settings,auth},layouts/main-layout}메인 레이아웃 만들기 #
이제 화면의 큰 틀인 MainLayoutComponent. 사이드바 + 툴바 + 메인 영역을 한 번 짜두면, 모든 페이지가 그 틀 안에서 router-outlet으로 갈아끼워집니다.
ng g c layouts/main-layout --change-detection=OnPush --skip-tests--change-detection=OnPush로 고급 #7에서 강조했던 OnPush를 처음부터 깔고, --skip-tests는 일단 spec 파일을 건너뜁니다 (테스트는 #6에서 모아서).
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatListModule } from '@angular/material/list';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
@Component({
selector: 'app-main-layout',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterOutlet, RouterLink, RouterLinkActive,
MatToolbarModule, MatSidenavModule, MatListModule, MatIconModule, MatButtonModule],
templateUrl: './main-layout.component.html',
styleUrl: './main-layout.component.scss',
})
export class MainLayoutComponent {
readonly menu = [
{ label: '대시보드', icon: 'dashboard', link: '/dashboard' },
{ label: '상품', icon: 'inventory_2', link: '/products' },
{ label: '주문', icon: 'receipt_long', link: '/orders' },
{ label: '설정', icon: 'settings', link: '/settings' },
];
readonly opened = signal(true);
toggle() { this.opened.update((v) => !v); }
}imports 배열에 등록한 모듈만 템플릿에서 쓸 수 있습니다. 템플릿은 mat-sidenav-container로 좌,우 영역을 나누고, 좌측에 mat-sidenav(사이드바), 우측에 mat-sidenav-content(메인 영역)을 둡니다.
<mat-toolbar color="primary" class="topbar">
<button mat-icon-button (click)="toggle()"><mat-icon>menu</mat-icon></button>
<span class="title">Admin Dashboard</span>
<span class="spacer"></span>
<button mat-icon-button><mat-icon>notifications</mat-icon></button>
<button mat-icon-button><mat-icon>account_circle</mat-icon></button>
</mat-toolbar>
<mat-sidenav-container class="layout">
<mat-sidenav [opened]="opened()" mode="side" class="sidenav">
<mat-nav-list>
@for (item of menu; track item.link) {
<a mat-list-item [routerLink]="item.link" routerLinkActive="active">
<mat-icon matListItemIcon>{{ item.icon }}</mat-icon>
<span matListItemTitle>{{ item.label }}</span>
</a>
}
</mat-nav-list>
</mat-sidenav>
<mat-sidenav-content class="content">
<router-outlet />
</mat-sidenav-content>
</mat-sidenav-container>스타일은 큰 영역만 잡아둡니다.
:host { display: flex; flex-direction: column; height: 100vh; }
.topbar { position: sticky; top: 0; z-index: 10; }
.topbar .title { font-weight: 500; margin-left: 8px; }
.topbar .spacer { flex: 1 1 auto; }
.layout { flex: 1; background: #f5f5f5; }
.sidenav { width: 240px; border-right: 1px solid rgba(0, 0, 0, 0.08); }
.content { padding: 24px; }
a.active { background: rgba(0, 0, 0, 0.04); font-weight: 600; }
@media (max-width: 768px) {
.sidenav { width: 200px; }
.content { padding: 16px; }
}routerLinkActive="active"가 핵심 — 현재 활성 라우트의 메뉴 항목에 자동으로 active 클래스가 붙어서 강조됩니다. 별도의 “지금 어느 페이지지?” 추적 코드 없이 라우터가 알아서 처리해 줍니다.
라우팅 골격 #
이제 app.routes.ts에 페이지들을 배치합니다. 핵심은 두 가지 — 레이아웃을 부모 라우트로 두는 것과, 각 feature를 lazy loading으로 분리하는 것입니다.
import { Routes } from '@angular/router';
import { MainLayoutComponent } from './layouts/main-layout/main-layout.component';
export const routes: Routes = [
{
path: '',
component: MainLayoutComponent,
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', loadComponent: () => import('./features/dashboard/dashboard.component').then(m => m.DashboardComponent) },
{ path: 'products', loadChildren: () => import('./features/products/products.routes').then(m => m.PRODUCTS_ROUTES) },
{ path: 'orders', loadChildren: () => import('./features/orders/orders.routes').then(m => m.ORDERS_ROUTES) },
{ path: 'settings', loadComponent: () => import('./features/settings/settings.component').then(m => m.SettingsComponent) },
],
},
{ path: 'login', loadComponent: () => import('./features/auth/login.component').then(m => m.LoginComponent) },
{ path: '**', redirectTo: 'dashboard' },
];여기서 두 가지 패턴을 같이 쓰고 있습니다.
loadComponent— 컴포넌트 하나를 lazy로. 단일 페이지에 적합.loadChildren— 자식 라우트 묶음을 lazy로. Products처럼 목록,상세,생성 페이지가 한 묶음으로 가는 도메인에 적합.
products.routes.ts는 자식 라우트만 별도로 정의해둡니다.
import { Routes } from '@angular/router';
export const PRODUCTS_ROUTES: Routes = [
{ path: '', loadComponent: () => import('./product-list.component').then(m => m.ProductListComponent) },
{ path: 'new', loadComponent: () => import('./product-form.component').then(m => m.ProductFormComponent) },
{ path: ':id', loadComponent: () => import('./product-detail.component').then(m => m.ProductDetailComponent) },
];각 feature 컴포넌트는 이번 글에서는 빈 placeholder로만 만들어두면 충분합니다 — 시리즈가 진행되면서 하나씩 채울 부분입니다.
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'app-product-list',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<h2>Products</h2><p>여기에 상품 목록이 들어옵니다 (#3).</p>`,
})
export class ProductListComponent {}루트 AppComponent는 <router-outlet /> 한 줄로 단순하게 둡니다.
첫 화면 — Dashboard Home #
대시보드 홈에는 placeholder 데이터로 stat 카드 4개를 띄워둡니다. 이 영역은 #4에서 차트가 들어올 부분입니다.
먼저 stat 카드를 shared/components로 빼둡니다. 여러 곳에서 재사용될 stateless 조각이기 때문입니다.
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'app-stat-card',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatCardModule, MatIconModule],
template: `
<mat-card>
<mat-card-content>
<div class="row">
<mat-icon>{{ icon() }}</mat-icon>
<div>
<div class="label">{{ label() }}</div>
<div class="value">{{ value() }}</div>
</div>
</div>
<div class="delta" [class.up]="delta() >= 0">
{{ delta() >= 0 ? '▲' : '▼' }} {{ delta() }}%
</div>
</mat-card-content>
</mat-card>
`,
styles: [`
.row { display: flex; gap: 12px; align-items: center; }
.label { font-size: 12px; color: rgba(0,0,0,0.6); }
.value { font-size: 24px; font-weight: 600; }
.delta { margin-top: 8px; font-size: 12px; color: #c62828; }
.delta.up { color: #2e7d32; }
`],
})
export class StatCardComponent {
label = input.required<string>();
value = input.required<string>();
icon = input.required<string>();
delta = input.required<number>();
}input.required<...>()는 고급 #2에서 본 시그널 입력입니다. 부모가 값을 안 주면 컴파일 에러로 잡아 줍니다.
이제 Dashboard 컴포넌트에서 카드 4개를 배치합니다.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { StatCardComponent } from '../../shared/components/stat-card/stat-card.component';
@Component({
selector: 'app-dashboard',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [StatCardComponent],
template: `
<h2>대시보드</h2>
<div class="stats">
<app-stat-card label="오늘 매출" value="₩1,284,000" icon="payments" [delta]="12" />
<app-stat-card label="신규 주문" value="48" icon="receipt_long" [delta]="-3" />
<app-stat-card label="활성 사용자" value="312" icon="group" [delta]="5" />
<app-stat-card label="재고 부족" value="7" icon="warning" [delta]="-20" />
</div>
<section class="placeholder">
<p>여기에 차트가 들어올 영역입니다 (#4).</p>
</section>
`,
styles: [`
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.placeholder {
margin-top: 24px; padding: 48px; background: #fff;
border-radius: 8px; text-align: center; color: rgba(0,0,0,0.6);
}
`],
})
export class DashboardComponent {}grid-template-columns: repeat(auto-fit, minmax(220px, 1fr))가 반응형의 핵심 — 화면 폭에 맞춰 카드가 4열,3열,2열,1열로 자연스럽게 줄어듭니다. 미디어 쿼리 없이 끝나는, 가성비 좋은 그리드 패턴입니다.
사이드바 토글과 동작 확인 #
데스크톱에서는 사이드바가 늘 열려 있고, 모바일에서는 햄버거 버튼으로 토글됩니다. 위 MainLayoutComponent의 opened 시그널이 그 역할 — 버튼을 누르면 opened()가 뒤집히고 [opened]="opened()" 바인딩으로 mat-sidenav가 열고 닫힙니다.
mode="side"는 사이드바가 컨텐츠를 옆으로 밀고, mode="over"는 컨텐츠 위로 떠서 어둡게 덮입니다. 모바일 햄버거 메뉴에서 흔히 보는 그 동작이 over 모드입니다. BreakpointObserver(['(max-width: 768px)'])를 시그널로 받아서 mode를 동적으로 바꾸는 게 표준 패턴인데, 이번 글에선 단순 토글까지만 두고 #5에서 함께 손봅니다.ng serve --open으로 띄워 다음을 확인합니다.
- 상단 툴바에 아이콘들이, 좌측 사이드바에 메뉴 4개가 뜨는가
- 햄버거 버튼을 누르면 사이드바가 열고 닫히는가
- 메뉴 클릭 시 URL이 바뀌고 우측 영역의 placeholder가 갈아끼워지는가
/dashboard에서 stat 카드 4개가 그리드로 보이는가 (창 폭 줄이면 줄어드는가)- 네트워크 탭에서
/products진입 시 별도 청크가 받아지는가 (lazy loading 확인)
마지막 항목이 의외로 중요한 점검 포인트입니다 — loadComponent/loadChildren이 진짜 분리된 청크로 빌드됐는지 눈으로 봐두면, 시리즈 후반에 번들 사이즈를 다룰 때 직관이 쌓여있게 됩니다.
마무리 #
이번 글에서는 어드민 대시보드의 골격을 다졌습니다. ng new로 프로젝트 셋업, Angular Material 추가, core / shared / features / layouts 폴더 구조, MainLayoutComponent (toolbar + sidenav + router-outlet), loadComponent,loadChildren로 lazy 라우팅, 그리고 stat 카드 4개의 첫 화면까지 정리했습니다. 이 골격은 시리즈 내내 거의 변하지 않을 토대고, 다음 글부터 이 위에 살이 붙기 시작합니다.
다음 글 **“앵귤러 실전 강좌 #2 인증 흐름”**에서는 /login 페이지를 만들고, JWT 토큰 저장, 라우트 가드로 인증되지 않은 접근 차단, HTTP 인터셉터로 자동 토큰 첨부와 만료 시 갱신까지 — 인증의 전체 흐름을 한 번에 잡아보겠습니다. 고급 #4에서 본 함수형 가드와 인터셉터 패턴이 실전에서 어떻게 결합되는지 확인할 수 있습니다.