앵귤러 실전 강좌 #6 테스트와 배포 — 트랙 마무리
지난 시간에는 Angular Material을 얹어 대시보드의 외모를 다듬었습니다. 이번 시간이 앵귤러 실전 강좌의 마지막 글입니다. 그동안 손으로 만들어 온 대시보드를 테스트로 한 번 단단히 묶고, Docker로 포장한 뒤, 실제 도메인에서 돌아가는 단계까지 옮기는 한 사이클을 끝까지 돌려 봅니다.
그리고 마지막에는 한 발 떨어져서, 앵귤러 트랙 27편 전체를 한 번 돌아보겠습니다.
무엇을 끝낼 것인가 #
기능을 새로 추가하지는 않습니다. 이미 만들어진 코드를 실제 사용자에게 닿게 만드는 작업이 이번 글의 일입니다 — 테스트 커버리지 채우기, Playwright E2E, 환경 분리, Docker 빌드, Cloudflare Pages 배포, GitHub Actions CI/CD 한 줄.
테스트 커버리지 — Service부터 #
중급 #7에서 본 패턴 그대로 갑니다. 2편에서 만든 AuthService는 로컬 스토리지에 토큰을 넣고 빼는 단순한 클래스였습니다.
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가 어떻게 변했는지 — 그냥 호출해서 검증하면 됩니다.
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을 가지고 있었습니다. 폼 검증 시나리오는 컴포넌트 테스트로 짚는 게 가장 단단합니다.
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로 가짜 응답을 흘려 줍니다.
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.
이제 첫 시나리오 — 로그인 → 상품 추가 → 차트 갱신.
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 셀렉터로 화면을 더듬는 대신 사용자 시점의 의미로 요소를 잡습니다. 디자인이 바뀌어도 의미가 그대로면 테스트가 안 깨집니다.
환경 변수 — dev와 prod 분리 #
배포로 넘어가기 전에 한 가지 정리가 필요합니다 — API URL이 dev에서는 http://localhost:3000, prod에서는 https://api.example.com처럼 달라야 합니다.
// dev
export const environment = { production: false, apiUrl: 'http://localhost:3000' };
// prod
export const environment = { production: true, apiUrl: 'https://api.example.com' };angular.json의 production configuration에 fileReplacements를 둡니다.
"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 파일로 갈아끼워 줍니다.
빌드와 Dockerize #
ng build --configuration production을 돌리면 dist/<프로젝트명>/browser/에 해시 붙은 index.html,main-*.js,lazy 청크들이 떨어집니다(Angular 17+). 이 폴더 통째를 정적 호스팅에 올리거나, 여러 환경에 동일하게 배포하려면 Docker 이미지로 묶습니다 — 빌드 단계(Node)에서 ng build, 런타임 단계(nginx)에는 정적 파일만 복사. 최종 이미지에 Node가 들어가지 않으니 크기가 절반 이하로 떨어집니다.
# ---------- 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로 보내야 앵귤러 라우터가 처리합니다.
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 레포를 연결하면 클릭 몇 번으로 끝납니다.
- Workers & Pages → Create application → Pages → Connect to Git
- 레포 선택
- Build command:
npm run build -- --configuration production - Build output directory:
dist/dashboard/browser - Environment variables:
NODE_VERSION=20
push가 일어나면 Cloudflare가 알아서 빌드하고 배포합니다. PR마다 preview URL도 자동으로 만들어 주니 리뷰가 훨씬 편해집니다. 정적 파일만 서빙하는 SPA에서는 굳이 Docker 이미지로 컨테이너 호스팅까지 갈 이유가 적습니다 — 컨테이너는 백엔드와 한 묶음으로 배포할 때 진가가 나옵니다.
CI/CD — GitHub Actions #
마지막 한 조각. push할 때마다 손으로 테스트 돌리고 빌드하면 결국 빠뜨리게 됩니다. GitHub Actions 한 파일로 자동화합니다.
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를 처음부터 끼고 가는 개발 흐름.
다음 단계 추천 #
앵귤러 자체로는 여기서 큰 그림이 닫힙니다. 그 다음에 시간을 쓰면 좋은 갈래는 셋입니다.
- 테스팅 트랙 — Playwright와 Vitest를 더 깊게. 시각 회귀 테스트(Percy/Chromatic), Component Testing, MSW(Mock Service Worker), 테스트 피라미드의 실무 적용. 한 번 깊이 가두면 평생 쓰는 기술이 됩니다.
- 상태 관리 심화 — NgRx Signal Store, Component Store, RxAngular, TanStack Query for Angular. 도메인이 커질수록 어떤 도구가 어떤 경우에 어울리는지 — 실전 #4의 결정을 더 단단히 굳히는 단계입니다.
- 백엔드 사이드 — NestJS 트랙(앵귤러와 디자인이 가장 비슷한 Node.js 프레임워크). DI, 데코레이터, 모듈 같은 익숙한 개념이 그대로 서버로 이어집니다. 풀스택을 직접 한 사이클 돌려보면 프런트의 결정들이 왜 그런 모양인지 다시 보입니다.
가장 강력한 다음 단계는 — 여러분 자신의 작은 사이드 프로젝트입니다. 27편의 글로 익힌 도구들을, 여러분이 진짜 쓰고 싶은 작은 도구를 만드는 데 한 번 적용해 보세요. 강좌 27편을 다시 읽는 것보다, 직접 만든 1,000줄짜리 앱 한 개가 훨씬 큰 자산이 됩니다.
긴 트랙을 끝까지 함께해주셔서 정말 감사드립니다. 어떤 강좌든 끝까지 가는 것 자체가 가장 어려운데, 그 어려운 걸 27편이나 해내신 것입니다. 새 트랙에서 또 뵙겠습니다 — 그때까지, 직접 만든 작은 앱 하나로 다시 만나뵐 수 있기를 바랍니다.