앵귤러 실전 강좌 #6 테스트와 배포 — 트랙 마무리

10 분 소요

지난 시간에는 Angular Material을 얹어 대시보드의 외모를 다듬었습니다. 이번 시간이 앵귤러 실전 강좌의 마지막 글입니다. 그동안 손으로 만들어 온 대시보드를 테스트로 한 번 단단히 묶고, Docker로 포장한 뒤, 실제 도메인에서 돌아가는 단계까지 옮기는 한 사이클을 끝까지 돌려 봅니다.

그리고 마지막에는 한 발 떨어져서, 앵귤러 트랙 27편 전체를 한 번 돌아보겠습니다.

무엇을 끝낼 것인가 #

기능을 새로 추가하지는 않습니다. 이미 만들어진 코드를 실제 사용자에게 닿게 만드는 작업이 이번 글의 일입니다 — 테스트 커버리지 채우기, Playwright E2E, 환경 분리, Docker 빌드, Cloudflare Pages 배포, GitHub Actions CI/CD 한 줄.

테스트 커버리지 — Service부터 #

중급 #7에서 본 패턴 그대로 갑니다. 2편에서 만든 AuthService는 로컬 스토리지에 토큰을 넣고 빼는 단순한 클래스였습니다.

src/app/core/auth/auth.service.spec.ts
describe('AuthService', () => {
  beforeEach(() => {
    localStorage.clear();
    TestBed.configureTestingModule({ providers: [AuthService] });
  });

  it('login은 토큰을 저장하고 isAuthenticated를 true로 만든다', () => {
    const auth = TestBed.inject(AuthService);
    auth.login('curtis', 'password');
    expect(auth.isAuthenticated()).toBeTrue();
    expect(localStorage.getItem('auth_token')).toBeTruthy();
  });

  it('logout은 토큰을 비우고 상태를 초기화한다', () => {
    const auth = TestBed.inject(AuthService);
    auth.login('curtis', 'password');
    auth.logout();
    expect(auth.isAuthenticated()).toBeFalse();
    expect(localStorage.getItem('auth_token')).toBeNull();
  });
});

isAuthenticated는 시그널이라 함수처럼 호출해 값을 꺼내면 됩니다. localStorage를 stub으로 갈아끼울 수도 있지만, 브라우저 기반 러너에서는 진짜 localStorage를 쓰는 게 더 자연스럽고 안전합니다 — beforeEach에서 clear()만 깔끔히 해주면 됩니다.

ProductsStore 테스트 — 시그널 store #

4편에서 만든 ProductsStore는 시그널 기반 상태 컨테이너였습니다. 메서드 호출 후 state가 어떻게 변했는지 — 그냥 호출해서 검증하면 됩니다.

src/app/products/products.store.spec.ts
describe('ProductsStore', () => {
  let store: ProductsStore;
  beforeEach(() => {
    TestBed.configureTestingModule({ providers: [ProductsStore] });
    store = TestBed.inject(ProductsStore);
  });

  it('add는 새 상품을 목록에 추가한다', () => {
    store.add({ name: '키보드', price: 90000, stock: 5 });
    expect(store.products().length).toBe(1);
    expect(store.products()[0].name).toBe('키보드');
  });

  it('totalValue는 가격×재고 합', () => {
    store.add({ name: 'A', price: 1000, stock: 2 });
    store.add({ name: 'B', price: 500, stock: 4 });
    expect(store.totalValue()).toBe(1000 * 2 + 500 * 4);
  });
});

시그널 store의 진짜 가치는 테스트 가능성이라는 게 여기서 보입니다 — Observable 구독이나 비동기 흐름 없이, 평범한 함수 호출과 결과 비교만으로 모든 시나리오가 검증됩니다.

컴포넌트 테스트 — Login 폼 #

2편LoginComponent는 Reactive Form을 가지고 있었습니다. 폼 검증 시나리오는 컴포넌트 테스트로 짚는 게 가장 단단합니다.

src/app/auth/login.component.spec.ts
describe('LoginComponent', () => {
  let fixture: ComponentFixture<LoginComponent>;
  let authSpy: jasmine.SpyObj<AuthService>;

  beforeEach(() => {
    authSpy = jasmine.createSpyObj<AuthService>('AuthService', ['login']);
    TestBed.configureTestingModule({
      imports: [LoginComponent, ReactiveFormsModule],
      providers: [{ provide: AuthService, useValue: authSpy }],
    });
    fixture = TestBed.createComponent(LoginComponent);
    fixture.detectChanges();
  });

  it('빈 입력으로 제출하면 login은 호출되지 않는다', () => {
    fixture.nativeElement.querySelector('form').dispatchEvent(new Event('submit'));
    expect(authSpy.login).not.toHaveBeenCalled();
  });

  it('유효한 입력이면 login이 호출된다', () => {
    const el: HTMLElement = fixture.nativeElement;
    const username: HTMLInputElement = el.querySelector('[data-testid="username"]')!;
    const password: HTMLInputElement = el.querySelector('[data-testid="password"]')!;
    username.value = 'curtis';
    username.dispatchEvent(new Event('input'));
    password.value = 'secret123';
    password.dispatchEvent(new Event('input'));
    el.querySelector('form')!.dispatchEvent(new Event('submit'));
    expect(authSpy.login).toHaveBeenCalledOnceWith('curtis', 'secret123');
  });
});

핵심은 두 가지 — AuthService를 spy로 갈아끼워 진짜 인증 로직과 격리시키고, data-testid로 폼 요소를 찾아 디자인 변경에 강한 테스트를 만드는 것.

HttpTestingController 회수 — Product API #

3편ProductApiService는 진짜 네트워크를 타게 두면 테스트가 흔들립니다. HttpTestingController로 가짜 응답을 흘려 줍니다.

src/app/products/product-api.service.spec.ts
describe('ProductApiService', () => {
  let service: ProductApiService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [ProductApiService, provideHttpClient(), provideHttpClientTesting()],
    });
    service = TestBed.inject(ProductApiService);
    httpMock = TestBed.inject(HttpTestingController);
  });
  afterEach(() => httpMock.verify());

  it('GET /api/products는 상품 배열을 흘려준다', () => {
    const fake = [{ id: '1', name: '키보드', price: 90000, stock: 5 }];
    service.list().subscribe((products) => expect(products).toEqual(fake));
    const req = httpMock.expectOne('/api/products');
    expect(req.request.method).toBe('GET');
    req.flush(fake);
  });

  it('500 응답이면 빈 배열로 폴백한다', () => {
    service.list().subscribe((products) => expect(products).toEqual([]));
    httpMock.expectOne('/api/products')
      .flush('boom', { status: 500, statusText: 'Server Error' });
  });
});

두 번째 케이스가 진짜 가치입니다 — 평소엔 발생하지 않을 500 에러 폴백 분기를 테스트가 강제로 한 번 밟아주는 것입니다. 진짜 서버를 일부러 죽이지 않는 한 손으로는 검증하기 어려운 시나리오입니다.

E2E 테스트 — Playwright #

단위/컴포넌트 테스트가 부품을 본다면 E2E는 사용자가 거치는 흐름을 봅니다. 앵귤러 공식 Protractor는 deprecate된 지 오래고, 요즘은 Playwright나 Cypress를 붙입니다. npm init playwright@latest로 설치하면 e2e/ 폴더와 playwright.config.ts가 함께 생성됩니다. 설정에는 dev 서버를 자동으로 띄우는 webServer 한 블록만 추가해 두면 충분합니다 — command: 'npm run start', url: 'http://localhost:4200', reuseExistingServer: !process.env.CI.

이제 첫 시나리오 — 로그인 → 상품 추가 → 차트 갱신.

e2e/dashboard.spec.ts
test('로그인하고 상품을 추가하면 차트가 갱신된다', async ({ page }) => {
  await page.goto('/login');
  await page.getByTestId('username').fill('curtis');
  await page.getByTestId('password').fill('password');
  await page.getByRole('button', { name: '로그인' }).click();
  await expect(page).toHaveURL(/\/dashboard/);

  await page.getByRole('link', { name: '상품' }).click();
  await page.getByRole('button', { name: '새 상품' }).click();
  await page.getByTestId('product-name').fill('E2E 키보드');
  await page.getByTestId('product-price').fill('120000');
  await page.getByTestId('product-stock').fill('3');
  await page.getByRole('button', { name: '저장' }).click();
  await expect(page.getByText('E2E 키보드')).toBeVisible();

  await page.getByRole('link', { name: '대시보드' }).click();
  await expect(page.getByTestId('total-value')).toContainText('360,000');
});

getByTestId, getByRole이 Playwright의 진짜 무기입니다 — CSS 셀렉터로 화면을 더듬는 대신 사용자 시점의 의미로 요소를 잡습니다. 디자인이 바뀌어도 의미가 그대로면 테스트가 안 깨집니다.

E2E 테스트는 숫자가 아니라 흐름을 검증하는 단계입니다. 하나의 시나리오로 로그인부터 핵심 기능까지 한 번에 거치는 “smoke 테스트” 한두 개를 두는 게 보통은 가장 가성비가 좋습니다. 단위,컴포넌트 테스트가 깊이를, E2E는 폭을 본다고 생각하시면 됩니다.

환경 변수 — dev와 prod 분리 #

배포로 넘어가기 전에 한 가지 정리가 필요합니다 — API URL이 dev에서는 http://localhost:3000, prod에서는 https://api.example.com처럼 달라야 합니다.

src/environments/environment.ts / environment.prod.ts
// dev
export const environment = { production: false, apiUrl: 'http://localhost:3000' };
// prod
export const environment = { production: true, apiUrl: 'https://api.example.com' };

angular.jsonproduction configuration에 fileReplacements를 둡니다.

angular.json
"configurations": {
  "production": {
    "fileReplacements": [
      { "replace": "src/environments/environment.ts",
        "with": "src/environments/environment.prod.ts" }
    ],
    "optimization": true,
    "outputHashing": "all"
  }
}

코드는 한 곳만 import하면 됩니다 — environment.apiUrl을 베이스로 사용하면 ng build가 production 빌드 때 prod 파일로 갈아끼워 줍니다.

노트
민감 정보(API 키, 시크릿)는 environment 파일에 절대 두지 마세요 — 클라이언트 번들에 그대로 포함되어 누구나 볼 수 있습니다. 진짜 시크릿은 백엔드에서 다루고, 프런트엔드에는 공개해도 되는 URL/플래그만 두는 게 원칙입니다.

빌드와 Dockerize #

ng build --configuration production을 돌리면 dist/<프로젝트명>/browser/에 해시 붙은 index.html,main-*.js,lazy 청크들이 떨어집니다(Angular 17+). 이 폴더 통째를 정적 호스팅에 올리거나, 여러 환경에 동일하게 배포하려면 Docker 이미지로 묶습니다 — 빌드 단계(Node)에서 ng build, 런타임 단계(nginx)에는 정적 파일만 복사. 최종 이미지에 Node가 들어가지 않으니 크기가 절반 이하로 떨어집니다.

Dockerfile
# ---------- Build stage ----------
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build -- --configuration production

# ---------- Runtime stage ----------
FROM nginx:alpine
COPY --from=builder /app/dist/dashboard/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

SPA에서 빠뜨리기 쉬운 게 404 fallback입니다. /dashboard/products처럼 깊은 URL을 직접 입력해 새로고침하면 nginx는 그 경로의 파일을 찾으려다 404를 냅니다. 모든 경로를 index.html로 보내야 앵귤러 라우터가 처리합니다.

nginx.conf
server {
  listen 80;
  root /usr/share/nginx/html;
  index index.html;

  location ~* \.(js|css|woff2|png|jpg|svg)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
  location / { try_files $uri $uri/ /index.html; }
}

docker build -t dashboard .docker run --rm -p 8080:80 dashboard. 이미지 크기는 보통 30~40MB 안쪽입니다(nginx:alpine + 정적 파일).

배포 — Cloudflare Pages #

배포 옵션은 셋입니다 — Vercel, Cloudflare Pages, Netlify. 셋 다 정적 호스팅 + 글로벌 CDN + 넉넉한 무료 플랜입니다. 저는 Cloudflare Pages를 추천드립니다 — 빌드 속도가 빠르고, 대역폭 제한이 사실상 없으며, 한국에서 응답이 가장 안정적입니다.

GitHub 레포를 연결하면 클릭 몇 번으로 끝납니다.

  1. Workers & Pages → Create application → Pages → Connect to Git
  2. 레포 선택
  3. Build command: npm run build -- --configuration production
  4. Build output directory: dist/dashboard/browser
  5. Environment variables: NODE_VERSION=20

push가 일어나면 Cloudflare가 알아서 빌드하고 배포합니다. PR마다 preview URL도 자동으로 만들어 주니 리뷰가 훨씬 편해집니다. 정적 파일만 서빙하는 SPA에서는 굳이 Docker 이미지로 컨테이너 호스팅까지 갈 이유가 적습니다 — 컨테이너는 백엔드와 한 묶음으로 배포할 때 진가가 나옵니다.

CI/CD — GitHub Actions #

마지막 한 조각. push할 때마다 손으로 테스트 돌리고 빌드하면 결국 빠뜨리게 됩니다. GitHub Actions 한 파일로 자동화합니다.

.github/workflows/ci.yml
name: CI/CD
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run test -- --watch=false --browsers=ChromeHeadless
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
      - run: npm run build -- --configuration production

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build -- --configuration production
      - name: Deploy to Cloudflare Pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CF_API_TOKEN }}
          accountId: ${{ secrets.CF_ACCOUNT_ID }}
          command: pages deploy dist/dashboard/browser --project-name=dashboard

흐름은 명확합니다 — test 잡이 단위,E2E,빌드를 한 번에 돌리고, 그게 모두 통과해야 main 브랜치에 한해 deploy 잡이 동작합니다. PR에서는 deploy 없이 test만 돌고, 머지되어 main에 들어가면 그제야 배포가 일어납니다.

secrets.CF_API_TOKEN, CF_ACCOUNT_ID는 GitHub 레포의 Settings → Secrets and variables → Actions에 등록합니다. 한 번 세팅해두면 그 다음부터는 그냥 git push만 누르면 됩니다.

마무리 — 실전 트랙 회고 #

여기까지가 앵귤러 실전 강좌 6편의 마지막 글입니다. 짧게 한 번 돌아보면:

  • #1 대시보드 골격 — Standalone + Router + 레이아웃
  • #2 인증 흐름 — Reactive Form 로그인, 토큰, 함수형 가드, 인터셉터
  • #3 폼 + API — 복잡한 폼 검증, HttpClient + Resource API
  • #4 상태 관리 — 시그널 store에서 NgRx Signal Store까지
  • #5 UI 라이브러리 — Angular Material 디자인 시스템과 테마
  • #6 테스트와 배포 — 테스트 커버리지, Docker, Cloudflare Pages, GitHub Actions

작은 데모가 아니라 로그인하고, 데이터를 받고, 폼으로 입력하고, 차트가 그려지고, 디자인이 입혀지고, 배포까지 되는 한 사이클을 끝까지 돌렸습니다. 실무에서 새 앵귤러 프로젝트가 시작될 때 거치는 결정들이 이 6편에 거의 다 담겼다고 생각하셔도 됩니다.

앵귤러 트랙 27편 — 전체 회고 #

이걸로 앵귤러 트랙 27편이 모두 끝났습니다. 한 번에 펼쳐 놓고 보면 이런 모양입니다.

  • 기초 7편 — “앵귤러로 화면을 만든다” (앵귤러란 / 컴포넌트 / 데이터 바인딩 / Directive,Pipe / Service,DI / Router / HttpClient)
  • 중급 7편 — “앵귤러를 실무에서 쓴다” (Reactive Forms / RxJS / Lifecycle,CD / Interceptor / Lazy,Guards / SSR / 테스트)
  • 고급 7편 — “앵귤러의 안쪽을 이해한다” (CD 심화 / Signals 깊이 / RxJS 깊이 / DI 심화 / SSR,Hydration / 마이크로프론트엔드 / 성능 튜닝)
  • 실전 6편 — “한 제품을 처음부터 끝까지” (골격 / 인증 / 폼+API / 상태 관리 / UI / 테스트와 배포)

기초가 도구의 이름을 익히는 단계, 중급은 그 도구로 매일의 문제를 푸는 패턴, 고급은 그 도구의 안쪽을 들여다보는 시간, 실전은 그 모든 걸 한 제품의 맥락 안에서 조립해보는 마무리였습니다. 시그널 기반 반응형 모델 + Standalone + 함수형 가드/인터셉터 + OnPush + lazy loading의 조합이 모던 앵귤러의 표준 골격이라는 것이 27편을 가로지르는 한 줄 결론입니다.

손에 남는 건 다음과 같은 것들입니다 — 새 앵귤러 프로젝트가 시작될 때 첫 설계 결정을 망설임 없이 내릴 수 있는 감각, Reactive Forms와 RxJS, Signals 중 상황에 맞는 도구를 골라 쓰는 판단력, HTTP Interceptor와 함수형 가드 같은 앵귤러스러운 패턴을 읽고 쓰는 능력, 테스트와 CI/CD를 처음부터 끼고 가는 개발 흐름.

다음 단계 추천 #

앵귤러 자체로는 여기서 큰 그림이 닫힙니다. 그 다음에 시간을 쓰면 좋은 갈래는 셋입니다.

  1. 테스팅 트랙 — Playwright와 Vitest를 더 깊게. 시각 회귀 테스트(Percy/Chromatic), Component Testing, MSW(Mock Service Worker), 테스트 피라미드의 실무 적용. 한 번 깊이 가두면 평생 쓰는 기술이 됩니다.
  2. 상태 관리 심화 — NgRx Signal Store, Component Store, RxAngular, TanStack Query for Angular. 도메인이 커질수록 어떤 도구가 어떤 경우에 어울리는지 — 실전 #4의 결정을 더 단단히 굳히는 단계입니다.
  3. 백엔드 사이드 — NestJS 트랙(앵귤러와 디자인이 가장 비슷한 Node.js 프레임워크). DI, 데코레이터, 모듈 같은 익숙한 개념이 그대로 서버로 이어집니다. 풀스택을 직접 한 사이클 돌려보면 프런트의 결정들이 왜 그런 모양인지 다시 보입니다.

가장 강력한 다음 단계는 — 여러분 자신의 작은 사이드 프로젝트입니다. 27편의 글로 익힌 도구들을, 여러분이 진짜 쓰고 싶은 작은 도구를 만드는 데 한 번 적용해 보세요. 강좌 27편을 다시 읽는 것보다, 직접 만든 1,000줄짜리 앱 한 개가 훨씬 큰 자산이 됩니다.

긴 트랙을 끝까지 함께해주셔서 정말 감사드립니다. 어떤 강좌든 끝까지 가는 것 자체가 가장 어려운데, 그 어려운 걸 27편이나 해내신 것입니다. 새 트랙에서 또 뵙겠습니다 — 그때까지, 직접 만든 작은 앱 하나로 다시 만나뵐 수 있기를 바랍니다.

X