개념
공장에서 자동차를 검사하는 순서
자동차 공장을 떠올려보자. 완성된 차를 도로에 몰아보는 것만으로 품질을 보증하지는 않는다. 순서가 있다.
- 부품 하나씩 — 브레이크 패드 하나가 규격대로 마찰력을 내는가
- 부품을 맞물려서 — 브레이크 패드와 디스크가 서로 맞물려 도는가
- 완성차로 — 시속 100km에서 밟았을 때 실제로 서는가
- 고객이 타보고 — 이 브레이크의 밟는 느낌이 받아들일 만한가
부품 검사에서 걸러낼 수 있는 불량을 굳이 완성차 주행 시험까지 끌고 가면, 비용은 수백 배가 되고 원인을 찾는 데도 훨씬 오래 걸린다. 반대로 부품이 전부 규격에 맞아도 조립하면 안 맞을 수 있고, 조립이 완벽해도 고객이 싫어할 수 있다. 어느 한 단계도 다른 단계를 대신하지 못한다.
소프트웨어 테스트 레벨이 정확히 이 구조다. 테스트 레벨은 "무엇을 대상으로, 어느 범위까지 함께 실행해서" 확인하는가를 기준으로 나눈다. 범위가 좁을수록(단위) 빠르고 싸고, 넓을수록(인수) 느리고 비싸지만 더 현실에 가까운 조건을 본다.
| 레벨 | 확인 대상 | 보통 누가 | 속도·비용 |
|---|---|---|---|
| 단위(Unit) | 함수·클래스 하나가 격리된 상태에서 맞게 동작하는가 | 개발자 | 매우 빠름·저렴 |
| 통합(Integration) | 두 개 이상의 모듈·서비스가 서로 맞물려 동작하는가 | 개발자, QA | 빠름·중간 |
| 시스템(System) | 전체 애플리케이션이 엔드투엔드로 요구사항대로 동작하는가 | QA | 느림·비쌈 |
| 인수(Acceptance) | 실제 사용자·발주자 관점에서 받아들일 만한가 | QA, 사용자, 발주자 | 가장 느림·비쌈 |
레슨 1에서 배운 결함 발견 시점별 비용이 여기서 다시 나타난다. 레벨은 개발 흐름의 시간 순서와 대체로 겹치기 때문에, 아래 레벨에서 잡는다는 건 곧 이르게 잡는다는 뜻이다.
같은 버그, 다른 레벨에서 다르게 보인다
"주문 금액 30,000원 이상이면 무료배송"이라는 기능을 예로 들어본다.
- 단위:
calculateShippingFee(30000)함수 하나만 떼어서0을 반환하는지 확인. 다른 함수·DB·네트워크는 전혀 관여하지 않는다. - 통합: 장바구니 모듈이 계산한 금액이 배송비 모듈에 정확히 전달되는지 확인. 두 모듈은 각자 단위 테스트를 통과했어도, 금액을 "원" 단위로 주고받기로 한 약속을 한쪽이 "센트"로 착각하면 통합 단계에서만 드러난다.
- 시스템: 로그인 → 상품 담기 → 결제까지 전체 흐름에서 무료배송이 실제로 적용되는지 확인. 프론트엔드가 계산 결과를 화면에 제대로 반영하는지까지 포함된다.
- 인수: 실제 사용자가 "이 조건이 헷갈리지 않고 명확한가"까지 포함해서 받아들일 만한 경험인지 확인. "30,000원 이상"이 배송비 제외 금액인지 포함 금액인지가 헷갈린다면, 계산은 다 맞아도 인수 단계에서 문제로 제기된다.
네 레벨 모두 통과해도 여전히 확인(Validation, 레슨 2)은 별개다 — 인수 테스트가 확인에 가장 가깝지만, 인수 테스트를 통과했다고 실제 만족도가 보장되진 않는다.
레벨이 올라갈수록 무엇이 '진짜'가 되는가
레벨을 가르는 실질적인 기준은 얼마나 많은 것을 진짜로 쓰느냐다. 단위 테스트는 대상 함수 하나만 진짜로 실행하고 나머지는 전부 가짜로 채운다. 시스템 테스트는 거의 모든 것을 진짜로 쓴다.
| DB | 외부 API | 다른 모듈 | 화면(UI) | 실행 시간 | |
|---|---|---|---|---|---|
| 단위 | 가짜 | 가짜 | 가짜 | 없음 | 밀리초 |
| 통합 | 진짜 또는 테스트용 | 대개 가짜 | 진짜 | 없음 | 초 |
| 시스템 | 진짜(테스트 환경) | 진짜 또는 샌드박스 | 진짜 | 진짜 | 분 |
| 인수 | 진짜(운영에 가까움) | 진짜 | 진짜 | 진짜 | 분~시간 |
표의 샌드박스 는 바깥 회사가 "시험용으로 쓰라" 고 따로 열어 둔 가짜 창구다. 진짜 카드사에 진짜 결제를 보내지 않고도 결제 흐름을 끝까지 돌려 볼 수 있다.
여기서 "가짜"라고 쓴 것들이 테스트 대역(test double) 이다. 영화의 스턴트 대역처럼, 진짜 대신 자리를 채워주는 가짜 객체를 말한다. 예를 들어 배송비 계산 함수가 환율 API를 호출한다면, 단위 테스트에서는 실제로 API를 부르는 대신 "이 함수를 부르면 항상 1300을 돌려주는 가짜" 를 끼워 넣는다.
가짜로 바꾸면 세 가지가 좋아진다.
- 빨라진다. 네트워크 왕복 200ms가 0ms가 된다.
- 결과가 항상 같다. 환율은 매일 바뀌지만 가짜는 언제나 1300이다. 테스트가 어제는 통과하고 오늘은 실패하는 일이 사라진다.
- 극단 상황을 만들 수 있다. 진짜 API가 타임아웃 나기를 기다릴 수는 없지만, 가짜에게는 "타임아웃 나라"고 시킬 수 있다.
대신 잃는 것도 분명하다. 가짜는 내가 믿는 대로 행동하지, 진짜가 행동하는 대로 행동하지 않는다. 진짜 API가 어느 날 응답 형식을 바꿔도 가짜는 예전 형식 그대로 답한다. 그래서 단위 테스트는 전부 초록불인데 운영에서 터지는 일이 생긴다. 이 빈틈을 메우는 것이 바로 통합 레벨의 존재 이유다.
스텁·목·페이크 같은 대역의 종류와 실제 작성법은 모듈 10(단위 테스트) 에서 다룬다. 지금은 "레벨이 낮을수록 가짜가 많고, 가짜가 많을수록 빠르지만 현실과 멀어진다"는 교환 관계만 가져가면 충분하다.
어느 레벨에서 테스트할지 고르는 기준
실무에서 실제로 부딪히는 질문은 "레벨이 몇 개냐"가 아니라 "이건 어느 레벨에서 봐야 하나" 다. 판단할 때 던지는 질문은 네 가지다.
| 질문 | 아래 레벨이 유리한 쪽 | 위 레벨이 필요한 쪽 |
|---|---|---|
| 격리할 수 있나 | 입력을 넣으면 출력이 나오는 순수한 계산 | 여러 컴포넌트가 함께 있어야만 성립하는 동작 |
| 경우의 수가 많은가 | 많다 — 30가지 조합을 초 단위로 돌릴 수 있다 | 적다 — 대표 흐름 몇 개면 된다 |
| 재현하기 쉬운가 | 값만 바꾸면 재현된다 | 실제 환경·타이밍이 있어야 재현된다 |
| 위험이 어디에 있나 | 계산 규칙이 틀릴 위험 | 연결·설정·환경이 틀릴 위험 |
구체적인 예로 정리하면 이렇다.
| 확인할 것 | 적합한 레벨 | 이유 |
|---|---|---|
| 배송비 경계값 29,999 / 30,000 / 30,001 | 단위 | 순수 계산. 세 케이스가 1초 안에 돈다 |
| 쿠폰 20종 × 회원등급 4종 조합 | 단위 | 80가지를 UI로 클릭하면 며칠, 함수로는 몇 초 |
| 주문 저장 후 재고가 실제로 줄었는가 | 통합 | DB가 진짜여야 확인되는 동작 |
| 결제 실패 시 주문이 롤백되는가 | 통합 | 두 모듈 사이의 트랜잭션 경계가 대상 |
표의 롤백 은 "중간에 실패하면 앞서 한 것까지 전부 없던 일로 되돌린다" 는 뜻이다. 돈이 빠져나갔는데 주문은 안 만들어지는 상태를 막는 장치다. | 로그인부터 결제 완료까지 이어지는가 | 시스템 | 화면·세션·라우팅이 모두 진짜여야 성립 | | 모바일에서 결제 버튼이 가려지지 않는가 | 시스템 | 실제 렌더링 결과가 대상 | | 이 정산 리포트가 회계팀 업무에 쓸 만한가 | 인수 | 판단 주체가 사용자다 |
핵심 원칙은 하나다. 각 결함은 그것을 만들어낼 수 있는 가장 낮은 레벨에서 확인한다. 경계값 80가지 조합을 시스템 레벨에서 클릭으로 확인하는 것은 낭비이고, 반대로 화면 레이아웃 깨짐을 단위 테스트로 잡으려는 것은 불가능하다.
실패가 어느 레벨에서 잡히느냐가 디버깅을 바꾼다
레벨 선택이 중요한 진짜 이유는 비용보다 원인 좁히기의 난이도다. 테스트가 빨간불이 되었을 때, 용의자가 몇 명이냐가 레벨마다 다르다.
| 실패한 레벨 | 용의자 범위 | 원인 파악까지 |
|---|---|---|
| 단위 | 그 함수 한 개 (수십 줄) | 몇 초 — 실패 메시지가 곧 원인 |
| 통합 | 맞물린 두세 모듈과 그 사이 계약 | 몇 분 |
| 시스템 | 프론트·백엔드·DB·설정·네트워크 등 수십 개 | 몇 시간, 때로는 며칠 |
프론트 는 사용자가 보는 화면 쪽, 백엔드 는 그 뒤에서 계산하고 저장하는 쪽을 가리킨다. 같은 버튼 하나가 안 먹어도 원인이 어느 쪽인지에 따라 고치는 사람이 달라진다. | 인수 | 위 전부 + 요구사항 해석 자체 | 회의가 필요할 수도 |
레슨 2에서 배운 오류 → 결함 → 실패의 전파를 떠올려보자. 실패를 늦게
관측할수록 결함에서 실패까지의 거리가 멀어지고, 그 사이에 낀 컴포넌트가
많을수록 역추적이 어려워진다. assertEquals(0, fee)가 깨지면 배송비 계산
로직을 보면 되지만, "결제가 안 돼요"라는 시스템 실패는 프론트의 요청
누락일 수도, 백엔드의 검증 실패일 수도, DB 커넥션 고갈일 수도, 테스트
환경의 잘못된 설정일 수도 있다.
그래서 QA 입장에서 시스템 테스트가 실패했을 때 가장 먼저 할 일은 종종 "이 실패를 더 낮은 레벨에서 재현할 수 있는가" 를 시도하는 것이다. API를 직접 호출해서(통합) 같은 오류가 나면 프론트엔드는 용의선상에서 빠진다. 이 좁히기가 결함 리포트의 품질을 결정한다.
레벨을 건너뛰면 실제로 무슨 일이 생기는가
사례 A — 통합이 비어 있던 팀
한 팀은 단위 테스트 커버리지가 85%였고 CI는 늘 초록불이었다. 주문 서비스와 회원 서비스가 REST API로 통신하는 구조였다.
회원 서비스 팀이 응답 필드 이름을 grade에서 memberGrade로 바꿨다.
자기 팀 단위 테스트는 전부 통과했다 — 새 이름으로 같이 고쳤으니까.
주문 서비스 팀의 단위 테스트도 전부 통과했다. 회원 API를 가짜로 대체해
놓았고, 그 가짜는 여전히 grade를 돌려주고 있었기 때문이다. 가짜는 진짜가
바뀐 줄 모른다.
결과적으로 두 팀 모두 초록불인 채로 배포됐고, 운영에서 모든 사용자가 등급 없음으로 처리되어 VIP 할인이 전부 사라졌다. 두 서비스를 실제로 붙여 호출하는 통합 테스트가 한 개만 있었어도 배포 전에 걸렸을 결함이다.
여기서 배울 점은 커버리지 숫자의 한계다. 단위 커버리지는 "가짜와 대화한 비율"이지 "진짜와 맞물린 비율"이 아니다. 서비스 경계를 넘는 약속(계약)은 정의상 단위 레벨에서 검증할 수 없다.
사례 B — 단위가 비어 있던 팀
반대 경우도 있다. 단위 테스트가 거의 없고 릴리즈 전에 QA가 2주간 수동 시스템 테스트를 도는 팀이다.
포인트 적립률 계산에 반올림 버그가 있었다. 결제 금액이 특정 소수점으로 떨어질 때만 1원이 어긋난다. 수동 시스템 테스트로는 이 조건을 만들려면 정확한 금액 조합을 우연히 골라야 하는데, 2주 동안 아무도 그 조합을 밟지 않았다. 단위 테스트라면 금액 200개를 넣어보는 데 1초면 충분했을 일이다.
버그는 출시 3개월 뒤 정산 불일치로 발견됐고, 소급 보정에 개발 2주와 고객 응대가 들었다. 경우의 수가 많은 계산 로직은 아래 레벨에서만 현실적으로 훑을 수 있다.
두 사례를 나란히 놓으면 결론이 하나로 모인다. 레벨은 선택지가 아니라 서로 다른 종류의 결함을 잡는 서로 다른 그물이다. 그물 하나의 코를 아무리 촘촘히 해도 다른 그물이 잡던 물고기는 잡히지 않는다.
피라미드가 뒤집히면 — 아이스크림콘 안티패턴
"아래 레벨에서 많이, 위로 갈수록 적게"를 그림으로 그리면 피라미드가 된다. 그런데 현실의 많은 조직은 정반대 모양을 하고 있다. 수동 시스템 테스트가 가장 두껍고, 통합이 얇고, 단위가 거의 없는 아이스크림콘 모양이다.
이 모양은 게을러서 생기는 게 아니라, 각 단계마다 합리적으로 보이는 선택이 쌓여서 생긴다.
| 왜 생기는가 | 그 순간에는 왜 합리적으로 보이는가 |
|---|---|
| 일정에 쫓겨 단위 테스트를 건너뜀 | 기능은 이미 동작한다. 테스트는 나중에 쓰면 된다 |
| 대신 QA가 손으로 확인 | 당장은 이게 더 빠르다. 사람이 이미 있다 |
| 레거시 코드라 단위 테스트가 어려움 | 함수가 DB·설정과 뒤엉켜 있어 떼어낼 수가 없다 |
| 눈에 보이는 것만 인정받음 | 화면에서 확인한 것만 "테스트했다"고 인정된다 |
| QA 조직이 개발과 분리됨 | QA는 코드 접근이 없으니 UI로만 확인할 수 있다 |
위험한 이유는 처음에는 문제가 안 보이다가 시간이 갈수록 급격히 악화된다는 점이다.
여기서 회귀(回歸) 라는 말이 처음 나온다. 고친 곳이 아니라, 고치지 않은 곳이 멀쩡한지 다시 확인하는 일을 가리킨다. 한 군데를 고쳤는데 엉뚱한 데가 망가지는 일이 흔해서, 바꿀 때마다 전체를 다시 훑어야 한다. 자세한 것은 다음 레슨에서 다룬다.
- 회귀 비용이 기능 수에 비례해 늘어난다. 기능이 두 배가 되면 수동 회귀도 두 배가 된다. 결국 "이번 릴리즈는 전체 회귀를 못 돌립니다"가 된다.
- 피드백이 며칠 단위가 된다. 개발자가 코드를 고친 지 사흘 뒤에 결함을 듣는다. 그때는 이미 그 코드를 잊었다.
- QA가 회귀 노동에 갇힌다. 탐색적 테스트나 위험 분석 같은, 사람만 할 수 있는 일에 쓸 시간이 남지 않는다.
- 릴리즈 주기를 줄일 수 없다. 배포마다 2주짜리 수동 검증이 필요하면 주 단위 배포는 물리적으로 불가능하다.
QA가 여기서 할 수 있는 가장 실질적인 기여는 결함을 레벨 관점으로 분류해서 보여주는 것이다. "지난 분기 결함 40건 중 23건은 단위 테스트로 잡을 수 있었던 계산·조건 오류였다"는 데이터는 "단위 테스트 좀 씁시다"라는 말보다 훨씬 강하다. 피라미드를 실제로 설계하고 조직에 맞게 조정하는 방법은 모듈 11(자동화 전략) 에서 다룬다.
레벨과 유형은 서로 다른 축이다
한 가지 미리 정리해둘 것이 있다. 레벨은 "어느 범위에서" 의 축이고, 다음 레슨에서 배울 테스트 유형(기능/비기능 등)은 "무엇을" 의 축이다. 둘은 곱해진다 — 단위 레벨에서 성능을 볼 수도 있고, 시스템 레벨에서 기능을 볼 수도 있다. 레벨을 정했다고 유형이 따라오지 않는다. 이 대비는 레슨 4에서 표로 자세히 다룬다.
실무에서 왜 필요한가
"이 버그는 왜 지금까지 안 잡혔나요?"에 답할 수 있다. QA가 가장 자주 받는 질문이고, 답은 대체로 "그 조건이 실행되는 레벨에서 테스트를 안 했기 때문"이다. 레벨 개념이 없으면 이 질문에 "죄송합니다, 놓쳤습니다"밖에 할 말이 없다. 있으면 "서비스 간 응답 계약이 대상이라 통합 레벨이 필요한데, 현재 통합 테스트가 없습니다"라고 재발 방지로 이어지는 답을 할 수 있다.
회귀 범위를 근거 있게 좁힐 수 있다. "이 수정의 영향 범위가 어디까지 인가"를 물었을 때, 변경이 단일 함수 내부에 갇혀 있다면 단위 테스트 통과로 충분하고, 모듈 간 인터페이스를 건드렸다면 통합부터 다시 봐야 한다. 레벨은 회귀 테스트 범위를 정하는 언어가 된다.
릴리즈 판단이 명확해진다. "테스트 다 됐나요?"라는 질문에 "네"보다 정확한 답은 "단위·통합은 CI에서 전부 통과했고, 시스템 레벨 주요 흐름 12개 중 11개 통과, 나머지 1개는 환경 이슈로 보류 중입니다"이다. 레벨별로 나눠 말할 수 있으면 위험이 어디에 남아 있는지가 드러난다.
개발자와의 협업이 구체적으로 바뀐다. "이건 조건 조합이 12가지라 UI로 다 못 돕니다. 단위 테스트로 커버해주시면 저는 대표 흐름 2개만 시스템에서 보겠습니다" — 이런 식의 분담은 레벨 개념이 공유돼 있을 때만 가능하다. QA가 무엇을 어디서 볼지 스스로 설계하는 것, 그게 테스터와 QA 엔지니어를 가르는 지점이다.