TDD - 단위 테스트는 가치를 주지 못했다¶
HJ, Ph.D. / Software Architect (js.seth.h@gmail.com) 초안: 2021년 04월 / 개정: 2025년 12월
Abstract¶
- 개인적으로는 TDD 실패 시기가 있다.
- 2005년 이후 TDD를 알고 있었으나, 실용적으로 쓸 수 없었다.
- 2010년경에는 설계 자체를 테스트 & TDD에 최적화하려고 시도했으나 별 성과가 없었다.
- 2015년경에는 Testee와 Tester의 관계를 재정립하고, TDD의 효용성 기준을 완전히 새로 정했다.
- 2020년 이후로는 전통적 형태의 TDD, 보통의 유닛 테스트(Unit Test)를 작성하진 않지만, 완전히 다른 기준에서 테스트 주도 개발을 적용하고 있다.
- 그에 몇 가지 원칙이 있다.
- Testee는 천재, Tester는 바보다. 고로 '영지식 증명'과 같은 접근이 필요하다.
- TDD는 개발에 함께 참여하는 사용자 챔피언(User Champion)이다.
- TDD의 궁극적 목표는 '정상 서비스 확신'이다.
- 환경에 대한 절대적 제어권이 필요하다.
- 그러니, 로컬에서 개발/테스트가 가능해야 한다. 정확히는 개발자 간 서버를 공유하지 마라.
- 고립 테스트(isolated test)는 확신을 제공하지 못한다. 비고립(Non-isolated)을 우선시한다.
- 의존성을 가진 연속적 테스트를 작성하고, 블록 단위로 운영한다.
- TDD 매우 비싸지만, '정상 서비스 확신'이라는 대체 불가능한 '증거'를 제공한다
TDD에 대한 기대와 현실¶
TDD(Test Driven Development)는 오랜 시간 소프트웨어 개발의 이상적인 방법론으로 여겨져 왔다. 많은 개발자들이 TDD를 통해 코드 품질과 시스템 신뢰성을 높일 수 있다고 믿는다.
하지만 나는 실제로 TDD가 나에게 효용성을 주는지 느끼지 못했다. 적어도 2015년 이전까지는 그랬다.
솔직히 말해, 테스트를 작성하는 데 정확한 기준이 제시되지 않았다. 그래서 BDD, GWT를 비롯한 여러 제안이 있으나, 솔루션이 된다고 느낀 적은 없다. 보통의 가이드대로 테스트 파일을 작성하는 것은 어렵지 않다. 기본적으로 실행하고 값을 점검하면 되니까. 그러나 아무리 많이 작성해도 프로젝트가 진행된다고 느낀 적은 거의 없었다.
// add.test.js
import { add } from './add';
describe('add()', () => {
describe('두 개의 정수를 더할 때', () => {
it('합계를 반환해야 합니다.', () => {
// Given
const a = 2;
const b = 3;
// When
const result = add(a, b);
// Then
expect(result).toBe(5);
});
});
describe('음수가 포함될 때', () => {
it('정상적으로 합산해야 합니다.', () => {
expect(add(-2, 3)).toBe(1);
expect(add(-2, -3)).toBe(-5);
});
});
describe('0이 포함될 때', () => {
it('다른 값이 그대로 반환되어야 합니다.', () => {
expect(add(0, 5)).toBe(5);
expect(add(5, 0)).toBe(5);
});
});
});
혹자는 말한다. 녹색이 많고 적색이 없으면 프로젝트가 잘 진행되는 것이라고...
테스트가 아무리 통과해도 문제는 계속 나온다. TDD가 효용성이 있다면, 프로젝트는 TDD가 없을 때보다 훨씬 매끄럽게 진행되어야 한다. 오류가 사전에 확인되고, 안정적인 시스템이 중간 배포되어야 한다. 그러나 현실은 그렇지 못했고, 처음엔 내가 테스트를 제대로 운영하지 못한다고 생각했다. 그래서 Testable Architecture라는 개념을 고민해보았다. 즉, 아키텍처 자체가 테스트와 상호 운용이 전제가 아니라서 효용성이 떨어진다고 보았던 것이다. 결론부터 말하면 이러한 접근은 실패했다. 애초에 Tester와 Testee의 본질, test case라는 추가 코드를 작성하여 얻고자 하는 본질을 직시하지 못했기 때문에 당연한 결과였다.
Tester와 Testee의 본질¶
As far as I know
나는 기존의 자료에서 Tester와 Testee를 정확히 나누어 본질을 정리한 자료를 본 적이 없었다.
하지만 불변의 본질, 그에 따른 한계점을 알아야 어디까지 작성할지 결정할 수 있는 법이다.
- Testee: 검사 대상
- Tester: 검사자, 즉 정상 여부를 판단한다? 아니, 우선은 Testee를 사용한다. 판단은 그다음의 문제다.
TDD에서 흔히 간과되는 점은 Tester와 Testee의 역할과 본질적 차이다. Testee는 실제로 동작하는 코드, 즉 우리가 검증하고자 하는 대상이다. 반면 Tester는 Testee를 사용하는 코드, 즉 입력을 주고 결과를 확인하는 역할을 한다. 대부분의 경우 TDD에 대한 가이드에서 "테스트 코드가 프로덕션 코드를 검증한다", "테스트는 요구사항을 명확히 한다" 정도의 구분에 그친다. 하지만 이것은 단순한 구분의 문제가 아니다.
Testee는 본질적으로 제품이다. 실제 운영 상황에서 겪을 수 있는 많은 문제를 대응해야 하니, 그만큼 복잡하고 섬세해진다. 즉, 제품이 해결하고자 하는 문제에 대해서 Testee야말로 개발자가 제시할 수 있는 최고의 해결책이다. 이는 수학적인 개념에서 '일반해'에 해당한다. 그런데 만약 Tester가 Testee보다 우월한 기능성을 가지고 있다면, Testee가 아니라 Tester가 제품이 되어야 한다. 쉽게 말해 Tester가 자신이 넣는 모든 입력에 대한 올바른 결과를 계산해 낼 수 있다면, Tester가 곧 제품이 된다. 즉, 제품 vs 품질 검사의 관계가 아니라, 제품 vs 더 우월한 제품이 되어버린다는 의미이다.
이를 심플하게 정리하면 Testee는 천재, Tester는 바보라는 소리다. 테스트 주도 개발에서 항상 염두에 두어야 할 것은 Tester가 Testee를 이해하려 하거나, 초월하려고 해서는 안 된다는 점이다. 이는 역학 관계상 강요될 수밖에 없는 본질적인 한계이다.
때문에 Testing 행위는 본질적으로 '영지식 증명(zero-knowledge proof)'에서 드러나는 역학 관계를 가져야 한다. Testee는 모든 논리와 복잡성을 내포하고 있지만, Tester는 그 내부를 알지 못한다. 아주 단순하게, Tester는 사전에 상황과 결과를 아는 몇 가지 케이스에 대해서 Testee가 동일한 결과를 낸다면, '네가 나보다 낫구나'라고 파악해야 한다. 이런 맥락에서 Tester가 Testee의 온갖 상태에 대해서 일일이 검증하는 방법은 본질을 거스르는 방법이다.
이런 전제에서 '명확한 기대값만으로 판단해야 한다' 같은 애매모호한 소리를 해서는 안 된다. Tester는 그저 사용자를 대신하는 존재다. 사용자가 로그인을 할 때는 세션 값을 확인하지 않는다. 화면에 '성공' 한 단어가 있으면 성공한 것으로 여길 뿐이다. 명확한 기대값 이전에 '기준 항목' 자체를 사용자 기준에서 줄여라. Tester는 멍청해야 한다. 그렇지 않으면 test를 만든 건지 product를 만드는 것인지 헷갈릴 것이다.
모니터를 만들고 품질 검사를 한다고 하자. 켜지고, 화면 조정 화면이 나온다면 충분하다. 애초에 사용자도 그 정도로 판단한다.
Tester = 개발에 함께 참여하는 사용자 챔피언¶
'사용자 챔피언(User Champion)'은 주로 기업이나 소프트웨어 환경에서 특정 제품이나 기술의 열정적이고 능숙한 사용자를 말한다.
우리는 Tester의 역할에 대해서 다시 정의할 필요가 있다.
지금까지의 논의는 주로 유닛 테스트에서 E2E 테스트로 이어지는 기술적 계층의 구분에 집중되어 있었다. 이러한 구분은 Testee의 스코프를 기준으로 한 구분이다. 이런 접근은 테스트의 본질적 역할이나 목적을 설명하지 못한다. 그렇기 때문에 실제 개발에서 대부분의 개발자들은 TDD에 일정한 역할을 부여하고, 잘 사용하는 데 실패한다. 정말로 필요한 것은 코드의 구조적 단위를 따라가는 테스트의 계층 구분이 아니라, Tester가 어떤 관점과 책임을 가져야 하는지가 더 중요하다.
Tester도 코드인데 SRP(Single Responsibility Principle: 단일 책임 원칙) 고민해본적은 있나?
Tester의 역할은 실제로 제품을 사용할 사용자, 즉 '사용자 챔피언'의 시각을 대변해야 한다. 사용자 챔피언은 제품의 가치를 이해하고, 실제 사용 시나리오에서 발생할 수 있는 문제를 예리하게 포착한다. 이들은 단순히 기능이 동작하는지 확인하는 것이 아니라, 제품이 실제로 '용도에 맞는가', '신뢰할 수 있는가'를 판단한다. 이러한 판단은 절대로 유닛 테스트 단위에서 동작하지 않는다. 어느 사용자 챔피언이 로그인이 된다고 목록에 5개의 데이터가 나왔다고 용도에 맞다고 판단하겠는가?
실제로 인간 사용자 챔피언이 개발 과정에 직접 참여할 때의 대표적 사례로, 마이크로소프트의 Excel 개발팀을 들 수 있다. Excel의 주요 기능 설계와 개선 과정에서, 회계사·금융 전문가 등 실제 파워유저들이 개발팀과 긴밀히 협업했다. 이들은 단순히 요구사항을 전달하는 수준이 아니라, 프로토타입을 직접 사용하며 실시간으로 피드백을 제공했다. 예를 들어, 피벗 테이블이나 고급 수식 기능은 개발자만으로는 발견하기 어려운 실제 업무 흐름과 문제점을 사용자 챔피언이 직접 지적하고, 그에 맞춰 제품이 개선되었다. MBA 교재에서도 자주 언급되는 이 사례처럼, 사용자 챔피언이 개발에 참여하면 제품은 실제 현장의 요구와 기대에 훨씬 더 부합하게 진화한다. 이런 협업은 단순한 품질 향상을 넘어, 소프트웨어가 정말로 인간의 관점에서 가치를 제공하는지를 지향하게 한다.
실제 개발 현장에서는 이런 사용자 챔피언이 팀에 직접 참여하는 것은 어렵다. 보편화된 분야라도 사용자 챔피언을 그렇게 흔하게 채용할 수 있는 것이 아니며, 새로운 서비스라면 동일은 고사하고 유사 서비스를 사용해 본 사람도 적다. 무엇보다 인간 사용자 챔피언은 개발자의 수정 속도를 따라올 수 없다. 일련의 기능이 제공되는지 확인하는 것은 꽤 긴 과정이라, 개발자가 몇 줄 고칠 때마다 대응해 줄 수 없다. 또 디버깅 중에는 실패의 연속이므로 인간의 멘탈로 견디기 어렵다.
그러니, 코드로 작성된 Tester는 인간 사용자 챔피언의 역할을 완벽히 대체할 수는 없겠지만, 다사다난한 개발/디버깅 과정에서 최선의 도움을 줄 수 있게끔 사용자 챔피언을 최대한 모방해야 한다. 즉, Tester는 실제 사용자의 관점에서 제품을 경험하는 기준에서 작성되어야 한다.
예를 들어, 단순히 Given-When-Then을 주고 API의 응답값이 올바른지 확인하는 것이 아니라, 상황-액션-액션-...-액션-결과에 이르는 과정에서 문제가 없으며, 인간 사용자 수준에서 원하는 결과를 얻었는지 확인하는 것에 집중해야 한다. 즉, 쇼핑몰이라면 "로그인 - 상품 검색 - 상품 선택 - 옵션 선택 - 결제 - 결제 확인 - 배송 조회 - 수령 확인"과 같은 A 시나리오와 "로그인 - 상품 검색 - 장바구니 넣기 - 상품 검색 - 장바구니 넣기 - 장바구니 결제하기 - 옵션 선택 - 결제"와 같은 B 시나리오를 확인해야, 인간 사용자 수준에서 만족할 수 있는 서비스가 제공되는지 확인되는 것이다.
모든 개발자가 각자 사용자 챔피언을 두는 것은 물리적으로, 정신적으로 불가능하다. 그러나 테스트 코드를 통해 그 역할을 일정 부분 대체할 수 있다. Tester가 사용자 챔피언의 시각을 내재화할수록, 제품은 실제 사용자에게 더 신뢰받는 결과물을 제공할 수 있다. 무엇보다 개발자는 TDD가 성공적이라면 최소한 최종 소비자가 서비스를 정상적으로 이용할 수 있는 최소 경로가 몇 개가 확보된다는 점을 확신할 수 있다.
궁극적 목표는 '정상 서비스 확신'¶
모니터 검사를 한다고 하자. 기판의 경로 하나하나 전류가 흐른다고, 사용자가 화면을 볼 수 있는가? 무엇을 위한 테스트인가?
품질 보증에서 진정으로 중요한 것은, 단순히 코드의 일부가 정상 동작하는지 확인하는 것이 아니라, 실제로 서비스가 다수의 사용자에게 안정적으로 제공될 수 있음을 확신하는 것이다. 단위 테스트나 GWT(Given-When-Then) 방식의 테스트는 지나치게 지엽적인 문제를 다루기 때문에, '서비스 수준'에서 가치를 논하는 것이 불가능하다. 그리고 사용자 챔피언을 모방한 서비스 수준의 Tester를 제작하는 노력을 감안하면, 납득 가능한 ROI(Return on Investment)는 하나뿐이다.
미 테스트를 작성하는 데는 상당한 시간과 기술이 필요하다. 단순한 기능 검증을 넘어서, 실제 사용자가 겪을 수 있는 다양한 정상 경로와 실패 경로까지 모두 아우르는 테스트를 만들어야 한다. 우선 서비스가 제공하는 기획적으로 의도된 기능의 정상 제공은 당연히 포함된다. 정상은 1개의 사례만이 아니라, 분기에 따라 기능이 차이가 있을 경우 각각의 사례가 다 필요하다. 그 이외에도 정상적인 서비스 성공뿐 아니라 거부와 그에 따른 조치도 확인되어야 하며, 실패 상황도 확인되어야 한다. 거부는 로그인시 비밀번호 틀림, 계정 잠금과 같이 기획된 경로를 말하며, 특정 이유로 거부된 경우 이를 조치하고 서비스가 정상 동작하는지도 확인해야한다. 그리고 뜻하지 않은 여러 실패 케이스(물리적 장애 & 개발자가 식별/코딩 못한 사례도 포함)에서도 사용자에게 우아하게 실패를 알릴 수 있는 지도 확인 대상 중 하나다.
결정론적 시스템이라는 제한하에, 루틴(Routine)의 결과는 크게 3가지로 구분된다.
의도대로 성공, 의도대로 거부, 실패 - 줄여서 성공/거부/실패
이 개념도 따로 문서로 논의할 주제이니 일단 생략
결과적으로 서비스 전체는 2~5가지 또는 그 이상의 입력과 상황으로 반복 검증하게 되고, 테스트 코드의 분량은 실제 제품 코드보다 훨씬 많아진다. 라인수로 본다면 족히 10배 이상의 라인을 필요로 한다. 코드 라인이 투입 시간을 대변하지 않는다는 것은 익히 알려진 사실이나, 아예 무관계 하지도 않다. 어찌되든 TDD에서 정말 가치를 얻고자 한다면, 미경험자의 예상보다 투입되는 리소스는 압도적으로 많은 것이 현실이다.
나의 경우, 테스트 코드와 제품 코드에 투입되는 시간이 1:1에 가까워지는 경우가 많다. 즉, 전체 개발 시간의 절반 가까이를 테스트 작성과 유지에 투자하게 된다. 이처럼 막대한 리소스를 투입하면서, 그 존재 가치를 인정받을 수 있는 납득 가능한 ROI(Return on Investment)는 솔직히 하나뿐이라고 생각한다. 서비스가 실제로 정상적으로 기동되고, 다수의 사용자가 문제없이 이용할 수 있다는 확신을 TDD에서 얻어야 한다. 테스트의 목적은 코드의 완벽함이 아니라, 시스템 전체가 실제 환경에서 기대한 대로 동작한다는 최소한의 증거를 확보하는 데 있다.
명확하게 다시 말해서 '증거'다. 이는 '신뢰'와 같은 추상적인 개념이 아니라, 이렇게 사용하면 정상 서비스를 제공받을 수 있다는 '확정된 사실'을 의미하는 것이다.
현대 소프트웨어는 복잡해질 만큼 복잡해져서, 대부분 여러 경로로 사용할 수 있다. 때문에 기획/개발팀이 사전에 파악하지 못한 경로로 사용자가 사용하는 것을 미리 막는 것은 거의 불가능하다. 그러나, 최소한의 정상 서비스 경로의 확보, 익히 알려진 거부 경로의 검증, 예상치 못한 오류 대응이 가능하다는 점을 수십 초 ~ 수 분의 프로그램 실행을 통해 어제 날짜의 물리적 증거를 갖는 것과 지난해 QA/QC 외주를 통해 기능을 확인했다와는 전혀 다른 수준의 증거력을 가져온다.
확신을 주는 테스트의 조건¶
-
환경에 대한 절대적 제어권
- 테스트가 신뢰를 얻으려면, 실행 환경이 완전히 통제되어야 한다.
- 외부 시스템, 네트워크, 데이터베이스 등 모든 의존성을 개발자가 직접 제어할 수 있어야 하며, 테스트 실행 시점마다 동일한 조건이 보장되어야 한다.
- 예를 들어, DB 자체가 drop database 및 recreation이 가능해야한다. 개인적 경험으론 PLC를 연동해야할 때, TCP/IP로 연결되는 에뮬레이터를 제작했다.
- 모든 개발자는 독립된 환경을 써야한다. 그러니 모든 테스트는 Local Machine에서 수행 가능해야한다. 테스트 인프라를 공유하지 마라. 결과가 오염된다.
-
비고립 테스트의 지향
- 단일 모듈이나 함수만을 대상으로 하는 고립 테스트는 실제 서비스의 복잡한 상호작용을 반영하지 못한다.
- 여러 컴포넌트가 연결된 비고립(Non-isolated) 테스트 & 사간의 시간 순서가 영향을 주는 시나리오 테스트가 실제 서비스와 내용에서 동작을 검증하므로, 테스트 결과의 신뢰도를 높일 수 있다.
- 솔직히 나는 고립(Isolated) 테스트는 아예 작성하지 않는다.
- 목업도 불가피한 경우가 아니면 하지 않는다. 전체 테스트에서 목업은 3개 미만.
우리는 실험실 실습(Lab Demo; Laboratory Demonstration)을 하는게 아니라, 상용 제품을 만든다.
-
의존성을 가진 테스트의 블록 운영
- 현실의 서비스는 여러 기능이 연속적으로 동작하며, 각 단계가 서로 영향을 미친다. 테스트 역시 단일 기능이 아니라, 여러 기능이 연속적으로 실행되어야한다.
- 그러나 기성 Test Runner들의 구조와 단위 기능당 Timeout등은 효과적인 도구다. 그러니 적당히 절충하여 개별 test에는 단위 기능을 작성하고, 테스트 실행은 블록 단위로 진행해야한다.
- 예를 들어, 회원가입 → 로그인 → 상품구매 → 결제 → 배송조회와 같은 실제 사용 흐름은 각 단위를 테스트(test기능)로 전체를 블록(description 기능)으로 묶어, 전체 서비스가 정상적으로 동작하는지 확인해야 한다.
테스트 피라미드
(1) End-To-End Testing (UI Testing) - 10%
(2) Integration Testing - 20%
(3) Unit Testing - 70%
TDD 공부를 좀 하다 보면 피라미드 그림과 함께 위와 같은 권장 비율이 제시되기도 한다.
이때, 흔히 '테스트 단위가 좀 더 쪼개질수록 어느 부분에서 에러가 발생했는지 좀 더 찾기가 쉬워진다'라고 하는데, 그건 틀렸다. 그건, 콜 스택(Call Stack)으로 찾아라.
그리고, 가치가 높고, 실패의 대가가 크며, 복잡할수록 자동화된 테스트가 더 필요하다. 이건 단순한 경제 원리로 봐도 그렇다. 솔직히, 피라미드 역순이 더 바람직하다.
참고로, 만약 Product(=Testee)가 (애플리케이션, 엔진이 아니고) '라이브러리'나 '프레임워크'라면 알려진 테스트 피라미드의 비중이 맞다.
개인적으로 위와 같은 비중이 흔히 알려진 것은 아직은 TDD가 라이브러리, 프레임워크 제작에서 적극 사용되는 반면, 애플리케이션에서는 잘 적용되지 않아서인 듯하다.
결론¶
TDD를 기술적 측면에만 보지마라. Unit Test, Integrating Test, End-To-End Test는 기술 내부적 단위 구분에 따른 것이다. 아무리 로컬 옵티마(Local Optima, 국소 최적해/극소점)를 산출해도 최종 결과가 좋지 않는 경우는 얼마든지 있고, 경험상 TDD 또한 그렇다. Test는 면죄부를 위한 기술적 절차가 아니라, 소프트웨어의 신뢰성과 서비스 가동성을 확보하기 위한 도구임을 잊지 말아야 한다. 이를 위해서는 Tester에 명확한 역활를 부여해야하고, 그것이 사용자 챔피언이라면 이 글의 세부 사항은 논리적 귀결과 파급 효과로 인한 결과이다.
TDD에 투입되는 리소스는 결코 적지 않다. 테스트와 제품에 드는 노력은 경험상 1:1에 에 가깝다. TDD는 별도의 일정이나 업무 단위로 분리할 수 있는 작업이 아니라, 전체 개발 공정 관리의 한 방법으로 봐야한다. 그러니, 나는 TDD에 대해서 All or Nothing을 제안한다.
TDD가 궁극적으로 제공하는 것은 '신뢰'라는 추상적 감정이 아니라, 실제 서비스가 정상적으로 동작한다는 '물리적 증거'다. 개발자는 이 증거를 통해 최소한의 정상 경로와 거부/실패 경로에 대한 확신을 확보할 수 있다. TDD의 성공은 테스트의 양이 아니라, 실제 서비스 품질과 사용자 경험을 보장하는 데 있다.
시스템 단위 구분이 있는 경우
가끔 '사용자'를 무조건 일반 대중으로 오해하는 사람들이 있다. 사용자는 이보다 더 포괄적인 개념으로 제작하는 시스템의 경계면을 기준으로 사용자를 의미한다.
즉 시스템이 API 서버라면, API 사용자가 사용자다.
만약 DB StoredProcedure, Back-end, Front-end를 분리하여 개발한다면, DB기준으로 Back-end Developer가 사용자이고 Back-end 시스템을 기준으로 Front-end Deveoloper가 사용자이며, Front-end 기준으로 웹사이트 사용자가 사용자이다. 같은 논리로 DirectX SDK와 같은 SDK를 개발한다면, 그 SDK사용자가 사용자의 지위를 갖는다.
SeeAlso¶
- 소프트웨어 아키텍처는 의사결정이지, 모방이 아니다 - TDD를 단순 모방하지 않고, 어떻게 사용할지 고민한 결과가 이 문서의 내용이다.
- 소프트웨어 아키텍처는 확장보다 불변에 의해 결정된다 - 'Tester와 Testee의 본질'은 나에게 불변의 사실을 깨달은 한 예시였다.
Author¶
HJ, Ph.D. / Software Architect
(js.seth.h@gmail.com)
https://js-seth-h.github.io/website/Biography/
Over 20 years in software development, focusing on design reasoning and system structure - as foundations for long-term productivity and structural clarity.
Researched semantic web and meta-browser architecture in graduate studies,
with an emphasis on structural separation between data and presentation.
Ph.D. in Software, Korea University
M.S. in Computer Science Education, Korea University
B.S. in Computer Science Education, Korea University
저작자표시-비영리-변경금지(CC BY-NC-ND 4.0)