icon
13장 : 실전 프로젝트

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


지난 절에서는 NestJS 백엔드 API와 React 프론트엔드를 통합하여 온라인 코드 에디터의 기본적인 기능을 구현했습니다. 이제 실전 프로젝트의 마지막 단계로, 개발된 애플리케이션의 품질을 확보하고, 사용자에게 안정적으로 서비스를 제공하며, 지속적으로 운영 및 개선하기 위한 전략을 수립하는 시간을 갖겠습니다.

테스트, 배포, 운영은 소프트웨어 개발 생명주기(SDLC)에서 개발만큼이나 중요한 부분입니다. 이 단계들을 효과적으로 계획하고 실행해야만 성공적인 프로젝트를 완성할 수 있습니다.


테스트 전략 수립

테스트는 소프트웨어의 버그를 줄이고, 기능이 요구사항에 맞게 동작하는지 확인하며, 미래의 변경 사항으로부터 시스템을 보호하는 데 필수적입니다. NestJS는 테스트 프레임워크인 Jest를 기본으로 제공하여 다양한 유형의 테스트를 쉽게 작성할 수 있도록 돕습니다.

백엔드 (NestJS) 테스트

단위 테스트 (Unit Tests)

  • 목표: 개별 함수, 클래스, 메서드와 같이 애플리케이션의 가장 작은 단위가 예상대로 동작하는지 검증합니다. 외부 의존성(데이터베이스, 외부 API 등)은 모킹(Mocking) 처리합니다.
  • 대상: 서비스(Service), 유틸리티 함수, 엔티티의 비즈니스 로직, DTO 유효성 검사 등.
  • Jest 활용: NestJS CLI는 서비스, 컨트롤러, 게이트웨이 등을 생성할 때 기본 단위 테스트 스펙 파일(.spec.ts)을 함께 생성합니다.
    // src/auth/auth.service.spec.ts (예시)
    import { Test, TestingModule } from '@nestjs/testing';
    import { AuthService } from './auth.service';
    import { getRepositoryToken } from '@nestjs/typeorm';
    import { User } from './entities/user.entity';
    import { JwtService } from '@nestjs/jwt';
    import { ConfigService } from '@nestjs/config';
    
    describe('AuthService', () => {
      let service: AuthService;
      let usersRepository: any; // Mock Repository
      let jwtService: JwtService;
    
      beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
          providers: [
            AuthService,
            {
              provide: getRepositoryToken(User),
              useValue: {
                findOne: jest.fn(),
                create: jest.fn(),
                save: jest.fn(),
              },
            },
            {
              provide: JwtService,
              useValue: {
                sign: jest.fn(() => 'mock_jwt_token'),
              },
            },
            {
              provide: ConfigService,
              useValue: {
                get: jest.fn((key: string) => {
                  if (key === 'JWT_SECRET') return 'test_secret';
                  return null;
                }),
              },
            },
          ],
        }).compile();
    
        service = module.get<AuthService>(AuthService);
        usersRepository = module.get(getRepositoryToken(User));
        jwtService = module.get<JwtService>(JwtService);
      });
    
      it('should be defined', () => {
        expect(service).toBeDefined();
      });
    
      it('should register a user', async () => {
        usersRepository.findOne.mockResolvedValue(null); // 기존 사용자 없음
        usersRepository.create.mockReturnValue({ id: '1', email: 'test@example.com' });
        usersRepository.save.mockResolvedValue({ id: '1', email: 'test@example.com' });
    
        const result = await service.register({
          email: 'test@example.com',
          password: 'password123',
          nickname: 'Tester',
        });
        expect(result).toEqual({ message: 'User registered successfully' });
        expect(usersRepository.create).toHaveBeenCalledWith(
          expect.objectContaining({ email: 'test@example.com' })
        );
      });
    
      it('should throw ConflictException if email already registered', async () => {
        usersRepository.findOne.mockResolvedValue({}); // 기존 사용자 존재
    
        await expect(
          service.register({
            email: 'test@example.com',
            password: 'password123',
            nickname: 'Tester',
          })
        ).rejects.toThrow('Email already registered');
      });
      // ... 로그인 테스트 등
    });

통합 테스트 (Integration Tests)

  • 목표: 여러 컴포넌트(컨트롤러, 서비스, 리포지토리)가 함께 동작하며 예상대로 통합되는지 검증합니다. 실제 데이터베이스 또는 외부 서비스의 모의 객체를 사용합니다.
  • 대상: API 엔드포인트, 미들웨어, 가드, 데이터베이스 상호작용 등.
  • Jest + Supertest 활용: NestJS는 통합 테스트를 위해 E2E Testing 섹션에서 app.e2e-spec.ts 파일을 제공하며, supertest 라이브러리를 사용하여 HTTP 요청을 시뮬레이션합니다.
    // src/project/project.controller.e2e-spec.ts (간략화된 예시)
    import { Test, TestingModule } from '@nestjs/testing';
    import * as request from 'supertest';
    import { INestApplication } from '@nestjs/common';
    import { AppModule } from '../app.module';
    import { AuthModule } from '../auth/auth.module';
    import { ProjectModule } from './project.module';
    import { TypeOrmModule } from '@nestjs/typeorm';
    import { User } from '../auth/entities/user.entity';
    import { Project } from './entities/project.entity';
    import { File } from '../file/entities/file.entity';
    import { Folder } from '../folder/entities/folder.entity';
    import { AuthService } from '../auth/auth.service';
    import { JwtService } from '@nestjs/jwt';
    
    describe('ProjectController (e2e)', () => {
      let app: INestApplication;
      let authService: AuthService;
      let jwtService: JwtService;
      let accessToken: string;
      let testUser: User;
    
      beforeAll(async () => {
        const moduleFixture: TestingModule = await Test.createTestingModule({
          imports: [
            AppModule, // 전체 AppModule 임포트 (통합 테스트용 DB 설정)
            TypeOrmModule.forRoot({ // 테스트용 인메모리 DB 또는 별도 DB 설정
                type: 'sqlite', // 개발용으로 sqlite 사용
                database: ':memory:',
                entities: [User, Project, File, Folder],
                synchronize: true,
            }),
            AuthModule,
            ProjectModule,
          ],
        }).compile();
    
        app = moduleFixture.createNestApplication();
        await app.init();
    
        authService = moduleFixture.get<AuthService>(AuthService);
        jwtService = moduleFixture.get<JwtService>(JwtService);
    
        // 테스트 유저 생성 및 토큰 발급
        testUser = await authService['usersRepository'].save({ // private 접근 주의
            email: 'test@example.com',
            password: await (authService as any)['bcrypt'].hash('password123', 10),
            nickname: 'TestUser',
        });
        accessToken = (await authService.login({ email: 'test@example.com', password: 'password123' })).accessToken;
      });
    
      afterAll(async () => {
        await app.close();
      });
    
      it('/projects (POST) - creates a project', async () => {
        return request(app.getHttpServer())
          .post('/projects')
          .set('Authorization', `Bearer ${accessToken}`)
          .send({ name: 'My Test Project' })
          .expect(201)
          .expect((res) => {
            expect(res.body.name).toEqual('My Test Project');
            expect(res.body.ownerId).toEqual(testUser.id);
          });
      });
    
      it('/projects (GET) - gets all user projects', async () => {
        await request(app.getHttpServer())
          .post('/projects')
          .set('Authorization', `Bearer ${accessToken}`)
          .send({ name: 'Another Project' });
    
        return request(app.getHttpServer())
          .get('/projects')
          .set('Authorization', `Bearer ${accessToken}`)
          .expect(200)
          .expect((res) => {
            expect(res.body.length).toBeGreaterThanOrEqual(2);
            expect(res.body[0].ownerId).toEqual(testUser.id);
          });
      });
      // ... 기타 프로젝트, 파일, 폴더 API 테스트
    });

E2E 테스트 (End-to-End Tests)

  • 목표: 실제 사용자 시나리오를 모방하여 전체 시스템(프론트엔드, 백엔드, 데이터베이스)이 완벽하게 통합되어 동작하는지 검증합니다.
  • 도구: Cypress, Playwright, Selenium 등.
  • 적용: 사용자 가입 -> 로그인 -> 프로젝트 생성 -> 파일 편집 -> 실시간 동기화 확인과 같은 복합적인 시나리오 테스트.

프론트엔드 (React) 테스트

  • 단위 테스트: 컴포넌트의 렌더링, 이벤트 처리 등 개별 UI 컴포넌트의 동작을 Jest + React Testing Library로 테스트합니다.
  • 통합 테스트: 여러 컴포넌트가 결합된 페이지의 동작을 테스트합니다. API 목킹을 통해 백엔드 의존성을 제거합니다.
  • E2E 테스트: 백엔드와 마찬가지로 Cypress나 Playwright를 사용하여 사용자 관점에서 전체 애플리케이션의 흐름을 테스트합니다.

테스트 자동화 (CI)

  • 지속적 통합 (CI): GitHub Actions, GitLab CI/CD, Jenkins와 같은 CI 도구를 사용하여 코드가 저장소에 푸시될 때마다 자동으로 테스트를 실행하고 코드 품질을 검증합니다. 모든 테스트가 통과해야만 다음 단계(배포)로 넘어갈 수 있도록 설정합니다.

배포 전략 수립 (Deployment Strategy)

안정적이고 효율적인 배포는 서비스 운영의 핵심입니다.

환경 구성

  • 개발 환경 (Development): 개발자가 로컬에서 코드 작성 및 테스트를 수행하는 환경. Docker Compose를 사용하여 PostgreSQL, Redis 등을 쉽게 실행할 수 있도록 구성합니다.
  • 스테이징 환경 (Staging): 프로덕션 환경과 최대한 유사하게 구성하여 최종 테스트 및 QA를 수행하는 환경.
  • 운영 환경 (Production): 실제 사용자들이 접근하는 서비스 환경. 고가용성, 확장성, 보안을 최우선으로 고려합니다.

백엔드 (NestJS) 배포

  • 컨테이너화: NestJS 애플리케이션을 Docker 이미지로 빌드합니다. Dockerfile을 작성하여 Node.js 런타임과 애플리케이션 코드를 포함시킵니다.
    # Dockerfile (간략화)
    # 빌드 환경
    FROM node:20-alpine AS builder
    
    WORKDIR /app
    
    COPY package*.json ./
    RUN npm install --omit=dev # 프로덕션 종속성만 설치
    
    COPY . .
    RUN npm run build # NestJS 애플리케이션 빌드
    
    # 실행 환경
    FROM node:20-alpine
    
    WORKDIR /app
    
    COPY --from=builder /app/node_modules ./node_modules
    COPY --from=builder /app/dist ./dist
    COPY package.json ./package.json
    
    EXPOSE 3000
    
    CMD ["node", "dist/main"]
  • 클라우드 서비스 선택
    • AWS ECS (Fargate): 서버리스 컨테이너 서비스로, 인프라 관리가 적고 확장성이 뛰어납니다. WebSocket 지원.
    • Google Cloud Run: 컨테이너를 서버리스 방식으로 실행하는 서비스로, 자동 스케일링과 HTTP/WebSocket 지원이 강력합니다.
    • Kubernetes (EKS/GKE): 더 세밀한 제어와 복잡한 오케스트레이션이 필요할 때 선택. (초기 단계에서는 오버헤드가 클 수 있음)
  • 데이터베이스 배포: AWS RDS (PostgreSQL) 또는 Google Cloud SQL (PostgreSQL)과 같은 관리형 데이터베이스 서비스를 사용합니다. 안정성, 백업, 복구, 확장성을 자동으로 처리해 줍니다.
  • Redis 배포: AWS ElastiCache (Redis) 또는 Google Cloud Memorystore (Redis)와 같은 관리형 캐싱 서비스를 사용합니다.
  • CI/CD 파이프라인
    • 소스 코드 관리: GitHub, GitLab.
    • CI 도구: GitHub Actions, GitLab CI/CD, AWS CodePipeline.
    • 워크플로우 예시

      개발자가 main 브랜치에 코드 푸시.

      CI 도구가 자동으로 트리거.

      코드 빌드, 단위/통합 테스트 실행.

      테스트 통과 시 Docker 이미지 빌드 및 컨테이너 레지스트리(ECR, GCR, Docker Hub)에 푸시.

      CD 도구가 새 이미지를 사용하여 스테이징 환경에 배포.

      QA 및 최종 승인 후, 동일 이미지를 운영 환경에 배포.

프론트엔드 (React) 배포

  • 정적 파일 호스팅: React 애플리케이션은 빌드 시 정적 HTML, CSS, JavaScript 파일로 변환됩니다.
  • 클라우드 서비스 선택
    • AWS S3 + CloudFront: S3에 빌드된 정적 파일을 호스팅하고, CloudFront (CDN)를 사용하여 전 세계 사용자에게 빠르게 콘텐츠를 전송합니다. HTTPS 설정 및 캐싱에 유리합니다.
    • Google Cloud Storage + Cloud CDN: AWS S3/CloudFront와 유사한 기능 제공.
    • Vercel, Netlify: 개발자 친화적인 배포 플랫폼으로, Git 저장소 연동을 통해 CI/CD를 쉽게 구축할 수 있습니다.
  • CI/CD 파이프라인: 백엔드와 유사하게 Git 푸시 시 자동으로 빌드되고 정적 호스팅 서비스로 배포되도록 설정합니다.

운영 전략 수립

서비스를 배포한 후에도 안정적으로 운영하고 지속적으로 개선하기 위한 전략이 필요합니다.

모니터링 및 로깅

  • 로깅
    • 백엔드: NestJS의 내장 로깅 기능을 활용하거나, Winston, Pino와 같은 라이브러리를 사용하여 구조화된 로그를 생성합니다. 로그는 CloudWatch Logs, Google Cloud Logging, ELK Stack(Elasticsearch, Logstash, Kibana)과 같은 중앙 집중식 로깅 시스템으로 전송합니다.
    • 프론트엔드: 브라우저 콘솔 로그 외에, Sentry, LogRocket, Datadog RUM과 같은 클라이언트 측 에러 모니터링 도구를 사용하여 사용자 경험에 영향을 미치는 문제를 추적합니다.
  • 모니터링
    • 인프라 모니터링: CPU 사용량, 메모리 사용량, 네트워크 트래픽, 디스크 사용량 등을 클라우드 공급자(AWS CloudWatch, Google Cloud Monitoring)의 기본 모니터링 도구로 추적합니다.
    • 애플리케이션 모니터링 (APM): 서비스의 응답 시간, 에러율, 트랜잭션 수 등을 New Relic, Datadog APM, Prometheus + Grafana와 같은 도구로 모니터링합니다. NestJS 애플리케이션의 특정 엔드포인트 성능, 데이터베이스 쿼리 시간 등을 측정합니다.
    • 알림 (Alerting): 정의된 임계값을 초과할 때(예: 에러율 5% 이상, CPU 사용량 80% 이상) 개발팀에 자동으로 알림(Slack, Email, PagerDuty)이 전송되도록 설정합니다.

보안 관리

  • 정기적인 보안 업데이트: Node.js 런타임, NestJS 및 모든 npm 패키지를 최신 보안 패치가 적용된 버전으로 유지합니다. Dependabot과 같은 도구를 사용하여 취약점을 자동으로 감지하고 알립니다.
  • 환경 변수 관리: 민감한 정보(DB 비밀번호, API 키, JWT Secret)는 코드에 직접 하드코딩하지 않고, 환경 변수, AWS Secrets Manager, Google Secret Manager와 같은 보안 서비스를 통해 관리합니다.
  • WAF (Web Application Firewall): AWS WAF, Cloudflare WAF 등을 사용하여 SQL Injection, XSS와 같은 일반적인 웹 공격으로부터 애플리케이션을 보호합니다.
  • HTTPS/WSS 강제: 모든 클라이언트-서버 통신은 TLS/SSL (HTTPS/WSS)을 통해 암호화되도록 강제합니다.

백업 및 복구

  • 데이터베이스 백업: PostgreSQL 데이터베이스의 정기적인 자동 백업을 설정하고, 복구 전략을 수립합니다. (AWS RDS의 자동 백업 기능 활용)
  • 데이터 복원 테스트: 주기적으로 백업된 데이터로 복원 테스트를 수행하여 복구 절차가 정상적으로 작동하는지 확인합니다.

스케일링 전략

  • 자동 스케일링: 사용자 트래픽 변화에 따라 백엔드 서버 인스턴스(ECS Fargate, Cloud Run)가 자동으로 늘어나거나 줄어들도록 설정합니다.
  • 데이터베이스 확장: 읽기 부하가 커지면 읽기 복제본(Read Replicas)을 추가하고, 쓰기 부하가 커지면 샤딩(Sharding) 또는 클러스터링을 고려합니다.
  • WebSocket 스케일링: Socket.IO Redis Adapter를 사용하여 여러 NestJS WebSocket 서버 인스턴스 간에 이벤트가 동기화되도록 구성합니다.

이번 13장 "실전 프로젝트"에서는 온라인 코드 에디터 및 실시간 협업 도구를 기획하고, NestJS 백엔드와 React 프론트엔드를 통합하여 구현하는 과정을 경험했습니다. 마지막으로, 개발된 시스템의 안정적인 서비스 제공을 위한 테스트, 배포, 운영 전략을 수립하는 중요성도 강조했습니다.

소프트웨어 개발은 단순히 코드를 작성하는 것을 넘어, 비즈니스 요구사항을 이해하고, 효율적으로 설계하며, 품질을 보증하고, 최종적으로 사용자에게 가치를 전달하는 전체 과정입니다. 이 책을 통해 얻은 NestJS에 대한 지식과 실전 프로젝트 경험이 여러분의 다음 프로젝트에 큰 도움이 되기를 바랍니다.