유닛·통합·E2E·TDD·커버리지
이 장을 읽기 전에: 프론트엔드 또는 백엔드의 기본적인 개발 경험이 있으면 구체적인 예시와 연결하기 쉽다.
테스트의 목적은 코드의 올바름을 증명하는 것이 아니다. 본래의 목적은 안심하고 변경할 수 있는 상태를 만드는 것이다.
이 장에서는 테스트의 종류, 도구 선정, 커버리지, TDD, AI 생성 코드 시대의 검증 방침을 정리한다.
� 한국 시장 맥락: 국내 주요 IT 기업(카카오·네이버·토스·라인)은 Vitest + Playwright 조합(TypeScript 기반)을 신규 프로젝트 표준으로 채택하는 추세다. Java/Kotlin 백엔드는 JUnit5 + Mockito + Testcontainers 조합이 사실상 표준이다. 공공·SI 환경은 JUnit4 레거시와 공존하는 경우가 많다.
이 섹션이 답하는 질문: 어떤 종류의 테스트를, 어디에 두텁게 두어야 하는가?
모든 것을 E2E로 확인하는 것은 느리고 망가지기 쉽다. 반대로 유닛 테스트만으로는 실제 접속 불량을 잡을 수 없다.
유닛 테스트를 토대로 한다 (피라미드의 밑면)
중요한 경계에는 통합 테스트를 둔다
주요 업무 플로만 E2E로 눌러둔다
"뭐든 E2E"도 "뭐든 목(Mock)"도 편향되어 있다. 중요한 것은 망가졌을 때 곤란한 장소에 따라 테스트 종류를 배치하는 것이다.
이 섹션이 답하는 질문: 유닛, 통합, E2E는 어떻게 다른가?
목은 편리하지만, 너무 늘리면 본물과의 어긋남을 숨긴다.
좋은 사용법:
토스페이먼츠·카카오페이 등 외부 과금 API의 고액 호출을 피한다
시각이나 난수를 안정화한다
실패 조건을 의도적으로 만든다
나쁜 사용법:
DB 접속이나 HTTP 경계를 전부 목으로 처리한다
실제의 JSON 직렬화 차분을 보지 않는다
계약 불정합을 목으로 숨긴다
// 좋은 목 사용 예 — 외부 결제 API만 목으로 처리
// 실제 DB는 Testcontainers로 실제 DB를 사용
import { createOrder } from './order-service';
import { PaymentGateway } from './payment-gateway';
// 결제 게이트웨이만 목으로 처리 (실제 호출 방지)
jest.mock('./payment-gateway');
const mockPayment = PaymentGateway as jest.MockedClass<typeof PaymentGateway>;
test('주문 생성 성공 시 결제 게이트웨이 호출', async () => {
mockPayment.prototype.charge.mockResolvedValue({ success: true, txId: 'tx_123' });
const order = await createOrder({ userId: 1, amount: 10000 });
expect(order.status).toBe('PAID');
expect(mockPayment.prototype.charge).toHaveBeenCalledWith({
amount: 10000,
currency: 'KRW',
});
});
목은 테스트를 빠르게 하지만, 현실 그 자체로 만들지 않는다. 경계의 일부는 본물로 확인할 필요가 있다.
이 섹션이 답하는 질문: 어떤 도구를 사용해서 테스트를 작성해야 하는가?
// JUnit5 + Mockito + Testcontainers — Spring Boot 테스트 예시
@SpringBootTest
@Testcontainers
class OrderServiceTest {
@Container
companion object {
@JvmField
val postgres = PostgreSQLContainer<Nothing>("postgres:16")
.apply { withDatabaseName("test_db") }
}
@Autowired
lateinit var orderService: OrderService
@MockBean
lateinit var paymentGateway: PaymentGateway // 결제 게이트웨이만 목
@Test
fun `주문 생성 성공 시 PAID 상태 반환`() {
// given
given(paymentGateway.charge(any()))
.willReturn(PaymentResult(success = true, txId = "tx_123"))
// when
val order = orderService.createOrder(
userId = 1L,
amount = 10000, // 원화
)
// then
assertThat(order.status).isEqualTo(OrderStatus.PAID)
assertThat(order.paymentTxId).isEqualTo("tx_123")
}
}
신규 TypeScript 계열: Vitest
기존 Jest 자산이 많다: Jest 계속
Java/Kotlin Spring Boot: JUnit5 + Mockito + Testcontainers
신규 E2E: Playwright를 우선 검토
기존 Cypress 자산이 많다: Cypress 계속도 합리적
⚠️ 테스트 도구는 유행보다 기존 자산과의 정합성이 중요하다. 이미 충분히 동작하는 Jest나 Cypress를 성능 비교만으로 무리하게 이전할 필요는 없다.
신규라면 모던한 선택지가 유력하지만, 기존 프로젝트에서는 이전 비용도 포함하여 판단해야 한다.
이 섹션이 답하는 질문: TDD나 BDD는 언제 유효한가?
TDD는 다음 사이클로 진행한다.
실패하는 테스트를 작성한다 (Red)
최소한의 구현으로 통과시킨다 (Green)
리팩터한다 (Refactor)
// TDD 예시 — 원화 금액 포맷 함수
// 1단계: 실패하는 테스트 먼저 작성
describe('formatKoreanWon', () => {
it('1000 → "1,000원"', () => {
expect(formatKoreanWon(1000)).toBe('1,000원');
});
it('10000 → "1만 원"', () => {
expect(formatKoreanWon(10000)).toBe('1만 원');
});
it('100000000 → "1억 원"', () => {
expect(formatKoreanWon(100000000)).toBe('1억 원');
});
});
// 2단계: 최소한의 구현
export function formatKoreanWon(amount: number): string {
if (amount >= 100_000_000) {
return `${amount / 100_000_000}억 원`;
}
if (amount >= 10_000) {
return `${amount / 10_000}만 원`;
}
return `${amount.toLocaleString('ko-KR')}원`;
}
적합한 장면:
로직이 복잡하다 (한국어 금액 포맷, 배달비 계산, 할인 규칙)
입출력이 명확하다
라이브러리나 도메인 모델을 굳히고 싶다
BDD는, 요건을 동작으로 정리하고 싶을 때 도움이 된다. 특히 업무 플로, 승인, 권한, 시나리오 분기가 많을 때 유효하다.
// BDD 스타일 — Kotlin + Kotest
class OrderBddTest : BehaviorSpec({
given("로그인한 사용자가") {
`when`("상품을 장바구니에 담고 결제를 시도할 때") {
then("토스페이먼츠 결제창이 열려야 한다") {
// ...
}
and("결제 성공 시") {
then("주문 상태가 PAID로 변경되어야 한다") {
// ...
}
then("카카오 알림톡이 발송되어야 한다") {
// ...
}
}
}
}
})
⚠️ TDD도 BDD도 교리가 아니다. 작성할 가치가 높은 장소에 사용해야 하며, 모든 코드에 기계적으로 적용하는 것이 아니다.
TDD는 로직을 굳히는 도구, BDD는 요건을 맞추는 도구다. 만능이 아니지만, 적합한 장면에서는 강하다.
이 섹션이 답하는 질문: 커버리지는 어디까지 목표로 해야 하는가?
커버리지는 품질 그 자체가 아니다. 통과한 행이 많다는 것과, 중요한 결함을 방지할 수 있다는 것은 별개 문제다.
100%만을 목적으로 한다
스냅샷만으로 커버리지를 버는 것
중요 로직보다 얇은 잔가지를 대량으로 테스트한다
# GitHub Actions — 커버리지 임계값 설정 예시
# 차분 기반으로 커버리지 저하를 감지
- name: 테스트 & 커버리지
run: npx vitest run --coverage
- name: 커버리지 임계값 확인
run: |
COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
echo "현재 커버리지: $COVERAGE%"
if (( $(echo "$COVERAGE < 70" | bc -l) )); then
echo "❌ 커버리지가 70% 미만입니다: $COVERAGE%"
exit 1
fi
⚠️ 높은 커버리지라도, 사양의 누락이나 접속 불량은 보통 남는다. 커버리지는 "보이지 않는 장소의 지도"로 사용하는 편이 실무적이다.
커버리지는 목표값보다, 중요 부분이 정말 검증되고 있는가를 보기 위해 사용해야 한다.
이 섹션이 답하는 질문: AI 생성 코드가 늘었을 때, 테스트는 무엇을 바꿔야 하는가?
AI가 코드를 작성하게 되면 코드 양은 늘기 쉽다. 한편 다음 문제도 늘어난다.
외관은 올바르지만 경계 조건이 엄격하지 않다
목에 의존한 테스트가 늘어난다
변경 범위가 넓은데 리뷰가 따라잡지 못한다
비슷한 구현이 여러 곳에 흩어진다
통합 테스트를 두텁게 한다 — AI 코드는 경계에서 실수하기 쉬움
차분 기반의 리뷰를 강화한다
계약 테스트나 경계 테스트를 중시한다
AI가 작성한 테스트도 반드시 리뷰한다
보안 스캔 (Snyk·OWASP)을 함께 실행한다
테스트 케이스의 초안 생성 (경계값·엣지 케이스 제안)
경계 조건의 후보 제시
실패 시 로그의 요약
중복된 테스트의 정리안
테스트의 타당성 판단
중요 업무 플로의 우선순위 결정 (결제·개인정보·인증)
보안의 최종 판단
본번 장애의 재현 확인
⚠️ AI가 생성한 테스트는 "어느 정도 그럴듯한" 경우가 많다. 그렇기 때문에 위험하며, 정말로 무엇을 검증하고 있는가를 인간이 읽지 않으면 의미가 없다.
AI 시대에 필요한 것은 테스트를 줄이는 것이 아니다. 무엇을 AI에 보조시키고, 무엇을 인간이 보증하는가의 경계를 명확히 하는 것이다.
// Testcontainers — 실제 PostgreSQL을 사용한 통합 테스트
// 목 없이 실제 DB로 테스트 → AI 생성 코드의 쿼리 오류를 조기 발견
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class UserRepositoryIntegrationTest {
companion object {
@Container
@JvmField
val postgres = PostgreSQLContainer<Nothing>("postgres:16").apply {
withDatabaseName("test")
withUsername("test")
withPassword("test")
}
@DynamicPropertySource
@JvmStatic
fun configureProperties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl)
registry.add("spring.datasource.username", postgres::getUsername)
registry.add("spring.datasource.password", postgres::getPassword)
}
}
@Autowired
lateinit var userRepository: UserRepository
@Test
fun `소프트 삭제된 사용자는 조회되지 않아야 한다`() {
// given — 개인정보보호법 대응: 소프트 삭제 패턴
val user = userRepository.save(User(
name = "홍길동",
email = "test@example.com",
deletedAt = LocalDateTime.now() // 삭제된 사용자
))
// when
val found = userRepository.findActiveById(user.id)
// then
assertThat(found).isNull() // 삭제된 사용자는 조회 불가
}
}
// playwright.config.ts — 국내 서비스 E2E 설정
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
// 한국어 로케일 설정
locale: 'ko-KR',
timezoneId: 'Asia/Seoul',
},
projects: [
{ name: 'chromium', use: { channel: 'chrome' } },
{ name: 'webkit', use: {} }, // iOS Safari 대응
],
});
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('결제 플로 E2E', () => {
test('로그인 → 상품 선택 → 토스페이먼츠 결제 완료', async ({ page }) => {
// 1. 로그인 (테스트 계정 사용, 소셜 로그인은 목 처리)
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'Test1234!');
await page.click('[type="submit"]');
await expect(page).toHaveURL('/dashboard');
// 2. 상품 선택
await page.goto('/products/1');
await page.click('[data-testid="add-to-cart"]');
// 3. 장바구니 → 결제
await page.goto('/cart');
await page.click('[data-testid="checkout"]');
// 4. 결제 완료 확인 (테스트 환경: 토스페이먼츠 테스트 키 사용)
await expect(page.locator('h1')).toContainText('주문이 완료되었습니다');
await expect(page.locator('[data-testid="order-number"]')).toBeVisible();
});
});
## ISMS-P 기술적 보호 조치 관련 테스트 체크리스트
### 접근 제어 테스트
- [ ] 인증되지 않은 사용자의 개인정보 API 접근 → 401/403 반환 확인
- [ ] 다른 사용자의 데이터를 직접 ID로 접근 → 403 또는 404 반환
- [ ] 관리자 권한 없이 관리자 API 접근 → 403 반환
### 개인정보 처리 테스트
- [ ] 비밀번호가 평문으로 DB에 저장되지 않는지 확인 (BCrypt 해시)
- [ ] 로그에 개인정보(이름·전화번호·주민번호)가 평문으로 출력되지 않는지 확인
- [ ] 소프트 삭제된 개인정보가 일반 조회에서 노출되지 않는지 확인
### SQL 인젝션 방지
- [ ] 모든 쿼리가 파라미터화 쿼리 또는 ORM을 사용하는지 확인
- [ ] 사용자 입력이 직접 SQL에 삽입되지 않는지 확인
유닛, 통합, E2E, 계약 테스트의 차이를 설명할 수 있는가?
중요한 경계에 통합 테스트를 두는 이유를 설명할 수 있는가?
목(Mock)의 사용 과도가 위험한 이유를 설명할 수 있는가?
신규 프로젝트와 기존 프로젝트에서 도구 선정의 사고 방식이 다름을 설명할 수 있는가?
TDD와 BDD가 유효한 장면을 설명할 수 있는가?
커버리지가 품질 그 자체가 아닌 이유를 설명할 수 있는가?
AI 생성 코드에서는 왜 경계 테스트와 통합 테스트가 중요해지는지 설명할 수 있는가?
AI 생성 테스트를 그대로 신뢰해서는 안 되는 이유를 설명할 수 있는가?
Testcontainers를 사용한 통합 테스트가 목(Mock) 기반보다 신뢰성이 높은 이유를 설명할 수 있는가?
ISMS-P 기술적 보호 조치와 테스트 설계의 연관성을 설명할 수 있는가?
⚠️ 편집 노트: 본 문서는 지속적으로 보완 중입니다. Vitest 최신 버전 기능, Spring Boot 테스트 관련 업데이트, Playwright 국내 서비스 통합 패턴은 공식 문서를 통해 최신 정보를 확인하세요.
©2024-2026 MDRules dev., Hand-crafted & made with Jaewoo Kim.
이메일문의: jaewoo@mdrules.dev
AI강의/개발/기술자문, AI 업무 자동화 컨설팅 문의: https://talk.naver.com/ct/w5umt5
AI 업무 자동화/에이전트/워크플로우설계 컨설팅/AI교육: https://mdrules.dev
이 작가의 멤버십 구독자 전용 콘텐츠입니다.
작가의 명시적 동의 없이 저작물을 공유, 게재 시 법적 제재를 받을 수 있습니다.
오직 멤버십 구독자만 볼 수 있는,
이 작가의 특별 연재 콘텐츠