앵귤러 실전 강좌 #1 대시보드 골격 만들기

9 분 소요

고급 #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

플래그의 의미 — --routingapp.routes.ts를 미리 만들고, --style=scss는 변수,중첩이 가능한 SCSS, --ssr=false는 닫힌 사용자용 어드민이라 SSR이 필요 없으니 꺼두고, --standalone은 Angular 17+의 기본값이지만 명시해둡니다.

Angular Material 셋업 #

UI 라이브러리는 Angular Material을 씁니다. 어드민은 디자인 자체보다 “기능을 빠르게 깔고 일관되게"가 우선이라, 검증된 컴포넌트 모음이 잘 어울립니다.

Angular Material 설치
ng add @angular/material

ng addnpm install과 다른 단계 — schematic을 함께 실행해 셋업까지 알아서 해줍니다. 받게 되는 질문은 셋:

  1. Choose a prebuilt theme name, or “custom”:Azure/Blue 같은 무난한 prebuilt 테마. 시리즈 후반에 custom으로 갈아탑니다.
  2. Set up global Angular Material typography styles?Yes.
  3. Include the Angular animations module?Include and enable animations.

설치가 끝나면 src/styles.scss에 테마 import가, app.config.tsprovideAnimations()가 자동으로 들어가 있습니다.

폴더 구조 #

빈 프로젝트에 폴더부터 그려둡니다. core / shared / features / layouts 4분할이 어드민에 잘 맞는 표준 구조입니다.

src/app/
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에서 모아서).

src/app/layouts/main-layout/main-layout.component.ts
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(메인 영역)을 둡니다.

src/app/layouts/main-layout/main-layout.component.html
<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>

스타일은 큰 영역만 잡아둡니다.

src/app/layouts/main-layout/main-layout.component.scss
: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으로 분리하는 것입니다.

src/app/app.routes.ts
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는 자식 라우트만 별도로 정의해둡니다.

src/app/features/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로만 만들어두면 충분합니다 — 시리즈가 진행되면서 하나씩 채울 부분입니다.

src/app/features/products/product-list.component.ts (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 조각이기 때문입니다.

src/app/shared/components/stat-card/stat-card.component.ts
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개를 배치합니다.

src/app/features/dashboard/dashboard.component.ts
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열로 자연스럽게 줄어듭니다. 미디어 쿼리 없이 끝나는, 가성비 좋은 그리드 패턴입니다.

사이드바 토글과 동작 확인 #

데스크톱에서는 사이드바가 늘 열려 있고, 모바일에서는 햄버거 버튼으로 토글됩니다. 위 MainLayoutComponentopened 시그널이 그 역할 — 버튼을 누르면 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에서 본 함수형 가드와 인터셉터 패턴이 실전에서 어떻게 결합되는지 확인할 수 있습니다.

X