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

테스트 유형별 차이와 활용
공개 API를 운영하다 보면 예상치 못한 대량 요청에 시달릴 수 있다. Rate Limiting과 API Key 관리로 API를 보호하는 방법을 정리했다.

admin/user 두 역할로 시작했는데, 요구사항이 복잡해지면서 RBAC만으로 부족해졌다. ABAC까지 고려한 권한 설계를 정리했다.

서비스가 3개로 늘어나면서 각각 로그인을 구현하는 게 지옥이었다. SSO로 한 번의 인증으로 모든 서비스에 접근하게 만든 이야기.

비밀번호 찾기가 CS의 절반을 차지했는데, Passkey를 도입하니 비밀번호 자체가 필요 없어졌다. 근데 구현이 생각보다 복잡했다.

처음 서비스를 만들 때는 테스트 코드를 하나도 안 짰습니다. "일단 빨리 만들어서 배포하는 게 중요하지, 테스트는 나중에"라고 생각했거든요. 그런데 어느 날 작은 기능 하나를 수정했는데, 전혀 관련 없어 보이던 다른 기능이 망가졌습니다. 사용자가 신고해서 알았어요. 그때 느꼈습니다. "아, 이러다가 서비스 망하겠구나."
그래서 테스트 코드를 짜기 시작했는데, 또 문제가 생겼습니다. 어떤 테스트를 어떻게 짜야 할지 감이 안 오더라고요. 유닛 테스트, 통합 테스트, 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 테스트를 최소한으로 작성하면, 빠르면서도 신뢰할 수 있는 테스트 스위트를 만들 수 있습니다. 처음엔 귀찮지만, 한번 익숙해지면 테스트 없이는 코드를 못 짤 정도로 안심이 됩니다.