소프트웨어 개발 단계를 다시 정리해보면 다음과 같다.
specification - design - implementation - testing - maintanance
우리는 지금까지 요구사항을 정리하여 use case description 을 만드는 specification
커뮤니케이션 다이어그램, 시퀀스 다이어그램, 클래스 다이어그램을 그리는 design, implementation 까지 모두 살펴보았다.
이제 마지막으로 testing 에 대해 정리해보자.
Verification & Validation (V & V)
중간고사 범위의 글에서 간략하게 정리했던 것처럼 testing 단계는 verification & validation 단계라고도 표현한다.
verification (검증) 은 소프트웨어를 specification 에 맞게 만들었는지를 확인하는 과정이다.
(are we building the product right)
validation (유효) 은 소프트웨어가 사용자가 원하는 방향대로 만들어졌는지를 확인하는 과정이다.
(are we building the right product)
사실 유저가 원하는 것이 곧 specification 아닌가? 라고 생각할 수도 있다.
하지만 이 둘은 엄밀히 구분된다.
예를 들어 게임회사에서 사용자를 열심히 인터뷰해서 specification 을 정의한 뒤 버그 하나 없이 완벽하게 돌아가는 게임을 만들었다고 해보자.
이 게임은 verification은 좋은 프로그램이다.
하지만 이 게임이 결국 재미가 없어서 유저를 만족시키지 못한다면, 이 프로그램은 validation 이 나쁜 프로그램이다.
보통 verfication 과 validation 을 합쳐서 V & V 라고 부른다.
V & V 과정은 소프트웨어가 목적에 부합하는지 확신(confidence)을 얻기 위한 과정이다.
하지만 V & V 과정을 모두 만족해서 confidence 를 얻었다고 해서 이 프로그램에 결함(버그)이 없다는 것을 의미하지는 않는다.
그저 사용자가 프로그램의 의도에 맞게 충분히 사용할 수 있다는 것을 나타낼 뿐이다.
즉, 소프트웨어가 출시되었다는 것은 버그가 없다는 것이 아니라
버그가 있지만, 사용자가 사용하는 입장에서 크게 문제될 버그는 아니라고 생각해서 출시하는 것이다.
이제부터는 verification 에 조금 더 집중해서 정리해보자.
왜냐하면 validation 은 결국 사용자와 소통하면서 불편한 점을 찾고 개선하는 과정이기 때문이다.
하지만 verification은 테크니컬한 부분으로 엔지니어가 직접 문제를 찾고 개선하는 과정이다.
verification 은 크게 static verification 과 dynamic verification 으로 나눌 수 있다.
이때 static verification 을 가리켜 inspection 이라고 부르고, dynamic verification 을 가리켜 testing 이라고 부른다.
static과 dynamic 을 구분하는 기준은 '프로그램의 실행 여부'에 있다.
software inspections 는 프로그램을 실행하기 전에 코드 문서를 보고 문제를 찾는 과정이다.
software testing 은 우리가 실제로 사용하는 테스트 데이터를 기반으로 프로그램을 실행해서 돌려보고 문제를 찾는 과정이다.
조금 더 구체적으로는
inspection 은 requirements specification, software architecture, UML design models, Database schemas, Program 모든 문서에 대해 진행한다.
testing 은 실행가능한 Program 과, requirements specification 을 기반으로 나오는 system prototype 에 대해서 수행한다.
여기에서 중요한 점은 Program 의 경우 inspection 과 testing 모두 수행되어야 한다는 것이다.
inspection 과 testing 은 서로 상호보완적이다.
inspection 은 성능, 사용성과 같은 non-functional requirement 를 파악할 수 없기 때문이다.
이런 특성은 직접 프로그램을 돌려봐야만 알 수 있다.
그리고 비교를 한다면 문제를 찾아내는 관점에서 봤을 때 inspection 이 testing 보다 더 효과적이라고 한다.
하지만 어쨌든 둘 다 해야한다
inspection
이제 inspection 에 대해서 조금 더 자세히 정리해보자.
inspection은 프로그램을 실행시키지 않고 사람이 직접 리뷰하는 것이다.
그리고 위에서 잠깐 보았듯 inspection은 implementation 전에도 할 수 있다.
specification, design, configuration data, test data 등등에 대해서 다 수행할 수 있기 때문이다.
최근에는 이 과정을 AI 의 도움을 받기도 한다.
코드리뷰도 AI 의 도움을 받아서 진행하는데, 나도 프로젝트를 할 때 AI 를 활용해서 하고 있다.
물론 AI 가 100% 완벽하게 해주는 것은 아니기 때문에 사람의 리뷰도 꼭 필요하다.
결국 전체 로직은 사람의 머릿속에 들어있기 때문이다.
그리고 확인하려는 기능이 중요하면 중요할수록 testing 보다 inspection 의 역할이 더 중요해진다.
inspection 은 에러를 찾는데 있어서 생각보다 아주 효율적인 방법이다.
앞서 XP 를 정리할 때도 pair programming 이 효율적이라고 이야기했듯
코드를 작성한 뒤에도 일단 컴파일을 돌리고 컴파일러에 의존해서 에러를 찾아내기보다
일단 내 눈으로 훑으면서 로직을 잘못 작성한 것은 없는지 일단 한번 검토하는 것이 중요하다.
결론은 inspection 너무 좋고 중요하니까 무조건 하자. 코드짜고나서 당연히 하는 것이다.
testing 도 물론 중요하지만, testing 으로는 논리적인 버그를 찾기 힘들다.
이런 버그는 inspection 으로 잘 찾아진다.
사실 정말 완벽하게 테스트를 한다고 하면
어떤 함수 로직에 if 문이 10개가 있다고하면 true/false 10가지 조합으로 1024가지를 테스트해봐야한다.
어떤 변수의 값 타입이 int 타입이라면 이 타입에 가능한 모든 int 값을 다 넣어봐야한다.
이런 테스트 방법을 exhaustive testing 이라고 한다.
사실상 불가능한 테스트 방법이고, 그렇기에 testing 으로는 모든 에러를 완벽하게 찾아낼 수 없다.
그래서 inspection 을 통해 '로직으로' 찾아내야 한다.
inspection 은 한번만 수행하더라도 많은 결점을 찾아낼 수 있다.
반면 testing 에서는 하나의 테스트에서 하나의 버그만 발생해도 그대로 테스트가 멈추므로, 그 테스트에서 버그가 여러개 있더라도 모두 찾아낼 수 없기 때문에 여러 번 테스트를 해야 한다. (고치고 돌리고 고치고 돌리기의 반복)
그리고 inspection 은 코드를 검토하는 reviewer 의 지식과 경험에 따라 당연히 보이는 양이 다를 것이다.
그래서 회사에서도 경험이 많고 지식이 많은 사람이라면 QA와 같은 testing 조직에 더 많이 배치한다.
개발하고 구현하는 것은 (설계가 잘 되어있다면) 누구나 할 수 있기 때문이다.
inspection 은 기본적으로 document 를 리뷰하는 formal한 접근방법이다.
개인단위로도 하고, 팀 단위로도 하고, 공식적으로, 체계적으로 하는 것이다.
inspection 의 목적은 결점(defect)을 '찾는 것'에 있지, 고치는 것에 있지 않다.
(formal 한 작업이기 때문이다. 에러를 찾아서 reporting 하는 것이 목적이다. 수정은 개발자가 할 것이다.
물론 개인적으로 개발하고나서 한 inspection 이라면 바로 고치겠지만, 어쨌든 목적은 '찾는 것' 이다.)
그리고 그 '결점'은 논리적 에러, 코드에서 에러로서 여길 만한 상황 등이 될 수 있다.
'에러로서 여길만한 상황' 에 대해 예를 들면, 변수를 선언했는데 assign 은 하지 않은 채 변수 값을 출력하는 상황을 생각할 수 있다.
이렇게 코드를 작성하고 실행하면 명시적인 에러는 안나겠지만 쓰레기값이 출력될 것이다.
이런 코드는 에러는 안 나지만 문제가 있는 코드이므로 고쳐야 한다.
또 포인터를 사용할 때 null 체크 없이 바로 활용하는 경우가 있을 수 있다.
나는 할당 했으니까 절대 null 이 될 수 없다고 믿고 null check 없이 사용했는데,
실수로 할당을 안해서 null 이 들어있는채로 돌아가는 순간 프로그램은 실행중에 에러를 뱉고 죽는 것이다.
따라서 포인터를 사용할 때는 null 을 항상 체크하도록 작성해야 한다.
이런 것은 testing 으로 찾으려면 명시적으로 null 값을 넣어보는 테스트를 작성하지 않는 이상 알 수 없다.
따라서 inspection 을 통해 이런 문제점을 사전에 찾는 것이 중요하다.
inspection 을 수행하려면 사전에 만족해야 하는 사항이 몇 가지 있다.
1. 명확한 specification 이 존재해야 한다.
기준이 되는 스펙이 없으면 static verification 은 수행할 수 없으니 당연히 스펙이 있어야 한다.
2. error checklist 가 준비되어 있어야 한다.
각자 알아서 inspection 을 하도록 하는 것이 아니라, 체크리스트를 준비하고 각 항목이 다 통과되는지 확인해야 한다.
3. 관리자가 inspection 과정에 cost 가 발생한다는 것을 이해해주어야 한다.
개발자의 관리자급이 개발 지식이 없다면 '구현 다했으면 빨리 출시하지 왜 진전이 없냐'고 할 수도 있다.
inspection 은 굉장히 시간이 오래걸리는 작업이지만, 장기적으로는 엄청 소프트웨어 퀄리티를 매우 높이기 때문에 관리자가 이를 이해하고 받아들여야 한다.
4. 관리자는 inspection 결과를 인사고과에 반영하면 안된다.
inspection 결과에서 버그가 많이 나왔을 때, 누가 제일 많이 버그를 만들었는지 찾아내서 불이익을 주고 그러면 안된다.
그러면 개발자는 에러를 찾는데 점점 소극적이게 될 것이다.
인사고과에 신경쓰지 않고 에러를 찾을 수 있도록 해주어야 한다.
inspection 을 수행하는 과정은 다음과 같다.
1. inspection team 에게 프로그램의 전반적인 개요를 설명한다.
2. code, 연관된 document 를 미리 받는다.
3. inspection 을 진행하고 발견한 error 를 기록하여 정리한다.
4. 개발팀이 발견된 error 를 수정한다.
5. 다시 inspection 을 진행한다. (어느정도 많이 바뀌었다면 거의 필요하다)
단계로 나열해보면 다음과 같이 표현할 수 있다.
planning -> overview -> individual preparation -> inspection meeting -> rework -> follow-up
상술했듯 inspection 을 하기 전에는 error checklist 가 필요하다.
checklist 는 일반적으로 사람들이 많이 하는 실수 (common error) 를 리스트로 준비해둔 것이다.
이때 체크리스트는 보통 사용하는 프로그래밍 언어에 의존적이다.
예를 들어 c, c++ 에는 포인터 개념이 있으므로 이에 대한 체크리스트 항목이 존재하겠지만
파이썬이나 자바에는 없는 개념이므로 이에 대한 체크리스트 항목은 없을 것이다.
보통 파이썬같이 타입 체크가 느슨한 언어일 수록 체크리스트 항목이 더 많다.
컴파일러가 어지간하면 다 알아서 해줘서 개발중에 에러를 확인하기 힘들기 때문이다.
체크리스트 항목의 예시를 보면 initialisation, constant naming, loop termination, array bound 등이 있다.
구체적으로 분류를 해보면 다음과 같이 6가지로 분류할 수 있다.
data faults : 변수 값을 사용하기 전에 초기화를 했는지, 배열의 upper bound 가 size 또는 size -1 과 같은지, 문자열 끝에 null 값이 있는지, 할당받은 메모리 이상으로 사용하는 버퍼 오버플로우는 없는지
control faults : 조건문, 반복문에 기입된 조건이 올바른지, 반복문이 종료되는지, { } 가 올바르게 잘 쳐져 있는지, case 문에서 모든 case 에 대해 다 기술했는지, 각 case 끝에 break 를 기술했는지
input/output faults : 모든 input 관련 변수가 input 에 사용되었는지, 모든 output 변수가 출력 전에 값이 할당 되어있는지, 예상하지 못한 사용자 입력에 대해 예외처리가 되어있는지
interface faults : 인터페이스에서 정의한 시그니처와 실제 호출하는 함수가 잘 맞는지 (파라미터 개수, 순서, 타입이 일치하는지)
storage management faults : 연결리스트에서 데이터가 수정되었을 때, 모든 link 가 올바르게 연결되어 있는지, 동적할당을 할 때 올바르게 할당이 잘 되었는지 (메모리가 부족하면 동적할당에 실패할 수도 있다. 실제 과거 C 코드를 보면 동적할당 코드 다음에는 항상 메모리 할당에 성공했는지 확인하는 코드가 존재한다), 메모리를 다 사용한 이후에 올바르게 반환되었는지 (메모리 누수 방지)
exception management faults : 발생할 수 있는 모든 exception 을 다 처리하고 있는지
inspection 에 걸리는 시간을 inspection rate 로 계산할 수 있다.
overview 단계에서는 평균적으로는 보통 한 시간에 500개 코드 문장 (statement) 를 처리한다고 한다.
individual preparation 단계에서는 보통 한 시간에 125개를
inspection meeting 단계에서는 보통 한 시간에 90~125개를 처리한다고 한다.
이를 통해 inspection 이 시간이 오래 걸리는 단계임을 알 수 있다.
그래서 이렇게 시간이 오래 걸리는 작업을 사람이 하기 힘들기 때문에 보통 static analyser 소프트웨어를 사용한다.
이 소프트웨어를 활용하여 컴파일 전에 소스크도를 파싱한 뒤 잠재적으로 에러가 날 수 있는 부분을 V & V team 에 알려준다.
하지만 소프트웨어도 공통적으로 자주 등장하는 부분을 찾는 것을 도와줄 뿐, 논리적인 로직 에러는 사람이 직접 찾아야 하기 때문에 inspection 단계는 그럼에도 수행해야 한다.
대표적인 예시로는 LINT 와 같은 것이 있다.
C와 같이 type checking 이 약한 언어에서 쓰기 좋고 java 같이 type checking 이 강한 언어에서는 컴파일러가 다 잡아주니 효율이 떨어진다.
근데 요즘엔 AI 가 워낙 이런 걸 잘해줘서 AI 에게 시키면 된다.
결론적으로 testing 은 테스트 가능한 전체 셋에서 굉장히 작은 일부분만 테스트하는 것이기 때문에 inspection 이 굉장히 중요하다.
Testing
테스팅은 우리가 의도한대로 프로그램이 동작하는지 확인하는 과정이다.
그리고 우리가 의도한 것과 다르게 동작하면 이를 defect 로 감지한다.
이때 테스팅을 통해서는 '에러가 있다' 는 것만 확인할 수 있고, '에러가 없다' 는 것은 확인할 수 없다는 것에 주의하자.
또한 non-functional requirement 의 경우에는 inspection 으로 검증할 수 없으며 반드시 testing 으로 검증해야한다.
testing 과 debugging 은 엄밀하게 구분된다.
testing 은 프로그램의 결점을 '찾는' 것이고,
debugging 은 버그를 찾아서 '제거하는' 과정이다.
이때 디버깅에서 제일 어려운 것은 버그가 발생한 원인을 찾는 것이다.
그리고 버그를 고쳤다면 다시 inspection 을 수행해야 하고 (reinspection)
testing 도 다시 수행해야 한다. (regression testing)
regression testing 은 새로 변경한 부분에 의해 새로운 fault 가 만들어지지 않는다는 것을 확인하는 과정이다.
testing 은 크게 component testing 과 system testing 으로 나누어진다.
component testing 은 개별적인 component를 테스트하는 과정으로, 중요한 시스템을 제외하고는 보통 개발한 사람이 테스트를 하기 때문에 그 개발자의 능력과 경험에 달려있다.
(물론 component 도 개발자가 inspection 을 함께 수행해야 한다)
system testing 은 component 단위를 통합한 서브시스템 또는 시스템을 테스팅하는 것으로, 보통 독립적인 테스트 팀에서 테스트를 한다.
특히 system testing 은 integration testing, release testing 2단계로 나누어서 진행한다.
integration testing 은 component 를 합치고나서, 컴포넌트 간 상호작용 과정에서 발생하는 결점을 찾는 과정이다.
이때 테스트 케이스는 처음에 시스템 아키텍처를 설계하고 인터페이스 정의하고, 다시 컴포넌트를 나누고 인터페이스를 설계할 때, 인터페이스를 테스트할 테스트 케이스를 만든다.
즉, 큰 단위에서 설계해나가면서 인터페이스가 정의될 때마다 큰 단위의 테스트 케이스가 함께 정의되고, 작은 단위가 구현될 때마다 기존에 만들어둔 테스트케이스를 활용해서 테스트해가며 통합하는 것이다.
이때 여러 컴포넌트를 한번에 통합하고 한번에 테스트하는 것이 아니라,
조금씩 점진적으로 하나씩 붙여가면서 테스트를 진행해야 한다. (incremental integration testing)
그래야 에러가 발생했을 때 그 위치도 찾기가 쉽다. (error localisation)
예를 들어 A, B, C, D 4개의 컴포넌트가 있을 때
먼저 A, B 를 integration 하고, 이 둘 사이의 인터페이스와 관련된 테스트 T1 ~ T3 을 수행한다.
다음으로는 C 를 추가적으로 integration 하고, 기존에 했던 테스트 T1 ~ T3 과 더불어 새로운 인터페이스에 대한 T4 테스트도 수행한다.
다음으로 D 를 추가적으로 integration 하고, 기존에 했던 테스트 T1 ~ T4 와 더불어 새로운 인터페이스에 대한 T5 테스트도 수행한다.
사실 제대로 설계를 했다면 integration testing 이 제일 쉽다.
하지만 설계를 제대로 하지 않고, 각자 알아서 구현하자고 하면 개발한 결과물을 합칠 때 에러가 많이 발생하므로 integration testing 이 어려워진다.
이렇게 integration testing 이 모두 끝났다면, 다음 단계로는 release testing 을 진행한다.
말 그대로 release, 고객에게 소프트웨어를 배포하기 전에 하는 테스팅이다.
이 테스팅 단계에서는 실제 유저가 사용하는 것처럼 테스트를 진행한다. (acceptance testing = alpha test,
이 테스트까지 끝나고 나면 정식 출시 전에 일부 유저를 대상으로 beta test 를 진행한 뒤 정식 출시한다)
그래서 보통은 input, output 만 가지고 판단하는 black-box 방식으로 테스트를 진행한다.
오직 시스템 specification (기능) 만 가지고 테스트를 진행하며, 테스터는 프로그램 구현에 대해 알지 못한다.
(integration testing 에서는 구현을 알아야 한다. interface 가 무엇이 있는지를 알아야 하기 때문이다. = white-box)
이때 자주 사용하는 테스트 기법 중 하나로 partition testing 기법이 있다.
예를 들어 어떤 함수 로직에서 조건문의 조건식이 어떤 정수형 변수의 값이 0보다 큰지 작은지에 따라 분기가 나뉘는 로직이 있다고 해보자.
테스트 input 으로 2개의 값만 넣을 수 있다고 할 때 어떤 값을 넣어보는 것이 좋을까?
당연히 상식적으로 양수 값과 음수값을 하나씩 넣어볼 것이다.
0보다 큰 분기를 탔을 때와 그렇지 않은 분기를 탔을 때의 로직을 모두 테스트 해봐야 하기 때문이다.
이렇게 각 로직의 영역별로 구간을 나누는 것을 가리켜 equivalence partition 이라고 한다.
위 예시에서는 양수와 음수 2개의 파티션으로 쪼개진 것이다.
따라서 양수 데이터는 모두 같은 종류의 데이터로 분류되고, 음수 데이터는 역시 모두 같은 하나의 종류의 데이터로 분류된다.
만약 if - else if - else 구문으로 로직이 구성되어 있다면 partition 은 3개가 될 것이고,
최소한의 개수의 테스트 케이스를 구성한다면 각 로직을 하나씩 테스트할 수 있도록 데이터를 구성해야 할 것이다.
(= 각 파티션에서 하나씩 테스트 케이스를 구성해야 한다)
또한 테스트 데이터를 선정할 때는 에러를 찾기 쉽도록 구성하기 위해, 각 파티션의 경계값으로 데이터를 구성하는 것이 좋다.
예를 들어 4 미만, 4~10, 10초과를 기준으로 partition 이 나뉘어졌다면, 테스트케이스는 3, 4, 10, 11 을 넣을 수 있다.
(또한 4~10 구간의 중간값인 7도 테스트 케이스로 넣을 수도 있다)
또 다른 예시를 보자.
어떤 배열에서 특정 원소의 index 를 찾는 함수가 있다고 해보자.
def search(item, collection):
for i in range(len(collection)):
if collection[i] == item:
return True, i
return False, -1
if collection: # pre-condition
search(item, collection)
파이썬 구현으로는 간단하게 이렇게 동작한다.
아이템이 collection 에 들어있으면 True 와 함께 그 인덱스를 반환하고,
아이템이 없으면 False 와 함께 -1 을 반환한다.
이때 이 함수가 동작하기 위한 pre-condition 은 collection 에 최소 1개 이상의 원소가 들어있어야 한다는 것이고,
이 함수의 실행 결과에 대한 post-condition 은 2가지로 나누어서 찾았을 때는 True 이면서 collection[i] = item 인 i 를 반환하고, 못 찾았을 때는 False 이면서 모든 i 에 대해 collection[i] = item 인 i 가 존재하지 않는다.
이제 이 함수에 대해 partition testing 을 수행해보자.
파티션은 먼저 pre-condition 기준으로 원소가 존재할 때와 존재하지 않을 때로 나눈다.
존재하지 않을 때는 빈 collection 으로 테스트하면 된다.
컬렉션에 원소가 존재할 때는 다시 찾으려는 원소가 존재하는 경우와 존재하지 않는 경우로 나누고
찾으려는 원소가 존재할 때는 경계값에 맞춰서 컬렉션에 원소가 1개만 존재할 때, 1개보다 많이 존재할 때를 구분해서 나눌 수 있다.
그리고 1개보다 많이 존재할 때는 찾으려는 원소의 위치가 collection 의 시작, 중간, 끝일 때 각각 모두 테스트할 수 있도록 구성해야 한다.
찾으려는 원소가 존재하지 않을 때도 컬렉션에 원소가 1개만 존재할 때, 1개보다 많이 존재할 때로 구분해서 테스트를 구성한다.
(즉, 이 경우에서는 원소가 1개 있을 때, 1개보다 많을 때의 로직을 서로 다르다고 보고 파티션으로 구분 한 것이다.)
정리해보면, 원소가 존재하는 상황에서는 6가지 상황을 테스트 해야한다.
1. 원소가 1개 있을 때 - 찾으려는 원소가 컬렉션에 존재하는 경우
2. 원소가 1개 있을 때 - 찾으려는 원소가 컬렉션에 없는 경우
3. 원소가 2개 이상 있을 때 - 찾으려는 원소가 컬렉션 처음에 있는 경우
4. 원소가 2개 이상 있을 때 - 찾으려는 원소가 컬렉션 중간에 있는 경우
5. 원소가 2개 이상 있을 때 - 찾으려는 원소가 컬렉션 마지막에 있는 경우
6. 원소가 2개 이상 있을 때 - 찾으려는 원소가 컬렉션에 없는 경우
찾으려는 원소가 없을 때는 pre-condition 만족을 하지 못하니 로직 자체가 돌아가지 않는다.
그리고 테스트 케이스를 만들 때는 위 6가지 상황을 만족하는 데이터를 구성하여 테스트를 진행하면 된다.
'CS > 소프트웨어공학' 카테고리의 다른 글
[소프트웨어공학] 20. Design Pattern (0) | 2025.06.09 |
---|---|
[소프트웨어공학] 19. System Design and Architecture (0) | 2025.06.08 |
[소프트웨어공학] 18. Refining the Requirements Model (1) | 2025.06.07 |
[소프트웨어공학] 17. State Machine 예제 (수정예정) (0) | 2025.06.07 |
[소프트웨어공학] 16. Designing Boundary Class (0) | 2025.06.07 |