본문 바로가기

테스트, 배포, 운영 전략 수립

온라인 코드 편집기의 단위·통합 테스트, 컨테이너 배포, SLO와 복구·보안 기준을 하나의 운영 전략으로 묶습니다.

지금까지 온라인 코드 에디터의 인증과 프로젝트·파일 REST API, React 편집 화면, 그리고 선택 실습인 실시간 Gateway를 만들었습니다. 이 글에서는 마지막 단계로 이 애플리케이션을 테스트하고, 배포하고, 운영하는 전략을 세웁니다.

먼저 확인할 범위를 정해 둡니다. 기본으로 확인할 흐름은 로그인한 소유자가 파일을 편집해 저장하고 다시 조회했을 때 바뀐 내용이 보이는 것, 그리고 다른 사용자는 그 파일에 접근할 수 없는 것입니다. 두 세션 사이의 실시간 반영은 Gateway를 추가한 경우에만 따로 확인하며, 다른 계정과의 공동 편집이나 공유 권한은 이 예제가 다루지 않습니다.


테스트 전략

테스트는 범위에 따라 나눕니다. 단위 테스트는 서비스 하나를 데이터베이스 없이 확인하고, E2E 테스트는 HTTP 요청부터 데이터베이스까지 실제로 연결해 확인합니다. 범위가 넓을수록 실제 동작에 가깝지만 느리고 준비할 것이 많으므로, 빠른 단위 테스트로 분기를 넓게 확인하고 E2E 테스트는 핵심 흐름에 집중합니다.

NestJS CLI로 만든 이 프로젝트는 8장에서 본 Jest 30 구성을 그대로 씁니다. 단위 테스트는 npm test로, E2E 테스트는 test/jest-e2e.json 설정을 지정하는 npm run test:e2e로 실행합니다.

단위 테스트

AuthService를 저장소와 JwtService의 테스트 대역으로 감싸 테스트합니다. 8장의 단위 테스트 작성과 모킹처럼 getRepositoryToken()으로 저장소 자리에 테스트 대역을 넣습니다.

src/auth/auth.service.spec.ts
import { ConflictException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import * as bcrypt from 'bcryptjs';
import { AuthService } from './auth.service';
import { User } from './entities/user.entity';

describe('AuthService', () => {
  let service: AuthService;
  const usersRepository = {
    findOne: jest.fn(),
    create: jest.fn(),
    save: jest.fn(),
  };
  const jwtService = { signAsync: jest.fn() };

  beforeEach(async () => {
    jest.clearAllMocks();
    const moduleRef = await Test.createTestingModule({
      providers: [
        AuthService,
        { provide: getRepositoryToken(User), useValue: usersRepository },
        { provide: JwtService, useValue: jwtService },
      ],
    }).compile();

    service = moduleRef.get(AuthService);
  });

  it('registers a new user with a hashed password', async () => {
    usersRepository.findOne.mockResolvedValue(null); // 같은 이메일의 사용자가 없습니다.
    usersRepository.create.mockImplementation((user: Partial<User>) => user);
    usersRepository.save.mockResolvedValue(undefined);

    const result = await service.register({ email: '[email protected]', password: 'password123' });

    expect(result).toEqual({ message: 'User registered successfully' });
    const created = usersRepository.create.mock.calls[0][0] as Partial<User>;
    expect(created.email).toBe('[email protected]');
    expect(created.nickname).toBeNull();
    expect(created.password).not.toBe('password123');
    expect(await bcrypt.compare('password123', created.password ?? '')).toBe(true);
  });

  it('rejects an email that is already registered', async () => {
    usersRepository.findOne.mockResolvedValue({ id: 'existing-user' });

    await expect(
      service.register({ email: '[email protected]', password: 'password123' }),
    ).rejects.toThrow(ConflictException);
    expect(usersRepository.save).not.toHaveBeenCalled();
  });

  it('issues a token when the password matches', async () => {
    const password = await bcrypt.hash('password123', 4); // 테스트에서는 낮은 비용으로 빠르게 만듭니다.
    usersRepository.findOne.mockResolvedValue({ id: 'user-1', email: '[email protected]', password });
    jwtService.signAsync.mockResolvedValue('signed-token');

    await expect(
      service.login({ email: '[email protected]', password: 'password123' }),
    ).resolves.toEqual({ accessToken: 'signed-token' });
    expect(jwtService.signAsync).toHaveBeenCalledWith({ sub: 'user-1', email: '[email protected]' });
  });
});

AuthService의 생성자는 User 저장소와 JwtService만 받으므로, 테스트 모듈에도 이 둘만 등록합니다. 서비스가 토큰을 만들 때 signAsync()를 쓰므로 테스트 대역도 같은 이름의 메서드를 제공해야 합니다. 테스트 대역에 sign()만 있으면 로그인 테스트는 signAsync is not a function 오류로 실패합니다.

첫 테스트는 create()에 넘긴 값을 꺼내, 비밀번호가 평문이 아니라 bcrypt.compare()로 확인되는 해시인지와 빠진 닉네임이 null이 되는지를 확인합니다. 두 번째 테스트는 예외의 종류와 함께 save()가 호출되지 않았다는 것까지 확인합니다. 예외만 확인하면 저장한 뒤에 예외를 던지는 잘못된 구현도 통과하기 때문입니다.

이 테스트가 통과해도 PostgreSQL에 사용자 행이 저장된다는 것까지 확인한 것은 아닙니다. save()는 테스트 대역이므로, 실제 저장은 데이터베이스를 연결한 E2E 테스트에서 확인합니다.

E2E 테스트

E2E 테스트는 AppModule 전체를 띄우고, 테스트 전용 PostgreSQL 데이터베이스에 연결해 HTTP 요청을 보냅니다. 먼저 개발용과 다른 데이터베이스를 만듭니다.

psql -U your_db_user -c 'CREATE DATABASE code_editor_test;'
test/projects.e2e-spec.ts
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { DataSource } from 'typeorm';
import { AppModule } from './../src/app.module';

describe('Projects (e2e)', () => {
  let app: INestApplication<App>;
  let ownerToken: string;
  let otherToken: string;

  const signUp = async (email: string): Promise<string> => {
    await request(app.getHttpServer())
      .post('/auth/register')
      .send({ email, password: 'password123' })
      .expect(201);
    const res = await request(app.getHttpServer())
      .post('/auth/login')
      .send({ email, password: 'password123' })
      .expect(201);
    return (res.body as { accessToken: string }).accessToken;
  };

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
    app = moduleRef.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true })); // main.ts와 같은 설정
    await app.init();

    // 개발용 데이터베이스를 지우지 않도록 이름을 확인한 뒤 테이블을 새로 만듭니다.
    const databaseName = app.get(ConfigService).getOrThrow<string>('DATABASE_NAME');
    if (!databaseName.endsWith('_test')) {
      throw new Error(`E2E tests must use a *_test database, not "${databaseName}".`);
    }
    await app.get(DataSource).synchronize(true);

    ownerToken = await signUp('[email protected]');
    otherToken = await signUp('[email protected]');
  }, 30_000);

  afterAll(async () => {
    await app?.close();
  });

  it('saves file content and hides it from other users', async () => {
    const server = app.getHttpServer();
    const owner = { Authorization: `Bearer ${ownerToken}` };

    const project = await request(server).post('/projects').set(owner).send({ name: 'Test Project' }).expect(201);
    const projectId = (project.body as { id: string }).id;

    const file = await request(server)
      .post(`/projects/${projectId}/files`)
      .set(owner)
      .send({ name: 'main.ts', path: '/main.ts', content: 'console.log(1);' })
      .expect(201);
    const fileId = (file.body as { id: string }).id;

    await request(server)
      .patch(`/projects/${projectId}/files/${fileId}/content`)
      .set(owner)
      .send({ content: 'console.log(2);' })
      .expect(200);

    // 수정 응답이 아니라 다시 조회한 결과로 저장을 확인합니다.
    const saved = await request(server).get(`/projects/${projectId}/files/${fileId}`).set(owner).expect(200);
    expect((saved.body as { content: string }).content).toBe('console.log(2);');

    await request(server)
      .get(`/projects/${projectId}/files/${fileId}`)
      .set('Authorization', `Bearer ${otherToken}`)
      .expect(404);
  });
});

@nestjs/config는 실행 환경에 이미 있는 변수를 .env의 값보다 우선합니다. 그래서 .env를 고치지 않고 실행할 때 데이터베이스 이름만 바꿔 줄 수 있습니다.

DATABASE_NAME=code_editor_test npm run test:e2e

createNestApplication()은 main.ts를 실행하지 않으므로, main.ts에서 등록한 ValidationPipe를 테스트에서도 똑같이 등록합니다. 이 줄이 없으면 DTO 검증이 빠진 앱을 테스트하게 됩니다. synchronize(true)는 모든 테이블을 지우고 엔티티에 맞춰 다시 만들므로, 그 전에 데이터베이스 이름이 _test로 끝나는지 확인해 개발 데이터를 지우는 사고를 막습니다.

회원 가입과 로그인에서 bcrypt 해시를 계산하므로 beforeAll의 제한 시간을 30초로 늘렸습니다. 13장 2절의 모듈 연결과 실행에서 만든 AppModule에서 템플릿의 AppController를 뺐으므로, 템플릿의 test/app.e2e-spec.ts는 GET /에서 404를 받아 실패합니다. 이 파일은 지우고 위의 테스트로 바꿉니다.

이 테스트는 소유자의 저장과 다른 사용자의 접근 거부라는 기본 흐름을 확인합니다. 폴더 삭제의 경로 조건이나 프로젝트 소속 검사처럼 앞에서 연습 문제로 따져 본 경우도, 같은 방식으로 요청과 기대 상태 코드를 적어 테스트로 옮길 수 있습니다.

프런트엔드 테스트

  • 컴포넌트 테스트: Vite 프로젝트는 같은 설정을 쓰는 Vitest와 React Testing Library로 화면의 렌더링과 이벤트 처리를 확인합니다. API는 가짜 응답으로 바꿔, 목록 화면의 로딩, 실패, 빈 목록 상태가 각각 올바르게 보이는지 확인할 수 있습니다.
  • 브라우저 E2E 테스트: Playwright나 Cypress로 회원 가입, 로그인, 프로젝트 생성, 파일 편집, 자동 저장, 새로고침 후 내용 확인까지 실제 브라우저에서 따라갑니다.
  • 협업 확장 테스트: Gateway를 추가했다면 브라우저 두 개를 열어 한쪽의 변경이 다른 쪽에 반영되는지, 다른 계정의 파일 참가가 거부되는지 확인합니다.

CI

11장의 CI/CD 파이프라인 구축처럼, 저장소에 코드를 올릴 때마다 lint, 단위 테스트, 빌드를 자동으로 실행하고 모두 통과해야 다음 단계로 넘어가게 합니다. E2E 테스트는 CI 작업에 PostgreSQL 서비스 컨테이너를 띄우고 DATABASE_NAME을 테스트 데이터베이스로 지정해 실행합니다.


배포 전략

환경 구성

  • 개발 환경: 개발자의 PC에서 코드를 작성하고 테스트합니다. PostgreSQL 같은 의존 서비스는 Docker Compose로 띄웁니다.
  • 스테이징 환경: 운영과 최대한 같은 구성으로 만들어, 배포하기 전에 최종 테스트와 QA를 합니다.
  • 운영 환경: 실제 사용자가 접속하는 환경으로, 가용성과 보안을 가장 먼저 고려합니다.

백엔드 컨테이너

백엔드는 11장의 Docker를 이용한 컨테이너화와 같은 다단계 Dockerfile로 이미지를 만듭니다. 아래 파일은 11장의 것과 내용이 같습니다. 이 프로젝트도 Nest CLI로 빌드해 진입 파일이 dist/main.js에 생기므로, COPY와 CMD가 가리키는 경로를 바꿀 필요가 없습니다. .env가 빌드 컨텍스트에 섞이지 않도록 11장의 .dockerignore도 함께 둡니다.

Dockerfile
# syntax=docker/dockerfile:1
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:24-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --chown=node:node --from=deps /app/node_modules ./node_modules
COPY --chown=node:node --from=builder /app/dist ./dist
COPY --chown=node:node package*.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]

builder 단계는 TypeScript와 Nest CLI까지 포함한 전체 의존성으로 빌드하고, runtime 단계에는 --omit=dev로 설치한 실행용 의존성과 빌드 결과만 복사합니다. 처음부터 --omit=dev로만 설치하면 빌드에 필요한 개발 의존성이 빠져 npm run build가 실패합니다.

이미지를 운영에 올리기 전에 개발용으로 정해 둔 값을 운영에 맞게 바꿉니다. synchronize: true는 끄고 migration으로 스키마를 바꿉니다. main.ts의 CORS와 Gateway의 cors.origin에 적은 http://localhost:5173은 운영 프런트엔드의 주소로 바꾸며, 환경 변수에서 읽게 하면 이미지를 다시 만들지 않고 바꿀 수 있습니다. JWT_SECRET과 데이터베이스 비밀번호는 이미지에 넣지 않고, 실행할 때 환경 변수나 클라우드의 secret 관리 서비스로 넘깁니다.

실행 환경 선택

  • 컨테이너 서비스: AWS ECS(Fargate)나 Google Cloud Run처럼 서버를 직접 관리하지 않는 컨테이너 서비스에서 실행합니다. Gateway를 쓴다면 WebSocket 연결 시간 제한과 같은 클라이언트를 같은 인스턴스로 보내는 스티키 세션을 지원하는지 확인합니다.
  • Kubernetes: 세밀한 제어나 여러 서비스의 오케스트레이션이 필요할 때 고르며, 초기에는 운영 부담이 클 수 있습니다.
  • 데이터베이스: AWS RDS나 Google Cloud SQL 같은 관리형 PostgreSQL을 쓰면 백업, 복구, 장애 조치를 서비스가 맡습니다.

배포 흐름

배포는 한 번 검증한 이미지를 그대로 옮기는 흐름으로 만듭니다. 개발자가 main 브랜치에 코드를 올리면 CI가 빌드와 테스트를 실행하고, 통과하면 Docker 이미지를 만들어 컨테이너 레지스트리에 올립니다. CD 도구는 이 이미지를 스테이징에 배포하고, QA와 승인을 거친 뒤 같은 이미지를 운영에 배포합니다. 운영에 배포할 때 이미지를 다시 빌드하면 스테이징에서 확인한 것과 다른 결과물이 나갈 수 있습니다.

검증한 이미지를 배포하고 운영 결과로 개선하는 흐름

계획된 build/test·staging 승인·동일 image 운영·SLO 관측·복구/개선·재검증으로 릴리스 근거가 바뀐다.

검증한 이미지를 배포하고 운영 결과로 개선하는 흐름

Nest · Release strategy

검증·배포·관측·개선 루프

단위·통합 테스트 뒤 만든 이미지를 스테이징에서 승인하고 같은 이미지를 운영에 배포하는 전략입니다. SLO·RTO·RPO와 정기 복원 테스트는 준비할 기준이며, 예제 실행이나 검증 완료를 뜻하지 않습니다.

같은 이미지의 배포와 관측 후 개선 계획된 빌드·테스트와 스테이징 승인 뒤 같은 이미지를 운영에 배포하고 SLO 관측을 복구·개선과 다음 검증에 연결한다. RTO·RPO와 실제 복원 결과는 따로 확인한다. 검증한 이미지 1 · BUILD build · test QA · 최종 승인 2 · STAGING 같은 이미지 복구 준비 3 · READINESS RPO · RTO 운영 배포 4 · DEPLOY 같은 이미지 오류율 · p95 5 · OBSERVE 정한 SLO 기준 복구 · 개선 6 · DECIDE RTO · RPO
같은 이미지의 배포와 관측 후 개선 계획된 빌드·테스트와 스테이징 승인 뒤 같은 이미지를 운영에 배포하고 SLO 관측을 복구·개선과 다음 검증에 연결한다. RTO·RPO와 실제 복원 결과는 따로 확인한다. 검증한 이미지 1 · BUILD build · test QA · 최종 승인 2 · STAGING 같은 이미지 복구 준비 3 · READINESS RPO · RTO 운영 배포 4 · DEPLOY 같은 이미지 오류율 · p95 5 · OBSERVE 정한 SLO 기준 복구 · 개선 6 · DECIDE RTO · RPO
검증한 이미지를 배포하고 운영 결과로 개선하는 흐름 빌드·스테이징 검증·복구 준비·운영 배포·관측·개선을 연결한다. 복원 테스트는 정기 운영 계획이며 각 배포에서 실행됐다는 관측을 뜻하지 않는다. 1 · BUILD 검증한 이미지 build · test 2 · STAGING QA · 최종 승인 같은 이미지 3 · READINESS 복구 준비 RPO · RTO 4 · DEPLOY 운영 배포 같은 이미지 5 · OBSERVE 오류율 · p95 정한 SLO 기준 6 · DECIDE 복구 · 개선 RTO · RPO
  1. 빌드

    코드 빌드와 단위·통합 테스트 후 이미지를 만듭니다.

  2. 스테이징

    같은 이미지를 배포하고 QA 및 최종 승인을 거칩니다.

  3. 복구 준비

    정기 복원 테스트와 장애 시 복구 목표·절차를 준비합니다.

  4. 운영 배포

    승인한 이미지를 운영에 배포합니다. 노출 단계는 환경에 맞게 정합니다.

  5. SLI 관측

    오류율과 응답 시간을 정한 SLO에 대조합니다.

  6. 복구·개선

    장애에 대응하고 관측 결과를 다음 개선 작업에 반영합니다.

실선은 배포 전략의 진행 순서, 점선은 관측 후 다음 개선으로 돌아가는 경로입니다. 복원 테스트는 정기 운영 계획이며 매 배포에서 수행했다는 증거는 아닙니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 계획된 빌드·테스트와 스테이징 승인 뒤 같은 이미지를 운영에 배포하고 SLO 관측을 복구·개선과 다음 검증에 연결한다. RTO·RPO와 실제 복원 결과는 따로 확인한다.
  • 빌드·스테이징 검증·복구 준비·운영 배포·관측·개선을 연결한다. 복원 테스트는 정기 운영 계획이며 각 배포에서 실행됐다는 관측을 뜻하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
build·staging 승인

계획된 build·test 뒤 staging에서 승인합니다.

대상: 검증 image · 근거: staging 승인운영 입력으로 사용할 image가 정해집니다.
같은 image 배포

승인한 같은 image를 운영에 배포합니다.

운영: 동일 image · 상태: 배포검증 대상과 실행 대상을 연결합니다.
SLO·복구·개선

SLO 관측 결과를 복구·개선과 다음 검증에 반영합니다.

자료: SLO · RTO·RPO: 실제 복원 별도운영 증거가 후속 검증 계획을 바꿉니다.

검증한 이미지를 배포하고 운영 결과로 개선하는 흐름

계획된 build/test·staging 승인·동일 image 운영·SLO 관측·복구/개선·재검증으로 릴리스 근거가 바뀐다.

검증한 이미지를 배포하고 운영 결과로 개선하는 흐름

Nest · Release strategy

검증·배포·관측·개선 루프

단위·통합 테스트 뒤 만든 이미지를 스테이징에서 승인하고 같은 이미지를 운영에 배포하는 전략입니다. SLO·RTO·RPO와 정기 복원 테스트는 준비할 기준이며, 예제 실행이나 검증 완료를 뜻하지 않습니다.

같은 이미지의 배포와 관측 후 개선 계획된 빌드·테스트와 스테이징 승인 뒤 같은 이미지를 운영에 배포하고 SLO 관측을 복구·개선과 다음 검증에 연결한다. RTO·RPO와 실제 복원 결과는 따로 확인한다. 검증한 이미지 1 · BUILD build · test QA · 최종 승인 2 · STAGING 같은 이미지 복구 준비 3 · READINESS RPO · RTO 운영 배포 4 · DEPLOY 같은 이미지 오류율 · p95 5 · OBSERVE 정한 SLO 기준 복구 · 개선 6 · DECIDE RTO · RPO
같은 이미지의 배포와 관측 후 개선 계획된 빌드·테스트와 스테이징 승인 뒤 같은 이미지를 운영에 배포하고 SLO 관측을 복구·개선과 다음 검증에 연결한다. RTO·RPO와 실제 복원 결과는 따로 확인한다. 검증한 이미지 1 · BUILD build · test QA · 최종 승인 2 · STAGING 같은 이미지 복구 준비 3 · READINESS RPO · RTO 운영 배포 4 · DEPLOY 같은 이미지 오류율 · p95 5 · OBSERVE 정한 SLO 기준 복구 · 개선 6 · DECIDE RTO · RPO
검증한 이미지를 배포하고 운영 결과로 개선하는 흐름 빌드·스테이징 검증·복구 준비·운영 배포·관측·개선을 연결한다. 복원 테스트는 정기 운영 계획이며 각 배포에서 실행됐다는 관측을 뜻하지 않는다. 1 · BUILD 검증한 이미지 build · test 2 · STAGING QA · 최종 승인 같은 이미지 3 · READINESS 복구 준비 RPO · RTO 4 · DEPLOY 운영 배포 같은 이미지 5 · OBSERVE 오류율 · p95 정한 SLO 기준 6 · DECIDE 복구 · 개선 RTO · RPO
  1. 빌드

    코드 빌드와 단위·통합 테스트 후 이미지를 만듭니다.

  2. 스테이징

    같은 이미지를 배포하고 QA 및 최종 승인을 거칩니다.

  3. 복구 준비

    정기 복원 테스트와 장애 시 복구 목표·절차를 준비합니다.

  4. 운영 배포

    승인한 이미지를 운영에 배포합니다. 노출 단계는 환경에 맞게 정합니다.

  5. SLI 관측

    오류율과 응답 시간을 정한 SLO에 대조합니다.

  6. 복구·개선

    장애에 대응하고 관측 결과를 다음 개선 작업에 반영합니다.

실선은 배포 전략의 진행 순서, 점선은 관측 후 다음 개선으로 돌아가는 경로입니다. 복원 테스트는 정기 운영 계획이며 매 배포에서 수행했다는 증거는 아닙니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 계획된 빌드·테스트와 스테이징 승인 뒤 같은 이미지를 운영에 배포하고 SLO 관측을 복구·개선과 다음 검증에 연결한다. RTO·RPO와 실제 복원 결과는 따로 확인한다.
  • 빌드·스테이징 검증·복구 준비·운영 배포·관측·개선을 연결한다. 복원 테스트는 정기 운영 계획이며 각 배포에서 실행됐다는 관측을 뜻하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
build·staging 승인

계획된 build·test 뒤 staging에서 승인합니다.

대상: 검증 image · 근거: staging 승인운영 입력으로 사용할 image가 정해집니다.
같은 image 배포

승인한 같은 image를 운영에 배포합니다.

운영: 동일 image · 상태: 배포검증 대상과 실행 대상을 연결합니다.
SLO·복구·개선

SLO 관측 결과를 복구·개선과 다음 검증에 반영합니다.

자료: SLO · RTO·RPO: 실제 복원 별도운영 증거가 후속 검증 계획을 바꿉니다.

위 그림은 이 흐름을 세 단계의 반복으로 보여 줍니다. 계획한 빌드와 테스트를 거친 이미지를 스테이징에서 승인하고, 승인한 같은 이미지를 운영에 배포합니다. 운영에서는 SLO를 관측하고, 그 결과를 복구와 개선, 다음 검증에 반영합니다. 그림 아래에 적혀 있듯이 SLO, RTO, RPO와 정기 복원 테스트는 미리 정해 둘 기준이며, 그림만으로 이 기준을 만족했다는 뜻은 아닙니다.

프런트엔드 배포

React 앱은 npm run build로 정적 HTML, CSS, JavaScript 파일이 되므로 AWS S3와 CloudFront, Google Cloud Storage와 Cloud CDN, Vercel, Netlify 같은 정적 호스팅에 올립니다. 이때 두 가지를 확인합니다.

첫째, VITE_API_URL은 빌드할 때 번들에 들어갑니다. VITE_API_URL=https://api.example.com npm run build처럼 빌드 단계에서 운영 백엔드 주소를 넘겨야 하며, 배포한 뒤에 서버의 환경 변수를 바꿔도 이미 만든 번들은 바뀌지 않습니다. 둘째, BrowserRouter는 /project/:projectId 같은 주소를 브라우저에서 처리하므로, 그 주소에서 새로고침하면 호스팅 서버가 해당 파일을 찾지 못해 404를 돌려줄 수 있습니다. 호스팅 설정에서 모르는 경로를 모두 index.html로 돌려주도록 해 둡니다.


운영 전략

로깅과 모니터링

  • 백엔드 로그: 10장과 11장에서 본 것처럼 JSON 구조화 로그를 남기고, CloudWatch Logs나 Google Cloud Logging 같은 중앙 로그 시스템으로 모읍니다. 폴더 삭제 실패처럼 원인을 찾아야 하는 오류는 프로젝트와 리소스의 ID를 함께 남깁니다.
  • 프런트엔드 오류: Sentry 같은 도구로 브라우저에서 난 오류를 모아, 사용자 화면에서만 보이는 문제를 추적합니다.
  • 지표와 알림: 11장의 모니터링과 로깅 시스템 구축처럼 응답 시간, 오류율, 요청 수를 수집하고, 정한 기준을 넘으면 팀에 알림을 보냅니다.

SLO, RTO, RPO

운영 기준은 도구를 고르기 전에 숫자로 정합니다. SLO(Service Level Objective)는 서비스가 지킬 목표로, 예를 들어 API 응답 시간 p95 300ms 이하, 월간 요청 성공률 99.9% 이상처럼 정합니다. RTO(Recovery Time Objective)는 장애가 난 뒤 서비스를 복구하기까지 허용하는 시간이고, RPO(Recovery Point Objective)는 복구했을 때 잃어도 되는 데이터의 시간 범위입니다. 여기서는 RTO 30분, RPO 5분을 예로 씁니다.

월간 성공률을 성공 요청 수를 전체 요청 수로 나눈 값으로 정하고, 이번 달 전체 요청이 100,000건이라고 해 보겠습니다. SLO 99.9%를 지키려면 실패는 최대 100건까지 허용되며, 이 허용량을 오류 예산(error budget)이라고 합니다. 실패가 이미 80건이면 남은 오류 예산은 20건입니다. 예산이 빠르게 줄어든다면 기능 배포를 멈추고 안정화 작업을 먼저 합니다.

오류 예산은 분모가 바뀌면 다시 계산합니다. 같은 달에 요청이 1,000건 늘고 그중 10건이 실패했다면, 전체 101,000건에 실패 90건입니다. 허용 실패는 101,000 × 0.001 = 101건이므로 남은 예산은 11건입니다. 처음 계산한 20건에서 새 실패 10건만 빼 10건이라고 하면 늘어난 분모를 반영하지 못한 것입니다.

새 버전을 일부 요청에만 먼저 배포할 때는 전체 평균만 보면 새 버전의 문제가 묻힐 수 있습니다. 같은 기간의 요청 10,000건 중 새 버전이 처리한 500건에서만 실패가 5건 났다면, 전체 실패율은 5 / 10,000 = 0.05%이지만 새 버전의 실패율은 5 / 500 = 1%입니다. 전체 성공률 99.95%는 SLO를 지키지만, 새 버전의 실패율이 0.2% 이하여야 확대한다는 기준을 정했다면 새 버전은 그 기준을 넘지 못합니다. 확대 여부는 버전별 분모로 계산하며, 관측 기간과 최소 요청 수도 함께 정해 둡니다.

요청 수 제한과 중복 요청

로그인 API는 비밀번호를 계속 바꿔 넣는 공격의 대상이 되기 쉬우므로 요청 수를 제한합니다. @nestjs/throttler를 설치하고 AppModule에 기본 한도를 등록합니다.

npm install @nestjs/throttler@6
src/app.module.ts (추가 부분)
import { ThrottlerModule } from '@nestjs/throttler';

@Module({
  imports: [
    // ConfigModule, TypeOrmModule, AuthModule, ProjectModule은 그대로 둡니다.
    ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]), // 기본 한도: 1분에 100회
  ],
})
export class AppModule {}

forRoot()는 한도 값을 등록할 뿐이고, 요청 횟수를 세는 일은 ThrottlerGuard가 적용된 곳에서만 일어납니다. 이 예제는 guard를 전역으로 등록하지 않으므로, 아래처럼 로그인 핸들러에 guard를 직접 붙이고 @Throttle()로 그 경로의 한도만 더 낮춥니다. 다른 경로에도 기본 한도를 적용하려면 APP_GUARD 토큰으로 ThrottlerGuard를 전역 guard로 등록합니다.

src/auth/auth.controller.ts (login 부분)
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';

  @UseGuards(ThrottlerGuard)
  @Throttle({ default: { limit: 5, ttl: 60_000 } }) // 로그인 시도는 1분에 5회까지
  @Post('login')
  login(@Body() loginUserDto: LoginUserDto) {
    return this.authService.login(loginUserDto);
  }

ThrottlerGuard는 기본으로 요청의 IP 주소별로 횟수를 세고, 한도를 넘으면 429 Too Many Requests로 응답합니다. ttl은 밀리초 단위입니다. 로드 밸런서나 프록시 뒤에서 실행하면 모든 요청이 프록시의 IP로 보일 수 있으므로, Express의 trust proxy 설정으로 원래 클라이언트 IP를 읽게 합니다. 서버를 여러 대로 늘리면 횟수도 인스턴스마다 따로 세므로, 한도를 함께 적용하려면 Redis 같은 공용 저장소를 쓰는 storage로 바꿉니다.

외부 서비스가 계속 실패할 때는 circuit breaker로 일정 시간 요청을 보내지 않고 바로 오류를 돌려줘, 대기 중인 요청이 쌓여 전체 서비스가 느려지는 것을 막을 수 있습니다. 결제처럼 같은 요청이 두 번 처리되면 안 되는 API에는 클라이언트가 보낸 idempotency key로 중복을 거릅니다.

idempotency key는 키를 조회하고, 없으면 처리하고, 결과를 저장하는 순서만으로는 동시 요청을 막지 못합니다. 같은 키의 요청 A와 B가 동시에 조회해 둘 다 키가 없다고 보면, 둘 다 처리해 변경이 두 번 일어납니다. 그래서 고유 제약을 건 행을 먼저 삽입하는 식으로 처리 권한을 원자적으로 하나만 가져가게 하고, 같은 키에 다른 요청 본문이 오면 거부합니다. 업무 변경을 저장한 뒤 완료 기록을 남기기 전에 프로세스가 멈추는 경우도 생각해야 하며, 같은 데이터베이스라면 업무 변경과 완료 기록을 한 트랜잭션으로 묶습니다.

보안

  • 의존성 업데이트: Node.js 런타임과 NestJS, npm 패키지를 보안 패치가 적용된 버전으로 유지하고, Dependabot 같은 도구로 알려진 취약점을 알림받습니다.
  • 비밀 정보: 데이터베이스 비밀번호와 JWT_SECRET은 코드나 이미지에 넣지 않고, AWS Secrets Manager나 Google Secret Manager 같은 서비스로 관리합니다.
  • 암호화된 통신: 운영에서는 모든 통신을 HTTPS와 WSS로만 받습니다. localStorage에 둔 토큰은 페이지의 스크립트가 읽을 수 있으므로, XSS를 막는 것도 토큰을 지키는 일입니다.
  • 웹 방화벽: AWS WAF나 Cloudflare WAF로 알려진 공격 패턴과 과도한 요청을 앞단에서 거릅니다.

백업과 복구

  • 자동 백업: 관리형 데이터베이스의 자동 백업과 특정 시점 복구 기능을 켜고, 보관 기간을 RPO에 맞춥니다.
  • 복원 연습: 정기적으로 백업에서 별도 데이터베이스로 복원해 보고, 걸린 시간과 복원된 데이터의 시점을 RTO, RPO와 비교합니다. 백업을 만들기만 하고 복원해 보지 않으면 복구 절차가 실제로 동작하는지 알 수 없습니다.

확장

  • 백엔드 서버: 트래픽에 따라 인스턴스 수를 자동으로 늘리고 줄입니다. 이 API는 상태를 데이터베이스에 두므로 HTTP 요청은 어느 인스턴스로 가도 됩니다.
  • WebSocket: 서버가 여러 대이면 한 서버의 방에 보낸 code_update가 다른 서버의 연결에는 닿지 않습니다. 12장의 WebSocket과 실시간 애플리케이션처럼 Socket.IO Redis adapter로 서버 사이에 이벤트를 전달하고, 스티키 세션을 쓰거나 클라이언트의 전송 방식을 WebSocket으로만 제한합니다.
  • 데이터베이스: 읽기 요청이 많아지면 읽기 복제본을 추가합니다. 복제본은 쓰기보다 조금 늦게 반영될 수 있으므로, 저장한 직후 다시 조회하는 요청은 기본 데이터베이스로 보냅니다.

정리

  • 단위 테스트는 가짜 저장소로 서비스의 분기를 빠르게 확인하며, 실제 저장은 데이터베이스를 연결한 E2E 테스트로 확인합니다.
  • E2E 테스트는 main.ts와 같은 전역 pipe를 등록하고, 테스트 전용 데이터베이스인지 확인한 뒤 스키마를 새로 만듭니다.
  • 다단계 Dockerfile로 빌드 도구는 builder 단계에만 두고, 스테이징에서 승인한 같은 이미지를 운영에 배포합니다.
  • 운영 배포 전에 synchronize, CORS 출처, 비밀 정보처럼 개발용으로 정한 값을 운영에 맞게 바꿉니다.
  • 프런트엔드의 VITE_API_URL은 빌드할 때 정해지며, 정적 호스팅에는 모르는 경로를 index.html로 돌려주는 설정이 필요합니다.
  • SLO와 오류 예산은 같은 기간의 분모로 계산하고, 새 버전의 확대 여부는 그 버전의 요청만으로 판단합니다.
  • 로그인 API에는 요청 수 제한을, 중복되면 안 되는 API에는 원자적으로 선점하는 idempotency key를 적용합니다.

연습 문제

1. 복구 목표의 판단

장애가 난 뒤 서비스를 복구하기까지 20분이 걸렸고, 복원된 데이터는 장애 직전보다 8분 전의 상태였습니다. RTO 30분과 RPO 5분 중 어떤 목표를 지켰을까요? 서비스가 정상 응답을 돌려준다는 이유로 두 목표를 모두 지켰다고 볼 수 있을까요?

해설 보기

복구에 걸린 20분은 RTO 30분 안이므로 RTO는 지켰습니다. 하지만 8분 분량의 데이터를 잃었으므로 RPO 5분은 지키지 못했습니다.

정상 응답은 서비스가 다시 동작한다는 뜻일 뿐, 잃은 데이터가 얼마인지는 알려 주지 않습니다. RTO는 시간에, RPO는 데이터에 관한 목표이므로 따로 확인합니다. RPO를 지키려면 백업이나 복제의 간격을 5분보다 짧게 줄여야 합니다.

2. 새 버전의 실패율

요청 10,000건 중 새 버전이 처리한 500건에서 실패가 5건 났고, 나머지 버전에서는 실패가 없었습니다. 새 버전의 실패율이 0.2% 이하일 때만 배포를 확대하기로 했습니다. 전체 실패율이 0.05%이므로 확대해도 될까요?

해설 보기

안 됩니다. 확대 기준은 새 버전에 관한 것이므로 새 버전의 요청만 분모로 씁니다. 5 / 500 = 1%로 기준 0.2%의 다섯 배입니다.

전체 실패율 0.05%는 새 버전의 실패가 나머지 9,500건의 성공에 섞여 작아 보이는 값입니다. 전체 SLO를 지키는지와 새 버전을 확대할지는 서로 다른 질문이므로 각각의 분모로 계산합니다.

3. 완료 기록 전에 멈춘 프로세스

idempotency key로 보호하는 API에서, 업무 변경은 데이터베이스에 저장했지만 키의 완료 기록을 남기기 전에 프로세스가 멈췄습니다. 클라이언트가 같은 키로 다시 요청했을 때 키의 완료 기록이 없다는 이유로 다시 처리해도 될까요?

해설 보기

안 됩니다. 첫 번째 변경이 이미 저장되어 있으므로 다시 처리하면 같은 변경이 두 번 반영됩니다.

같은 데이터베이스라면 업무 변경과 키의 완료 기록을 한 트랜잭션으로 커밋해, 둘 다 저장되거나 둘 다 저장되지 않게 합니다. 외부 결제처럼 데이터베이스 밖에서 일어나는 효과가 있다면 한 트랜잭션으로 묶을 수 없으므로, 외부 서비스에 이미 처리된 요청인지 조회해 맞추는 절차를 따로 둡니다.

이것으로 13장과 NestJS 트랙을 마칩니다. 13장에서는 요구사항과 데이터 모델을 정하는 데서 시작해, 인증과 소유 확인을 갖춘 프로젝트·파일 API, 선택 실습인 실시간 Gateway, React의 인증 상태와 자동 저장 에디터를 만들고, 마지막으로 테스트와 배포, 운영 기준을 세웠습니다. 처음부터 다시 훑어보고 싶다면 NestJS 교재의 목차로 돌아가세요.