본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 11. 3. · 15 Views
TDD 핵심 개념 완벽 정리
테스트 주도 개발(TDD)의 핵심 개념을 초급 개발자도 쉽게 이해할 수 있도록 정리했습니다. Red-Green-Refactor 사이클부터 실무 적용 노하우까지, TDD의 모든 것을 다룹니다.
목차
- TDD란 무엇인가 - 테스트 주도 개발의 기본 개념
- Red-Green-Refactor 사이클 - TDD의 핵심 워크플로우
- 단위 테스트 작성하기 - 가장 작은 단위로 테스트하는 방법
- 테스트 더블 활용하기 - Mock, Stub, Spy의 이해
- 테스트 커버리지 이해하기 - 얼마나 테스트했는가
- 통합 테스트의 중요성 - 컴포넌트 간 상호작용 검증
- 테스트 우선 vs 테스트 후 작성 - TDD의 진정한 가치
- 리팩토링과 TDD - 안전하게 코드 개선하기
- TDD 시작하기 - 실전 적용 가이드
1. TDD란 무엇인가 - 테스트 주도 개발의 기본 개념
시작하며
여러분이 새로운 기능을 개발할 때 이런 상황을 겪어본 적 있나요? 코드를 다 작성하고 나서 테스트를 해보니 예상과 다르게 동작하고, 어디서부터 잘못됐는지 찾기가 막막한 상황 말이죠.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 코드를 먼저 작성하고 나중에 테스트하면, 버그를 찾는데 시간이 오래 걸리고, 코드의 설계가 테스트하기 어려운 구조로 만들어지기 쉽습니다.
바로 이럴 때 필요한 것이 TDD(Test-Driven Development)입니다. 테스트를 먼저 작성하고 그 테스트를 통과하는 코드를 만드는 방식으로, 개발 과정에서 발생하는 많은 문제를 사전에 방지할 수 있습니다.
개요
간단히 말해서, TDD는 테스트 코드를 먼저 작성하고, 그 테스트를 통과하는 최소한의 코드를 작성한 후, 코드를 개선하는 개발 방법론입니다. 왜 이 방법이 필요할까요?
실무에서는 요구사항이 자주 변경되고, 여러 개발자가 협업하며, 코드의 복잡도가 점점 증가합니다. 예를 들어, 결제 시스템을 개발할 때 다양한 예외 상황(카드 승인 실패, 네트워크 오류 등)을 미리 테스트로 정의해두면, 실제 운영 환경에서 발생할 수 있는 문제를 사전에 파악할 수 있습니다.
기존에는 코드를 먼저 작성하고 나중에 테스트를 추가했다면, 이제는 테스트를 먼저 작성하고 그 테스트를 통과하는 코드를 만들 수 있습니다. 이렇게 하면 코드가 자연스럽게 테스트 가능한 구조로 설계됩니다.
TDD의 핵심 특징은 세 가지입니다. 첫째, 실패하는 테스트를 먼저 작성합니다(Red).
둘째, 테스트를 통과하는 최소한의 코드를 작성합니다(Green). 셋째, 코드를 리팩토링하여 품질을 개선합니다(Refactor).
이러한 특징들이 중요한 이유는 개발 과정에서 명확한 목표를 제시하고, 코드의 품질을 지속적으로 높일 수 있기 때문입니다.
코드 예제
// 테스트 코드를 먼저 작성합니다 (Red 단계)
describe('Calculator', () => {
test('두 숫자를 더한 결과를 반환해야 합니다', () => {
const calculator = new Calculator();
const result = calculator.add(2, 3);
expect(result).toBe(5);
});
});
// 테스트를 통과하는 최소한의 코드를 작성합니다 (Green 단계)
class Calculator {
add(a, b) {
return a + b;
}
}
설명
이것이 하는 일: TDD는 개발자가 코드를 작성하기 전에 명확한 목표(테스트)를 설정하고, 그 목표를 달성하는 방식으로 개발을 진행하도록 돕습니다. 첫 번째로, Red 단계에서는 실패하는 테스트를 작성합니다.
위 예제에서 Calculator 클래스가 아직 존재하지 않는 상태에서 테스트를 작성하므로, 이 테스트는 실패합니다. 왜 이렇게 하는지 궁금하실 텐데요, 실패하는 테스트를 먼저 작성하면 우리가 구현해야 할 기능을 명확하게 정의할 수 있고, 테스트가 제대로 동작하는지 확인할 수 있습니다.
그 다음으로, Green 단계에서는 테스트를 통과하는 최소한의 코드를 작성합니다. Calculator 클래스를 만들고 add 메서드를 구현하면 테스트가 통과됩니다.
내부에서는 단순히 두 숫자를 더한 값을 반환하는 로직만 실행됩니다. 이 단계에서는 완벽한 코드보다 테스트를 통과하는 것이 목표입니다.
마지막으로, Refactor 단계에서는 코드의 품질을 개선합니다. 중복 제거, 변수명 개선, 성능 최적화 등을 진행하면서도 테스트가 계속 통과하는지 확인합니다.
최종적으로 깨끗하고 유지보수하기 쉬운 코드를 만들어냅니다. 여러분이 이 방법을 사용하면 버그가 적고 유지보수하기 쉬운 코드를 작성할 수 있습니다.
또한 코드 변경 시 기존 기능이 깨지지 않았는지 빠르게 확인할 수 있고, 다른 개발자가 코드를 이해하기 쉬워집니다. 테스트 자체가 살아있는 문서 역할을 하기 때문입니다.
실전 팁
💡 처음에는 간단한 기능부터 TDD를 적용해보세요. 복잡한 기능에 바로 적용하면 어렵게 느껴질 수 있습니다.
💡 테스트를 먼저 작성할 때 "이 기능이 성공하려면 어떤 조건을 만족해야 하는가?"를 생각하면 좋은 테스트를 작성할 수 있습니다.
💡 Green 단계에서는 완벽한 코드를 작성하려고 하지 마세요. 일단 테스트를 통과시킨 후 Refactor 단계에서 개선하는 것이 TDD의 핵심입니다.
💡 테스트가 실패할 때 당황하지 마세요. Red 단계에서 실패는 정상이며, 오히려 테스트가 제대로 동작한다는 증거입니다.
💡 TDD를 할 때는 작은 단위로 자주 커밋하세요. Red-Green-Refactor 사이클마다 커밋하면 문제가 생겼을 때 되돌리기 쉽습니다.
2. Red-Green-Refactor 사이클 - TDD의 핵심 워크플로우
시작하며
여러분이 TDD를 처음 시작할 때 이런 고민을 해본 적 있나요? 테스트를 먼저 작성하라는데, 정확히 어떤 순서로 무엇을 해야 하는지 막막하신가요?
이런 혼란은 TDD의 구체적인 작업 흐름을 모르기 때문에 발생합니다. 많은 초급 개발자들이 TDD를 시도하다가 "언제 테스트를 작성하고, 언제 코드를 작성해야 하는지" 헷갈려서 포기하곤 합니다.
바로 이럴 때 필요한 것이 Red-Green-Refactor 사이클입니다. 이 명확한 3단계 프로세스를 따르면, TDD를 체계적이고 효율적으로 실천할 수 있습니다.
개요
간단히 말해서, Red-Green-Refactor는 TDD의 작업 흐름을 나타내는 3단계 사이클입니다. Red(실패하는 테스트 작성) → Green(테스트 통과) → Refactor(코드 개선) 순서로 반복합니다.
왜 이 사이클이 필요한지 실무 관점에서 설명하면, 개발자가 명확한 가이드라인 없이 작업하면 중요한 단계를 건너뛰거나 순서가 뒤바뀌어 TDD의 효과를 제대로 누리지 못합니다. 예를 들어, 사용자 인증 기능을 개발할 때 이 사이클을 따르면 "비밀번호 검증 실패", "토큰 생성", "세션 관리" 등 각 단계를 체계적으로 구현할 수 있습니다.
기존에는 머릿속으로 대충 계획을 세우고 코딩을 시작했다면, 이제는 각 단계마다 명확한 목표와 완료 조건을 가지고 개발할 수 있습니다. 이 사이클의 핵심 특징은 세 가지입니다.
첫째, Red 단계에서는 반드시 실패하는 테스트를 확인합니다. 둘째, Green 단계에서는 최소한의 코드만 작성합니다.
셋째, Refactor 단계에서는 테스트가 통과하는 상태를 유지하면서 코드를 개선합니다. 이러한 특징들이 중요한 이유는 개발 과정에서 발생할 수 있는 실수를 방지하고, 코드의 품질을 점진적으로 높일 수 있기 때문입니다.
코드 예제
// Red: 실패하는 테스트 작성
test('유효한 이메일인지 검증해야 합니다', () => {
expect(isValidEmail('test@example.com')).toBe(true);
expect(isValidEmail('invalid-email')).toBe(false);
});
// Green: 테스트를 통과하는 최소 코드
function isValidEmail(email) {
return email.includes('@');
}
// Refactor: 코드 개선 (정규식 사용)
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
설명
이것이 하는 일: Red-Green-Refactor 사이클은 개발자가 체계적으로 기능을 구현하고 코드 품질을 높이는 구조화된 프로세스를 제공합니다. 첫 번째로, Red 단계에서는 실패하는 테스트를 작성하고 실행합니다.
위 예제에서 isValidEmail 함수가 아직 정의되지 않았거나 잘못 구현되어 있으면 테스트가 실패합니다. 왜 이렇게 하는지 설명하면, 실패를 확인함으로써 테스트가 실제로 우리가 검증하려는 것을 검증하고 있다는 것을 확신할 수 있습니다.
만약 테스트가 항상 통과한다면, 그 테스트는 아무것도 검증하지 않는 무의미한 테스트일 수 있습니다. 그 다음으로, Green 단계에서는 테스트를 통과시키는 것만 집중합니다.
처음에는 단순히 includes('@')로 이메일을 검증했습니다. 이것은 완벽하지 않지만 테스트를 통과시킵니다.
내부에서는 "일단 동작하게 만들기"가 목표이며, 이 과정에서 문제의 본질에 집중할 수 있습니다. 마지막으로, Refactor 단계에서는 테스트가 통과하는 상태를 유지하면서 코드를 개선합니다.
정규식을 사용하여 더 정확한 이메일 검증을 구현하고, 각 리팩토링 후마다 테스트를 실행하여 기능이 여전히 동작하는지 확인합니다. 최종적으로 깨끗하고 정확한 코드를 만들어냅니다.
여러분이 이 사이클을 사용하면 개발 속도가 빨라지고, 버그가 줄어들며, 코드 변경에 대한 두려움이 사라집니다. 테스트가 안전망 역할을 하기 때문에 자신감 있게 리팩토링할 수 있고, 각 단계마다 명확한 목표가 있어 집중력이 높아집니다.
또한 이 사이클을 반복하면서 자연스럽게 모듈화되고 테스트 가능한 코드를 작성하게 됩니다.
실전 팁
💡 Red 단계에서 테스트를 실행했을 때 정말로 실패하는지 꼭 확인하세요. 테스트가 실패하지 않는다면 뭔가 잘못된 것입니다.
💡 Green 단계에서는 "가장 빠르게" 테스트를 통과시키는 것이 목표입니다. 하드코딩을 해도 괜찮습니다. 나중에 Refactor 단계에서 개선하면 됩니다.
💡 Refactor 단계에서는 한 번에 하나씩만 변경하세요. 변경할 때마다 테스트를 실행하여 기능이 깨지지 않았는지 확인하는 것이 안전합니다.
💡 각 사이클은 짧게 유지하세요. 이상적으로는 5-10분 안에 한 사이클을 완료하는 것이 좋습니다. 너무 크면 여러 개의 작은 사이클로 나누세요.
💡 Refactor 단계를 건너뛰지 마세요. 많은 개발자가 시간에 쫓겨 이 단계를 생략하는데, 이것이 기술 부채가 쌓이는 주요 원인입니다.
3. 단위 테스트 작성하기 - 가장 작은 단위로 테스트하는 방법
시작하며
여러분이 복잡한 애플리케이션을 개발할 때 이런 상황을 겪어본 적 있나요? 어떤 버그가 발생했는데, 문제가 있는 부분을 찾기 위해 전체 애플리케이션을 실행하고 여러 단계를 거쳐야 해서 디버깅에 몇 시간씩 걸리는 상황 말이죠.
이런 문제는 테스트의 범위가 너무 넓을 때 발생합니다. 전체 시스템을 한 번에 테스트하면 문제의 원인을 찾기 어렵고, 테스트 실행 시간도 오래 걸리며, 다른 부분의 영향을 받아 테스트가 불안정해집니다.
바로 이럴 때 필요한 것이 단위 테스트입니다. 함수나 메서드 같은 가장 작은 단위로 테스트하면, 문제를 빠르게 찾을 수 있고 코드의 각 부분이 독립적으로 올바르게 동작하는지 확인할 수 있습니다.
개요
간단히 말해서, 단위 테스트는 애플리케이션의 가장 작은 단위(함수, 메서드, 클래스)가 의도한 대로 동작하는지 검증하는 테스트입니다. 왜 단위 테스트가 필요한지 실무 관점에서 설명하면, 대규모 프로젝트에서는 수백, 수천 개의 함수가 서로 연결되어 있습니다.
이런 환경에서 각 함수가 올바르게 동작한다는 것을 보장하지 않으면, 작은 버그가 눈덩이처럼 커져서 전체 시스템에 영향을 미칩니다. 예를 들어, 가격 계산 함수에 버그가 있으면 주문, 결제, 정산 등 모든 관련 기능에 문제가 생길 수 있습니다.
기존에는 전체 애플리케이션을 실행하고 수동으로 테스트했다면, 이제는 개별 함수를 격리된 환경에서 자동으로 테스트할 수 있습니다. 이렇게 하면 몇 초 만에 수백 개의 테스트를 실행할 수 있습니다.
단위 테스트의 핵심 특징은 세 가지입니다. 첫째, 빠릅니다.
외부 의존성(데이터베이스, API 등) 없이 실행되므로 밀리초 단위로 완료됩니다. 둘째, 독립적입니다.
다른 테스트나 외부 상태에 영향받지 않습니다. 셋째, 반복 가능합니다.
언제 실행해도 같은 결과가 나옵니다. 이러한 특징들이 중요한 이유는 개발 과정에서 즉각적인 피드백을 받고, 안정적으로 코드 품질을 검증할 수 있기 때문입니다.
코드 예제
// 테스트할 함수: 할인 가격 계산
function calculateDiscount(price, discountPercent) {
if (price < 0 || discountPercent < 0 || discountPercent > 100) {
throw new Error('유효하지 않은 값입니다');
}
return price * (1 - discountPercent / 100);
}
// 단위 테스트: 정상 케이스
test('할인율 적용 시 올바른 가격을 반환해야 합니다', () => {
expect(calculateDiscount(10000, 20)).toBe(8000);
});
// 단위 테스트: 경계 값 테스트
test('100% 할인 시 0을 반환해야 합니다', () => {
expect(calculateDiscount(10000, 100)).toBe(0);
});
// 단위 테스트: 예외 상황
test('음수 가격은 에러를 발생시켜야 합니다', () => {
expect(() => calculateDiscount(-100, 20)).toThrow('유효하지 않은 값입니다');
});
설명
이것이 하는 일: 단위 테스트는 코드의 가장 작은 부분을 격리된 환경에서 테스트하여, 각 부분이 정확하게 동작하는지 검증하고 문제를 빠르게 찾을 수 있게 합니다. 첫 번째로, 정상 케이스를 테스트합니다.
위 예제에서 10,000원에 20% 할인을 적용하면 8,000원이 나와야 하는지 확인합니다. 왜 이렇게 하는지 설명하면, 함수의 기본 동작이 올바른지 확인하는 것이 가장 기본적이고 중요한 테스트이기 때문입니다.
이 테스트가 통과하지 않으면 함수의 핵심 로직에 문제가 있다는 뜻입니다. 그 다음으로, 경계 값을 테스트합니다.
100% 할인처럼 극단적인 상황에서도 함수가 올바르게 동작하는지 확인합니다. 내부에서는 0% 할인, 100% 할인 같은 특수한 케이스에서 로직이 깨지지 않는지 검증합니다.
경계 값에서 버그가 자주 발생하므로 이런 테스트가 매우 중요합니다. 마지막으로, 예외 상황을 테스트합니다.
음수 가격이나 101% 할인 같은 잘못된 입력이 들어왔을 때 적절한 에러를 발생시키는지 확인합니다. 최종적으로 함수가 모든 상황에서 예측 가능하게 동작한다는 것을 보장합니다.
여러분이 단위 테스트를 작성하면 코드 변경 시 기존 기능이 깨지지 않았는지 즉시 알 수 있습니다. 또한 함수의 사용법을 보여주는 문서 역할을 하고, 리팩토링할 때 안전망을 제공하며, 버그를 조기에 발견하여 수정 비용을 크게 줄일 수 있습니다.
실무에서 단위 테스트가 잘 작성된 프로젝트는 유지보수 비용이 현저히 낮고 배포 시 사고가 적습니다.
실전 팁
💡 하나의 테스트에서는 하나의 것만 검증하세요. 여러 가지를 한 번에 테스트하면 실패했을 때 원인을 찾기 어렵습니다.
💡 테스트 이름은 "무엇을 테스트하는지"가 명확히 드러나도록 작성하세요. 예: "20% 할인 적용 시 올바른 가격 반환"
💡 AAA 패턴을 사용하세요. Arrange(준비), Act(실행), Assert(검증) 순서로 테스트를 구조화하면 읽기 쉽습니다.
💡 외부 의존성(DB, API 등)은 Mock이나 Stub으로 대체하세요. 단위 테스트는 빠르고 독립적이어야 합니다.
💡 정상 케이스뿐만 아니라 예외 상황과 경계 값도 꼭 테스트하세요. 실제 버그의 80%는 예외 상황에서 발생합니다.
4. 테스트 더블 활용하기 - Mock, Stub, Spy의 이해
시작하며
여러분이 외부 API를 호출하는 코드를 테스트할 때 이런 고민을 해본 적 있나요? 실제 API를 호출하면 테스트가 느려지고, 비용이 발생하며, 네트워크 상태에 따라 테스트 결과가 달라지는 문제 말이죠.
이런 문제는 테스트 대상 코드가 외부 의존성을 가질 때 자주 발생합니다. 데이터베이스, 외부 API, 파일 시스템, 이메일 서비스 등에 의존하는 코드는 단위 테스트하기가 매우 어렵습니다.
테스트 환경을 구성하는 것도 복잡하고, 테스트 실행 시간도 오래 걸립니다. 바로 이럴 때 필요한 것이 테스트 더블(Test Double)입니다.
Mock, Stub, Spy 같은 가짜 객체를 사용하여 외부 의존성을 대체하면, 빠르고 안정적인 단위 테스트를 작성할 수 있습니다.
개요
간단히 말해서, 테스트 더블은 테스트 목적으로 실제 객체를 대신하는 가짜 객체입니다. Mock은 호출을 검증하고, Stub은 미리 정의된 값을 반환하며, Spy는 실제 객체를 감싸서 호출을 기록합니다.
왜 테스트 더블이 필요한지 실무 관점에서 설명하면, 현대 애플리케이션은 여러 외부 시스템과 연동됩니다. 결제 게이트웨이, 이메일 서비스, SMS 발송, 클라우드 스토리지 등 다양한 외부 서비스를 사용하죠.
예를 들어, 주문 완료 후 이메일을 발송하는 기능을 테스트할 때, 실제로 이메일을 보내지 않고도 이메일 발송 함수가 올바른 인자와 함께 호출되었는지 검증할 수 있습니다. 기존에는 테스트를 위해 실제 외부 서비스를 호출하거나 복잡한 테스트 환경을 구축했다면, 이제는 가짜 객체로 대체하여 몇 밀리초 만에 테스트를 완료할 수 있습니다.
테스트 더블의 핵심 특징은 세 가지입니다. 첫째, Stub은 정해진 응답을 반환하여 외부 의존성을 제거합니다.
둘째, Mock은 함수가 올바르게 호출되었는지 검증합니다. 셋째, Spy는 실제 동작을 유지하면서 호출 정보를 기록합니다.
이러한 특징들이 중요한 이유는 다양한 시나리오에서 적절한 도구를 선택하여 효과적인 테스트를 작성할 수 있기 때문입니다.
코드 예제
// 테스트할 서비스
class OrderService {
constructor(emailService, paymentGateway) {
this.emailService = emailService;
this.paymentGateway = paymentGateway;
}
async createOrder(order) {
// Stub: 결제 게이트웨이의 응답을 가짜로 대체
const payment = await this.paymentGateway.charge(order.amount);
if (payment.success) {
// Mock: 이메일 발송 함수가 호출되었는지 검증
await this.emailService.sendConfirmation(order.email);
return { success: true, orderId: order.id };
}
}
}
// 테스트 코드
test('주문 성공 시 확인 이메일을 발송해야 합니다', async () => {
// Stub: 결제가 항상 성공하도록 설정
const paymentGateway = { charge: jest.fn().mockResolvedValue({ success: true }) };
// Mock: 이메일 발송 함수 생성
const emailService = { sendConfirmation: jest.fn() };
const orderService = new OrderService(emailService, paymentGateway);
await orderService.createOrder({ id: 1, amount: 10000, email: 'test@example.com' });
// Mock 검증: 올바른 이메일로 발송되었는지 확인
expect(emailService.sendConfirmation).toHaveBeenCalledWith('test@example.com');
});
설명
이것이 하는 일: 테스트 더블은 실제 외부 시스템을 호출하지 않고도 코드의 동작을 검증할 수 있게 하여, 단위 테스트를 빠르고 독립적으로 만듭니다. 첫 번째로, Stub을 사용하여 결제 게이트웨이를 대체합니다.
paymentGateway.charge 함수가 항상 성공 응답을 반환하도록 설정합니다. 왜 이렇게 하는지 설명하면, 실제 결제를 테스트할 때마다 처리하면 비용이 발생하고, 결제 실패 시나리오를 재현하기도 어렵기 때문입니다.
Stub을 사용하면 원하는 시나리오를 쉽게 만들 수 있습니다. 그 다음으로, Mock을 사용하여 이메일 발송 함수를 생성합니다.
jest.fn()으로 만든 Mock 함수는 호출 여부와 인자를 추적합니다. 내부에서는 실제 이메일을 보내지 않지만, 함수가 올바른 이메일 주소와 함께 호출되었는지 검증할 수 있습니다.
이렇게 하면 테스트 실행 시 실제 이메일이 발송되지 않으면서도 로직을 검증할 수 있습니다. 마지막으로, expect().toHaveBeenCalledWith()를 사용하여 Mock이 올바른 인자와 함께 호출되었는지 검증합니다.
최종적으로 외부 의존성 없이 주문 서비스의 비즈니스 로직이 올바르게 동작한다는 것을 확인합니다. 여러분이 테스트 더블을 사용하면 테스트 속도가 획기적으로 빨라지고, 외부 서비스의 상태에 영향받지 않는 안정적인 테스트를 만들 수 있습니다.
또한 에러 상황이나 극단적인 케이스를 쉽게 재현할 수 있고, 테스트 비용(API 호출 비용 등)을 절감할 수 있습니다. 실무에서는 CI/CD 파이프라인에서 수천 개의 테스트를 몇 초 만에 실행할 수 있어 개발 생산성이 크게 향상됩니다.
실전 팁
💡 Stub은 "입력을 제어"할 때, Mock은 "출력을 검증"할 때 사용하세요. 이 차이를 이해하면 적절한 도구를 선택할 수 있습니다.
💡 너무 많은 것을 Mock하지 마세요. Mock이 많아지면 테스트가 구현 세부사항에 의존하게 되어 리팩토링이 어려워집니다.
💡 jest.fn()의 mockResolvedValue()는 Promise를 반환하는 비동기 함수를 Stub할 때 사용합니다. 동기 함수는 mockReturnValue()를 쓰세요.
💡 Mock의 호출 횟수도 검증하세요. toHaveBeenCalledTimes(1)로 함수가 정확히 한 번만 호출되었는지 확인할 수 있습니다.
💡 테스트마다 Mock을 초기화하세요. beforeEach()에서 jest.clearAllMocks()를 호출하면 이전 테스트의 호출 기록이 남지 않습니다.
5. 테스트 커버리지 이해하기 - 얼마나 테스트했는가
시작하며
여러분이 열심히 테스트 코드를 작성하고 나서 이런 궁금증을 가져본 적 있나요? 내가 작성한 테스트가 충분한지, 아직 테스트하지 못한 코드가 얼마나 남았는지 어떻게 알 수 있을까요?
이런 불확실성은 테스트의 효과를 측정할 방법이 없을 때 발생합니다. 많은 개발자들이 "대충 테스트를 작성했으니 괜찮겠지"라고 생각하다가, 배포 후 테스트하지 않은 부분에서 버그가 발생하는 경험을 합니다.
바로 이럴 때 필요한 것이 테스트 커버리지입니다. 코드의 몇 퍼센트가 테스트되었는지 정량적으로 측정하여, 테스트가 부족한 부분을 찾아낼 수 있습니다.
개요
간단히 말해서, 테스트 커버리지는 전체 코드 중에서 테스트가 실행된 코드의 비율을 나타내는 지표입니다. Line Coverage(라인 커버리지), Branch Coverage(분기 커버리지), Function Coverage(함수 커버리지) 등 다양한 측정 방법이 있습니다.
왜 테스트 커버리지가 필요한지 실무 관점에서 설명하면, 팀이 테스트 작성에 얼마나 노력을 기울여야 하는지 가이드라인을 제공하고, 테스트가 부족한 위험한 영역을 식별할 수 있습니다. 예를 들어, 결제 처리 모듈의 커버리지가 50%라면, 나머지 50%에서 치명적인 버그가 숨어있을 가능성이 높다는 신호입니다.
기존에는 감으로 "이 정도면 충분하다"고 판단했다면, 이제는 구체적인 숫자로 테스트의 완성도를 평가할 수 있습니다. 커버리지 리포트를 보면 어떤 파일, 어떤 함수, 어떤 라인이 테스트되지 않았는지 한눈에 파악할 수 있습니다.
테스트 커버리지의 핵심 특징은 세 가지입니다. 첫째, Line Coverage는 실행된 코드 라인의 비율을 측정합니다.
둘째, Branch Coverage는 if문이나 switch문의 모든 경로가 테스트되었는지 확인합니다. 셋째, Function Coverage는 모든 함수가 최소 한 번은 호출되었는지 검증합니다.
이러한 특징들이 중요한 이유는 다양한 관점에서 테스트의 완성도를 평가하여 사각지대를 최소화할 수 있기 때문입니다.
코드 예제
// 테스트 대상 함수
function getUserStatus(user) {
if (!user) {
return 'unknown';
}
if (user.isActive) {
return user.isPremium ? 'premium' : 'active';
}
return 'inactive';
}
// 불완전한 테스트 (Branch Coverage 50%)
test('활성 사용자는 active를 반환합니다', () => {
expect(getUserStatus({ isActive: true, isPremium: false })).toBe('active');
});
// 완전한 테스트 (Branch Coverage 100%)
describe('getUserStatus', () => {
test('user가 null이면 unknown을 반환합니다', () => {
expect(getUserStatus(null)).toBe('unknown');
});
test('프리미엄 사용자는 premium을 반환합니다', () => {
expect(getUserStatus({ isActive: true, isPremium: true })).toBe('premium');
});
test('일반 활성 사용자는 active를 반환합니다', () => {
expect(getUserStatus({ isActive: true, isPremium: false })).toBe('active');
});
test('비활성 사용자는 inactive를 반환합니다', () => {
expect(getUserStatus({ isActive: false })).toBe('inactive');
});
});
설명
이것이 하는 일: 테스트 커버리지는 코드의 어느 부분이 테스트되었고 어느 부분이 테스트되지 않았는지 명확하게 보여주어, 테스트 전략을 개선할 수 있게 합니다. 첫 번째로, Line Coverage를 측정합니다.
위 예제에서 불완전한 테스트는 한 가지 경로만 테스트하므로 많은 코드 라인이 실행되지 않습니다. 왜 이렇게 하는지 설명하면, 실행되지 않은 코드는 버그가 숨어있을 가능성이 높기 때문입니다.
커버리지 도구는 빨간색으로 실행되지 않은 라인을 표시해줍니다. 그 다음으로, Branch Coverage를 확인합니다.
getUserStatus 함수에는 여러 분기가 있습니다: user가 null인 경우, isActive가 true/false인 경우, isPremium이 true/false인 경우 등입니다. 내부에서는 모든 if문의 true/false 경로가 최소 한 번씩 실행되었는지 추적합니다.
완전한 테스트 세트는 모든 분기를 커버하므로 Branch Coverage가 100%가 됩니다. 마지막으로, Function Coverage를 검증합니다.
모든 함수가 최소 한 번은 호출되었는지 확인합니다. 최종적으로 100% 커버리지를 달성하면 코드의 모든 부분이 테스트되었다는 것을 의미하지만, 이것이 버그가 없다는 보장은 아닙니다.
여러분이 테스트 커버리지를 추적하면 테스트가 부족한 영역을 객관적으로 파악할 수 있고, 팀 내에서 테스트 품질에 대한 공통 기준을 설정할 수 있습니다. CI/CD 파이프라인에서 커버리지가 일정 수준 이하로 떨어지면 배포를 차단하도록 설정하여 코드 품질을 지속적으로 유지할 수 있습니다.
다만, 커버리지 100%가 목표가 아니라 의미 있는 테스트를 작성하는 것이 중요합니다. 실무에서는 보통 70-80% 정도의 커버리지를 목표로 하며, 중요한 비즈니스 로직은 100%를 지향합니다.
실전 팁
💡 커버리지 100%를 맹목적으로 추구하지 마세요. getter/setter 같은 단순한 코드까지 테스트하는 것은 시간 낭비입니다.
💡 중요한 비즈니스 로직(결제, 인증, 데이터 처리 등)은 100% 커버리지를 목표로 하세요. 버그의 영향이 크기 때문입니다.
💡 Jest에서 커버리지 측정하기: npm test -- --coverage 명령어를 실행하면 상세한 리포트를 볼 수 있습니다.
💡 커버리지 리포트의 "빨간색" 라인을 우선적으로 확인하세요. 이 부분들이 테스트되지 않은 위험한 영역입니다.
💡 Branch Coverage가 Line Coverage보다 중요합니다. 모든 라인이 실행되어도 모든 분기가 테스트되지 않았을 수 있습니다.
6. 통합 테스트의 중요성 - 컴포넌트 간 상호작용 검증
시작하며
여러분이 각 함수를 완벽하게 테스트했는데도 실제 애플리케이션을 실행하면 오류가 발생하는 경험을 해본 적 있나요? 각 부분은 잘 동작하는데 합쳐놓으니까 문제가 생기는 당혹스러운 상황 말이죠.
이런 문제는 단위 테스트만으로는 컴포넌트 간의 상호작용을 검증할 수 없기 때문에 발생합니다. 함수 A가 올바르게 동작하고, 함수 B도 올바르게 동작하지만, A와 B가 함께 동작할 때는 데이터 형식이 맞지 않거나 호출 순서가 잘못되어 문제가 생길 수 있습니다.
바로 이럴 때 필요한 것이 통합 테스트입니다. 여러 컴포넌트가 함께 동작하는 시나리오를 테스트하여, 실제 환경에서 발생할 수 있는 문제를 조기에 발견할 수 있습니다.
개요
간단히 말해서, 통합 테스트는 여러 모듈, 클래스, 함수가 함께 동작할 때 올바르게 상호작용하는지 검증하는 테스트입니다. 단위 테스트보다 넓은 범위를 테스트하지만, 전체 시스템을 테스트하는 E2E(End-to-End) 테스트보다는 작은 범위를 다룹니다.
왜 통합 테스트가 필요한지 실무 관점에서 설명하면, 대부분의 버그는 컴포넌트 경계에서 발생합니다. 함수 내부의 로직보다는 함수 간의 데이터 전달, API 호출과 응답 처리, 데이터베이스 조회와 비즈니스 로직의 연결 등에서 문제가 생깁니다.
예를 들어, 사용자 등록 기능은 유효성 검증, 데이터베이스 저장, 이메일 발송이 순차적으로 동작해야 하는데, 이 흐름을 통합 테스트로 검증해야 합니다. 기존에는 단위 테스트만 작성하고 통합은 수동 테스트에 의존했다면, 이제는 컴포넌트 간 상호작용을 자동화된 테스트로 검증할 수 있습니다.
이렇게 하면 리팩토링이나 의존성 업데이트 후에도 시스템이 여전히 올바르게 동작하는지 빠르게 확인할 수 있습니다. 통합 테스트의 핵심 특징은 세 가지입니다.
첫째, 실제 환경과 유사하게 여러 컴포넌트를 함께 테스트합니다. 둘째, 외부 의존성(DB, API)을 실제로 사용하거나 더 정교한 Fake로 대체합니다.
셋째, 단위 테스트보다 느리지만 더 높은 신뢰도를 제공합니다. 이러한 특징들이 중요한 이유는 단위 테스트가 놓치는 통합 이슈를 찾아내어 프로덕션 환경의 안정성을 높일 수 있기 때문입니다.
코드 예제
// 여러 컴포넌트를 함께 테스트
class UserService {
constructor(userRepository, emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
async registerUser(userData) {
// 데이터베이스에 사용자 저장
const user = await this.userRepository.create(userData);
// 환영 이메일 발송
await this.emailService.sendWelcome(user.email);
return user;
}
}
// 통합 테스트: 실제 데이터베이스와 Mock 이메일 서비스 사용
describe('UserService Integration Test', () => {
let userService;
let userRepository;
let emailService;
beforeEach(() => {
// 실제 데이터베이스 연결 (테스트 DB)
userRepository = new UserRepository(testDatabase);
// 이메일은 Mock 사용
emailService = { sendWelcome: jest.fn() };
userService = new UserService(userRepository, emailService);
});
test('사용자 등록 시 DB 저장과 이메일 발송이 함께 동작해야 합니다', async () => {
const userData = { name: 'John', email: 'john@example.com' };
const user = await userService.registerUser(userData);
// DB에 실제로 저장되었는지 확인
const savedUser = await userRepository.findByEmail('john@example.com');
expect(savedUser).toBeDefined();
expect(savedUser.name).toBe('John');
// 이메일이 발송되었는지 확인
expect(emailService.sendWelcome).toHaveBeenCalledWith('john@example.com');
});
});
설명
이것이 하는 일: 통합 테스트는 컴포넌트 간의 경계를 넘어서 전체 흐름이 올바르게 동작하는지 검증하여, 단위 테스트가 놓치는 통합 이슈를 찾아냅니다. 첫 번째로, 실제 데이터베이스를 사용하여 UserRepository를 테스트합니다.
단위 테스트에서는 Mock을 사용했지만, 통합 테스트에서는 실제 데이터베이스(테스트 전용)에 연결합니다. 왜 이렇게 하는지 설명하면, SQL 쿼리가 올바른지, 데이터베이스 제약 조건이 잘 동작하는지 확인하기 위해서입니다.
Mock으로는 이런 문제를 찾을 수 없습니다. 그 다음으로, UserService가 Repository와 EmailService를 올바르게 조율하는지 검증합니다.
사용자를 저장하고 이메일을 발송하는 전체 흐름을 테스트합니다. 내부에서는 데이터베이스 트랜잭션, 에러 처리, 비동기 작업의 순서 등이 올바르게 처리되는지 확인합니다.
이메일은 실제로 발송하지 않고 Mock을 사용하여 테스트 속도를 유지합니다. 마지막으로, 통합 테스트 후 데이터베이스에 실제로 저장된 데이터를 조회하여 검증합니다.
findByEmail을 호출하여 데이터가 정확하게 저장되었는지 확인합니다. 최종적으로 전체 사용자 등록 플로우가 예상대로 동작한다는 높은 신뢰도를 얻습니다.
여러분이 통합 테스트를 작성하면 컴포넌트 간 인터페이스 변경 시 즉시 문제를 발견할 수 있고, 리팩토링 후에도 시스템이 여전히 올바르게 동작하는지 확인할 수 있습니다. 또한 실제 환경에서 발생할 수 있는 데이터베이스 제약 조건 위반, 트랜잭션 문제, 동시성 이슈 등을 조기에 발견할 수 있습니다.
실무에서는 테스트 피라미드 전략을 따라 단위 테스트를 가장 많이 작성하고, 통합 테스트는 중간 정도, E2E 테스트는 가장 적게 작성하는 것이 효율적입니다.
실전 팁
💡 통합 테스트용 별도의 테스트 데이터베이스를 사용하세요. 실제 DB를 사용하면 데이터가 오염될 수 있습니다.
💡 각 테스트 후 데이터베이스를 초기화하세요. beforeEach에서 DB를 clean up하여 테스트 간 독립성을 유지합니다.
💡 모든 외부 서비스를 실제로 호출할 필요는 없습니다. 중요한 것(DB)은 실제로, 덜 중요한 것(이메일)은 Mock으로 처리하세요.
💡 통합 테스트는 "Happy Path"와 주요 에러 시나리오를 중심으로 작성하세요. 모든 경우의 수는 단위 테스트에서 커버합니다.
💡 Docker를 활용하면 통합 테스트용 데이터베이스를 쉽게 구성할 수 있습니다. 테스트 전에 컨테이너를 시작하고, 후에 정리하세요.
7. 테스트 우선 vs 테스트 후 작성 - TDD의 진정한 가치
시작하며
여러분이 TDD를 시도하면서 이런 고민을 해본 적 있나요? "테스트를 먼저 작성하는 것과 나중에 작성하는 것이 무슨 차이가 있을까?
결과적으로 테스트가 있으면 되는 거 아닌가?"라는 의문 말이죠. 이런 질문은 많은 개발자들이 가지는 자연스러운 의문입니다.
표면적으로는 테스트 코드가 존재한다는 점에서 동일해 보이지만, 실제로는 테스트 작성 시점이 코드의 설계와 품질에 근본적인 차이를 만듭니다. 바로 이것이 TDD의 진정한 가치입니다.
테스트를 먼저 작성하면 자연스럽게 테스트 가능한 코드, 결합도가 낮은 코드, 명확한 인터페이스를 가진 코드를 작성하게 됩니다.
개요
간단히 말해서, 테스트 우선 작성은 코드의 설계를 개선하는 도구이고, 테스트 후 작성은 이미 작성된 코드를 검증하는 도구입니다. 둘 다 테스트를 만들지만, 얻는 효과가 완전히 다릅니다.
왜 작성 시점이 중요한지 실무 관점에서 설명하면, 코드를 먼저 작성하면 테스트하기 어려운 구조로 만들어지기 쉽습니다. 전역 변수에 의존하거나, 여러 책임을 한 함수에 몰아넣거나, 외부 의존성을 하드코딩하는 등의 문제가 발생합니다.
예를 들어, 결제 처리 함수를 코드 먼저 작성하면 데이터베이스, 결제 게이트웨이, 로깅이 모두 하나의 함수에 섞여 테스트하기 매우 어려운 코드가 됩니다. 기존에는 "일단 동작하게 만들고 나중에 테스트를 추가하자"고 생각했다면, 이제는 "테스트를 먼저 작성하여 좋은 설계를 자연스럽게 유도하자"는 관점으로 전환할 수 있습니다.
테스트 우선 작성의 핵심 특징은 세 가지입니다. 첫째, 인터페이스를 먼저 설계하게 됩니다.
함수를 어떻게 사용할지 먼저 생각하므로 사용하기 쉬운 API가 만들어집니다. 둘째, 의존성 주입을 자연스럽게 적용하게 됩니다.
테스트에서 Mock을 주입하려면 의존성이 외부에서 주입되어야 합니다. 셋째, 단일 책임 원칙을 따르게 됩니다.
테스트하기 어려운 함수는 보통 너무 많은 일을 하고 있다는 신호입니다. 이러한 특징들이 중요한 이유는 좋은 설계를 강제하여 유지보수하기 쉬운 코드를 만들기 때문입니다.
코드 예제
// 테스트 후 작성: 테스트하기 어려운 코드
function processPayment(amount) {
// 전역 변수와 하드코딩된 의존성
const apiKey = process.env.PAYMENT_API_KEY;
const result = fetch('https://payment.com/charge', {
method: 'POST',
body: JSON.stringify({ amount, apiKey })
});
database.saveTransaction(result); // 직접 DB 접근
logger.log('Payment processed'); // 직접 로깅
return result;
}
// 테스트 우선 작성: 테스트 가능한 코드
class PaymentProcessor {
constructor(paymentGateway, transactionRepository, logger) {
this.paymentGateway = paymentGateway;
this.transactionRepository = transactionRepository;
this.logger = logger;
}
async processPayment(amount) {
const result = await this.paymentGateway.charge(amount);
await this.transactionRepository.save(result);
this.logger.log('Payment processed');
return result;
}
}
// 테스트가 쉬워짐
test('결제 처리 시 트랜잭션을 저장해야 합니다', async () => {
const mockGateway = { charge: jest.fn().mockResolvedValue({ id: 1 }) };
const mockRepo = { save: jest.fn() };
const mockLogger = { log: jest.fn() };
const processor = new PaymentProcessor(mockGateway, mockRepo, mockLogger);
await processor.processPayment(10000);
expect(mockRepo.save).toHaveBeenCalledWith({ id: 1 });
});
설명
이것이 하는 일: 테스트 우선 작성은 "테스트 가능성"을 코드 설계의 중심에 두어, 자연스럽게 SOLID 원칙을 따르는 고품질 코드를 만들도록 유도합니다. 첫 번째로, 테스트 후 작성 방식의 문제점을 살펴봅니다.
processPayment 함수는 환경 변수, HTTP 요청, 데이터베이스, 로거에 직접 의존합니다. 왜 이것이 문제인지 설명하면, 이 함수를 테스트하려면 실제 결제 API를 호출하고, 데이터베이스를 구성하고, 환경 변수를 설정해야 합니다.
테스트가 느리고, 불안정하며, 실행하기 복잡합니다. 그 다음으로, 테스트 우선 작성 방식을 살펴봅니다.
테스트를 먼저 작성하려고 하면 "어떻게 Mock을 주입하지?"라는 질문이 자연스럽게 생깁니다. 내부에서는 이 질문에 답하기 위해 생성자를 통해 의존성을 주입받는 구조로 설계하게 됩니다.
결과적으로 의존성 주입, 인터페이스 분리, 단일 책임 원칙이 자동으로 적용됩니다. 마지막으로, 테스트 우선 작성의 결과물을 확인합니다.
PaymentProcessor 클래스는 각 의존성을 외부에서 주입받으므로 테스트에서 쉽게 Mock으로 대체할 수 있습니다. 최종적으로 몇 줄의 테스트 코드만으로 모든 시나리오를 검증할 수 있는 깨끗한 설계가 완성됩니다.
여러분이 테스트 우선 작성을 실천하면 리팩토링이 쉬워지고, 버그가 줄어들며, 새로운 기능 추가 시 기존 코드를 깨뜨릴 위험이 낮아집니다. 또한 코드 리뷰 시 "이 함수를 어떻게 테스트하지?"라는 질문이 사라지고, 팀 전체의 코드 품질이 일관되게 유지됩니다.
실무에서는 처음에는 테스트 우선 작성이 느리게 느껴질 수 있지만, 익숙해지면 오히려 더 빠르고 안정적으로 개발할 수 있습니다. 디버깅 시간과 버그 수정 시간이 크게 줄어들기 때문입니다.
실전 팁
💡 테스트를 먼저 작성하면서 "이 함수를 테스트하기 어렵다"고 느껴지면, 그것은 설계를 개선해야 한다는 신호입니다.
💡 의존성 주입이 어색하다면 간단한 함수부터 시작하세요. 인자로 받는 것도 의존성 주입의 한 형태입니다.
💡 테스트 우선 작성을 연습할 때는 Kata(코딩 도장)를 활용하세요. FizzBuzz, 로마 숫자 변환 같은 작은 문제로 연습하면 좋습니다.
💡 "테스트 가능성"을 코드 리뷰의 중요한 기준으로 삼으세요. "이 코드를 어떻게 테스트할 건가요?"라는 질문이 자연스럽게 나와야 합니다.
💡 레거시 코드에 테스트를 추가할 때는 먼저 리팩토링하여 테스트 가능하게 만드세요. 테스트하기 어려운 코드에 억지로 테스트를 작성하지 마세요.
8. 리팩토링과 TDD - 안전하게 코드 개선하기
시작하며
여러분이 기존 코드를 개선하려고 할 때 이런 두려움을 느껴본 적 있나요? "이 코드를 수정하면 다른 곳에서 뭔가 깨질 것 같은데..."라는 불안감 말이죠.
이런 두려움은 코드 변경의 영향 범위를 정확히 알 수 없을 때 발생합니다. 많은 개발자들이 "동작하는 코드는 건드리지 말자"는 태도를 가지게 되고, 결과적으로 코드가 점점 더 복잡해지고 유지보수하기 어려워집니다.
바로 이럴 때 필요한 것이 TDD와 함께하는 리팩토링입니다. 테스트가 안전망 역할을 해주므로, 자신감 있게 코드를 개선하고 즉시 문제를 발견할 수 있습니다.
개요
간단히 말해서, TDD 환경에서의 리팩토링은 동작을 변경하지 않고 코드의 구조를 개선하는 작업입니다. 테스트가 계속 통과하는 한, 내부 구현을 마음껏 변경할 수 있습니다.
왜 TDD와 리팩토링이 함께 가야 하는지 실무 관점에서 설명하면, 좋은 소프트웨어는 지속적인 개선을 통해 만들어집니다. 처음부터 완벽한 코드를 작성하는 것은 불가능하며, 요구사항이 변하면서 코드도 진화해야 합니다.
예를 들어, 처음에는 간단한 할인 로직이었는데 시간이 지나면서 쿠폰, 포인트, 등급별 할인 등이 추가되어 복잡해집니다. 이때 테스트 없이 리팩토링하면 기존 기능을 깨뜨릴 위험이 큽니다.
기존에는 "리팩토링은 위험하니까 꼭 필요할 때만 하자"고 생각했다면, 이제는 "테스트가 있으니까 매일 조금씩 개선하자"는 자세로 전환할 수 있습니다. 리팩토링과 TDD의 핵심 특징은 세 가지입니다.
첫째, 작은 단위로 자주 리팩토링합니다. 한 번에 많이 변경하지 않습니다.
둘째, 각 변경 후 테스트를 실행합니다. 즉시 피드백을 받습니다.
셋째, 테스트가 실패하면 즉시 되돌립니다. 안전한 상태를 유지합니다.
이러한 특징들이 중요한 이유는 리팩토링의 위험을 최소화하고 코드 품질을 지속적으로 높일 수 있기 때문입니다.
코드 예제
// 리팩토링 전: 긴 함수, 중복 코드, 복잡한 조건문
function calculatePrice(product, user) {
let price = product.price;
// 등급별 할인
if (user.grade === 'gold') {
price = price * 0.9;
} else if (user.grade === 'silver') {
price = price * 0.95;
}
// 쿠폰 할인
if (user.coupon) {
if (user.coupon.type === 'percent') {
price = price * (1 - user.coupon.value / 100);
} else if (user.coupon.type === 'fixed') {
price = price - user.coupon.value;
}
}
return price;
}
// 기존 테스트는 계속 통과해야 함
test('골드 등급은 10% 할인을 받습니다', () => {
const product = { price: 10000 };
const user = { grade: 'gold' };
expect(calculatePrice(product, user)).toBe(9000);
});
// 리팩토링 후: 책임 분리, 명확한 함수명, 확장 가능한 구조
class PriceCalculator {
calculatePrice(product, user) {
let price = product.price;
price = this.applyGradeDiscount(price, user.grade);
price = this.applyCouponDiscount(price, user.coupon);
return price;
}
applyGradeDiscount(price, grade) {
const discounts = { gold: 0.9, silver: 0.95, bronze: 1.0 };
return price * (discounts[grade] || 1.0);
}
applyCouponDiscount(price, coupon) {
if (!coupon) return price;
if (coupon.type === 'percent') {
return price * (1 - coupon.value / 100);
}
return price - coupon.value;
}
}
// 테스트는 여전히 통과
test('골드 등급은 10% 할인을 받습니다', () => {
const calculator = new PriceCalculator();
const product = { price: 10000 };
const user = { grade: 'gold' };
expect(calculator.calculatePrice(product, user)).toBe(9000);
});
설명
이것이 하는 일: TDD와 함께하는 리팩토링은 테스트를 통해 동작의 정확성을 보장하면서, 코드의 구조와 가독성을 지속적으로 개선합니다. 첫 번째로, 리팩토링 전 코드의 문제점을 파악합니다.
calculatePrice 함수는 할인 계산의 모든 로직을 포함하고 있어 길고 복잡합니다. 왜 이것이 문제인지 설명하면, 새로운 할인 유형을 추가하거나 기존 로직을 수정할 때 이 거대한 함수를 이해하고 조심스럽게 변경해야 하기 때문입니다.
또한 각 할인 로직을 독립적으로 테스트하기 어렵습니다. 그 다음으로, 작은 단위로 리팩토링을 진행합니다.
먼저 등급 할인을 applyGradeDiscount 함수로 추출하고 테스트를 실행합니다. 통과하면 다음으로 쿠폰 할인을 applyCouponDiscount 함수로 추출하고 다시 테스트를 실행합니다.
내부에서는 각 단계마다 테스트가 통과하는지 확인하여, 문제가 생기면 즉시 이전 상태로 되돌릴 수 있습니다. 마지막으로, 리팩토링 후에도 모든 테스트가 통과하는 것을 확인합니다.
외부에서 보는 동작은 전혀 변하지 않았지만, 내부 구조는 훨씬 깨끗해졌습니다. 최종적으로 새로운 할인 유형을 추가하거나 기존 로직을 수정하기 쉬운 구조가 만들어집니다.
여러분이 TDD와 함께 리팩토링하면 "깨질까봐 무섭다"는 두려움 없이 코드를 개선할 수 있습니다. 매일 조금씩 코드를 정리하는 습관이 생기고, 기술 부채가 쌓이는 것을 방지할 수 있습니다.
또한 새로운 팀원이 합류했을 때 테스트가 코드의 의도를 설명해주어 온보딩이 빨라집니다. 실무에서는 리팩토링을 별도의 작업으로 생각하지 말고, 기능 개발의 일부로 자연스럽게 진행하는 것이 좋습니다.
"보이스카우트 규칙"을 따라 코드를 읽을 때마다 조금씩 개선하세요.
실전 팁
💡 리팩토링 중에는 절대로 기능을 추가하지 마세요. 리팩토링과 기능 추가는 별개의 작업입니다. 섞으면 문제의 원인을 찾기 어렵습니다.
💡 한 번에 하나씩만 변경하세요. 변수명 변경, 함수 추출, 조건문 단순화 등을 동시에 하지 말고 순차적으로 진행하세요.
💡 각 리팩토링 단계마다 커밋하세요. 문제가 생기면 쉽게 되돌릴 수 있습니다.
💡 IDE의 자동 리팩토링 기능을 활용하세요. 변수명 변경, 메서드 추출 등은 자동화 도구를 사용하면 실수를 줄일 수 있습니다.
💡 리팩토링 전에 테스트 커버리지를 확인하세요. 커버리지가 낮으면 먼저 테스트를 추가한 후 리팩토링하는 것이 안전합니다.
9. TDD 시작하기 - 실전 적용 가이드
시작하며
여러분이 TDD에 대해 배우고 나서 이런 막막함을 느껴본 적 있나요? "이론은 이해했는데, 실제 프로젝트에서 어디서부터 어떻게 시작해야 할지 모르겠다"는 고민 말이죠.
이런 어려움은 TDD를 처음 도입할 때 누구나 겪는 문제입니다. 기존 프로젝트에 TDD를 적용하려니 레거시 코드가 많고, 새 프로젝트를 시작하려니 무엇부터 테스트해야 할지 감이 잡히지 않습니다.
또한 팀원들을 설득하고 협업 방식을 바꾸는 것도 쉽지 않습니다. 바로 이럴 때 필요한 것이 체계적인 TDD 도입 전략입니다.
작은 것부터 시작하여 점진적으로 확대하고, 팀 전체가 함께 성장하는 접근 방식이 필요합니다.
개요
간단히 말해서, TDD 실전 적용은 한 번에 모든 것을 바꾸려 하지 않고, 작은 성공 경험을 쌓으면서 점진적으로 TDD 문화를 만들어가는 과정입니다. 왜 이런 점진적 접근이 필요한지 실무 관점에서 설명하면, 개발 팀은 항상 기능 개발 일정에 쫓기고 있습니다.
갑자기 "오늘부터 모든 코드를 TDD로 작성합니다"라고 선언하면 일정이 지연되고, 팀원들이 저항하며, 결국 실패로 끝나기 쉽습니다. 예를 들어, 대규모 레거시 프로젝트라면 새로운 기능이나 버그 수정부터 TDD를 적용하고, 점차 범위를 넓혀가는 것이 현실적입니다.
기존에는 "완벽하게 준비된 후에 시작하자"고 생각했다면, 이제는 "오늘 당장 작은 것부터 시작하자"는 마인드로 전환할 수 있습니다. TDD 도입의 핵심 전략은 세 가지입니다.
첫째, 작고 간단한 기능부터 시작합니다. 유틸리티 함수나 새로운 기능이 연습하기 좋습니다.
둘째, 팀 내에서 성공 사례를 공유합니다. "TDD 덕분에 버그를 조기에 발견했다"는 경험을 나눕니다.
셋째, 도구와 환경을 먼저 구축합니다. 테스트 프레임워크, CI/CD 통합, 코드 커버리지 측정 등을 준비합니다.
이러한 전략들이 중요한 이유는 TDD가 단순히 기술이 아니라 팀 문화이기 때문입니다.
코드 예제
// Step 1: 가장 간단한 기능부터 TDD 시작
// 예: 새로운 유틸리티 함수
test('문자열의 첫 글자를 대문자로 변환해야 합니다', () => {
expect(capitalize('hello')).toBe('Hello');
});
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
// Step 2: 조금 더 복잡한 비즈니스 로직
// 예: 새로운 기능 추가
describe('포인트 계산', () => {
test('구매 금액의 1%를 포인트로 적립해야 합니다', () => {
const calculator = new PointCalculator();
expect(calculator.calculate(10000)).toBe(100);
});
test('VIP 회원은 3%를 적립해야 합니다', () => {
const calculator = new PointCalculator();
expect(calculator.calculate(10000, 'VIP')).toBe(300);
});
});
class PointCalculator {
calculate(amount, grade = 'NORMAL') {
const rates = { NORMAL: 0.01, VIP: 0.03 };
return amount * rates[grade];
}
}
// Step 3: 기존 코드에 테스트 추가
// 레거시 코드를 테스트 가능하게 리팩토링
// Before: 테스트하기 어려운 레거시 코드
function sendNotification(userId, message) {
const user = database.findUser(userId); // 직접 DB 접근
emailService.send(user.email, message); // 전역 서비스 사용
}
// After: 테스트 가능하게 리팩토링
class NotificationService {
constructor(userRepository, emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
async sendNotification(userId, message) {
const user = await this.userRepository.findById(userId);
await this.emailService.send(user.email, message);
}
}
// 이제 테스트 작성 가능
test('사용자에게 알림을 전송해야 합니다', async () => {
const mockRepo = { findById: jest.fn().mockResolvedValue({ email: 'test@example.com' }) };
const mockEmail = { send: jest.fn() };
const service = new NotificationService(mockRepo, mockEmail);
await service.sendNotification(1, 'Hello');
expect(mockEmail.send).toHaveBeenCalledWith('test@example.com', 'Hello');
});
설명
이것이 하는 일: TDD 실전 적용은 작은 성공을 반복하면서 팀이 TDD에 익숙해지고, 점차 적용 범위를 넓혀가는 체계적인 프로세스입니다. 첫 번째로, 가장 간단한 함수부터 TDD를 시작합니다.
capitalize 같은 유틸리티 함수는 외부 의존성이 없고 로직이 단순하여 TDD 연습에 완벽합니다. 왜 이렇게 하는지 설명하면, 처음부터 복잡한 코드에 TDD를 적용하면 어렵고 시간이 오래 걸려 좌절감을 느끼기 쉽기 때문입니다.
작은 성공 경험이 자신감을 줍니다. 그 다음으로, 조금 더 복잡한 비즈니스 로직으로 확장합니다.
포인트 계산처럼 중요하지만 외부 의존성이 적은 기능이 좋습니다. 내부에서는 여러 시나리오를 테스트하면서 TDD의 가치를 체감하게 됩니다.
"테스트를 먼저 작성하니까 요구사항이 명확해지네", "리팩토링이 쉬워졌네"라는 경험을 합니다. 마지막으로, 레거시 코드에도 점진적으로 테스트를 추가합니다.
버그를 수정하거나 기능을 추가할 때, 해당 부분을 먼저 테스트 가능하게 리팩토링합니다. 최종적으로 프로젝트 전체의 테스트 커버리지가 점점 높아지고, 팀의 TDD 역량이 성장합니다.
여러분이 이런 단계적 접근을 따르면 TDD 도입의 성공률이 크게 높아집니다. 팀원들이 TDD의 가치를 직접 경험하면서 자연스럽게 받아들이게 되고, 실패의 위험을 최소화하면서 점진적으로 개발 문화를 바꿀 수 있습니다.
또한 각 단계에서 학습한 내용을 다음 단계에 적용하여 지속적으로 개선할 수 있습니다. 실무에서는 보통 3-6개월 정도 꾸준히 연습하면 TDD가 자연스러운 개발 방식이 됩니다.
실전 팁
💡 매일 15분씩 TDD Kata를 연습하세요. FizzBuzz, 로마 숫자 변환, 문자열 계산기 등 간단한 문제로 TDD 근육을 키우세요.
💡 페어 프로그래밍으로 TDD를 배우세요. 한 명이 테스트를 작성하고 다른 한 명이 구현하는 "Ping-Pong" 방식이 효과적입니다.
💡 TDD를 강제하지 마세요. 팀원들이 자발적으로 참여할 수 있도록 성공 사례를 공유하고, 교육 기회를 제공하세요.
💡 테스트 작성 시간을 일정에 포함하세요. "TDD는 시간이 더 걸린다"는 인식을 바꾸려면 일정 산정 시 테스트 작성 시간을 명시적으로 포함해야 합니다.
💡 CI/CD에 테스트를 통합하세요. 모든 Pull Request에서 자동으로 테스트가 실행되고, 실패하면 머지가 차단되도록 설정하면 자연스럽게 TDD 문화가 정착됩니다.
이 카드뉴스가 포함된 코스
댓글 (0)
함께 보면 좋은 카드 뉴스
서비스 메시 완벽 가이드
마이크로서비스 간 통신을 안전하고 효율적으로 관리하는 서비스 메시의 핵심 개념부터 실전 도입까지, 초급 개발자를 위한 완벽한 입문서입니다. Istio와 Linkerd 비교, 사이드카 패턴, 실무 적용 노하우를 담았습니다.
EFK 스택 로깅 완벽 가이드
마이크로서비스 환경에서 로그를 효과적으로 수집하고 분석하는 EFK 스택(Elasticsearch, Fluentd, Kibana)의 핵심 개념과 실전 활용법을 초급 개발자도 쉽게 이해할 수 있도록 정리한 가이드입니다.
Grafana 대시보드 완벽 가이드
실시간 모니터링의 핵심, Grafana 대시보드를 처음부터 끝까지 배워봅니다. Prometheus 연동부터 알람 설정까지, 초급 개발자도 쉽게 따라할 수 있는 실전 가이드입니다.
분산 추적 완벽 가이드
마이크로서비스 환경에서 요청의 전체 흐름을 추적하는 분산 추적 시스템의 핵심 개념을 배웁니다. Trace, Span, Trace ID 전파, 샘플링 전략까지 실무에 필요한 모든 것을 다룹니다.
CloudFront CDN 완벽 가이드
AWS CloudFront를 활용한 콘텐츠 배포 최적화 방법을 실무 관점에서 다룹니다. 배포 생성부터 캐시 설정, HTTPS 적용까지 단계별로 알아봅니다.