오늘만 무료

2026 소프트웨어 테스트 완전 가이드

유닛·통합·E2E·TDD·커버리지

by AI개발자
소프트웨어엔지니어링_0.png
이 장을 읽기 전에: 프론트엔드 또는 백엔드의 기본적인 개발 경험이 있으면 구체적인 예시와 연결하기 쉽다.


테스트의 목적은 코드의 올바름을 증명하는 것이 아니다. 본래의 목적은 안심하고 변경할 수 있는 상태를 만드는 것이다.

이 장에서는 테스트의 종류, 도구 선정, 커버리지, TDD, AI 생성 코드 시대의 검증 방침을 정리한다.

� 한국 시장 맥락: 국내 주요 IT 기업(카카오·네이버·토스·라인)은 Vitest + Playwright 조합(TypeScript 기반)을 신규 프로젝트 표준으로 채택하는 추세다. Java/Kotlin 백엔드는 JUnit5 + Mockito + Testcontainers 조합이 사실상 표준이다. 공공·SI 환경은 JUnit4 레거시와 공존하는 경우가 많다.



1. 테스트 전략

이 섹션이 답하는 질문: 어떤 종류의 테스트를, 어디에 두텁게 두어야 하는가?


기본 사고 방식

모든 것을 E2E로 확인하는 것은 느리고 망가지기 쉽다. 반대로 유닛 테스트만으로는 실제 접속 불량을 잡을 수 없다.

swe-2026-12-01.png


원칙

유닛 테스트를 토대로 한다 (피라미드의 밑면)

중요한 경계에는 통합 테스트를 둔다

주요 업무 플로만 E2E로 눌러둔다


판단 플로

swe-2026-12-02.png


정리

"뭐든 E2E"도 "뭐든 목(Mock)"도 편향되어 있다. 중요한 것은 망가졌을 때 곤란한 장소에 따라 테스트 종류를 배치하는 것이다.



2. 테스트의 종류

이 섹션이 답하는 질문: 유닛, 통합, E2E는 어떻게 다른가?


비교

swe-2026-12-03.png

목(Mock)의 사용법

목은 편리하지만, 너무 늘리면 본물과의 어긋남을 숨긴다.


좋은 사용법:

토스페이먼츠·카카오페이 등 외부 과금 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',

});

});


정리

목은 테스트를 빠르게 하지만, 현실 그 자체로 만들지 않는다. 경계의 일부는 본물로 확인할 필요가 있다.



3. 테스트 프레임워크 선정

이 섹션이 답하는 질문: 어떤 도구를 사용해서 테스트를 작성해야 하는가?


주요 선택지

swe-2026-12-04.png


TypeScript / JavaScript

swe-2026-12-05.png


Java / Kotlin (Spring Boot) — 국내 엔터프라이즈 표준


// 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")

}

}


E2E

swe-2026-12-06.png


선택 방법

신규 TypeScript 계열: Vitest

기존 Jest 자산이 많다: Jest 계속

Java/Kotlin Spring Boot: JUnit5 + Mockito + Testcontainers

신규 E2E: Playwright를 우선 검토

기존 Cypress 자산이 많다: Cypress 계속도 합리적


⚠️ 테스트 도구는 유행보다 기존 자산과의 정합성이 중요하다. 이미 충분히 동작하는 Jest나 Cypress를 성능 비교만으로 무리하게 이전할 필요는 없다.


정리

신규라면 모던한 선택지가 유력하지만, 기존 프로젝트에서는 이전 비용도 포함하여 판단해야 한다.



4. TDD / BDD의 실천적인 위치

이 섹션이 답하는 질문: TDD나 BDD는 언제 유효한가?


TDD

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는, 요건을 동작으로 정리하고 싶을 때 도움이 된다. 특히 업무 플로, 승인, 권한, 시나리오 분기가 많을 때 유효하다.


// BDD 스타일 — Kotlin + Kotest

class OrderBddTest : BehaviorSpec({

given("로그인한 사용자가") {

`when`("상품을 장바구니에 담고 결제를 시도할 때") {

then("토스페이먼츠 결제창이 열려야 한다") {

// ...

}

and("결제 성공 시") {

then("주문 상태가 PAID로 변경되어야 한다") {

// ...

}

then("카카오 알림톡이 발송되어야 한다") {

// ...

}

}

}

}

})


주의점

⚠️ TDD도 BDD도 교리가 아니다. 작성할 가치가 높은 장소에 사용해야 하며, 모든 코드에 기계적으로 적용하는 것이 아니다.


정리

TDD는 로직을 굳히는 도구, BDD는 요건을 맞추는 도구다. 만능이 아니지만, 적합한 장면에서는 강하다.



5. 커버리지의 사고 방식

이 섹션이 답하는 질문: 커버리지는 어디까지 목표로 해야 하는가?


먼저 파악해야 할 것

커버리지는 품질 그 자체가 아니다. 통과한 행이 많다는 것과, 중요한 결함을 방지할 수 있다는 것은 별개 문제다.


현실적인 사용법

swe-2026-12-07.png


무엇이 위험한가

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


⚠️ 높은 커버리지라도, 사양의 누락이나 접속 불량은 보통 남는다. 커버리지는 "보이지 않는 장소의 지도"로 사용하는 편이 실무적이다.


정리

커버리지는 목표값보다, 중요 부분이 정말 검증되고 있는가를 보기 위해 사용해야 한다.



6. AI 시대의 테스트 전략

이 섹션이 답하는 질문: AI 생성 코드가 늘었을 때, 테스트는 무엇을 바꿔야 하는가?


무엇이 변하는가

AI가 코드를 작성하게 되면 코드 양은 늘기 쉽다. 한편 다음 문제도 늘어난다.

외관은 올바르지만 경계 조건이 엄격하지 않다

목에 의존한 테스트가 늘어난다

변경 범위가 넓은데 리뷰가 따라잡지 못한다

비슷한 구현이 여러 곳에 흩어진다


대응 방침

통합 테스트를 두텁게 한다 — AI 코드는 경계에서 실수하기 쉬움

차분 기반의 리뷰를 강화한다

계약 테스트나 경계 테스트를 중시한다

AI가 작성한 테스트도 반드시 리뷰한다

보안 스캔 (Snyk·OWASP)을 함께 실행한다


AI에 적합한 것

테스트 케이스의 초안 생성 (경계값·엣지 케이스 제안)

경계 조건의 후보 제시

실패 시 로그의 요약

중복된 테스트의 정리안


AI에 너무 맡겨서는 안 되는 것

테스트의 타당성 판단

중요 업무 플로의 우선순위 결정 (결제·개인정보·인증)

보안의 최종 판단

본번 장애의 재현 확인


⚠️ AI가 생성한 테스트는 "어느 정도 그럴듯한" 경우가 많다. 그렇기 때문에 위험하며, 정말로 무엇을 검증하고 있는가를 인간이 읽지 않으면 의미가 없다.


정리

AI 시대에 필요한 것은 테스트를 줄이는 것이 아니다. 무엇을 AI에 보조시키고, 무엇을 인간이 보증하는가의 경계를 명확히 하는 것이다.



7. 국내 테스트 실무 특이사항

Spring Boot Testcontainers — PostgreSQL 통합 테스트


// 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 — 카카오·네이버 로그인 E2E 테스트


// 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 관련 테스트 고려사항


## 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


이 작가의 멤버십 구독자 전용 콘텐츠입니다.
작가의 명시적 동의 없이 저작물을 공유, 게재 시 법적 제재를 받을 수 있습니다.

brunch membership
AI개발자작가님의 멤버십을 시작해 보세요!

AI Workflow Architect, LLM Engineer, Vibe Engineering, Claude Code, AI 업무 자동화 컨설팅/AI강의

83 구독자

오직 멤버십 구독자만 볼 수 있는,
이 작가의 특별 연재 콘텐츠

  • 최근 30일간 24개의 멤버십 콘텐츠 발행
  • 총 44개의 혜택 콘텐츠
최신 발행글 더보기
이전 11화2026 모니터링, 옵저버빌리티 완전 가이드