
유닛 테스트 vs 통합 테스트 vs E2E 테스트
테스트 유형별 차이와 활용

테스트 유형별 차이와 활용
REST는 왜 지금도 지배적인가, GraphQL은 어떤 문제를 해결하는가, gRPC는 언제 진짜 빛나는가. 세 프로토콜의 차이와 선택 기준을 실전 코드와 함께 정리했다.

Kubernetes는 처음엔 용어만 봐도 압도된다. Pod, ReplicaSet, Deployment, Service, Ingress가 각각 무엇이고 어떻게 연결되는지, ConfigMap과 Secret까지 실전 YAML과 함께 한 번에 정리한다.

단어와 문장을 숫자 벡터로 바꾸면 '의미'를 수학으로 계산할 수 있다. 코사인 유사도, ANN 알고리즘, OpenAI 임베딩 API까지 원리부터 실전까지 한번에 정리했다.

API는 한번 공개하면 마음대로 바꾸지 못한다. 클라이언트를 깨트리지 않으면서 API를 진화시키는 버저닝 전략 4가지를 비교하고, GitHub·Stripe·Twilio의 실제 선택을 분석한다.

처음 서비스를 만들 때는 테스트 코드를 하나도 안 짰습니다. "일단 빨리 만들어서 배포하는 게 중요하지, 테스트는 나중에"라고 생각했거든요. 그런데 어느 날 작은 기능 하나를 수정했는데, 전혀 관련 없어 보이던 다른 기능이 망가졌습니다. 사용자가 신고해서 알았어요. 그때 느꼈습니다. "아, 이러다가 서비스 망하겠구나."
그래서 테스트 코드를 짜기 시작했는데, 또 문제가 생겼습니다. 어떤 테스트를 어떻게 짜야 할지 감이 안 오더라고요. 유닛 테스트, 통합 테스트, E2E 테스트... 용어는 들어봤는데, 실제로 뭘 어떻게 테스트해야 하는지 막막했습니다.
그래서 직접 부딪혀가면서 배웠습니다. 실패하고, 고치고, 다시 시도하면서요. 그 과정에서 깨달은 걸 정리해봅니다.
유닛 테스트는 개별 함수나 클래스 하나만 따로 떼어서 테스트하는 겁니다. 처음엔 이게 왜 필요한지 이해가 안 갔어요. "함수 하나 테스트해서 뭐가 달라지겠어?"라고 생각했거든요.
그런데 이런 일이 있었습니다. 가격 계산 함수를 만들었는데, 할인율 적용 로직에 버그가 있었어요. 근데 그걸 발견한 게 사용자가 결제하려다가 이상한 금액이 나와서 문의했을 때였습니다. 완전 창피했죠.
그 이후로 가격 계산 같은 중요한 로직은 무조건 유닛 테스트를 짜기 시작했습니다:
// 가격 계산 함수
function calculatePrice(basePrice, discountRate) {
if (discountRate < 0 || discountRate > 100) {
throw new Error('할인율은 0-100 사이여야 합니다');
}
return basePrice * (1 - discountRate / 100);
}
// 유닛 테스트
describe('calculatePrice', () => {
test('정상적인 할인 계산', () => {
expect(calculatePrice(10000, 10)).toBe(9000);
});
test('할인율 0%', () => {
expect(calculatePrice(10000, 0)).toBe(10000);
});
test('할인율 100%', () => {
expect(calculatePrice(10000, 100)).toBe(0);
});
test('잘못된 할인율은 에러', () => {
expect(() => calculatePrice(10000, -10)).toThrow();
expect(() => calculatePrice(10000, 150)).toThrow();
});
});
이렇게 짜놓으니까, 나중에 가격 계산 로직을 수정할 때 안심이 되더라고요. 테스트만 돌려보면 "아, 이 수정이 기존 기능을 망가뜨리지 않았구나"를 바로 확인할 수 있으니까요.
유닛 테스트의 핵심은 빠르고 독립적이라는 겁니다. 데이터베이스도 필요 없고, API 호출도 필요 없고, 그냥 함수 하나만 실행해서 결과를 확인합니다. 그래서 수백 개의 유닛 테스트를 몇 초 만에 다 돌릴 수 있어요.
유닛 테스트만 짜다 보니 또 문제가 생겼습니다. 각 함수는 다 잘 작동하는데, 그것들을 합쳤을 때 문제가 생기는 거예요. 예를 들면 이런 식이었습니다:
그런데 실제로 사용자가 회원가입을 하면, 데이터는 저장되는데 이메일은 안 가는 거예요. 왜냐하면 이메일 발송 함수를 호출하는 걸 깜빡했거든요. 유닛 테스트로는 이런 걸 잡아낼 수 없었습니다.
그래서 통합 테스트가 필요했습니다. 여러 모듈이 함께 작동하는 걸 테스트하는 거죠:
describe('사용자 등록 플로우', () => {
test('회원가입 시 DB 저장 + 이메일 발송', async () => {
// 실제 서비스 로직 호출
const result = await userService.register({
email: 'test@example.com',
password: 'password123',
name: '홍길동'
});
// DB에 저장되었는지 확인
const savedUser = await db.users.findOne({
email: 'test@example.com'
});
expect(savedUser).toBeDefined();
expect(savedUser.name).toBe('홍길동');
// 이메일이 발송되었는지 확인
const sentEmails = await emailService.getSentEmails();
expect(sentEmails).toContainEqual(
expect.objectContaining({
to: 'test@example.com',
subject: expect.stringContaining('가입')
})
);
});
});
통합 테스트를 짜면서 깨달은 건, 실제 의존성을 사용한다는 겁니다. 유닛 테스트에서는 데이터베이스를 모킹(mocking)하지만, 통합 테스트에서는 실제 테스트 데이터베이스를 씁니다. 물론 프로덕션 DB가 아니라 테스트용 DB지만요.
그래서 유닛 테스트보다 느립니다. 하지만 "실제로 이 기능들이 함께 작동하는가?"를 확인할 수 있다는 게 큰 장점이었습니다.
통합 테스트까지 짜놓으니 많이 안심이 됐습니다. 그런데 또 문제가 생겼어요. 백엔드 로직은 다 잘 작동하는데, 프론트엔드에서 버튼을 잘못 연결해서 회원가입이 안 되는 거예요.
예를 들면:
/api/register ✅/api/signup으로 요청 ❌URL이 달라서 404 에러가 났는데, 통합 테스트로는 이걸 못 잡았습니다. 왜냐하면 통합 테스트는 백엔드만 테스트했거든요.
그래서 E2E(End-to-End) 테스트가 필요했습니다. 실제 사용자가 브라우저에서 클릭하고, 입력하고, 제출하는 전체 과정을 테스트하는 거죠:
describe('회원가입 E2E', () => {
test('사용자가 회원가입 폼을 작성하고 제출', async () => {
// 1. 회원가입 페이지로 이동
await page.goto('https://myservice.com/signup');
// 2. 폼 입력
await page.fill('[name=email]', 'test@example.com');
await page.fill('[name=password]', 'password123');
await page.fill('[name=name]', '홍길동');
// 3. 제출 버튼 클릭
await page.click('button[type=submit]');
// 4. 성공 메시지 확인
await expect(page.locator('.success-message')).toBeVisible();
await expect(page).toHaveURL('/welcome');
// 5. 실제로 로그인 가능한지 확인
await page.goto('https://myservice.com/login');
await page.fill('[name=email]', 'test@example.com');
await page.fill('[name=password]', 'password123');
await page.click('button[type=submit]');
await expect(page).toHaveURL('/dashboard');
});
});
E2E 테스트를 처음 짜봤을 때 느낀 건, 진짜 사용자 관점이라는 겁니다. 코드 내부가 어떻게 작동하는지는 신경 안 쓰고, 그냥 "사용자가 이렇게 하면 이렇게 되어야 한다"만 확인합니다.
하지만 E2E 테스트는 엄청 느립니다. 브라우저를 실제로 띄우고, 페이지를 로드하고, 클릭하고... 한 테스트에 몇 초씩 걸립니다. 그리고 가끔 이유 없이 실패하기도 해요. 네트워크가 느리거나, 페이지 로딩이 늦어지거나 하면요.
세 가지 테스트를 다 짜보고 나서 깨달은 게 있습니다. 전부 다 E2E로 테스트하면 안 된다는 거요. E2E는 느리고 불안정하니까요. 그렇다고 유닛 테스트만 짜면, 통합 문제를 못 잡습니다.
그래서 "테스트 피라미드"라는 개념을 알게 됐습니다:
/\
/E2E\ ← 적게 (느리고 비쌈)
/------\
/통합테스트\ ← 적당히 (중간 속도)
/----------\
/유닛 테스트 \ ← 많이 (빠르고 저렴)
/--------------\
제 경험상 이렇게 하는 게 좋더라고요:
유닛 테스트 (70-80%)처음엔 테스트 이름을 대충 지었습니다. test('works') 이런 식으로요. 그런데 나중에 테스트가 실패하면, 어떤 테스트가 왜 실패했는지 알 수가 없더라고요.
지금은 이렇게 짭니다:
// ❌ 나쁜 예
test('works', () => { ... });
// ✅ 좋은 예
test('할인율이 음수면 에러를 던진다', () => { ... });
test('사용자 등록 시 환영 이메일이 발송된다', () => { ... });
테스트 코드도 프로덕션 코드만큼 중요합니다. 중복 코드가 많으면 유지보수가 힘들어요. 그래서 공통 로직은 헬퍼 함수로 빼냅니다:
// 공통 테스트 헬퍼
function createTestUser(overrides = {}) {
return {
email: 'test@example.com',
password: 'password123',
name: '테스트',
...overrides
};
}
// 여러 테스트에서 재사용
test('이메일 중복 체크', async () => {
const user = createTestUser();
await userService.register(user);
await expect(userService.register(user)).rejects.toThrow('이미 존재');
});
처음엔 테스트들이 서로 의존하게 짰어요. "테스트 A가 만든 데이터를 테스트 B가 쓴다" 이런 식으로요. 그런데 테스트 A가 실패하면 테스트 B도 실패하더라고요.
지금은 각 테스트가 독립적으로 작동하도록 짭니다. beforeEach에서 필요한 데이터를 준비하고, afterEach에서 정리합니다:
describe('사용자 서비스', () => {
beforeEach(async () => {
// 각 테스트 전에 DB 초기화
await db.users.deleteMany({});
});
test('회원가입', async () => {
// 이 테스트는 다른 테스트와 무관하게 작동
});
test('로그인', async () => {
// 이 테스트도 독립적
});
});
유닛 테스트는 개별 함수의 정확성을, 통합 테스트는 모듈 간 협업을, E2E 테스트는 전체 사용자 경험을 검증합니다. 테스트 피라미드를 따라 유닛 테스트를 많이, 통합 테스트를 적당히, E2E 테스트를 최소한으로 작성하면, 빠르면서도 신뢰할 수 있는 테스트 스위트를 만들 수 있습니다. 처음엔 귀찮지만, 한번 익숙해지면 테스트 없이는 코드를 못 짤 정도로 안심이 됩니다.