- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며: 왜 테스트하는가
소프트웨어 버그의 비용은 발견 시점이 늦을수록 기하급수적으로 증가한다. 개발 단계에서 발견하면 수정 비용이 1이라면, QA 단계에서는 10, 프로덕션에서는 100 이상이 된다. IBM Systems Sciences Institute의 연구에 따르면 프로덕션에서 발견된 결함의 수정 비용은 설계 단계 대비 최대 100배에 달한다.
테스트는 단순히 버그를 잡는 행위가 아니다. 코드의 설계를 개선하고, 리팩토링의 안전망을 제공하며, 팀 간 의사소통의 도구가 된다. 잘 작성된 테스트는 살아있는 문서이자 시스템의 명세서다.
테스트의 ROI
테스트 작성에 드는 시간을 비용으로 보는 팀이 있다. 하지만 실제로는 정반대다.
디버깅 시간 감소: 테스트가 잘 갖춰진 코드베이스에서는 문제 발생 시 원인 파악이 빠르다. 어떤 테스트가 실패했는지만 보면 된다.
리팩토링 자신감: 테스트 없이 레거시 코드를 수정하는 것은 눈을 감고 수술하는 것과 같다. 테스트가 있으면 구조를 대담하게 변경할 수 있다.
배포 속도 향상: 자동화된 테스트 스위트는 수동 QA 시간을 대폭 줄인다. CI/CD 파이프라인에서 자동으로 품질을 검증한다.
문서화 효과: 테스트 코드는 사용 예제이자 행동 명세서다. 새로운 팀원이 시스템을 이해하는 가장 빠른 방법이다.
1. 테스트 피라미드
테스트 피라미드는 Mike Cohn이 제안한 개념으로, 테스트의 종류별 비율을 시각적으로 표현한다.
피라미드 구조
/ E2E \ ~10% 느리지만 사용자 관점 검증
/ 통합 \ ~20% 컴포넌트 간 상호작용 검증
/ 유닛 \ ~70% 빠르고 격리된 단위 검증
유닛 테스트 (70%): 가장 아래층에 위치하며 개별 함수, 메서드, 클래스를 독립적으로 검증한다. 실행이 빠르고 작성이 쉽다.
통합 테스트 (20%): 중간층으로 여러 컴포넌트가 함께 동작하는 것을 검증한다. API 엔드포인트, 데이터베이스 쿼리, 외부 서비스 연동 등을 테스트한다.
E2E 테스트 (10%): 최상위층으로 실제 사용자 시나리오를 전체적으로 검증한다. 브라우저 자동화를 통해 전체 워크플로를 테스트한다.
역피라미드 안티패턴
많은 팀이 E2E 테스트에 과도하게 의존하는 역피라미드(아이스크림 콘) 패턴에 빠진다.
\ E2E / ~70% 느리고 불안정
\ 통합 / ~20%
\ 유닛 / ~10% 거의 없음
이 패턴의 문제점은 명확하다.
- E2E 테스트는 느려서 피드백 루프가 길어진다
- 플레이키(flaky) 테스트가 빈번하게 발생한다
- 실패 원인 파악이 어렵다
- 유지보수 비용이 기하급수적으로 증가한다
올바른 접근은 피라미드 하단부터 탄탄하게 쌓는 것이다. 유닛 테스트로 핵심 로직을 보호하고, 통합 테스트로 연결 부위를 검증하며, E2E 테스트로 핵심 사용자 여정만 확인한다.
2. 유닛 테스트
유닛 테스트는 가장 작은 단위의 코드를 격리하여 검증하는 테스트다.
좋은 유닛 테스트의 조건: FIRST 원칙
Fast(빠름): 밀리초 단위로 실행되어야 한다. 느린 유닛 테스트는 개발자가 자주 실행하지 않게 만든다.
Isolated(격리): 다른 테스트에 의존하지 않고, 외부 시스템(DB, 네트워크, 파일시스템)에 의존하지 않는다.
Repeatable(반복가능): 어떤 환경에서든, 몇 번을 실행하든 같은 결과를 반환한다.
Self-validating(자기검증): 테스트 결과가 자동으로 pass/fail로 판별된다. 수동 확인이 필요하면 안 된다.
Timely(적시): 프로덕션 코드를 작성하기 전이나 직후에 작성한다.
모킹(Mocking) 전략
외부 의존성을 격리하기 위해 모킹을 사용한다. 하지만 과도한 모킹은 테스트를 구현 세부사항에 결합시킨다.
# Python - pytest + unittest.mock
from unittest.mock import Mock, patch
import pytest
class PaymentService:
def __init__(self, gateway):
self.gateway = gateway
def charge(self, amount, card_token):
if amount <= 0:
raise ValueError("Amount must be positive")
return self.gateway.process_payment(amount, card_token)
class TestPaymentService:
def test_charge_positive_amount(self):
# Arrange
mock_gateway = Mock()
mock_gateway.process_payment.return_value = "txn_123"
service = PaymentService(mock_gateway)
# Act
result = service.charge(100, "card_abc")
# Assert
assert result == "txn_123"
mock_gateway.process_payment.assert_called_once_with(100, "card_abc")
def test_charge_negative_amount_raises(self):
mock_gateway = Mock()
service = PaymentService(mock_gateway)
with pytest.raises(ValueError, match="Amount must be positive"):
service.charge(-50, "card_abc")
mock_gateway.process_payment.assert_not_called()
// JavaScript - Jest
describe('PaymentService', () => {
it('should charge positive amount', async () => {
const mockGateway = {
processPayment: jest.fn().mockResolvedValue('txn_123'),
};
const service = new PaymentService(mockGateway);
const result = await service.charge(100, 'card_abc');
expect(result).toBe('txn_123');
expect(mockGateway.processPayment).toHaveBeenCalledWith(100, 'card_abc');
});
it('should reject negative amount', async () => {
const mockGateway = { processPayment: jest.fn() };
const service = new PaymentService(mockGateway);
await expect(service.charge(-50, 'card_abc'))
.rejects.toThrow('Amount must be positive');
expect(mockGateway.processPayment).not.toHaveBeenCalled();
});
});
커버리지의 함정
코드 커버리지 100%를 목표로 삼는 것은 잘못된 접근이다. 중요한 것은 커버리지 숫자가 아니라 테스트의 품질이다.
의미 있는 커버리지: 비즈니스 로직, 경계 조건, 에러 처리를 검증한다.
무의미한 커버리지: getter/setter, 단순 위임 메서드, 프레임워크 코드를 테스트하는 것은 시간 낭비다.
실용적인 목표는 핵심 비즈니스 로직 80% 이상, 전체 코드 60~80% 정도다. 100% 커버리지보다 의미 있는 assertion을 가진 테스트가 더 중요하다.
3. 통합 테스트
통합 테스트는 여러 컴포넌트가 함께 동작하는 것을 검증한다. 실제 데이터베이스, 메시지 큐, 외부 API와의 상호작용을 테스트한다.
API 테스트
REST API 테스트는 통합 테스트의 대표적인 예다.
# Python - FastAPI + pytest + httpx
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.mark.asyncio
async def test_create_user():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/users", json={
"name": "Alice",
"email": "alice@example.com"
})
assert response.status_code == 201
data = response.json()
assert data["name"] == "Alice"
assert "id" in data
@pytest.mark.asyncio
async def test_create_user_duplicate_email():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
await client.post("/users", json={
"name": "Alice",
"email": "alice@example.com"
})
response = await client.post("/users", json={
"name": "Bob",
"email": "alice@example.com"
})
assert response.status_code == 409
테스트컨테이너(Testcontainers)
테스트컨테이너는 Docker 컨테이너를 활용하여 실제 데이터베이스, Redis, Kafka 등을 테스트에 사용하는 라이브러리다. 목(mock) 대신 실제 인프라를 사용하므로 더 신뢰도 높은 테스트가 가능하다.
// Java - Testcontainers + JUnit 5
@Testcontainers
@SpringBootTest
class UserRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndFindUser() {
User user = new User("Alice", "alice@example.com");
userRepository.save(user);
Optional<User> found = userRepository.findByEmail("alice@example.com");
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("Alice");
}
@Test
void shouldReturnEmptyForNonExistentUser() {
Optional<User> found = userRepository.findByEmail("nobody@example.com");
assertThat(found).isEmpty();
}
}
# Python - testcontainers
from testcontainers.postgres import PostgresContainer
import psycopg2
def test_user_crud():
with PostgresContainer("postgres:16") as postgres:
conn = psycopg2.connect(
host=postgres.get_container_host_ip(),
port=postgres.get_exposed_port(5432),
user=postgres.username,
password=postgres.password,
dbname=postgres.dbname,
)
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(100) UNIQUE
)
""")
cursor.execute(
"INSERT INTO users (name, email) VALUES (%s, %s) RETURNING id",
("Alice", "alice@example.com")
)
user_id = cursor.fetchone()[0]
cursor.execute("SELECT name FROM users WHERE id = %s", (user_id,))
assert cursor.fetchone()[0] == "Alice"
conn.commit()
conn.close()
DB 테스트 전략
데이터베이스 테스트에서 주의할 점은 다음과 같다.
테스트 격리: 각 테스트는 독립적이어야 한다. 트랜잭션 롤백이나 테스트 전 데이터 초기화를 사용한다.
마이그레이션 검증: 스키마 변경이 기존 데이터와 호환되는지 검증한다.
시드 데이터 관리: 테스트에 필요한 기초 데이터를 체계적으로 관리한다. Factory 패턴이나 Fixture를 활용한다.
4. E2E 테스트
E2E(End-to-End) 테스트는 실제 사용자 관점에서 전체 시스템을 검증한다.
Playwright
Playwright는 Microsoft가 개발한 브라우저 자동화 프레임워크로, Chromium, Firefox, WebKit을 모두 지원한다.
// Playwright 테스트 예제
import { test, expect } from '@playwright/test';
test.describe('사용자 로그인 흐름', () => {
test('유효한 자격증명으로 로그인', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('이메일').fill('user@example.com');
await page.getByLabel('비밀번호').fill('SecurePass123');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('환영합니다')).toBeVisible();
});
test('잘못된 비밀번호로 로그인 실패', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('이메일').fill('user@example.com');
await page.getByLabel('비밀번호').fill('wrong');
await page.getByRole('button', { name: '로그인' }).click();
await expect(page.getByText('이메일 또는 비밀번호가 잘못되었습니다')).toBeVisible();
await expect(page).toHaveURL('/login');
});
});
Cypress
Cypress는 JavaScript E2E 테스트 프레임워크로, 개발자 경험이 뛰어나다.
// Cypress 테스트 예제
describe('장바구니 기능', () => {
beforeEach(() => {
cy.visit('/products');
});
it('상품을 장바구니에 추가', () => {
cy.get('[data-testid="product-card"]').first().within(() => {
cy.get('[data-testid="add-to-cart"]').click();
});
cy.get('[data-testid="cart-badge"]').should('have.text', '1');
cy.get('[data-testid="cart-icon"]').click();
cy.get('[data-testid="cart-item"]').should('have.length', 1);
});
it('장바구니에서 수량 변경', () => {
cy.get('[data-testid="product-card"]').first()
.find('[data-testid="add-to-cart"]').click();
cy.get('[data-testid="cart-icon"]').click();
cy.get('[data-testid="quantity-increase"]').click();
cy.get('[data-testid="quantity-input"]').should('have.value', '2');
});
});
플레이키(Flaky) 테스트 대응
플레이키 테스트는 같은 코드인데도 때때로 성공하고 때때로 실패하는 테스트다. E2E 테스트에서 특히 빈번하다.
원인과 대책:
- 타이밍 문제: 명시적 대기(explicit wait)를 사용한다.
sleep이 아니라 특정 조건을 기다린다 - 테스트 간 의존성: 각 테스트를 독립적으로 만든다. 공유 상태를 제거한다
- 네트워크 불안정: API 응답을 모킹하거나, 재시도 로직을 추가한다
- 동적 데이터: 테스트 데이터를 고정하거나 패턴 매칭을 사용한다
// Playwright - 플레이키 테스트 방지 패턴
test('데이터 로딩 후 목록 표시', async ({ page }) => {
await page.goto('/users');
// 나쁜 예: 고정 시간 대기
// await page.waitForTimeout(3000);
// 좋은 예: 특정 요소가 나타날 때까지 대기
await page.waitForSelector('[data-testid="user-list"]');
const users = page.getByTestId('user-item');
await expect(users).toHaveCount(10);
});
5. TDD (테스트 주도 개발)
TDD는 Kent Beck이 체계화한 개발 방법론으로, 테스트를 먼저 작성하고 그 테스트를 통과하는 코드를 작성하는 방식이다.
Red-Green-Refactor 사이클
Red (실패): 아직 존재하지 않는 기능에 대한 테스트를 작성한다. 당연히 실패한다.
Green (성공): 테스트를 통과시키는 최소한의 코드를 작성한다. 코드의 우아함은 고려하지 않는다.
Refactor (개선): 테스트가 통과하는 상태를 유지하면서 코드를 개선한다. 중복 제거, 네이밍 개선, 구조 변경 등을 수행한다.
실전 예제: 비밀번호 검증기
# Step 1: Red - 실패하는 테스트 작성
def test_password_minimum_length():
validator = PasswordValidator()
assert validator.validate("short") == False
def test_password_requires_uppercase():
validator = PasswordValidator()
assert validator.validate("longpassword1") == False
def test_password_requires_number():
validator = PasswordValidator()
assert validator.validate("LongPassword") == False
def test_valid_password():
validator = PasswordValidator()
assert validator.validate("SecurePass123") == True
# Step 2: Green - 테스트를 통과시키는 코드
class PasswordValidator:
def validate(self, password: str) -> bool:
if len(password) < 8:
return False
if not any(c.isupper() for c in password):
return False
if not any(c.isdigit() for c in password):
return False
return True
# Step 3: Refactor - 코드 개선
from dataclasses import dataclass
from typing import List, Callable
@dataclass
class ValidationRule:
check: Callable[[str], bool]
message: str
class PasswordValidator:
def __init__(self):
self.rules: List[ValidationRule] = [
ValidationRule(
check=lambda p: len(p) >= 8,
message="Password must be at least 8 characters"
),
ValidationRule(
check=lambda p: any(c.isupper() for c in p),
message="Password must contain uppercase letter"
),
ValidationRule(
check=lambda p: any(c.isdigit() for c in p),
message="Password must contain a digit"
),
]
def validate(self, password: str) -> bool:
return all(rule.check(password) for rule in self.rules)
def get_errors(self, password: str) -> List[str]:
return [
rule.message
for rule in self.rules
if not rule.check(password)
]
TDD의 핵심 원칙
- 한 번에 하나의 테스트만 실패시킨다: 여러 테스트를 한꺼번에 작성하지 않는다
- 가장 간단한 구현부터 시작한다: 하드코딩으로 시작해도 괜찮다
- 테스트가 코드를 이끈다: 테스트 없이 프로덕션 코드를 작성하지 않는다
- 작은 스텝으로 전진한다: 큰 도약보다 작은 반복이 안전하다
6. BDD (행동 주도 개발)
BDD는 Dan North가 제안한 방법론으로, 비즈니스 요구사항을 자연어에 가까운 형태로 기술하고 이를 실행 가능한 테스트로 변환한다.
Given-When-Then 패턴
BDD에서는 시나리오를 세 부분으로 나누어 기술한다.
Given (주어진 상태): 테스트의 전제 조건을 기술한다.
When (행동): 테스트하려는 행동을 기술한다.
Then (결과): 기대하는 결과를 기술한다.
Cucumber 예제
# features/login.feature
Feature: 사용자 로그인
사용자가 올바른 자격증명으로 시스템에 로그인할 수 있다
Scenario: 성공적인 로그인
Given 이메일 "user@example.com"으로 등록된 사용자가 있다
And 비밀번호는 "SecurePass123"이다
When 로그인 페이지에서 올바른 자격증명으로 로그인한다
Then 대시보드 페이지로 이동한다
And "환영합니다" 메시지가 표시된다
Scenario: 잘못된 비밀번호로 로그인 실패
Given 이메일 "user@example.com"으로 등록된 사용자가 있다
When 잘못된 비밀번호 "wrongpass"로 로그인한다
Then 로그인 페이지에 남아있다
And 에러 메시지가 표시된다
Scenario Outline: 비밀번호 정책 검증
When 비밀번호 "<password>"로 회원가입을 시도한다
Then 결과는 "<result>"이다
Examples:
| password | result |
| short | 실패 |
| NoDigitHere | 실패 |
| secure123Pass | 성공 |
# step_definitions/login_steps.py
from behave import given, when, then
@given('이메일 "{email}"으로 등록된 사용자가 있다')
def step_user_exists(context, email):
context.user = create_test_user(email=email)
@when('로그인 페이지에서 올바른 자격증명으로 로그인한다')
def step_login_with_valid_credentials(context):
context.response = context.client.post('/login', json={
'email': context.user.email,
'password': context.user.password,
})
@then('대시보드 페이지로 이동한다')
def step_redirected_to_dashboard(context):
assert context.response.headers['Location'] == '/dashboard'
BDD의 가치
BDD는 기술팀과 비즈니스팀 사이의 의사소통 도구다. Gherkin 형식의 시나리오는 개발자가 아닌 사람도 읽고 이해할 수 있다. 이를 통해 요구사항의 오해를 줄이고, 인수 조건을 명확하게 정의할 수 있다.
7. 성능 테스트
성능 테스트는 시스템이 부하 상황에서 어떻게 동작하는지 검증한다.
성능 테스트의 종류
부하 테스트(Load Test): 예상 트래픽 수준에서 시스템이 정상 동작하는지 검증한다.
스트레스 테스트(Stress Test): 시스템의 한계점을 찾기 위해 점진적으로 부하를 증가시킨다.
스파이크 테스트(Spike Test): 갑작스러운 트래픽 급증에 시스템이 어떻게 반응하는지 검증한다.
내구성 테스트(Soak Test): 장시간 동안 지속적인 부하를 가해 메모리 누수 등의 문제를 탐지한다.
k6를 활용한 부하 테스트
k6는 Grafana Labs에서 개발한 현대적인 부하 테스트 도구다. JavaScript로 테스트를 작성하며 CLI에서 실행한다.
// load-test.js - k6 부하 테스트
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';
const errorRate = new Rate('errors');
const responseTime = new Trend('response_time');
export const options = {
stages: [
{ duration: '2m', target: 100 }, // 2분간 100명까지 증가
{ duration: '5m', target: 100 }, // 5분간 100명 유지
{ duration: '2m', target: 200 }, // 2분간 200명까지 증가
{ duration: '5m', target: 200 }, // 5분간 200명 유지
{ duration: '2m', target: 0 }, // 2분간 0명으로 감소
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% 요청이 500ms 미만
errors: ['rate<0.01'], // 에러율 1% 미만
},
};
export default function () {
const loginRes = http.post('https://api.example.com/login', JSON.stringify({
username: 'testuser',
password: 'testpass',
}), {
headers: { 'Content-Type': 'application/json' },
});
const success = check(loginRes, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
'has auth token': (r) => r.json('token') !== undefined,
});
errorRate.add(!success);
responseTime.add(loginRes.timings.duration);
if (loginRes.status === 200) {
const token = loginRes.json('token');
const profileRes = http.get('https://api.example.com/profile', {
headers: { Authorization: `Bearer ${token}` },
});
check(profileRes, {
'profile status is 200': (r) => r.status === 200,
});
}
sleep(1);
}
JMeter
Apache JMeter는 오래된 역사를 가진 성능 테스트 도구다. GUI 기반으로 테스트 계획을 작성할 수 있어 비개발자도 사용할 수 있다. 다만 k6에 비해 리소스 소비가 크고 스크립트 작성이 번거로운 편이다.
k6 vs JMeter 비교:
| 항목 | k6 | JMeter |
|---|---|---|
| 스크립트 언어 | JavaScript | XML (GUI) |
| 리소스 효율 | Go 기반, 경량 | Java 기반, 무거움 |
| CI/CD 통합 | CLI 기반으로 쉬움 | 가능하지만 설정 복잡 |
| 프로토콜 지원 | HTTP, WebSocket, gRPC | 매우 다양 |
| 분산 실행 | k6 Cloud | 별도 설정 필요 |
| 학습 곡선 | 낮음 | 중간 |
8. 보안 테스트
보안 테스트는 애플리케이션의 취약점을 사전에 발견하고 대응하는 과정이다.
OWASP ZAP
OWASP ZAP(Zed Attack Proxy)은 오픈소스 웹 애플리케이션 보안 스캐너다. CI/CD 파이프라인에 통합하여 자동으로 보안 스캔을 수행할 수 있다.
# GitHub Actions에서 ZAP 스캔 실행
name: Security Scan
on:
pull_request:
branches: [main]
jobs:
zap-scan:
runs-on: ubuntu-latest
steps:
- name: Start Application
run: docker compose up -d
- name: ZAP Baseline Scan
uses: zaproxy/action-baseline@v0.12.0
with:
target: 'http://localhost:3000'
rules_file_name: '.zap-rules.tsv'
cmd_options: '-a -j -l WARN'
- name: Upload Report
uses: actions/upload-artifact@v4
if: always()
with:
name: zap-report
path: report_html.html
퍼징(Fuzzing)
퍼징은 무작위 또는 반무작위 입력을 프로그램에 주입하여 예상치 못한 동작이나 크래시를 유발하는 테스트 기법이다.
# Python - Hypothesis를 활용한 프로퍼티 기반 테스트
from hypothesis import given, strategies as st
import json
@given(st.text())
def test_json_roundtrip(s):
"""어떤 문자열이든 JSON 인코딩/디코딩 후 원본과 같아야 한다"""
encoded = json.dumps(s)
decoded = json.loads(encoded)
assert decoded == s
@given(st.integers(min_value=0, max_value=1000))
def test_discount_never_exceeds_price(price):
"""할인 후 가격이 음수가 되면 안 된다"""
discount = calculate_discount(price, percentage=50)
assert discount >= 0
assert discount <= price
의존성 취약점 스캔
서드파티 라이브러리의 알려진 취약점을 감지하는 것도 보안 테스트의 중요한 부분이다.
# GitHub Actions - 의존성 취약점 스캔
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
- name: npm audit
run: npm audit --audit-level=high
- name: Python safety check
run: pip install safety && safety check
9. 카오스 엔지니어링
카오스 엔지니어링은 프로덕션 환경에서 의도적으로 장애를 주입하여 시스템의 복원력을 검증하는 실험 방법이다. Netflix가 선구자 역할을 했으며, 현재는 많은 대규모 서비스에서 도입하고 있다.
카오스 엔지니어링의 원칙
- 정상 상태에 대한 가설을 세운다: 시스템이 정상적으로 동작하는 상태를 정의한다
- 실세계 이벤트를 시뮬레이션한다: 서버 장애, 네트워크 지연, 디스크 부족 등을 주입한다
- 프로덕션에서 실험한다: 스테이징이 아닌 실제 환경에서 테스트해야 진정한 복원력을 검증할 수 있다
- 폭발 반경을 최소화한다: 실험이 사용자에게 미치는 영향을 최소화한다
- 자동화한다: 지속적으로 실험을 수행한다
Chaos Monkey
Netflix가 개발한 Chaos Monkey는 무작위로 프로덕션 인스턴스를 종료시킨다. 이를 통해 인스턴스 하나가 죽어도 서비스가 정상 동작하는지 검증한다.
Simian Army라는 더 넓은 도구 모음도 있다. Latency Monkey(네트워크 지연 주입), Conformity Monkey(비표준 인스턴스 종료), Chaos Gorilla(전체 가용영역 장애 시뮬레이션) 등이 포함된다.
LitmusChaos
LitmusChaos는 Kubernetes 환경에 특화된 카오스 엔지니어링 플랫폼이다. CNCF 프로젝트로 클라우드 네이티브 환경에서 널리 사용된다.
# LitmusChaos - Pod 삭제 실험
apiVersion: litmuschaos.io/v1alpha1
kind: ChaosEngine
metadata:
name: pod-delete-chaos
namespace: production
spec:
appinfo:
appns: 'production'
applabel: 'app=payment-service'
appkind: 'deployment'
engineState: 'active'
chaosServiceAccount: litmus-admin
experiments:
- name: pod-delete
spec:
components:
env:
- name: TOTAL_CHAOS_DURATION
value: '30'
- name: CHAOS_INTERVAL
value: '10'
- name: FORCE
value: 'false'
probe:
- name: payment-health-check
type: httpProbe
httpProbe/inputs:
url: 'http://payment-service:8080/health'
method:
get:
criteria: '=='
responseCode: '200'
mode: Continuous
runProperties:
probeTimeout: 5
interval: 2
retry: 3
# LitmusChaos - 네트워크 지연 실험
apiVersion: litmuschaos.io/v1alpha1
kind: ChaosEngine
metadata:
name: network-delay-chaos
spec:
appinfo:
appns: 'production'
applabel: 'app=order-service'
appkind: 'deployment'
engineState: 'active'
chaosServiceAccount: litmus-admin
experiments:
- name: pod-network-latency
spec:
components:
env:
- name: NETWORK_LATENCY
value: '2000'
- name: TOTAL_CHAOS_DURATION
value: '60'
- name: NETWORK_INTERFACE
value: 'eth0'
게임 데이(Game Day)
게임 데이는 팀 전체가 참여하여 장애 시나리오를 실행하고 대응하는 연습이다.
게임 데이 진행 순서:
- 계획: 테스트할 장애 시나리오를 선정한다 (예: 주 데이터베이스 장애)
- 가설 수립: 장애 발생 시 예상되는 시스템 동작을 정의한다
- 실행: 장애를 주입하고 시스템의 반응을 관찰한다
- 관찰: 모니터링 대시보드, 알림, 로그를 실시간으로 확인한다
- 분석: 가설과 실제 결과를 비교하고 개선점을 도출한다
- 개선: 발견된 약점을 수정하고 다음 게임 데이를 계획한다
게임 데이 시나리오 예시:
- 주 데이터베이스 페일오버가 30초 내에 완료되는가
- 한 서비스가 다운되었을 때 의존 서비스들이 적절히 폴백하는가
- CDN이 장애나면 오리진 서버가 트래픽을 감당할 수 있는가
- 배포 중 롤백이 필요한 상황에서 자동 롤백이 동작하는가
10. 테스트 자동화와 CI/CD 통합
모든 테스트 전략은 CI/CD 파이프라인에 통합되어야 가치를 발휘한다.
테스트 파이프라인 설계
# GitHub Actions - 전체 테스트 파이프라인
name: Test Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Unit Tests
run: |
npm ci
npm run test:unit -- --coverage
- name: Upload Coverage
uses: codecov/codecov-action@v4
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Run Integration Tests
run: |
npm ci
npm run test:integration
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
e2e-tests:
runs-on: ubuntu-latest
needs: integration-tests
steps:
- uses: actions/checkout@v4
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E Tests
run: npm run test:e2e
- name: Upload Test Report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
security-scan:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- name: Run Security Scan
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
severity: 'CRITICAL,HIGH'
performance-test:
runs-on: ubuntu-latest
needs: integration-tests
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Run k6 Load Test
uses: grafana/k6-action@v0.3.1
with:
filename: tests/performance/load-test.js
테스트 전략 매트릭스
| 테스트 유형 | 실행 시점 | 실행 빈도 | 실행 시간 | 차단 여부 |
|---|---|---|---|---|
| 유닛 테스트 | 모든 PR/커밋 | 매우 빈번 | 초~분 | 반드시 차단 |
| 통합 테스트 | 모든 PR | 빈번 | 분 | 반드시 차단 |
| E2E 테스트 | PR, 머지 | 일일 | 분~시간 | 주요 흐름 차단 |
| 성능 테스트 | 머지 후 | 주간 | 시간 | 임계치 초과 시 |
| 보안 테스트 | PR, 야간 | 일일 | 분 | CRITICAL 차단 |
| 카오스 테스트 | 게임 데이 | 월간 | 시간 | 비차단 (관찰) |
마치며: 테스트 문화를 만드는 것이 핵심
도구와 기법은 중요하지만, 가장 중요한 것은 테스트를 당연하게 여기는 문화를 만드는 것이다.
테스트를 기술 부채로 보지 않는다. 테스트 작성 시간은 투자이지 비용이 아니다.
테스트를 코드 리뷰의 필수 항목으로 만든다. 새로운 기능에 테스트가 없으면 리뷰를 통과하지 못하게 한다.
실패하는 테스트를 방치하지 않는다. 깨진 테스트를 그대로 두면 테스트에 대한 신뢰가 무너진다.
테스트 인프라에 투자한다. CI/CD 파이프라인, 테스트 환경, 테스트 데이터 관리에 시간과 자원을 투자한다.
테스트는 소프트웨어 품질의 마지막 방어선이 아니라 첫 번째 공격 라인이다. 잘 짜인 테스트 전략은 개발 속도를 높이고, 버그를 줄이며, 팀의 자신감을 키운다. 유닛 테스트부터 시작하여 점진적으로 테스트 영역을 넓혀가자.