[Dev] 로봇 개발과 테스트 코드
로봇 개발과 테스트 코드
아직도 많은 개발자들에게 ‘테스트 코드를 작성한다’라는 개념은 왠지 모르게 부담스럽거나 추가적인 번거로움으로 느껴질 수 있다. 특히 빠른 개발 속도와 결과물을 강조하는 환경일수록, 테스트 코드 작성이 우선순위에서 밀려나는 경우가 흔하다. 그러나 로봇 소프트웨어처럼 하드웨어와 직접 연동되고 동작 안정성이 중요한 도메인에서는 테스트 코드의 중요성을 결코 간과할 수 없다. 이번 글에서는 테스트 코드가 무엇이며, 왜 필요한지, 그리고 어떻게 작성하고 자동화할 수 있는지 살펴본다.
1. 테스트 코드란 무엇인가?
테스트 코드(Test Code)란, 구현한 기능이나 로직이 의도대로 동작하는지 자동으로 검증하기 위한 프로그램 코드를 의미한다. 예를 들어 로봇이 ‘장애물을 피하고 목적지로 이동’하는 기능을 구현했다면, 장애물을 정상적으로 감지하고 회피하는지, 특정 센서 입력값이 비정상일 때도 예외 처리가 적절히 이뤄지는지를 자동으로 검사할 수 있도록 만든 코드가 테스트 코드에 해당한다.
일반적으로 테스트 코드는 규모와 범위에 따라 다양한 단계로 구성된다. 가장 작은 단위(함수, 메서드, 클래스 등)를 대상으로 하는 유닛 테스트(Unit Test)가 있고, 여러 모듈을 연동해 전체 기능을 점검하는 통합 테스트(Integration Test)가 있다. 로봇 소프트웨어에서는 하드웨어 의존성이 크기 때문에, 단순히 유닛 테스트만으로 모든 상황을 검증하기는 어렵다. 따라서 통합 테스트도 함께 고려해 종합적인 시나리오가 의도대로 동작하는지 확인해야 한다. 유닛 테스트와 통합 테스트 이외에도 E2E 테스트, Nightly 테스트와 같은 더 넓은 범위의 테스트들도 있다. 컴파일 옵션에 따라 전체적인 프로세스 시간이 많이 길어지는 경우, 혹은 대용량의 데이터를 기반으로 테스트를 수행해야 하는 경우 특정 시간을 정해서 테스트를 수행하며, 보통 새벽에 많이 수행하기 때문에 Nightly 테스트라고 부르기도 한다.
2. 테스트 코드를 왜 작성해야 하는가?
“굳이 테스트 코드를 작성하지 않아도, 일단 코드를 돌려보면 제대로 작동하는지 확인할 수 있지 않을까?”라고 생각할 수 있다. 그러나 수동 테스트에는 분명한 한계가 존재한다. 특히 로봇 소프트웨어는 물리적인 환경에서 동작하기 때문에, 현장에서 모든 기능을 매번 직접 검증하는 데에는 많은 비용과 시간이 든다. 게다가 사람은 반복되는 테스트 과정에서 실수를 하기도 쉽다.
테스트 코드를 작성하면 다음과 같은 이점을 얻을 수 있다.
안정성 확보
코드가 변경되거나 신규 기능이 추가되었을 때, 기존 기능이 문제없이 동작하는지 빠르게 확인할 수 있다. 본인이 작성한 코드도 몇일이 지나면 기억이 나지 않는다. 심지어 여러사람이 함께 협업을 하는 경우라고 하면 이러한 안정성 확보는 무엇보다 중요하다.기술 부채 감소
기능이 늘어날수록 “어디선가 예기치 않은 버그가 생기면 어떡하지?”라는 우려가 커진다. 테스트 코드가 있으면 수정 작업 후에도 빠르게 문제 여부를 파악할 수 있어 기술 부채가 쌓이는 속도를 줄일 수 있다.개발 효율 증가
처음에는 테스트 코드를 작성하는 데 드는 시간이 부담이 될 수 있다. 그러나 장기적으로 보면 문제를 미리 발견·해결하는 과정이 빨라져, 전체 개발 효율이 오히려 높아진다.협업 효율 상승
팀원이 새 코드를 추가하거나 기존 코드를 수정할 때, 해당 로직이 어떻게 동작해야 하는지 테스트 코드를 통해 빠르게 파악할 수 있다. 즉, 테스트 코드는 그 코드의 사용법에 대한 설명서이다.코드의 구조화
테스트 코드를 작성하다 보면 자연스럽게 코드의 구조가 개선된다. 특히 유닛테스트는 하나의 함수에 대한 기능을 테스트하는 테스트이기 때문에 하나의 함수에 너무 많은 기능이 들어가게 되면 정확한 유닛 테스트를 작성하기 어려워 진다. 따라서 유닛 테스트 코드를 작성하다 보면 자연스럽게 하나의 함수는 최소한의 기능만 갖도록 구조화가 된다.코드의 디버깅
테스트 코드를 작성하다 보면 다양한 테스트 케이스를 고민하게 되고, 그 과정에서 다양한 코드의 버그들을 발견하게 된다.
3. Unit Test와 Integration Test
테스트 코드를 구성할 때 자주 등장하는 개념이 바로 ‘유닛 테스트(Unit Test)’와 ‘통합 테스트(Integration Test)’다. 보통 두 테스트는 다음과 같은 차이가 있다.
- 유닛 테스트(Unit Test)
- 함수나 클래스, 혹은 모듈 수준으로 쪼갠 작은 단위를 테스트한다.
- 외부 의존성(네트워크, 하드웨어, 데이터베이스 등)을 가짜 객체(Mock)로 대체해 테스트한다.
- 내부 함수끼리의 의존 관계가 있는 경우 각 함수들의 출력을 Mocking 하여 원하는 시나리오를 테스트한다.
- 각 단위가 제대로 동작하는지 확인하므로 테스트가 빠르게 끝나고, 문제 발생 지점을 정확히 파악하기 쉽다.
- 통합 테스트(Integration Test)
- 여러 모듈이 합쳐졌을 때 전체 기능이 정상적으로 작동하는지를 검증한다.
- 로봇 소프트웨어라면 실제 센서값 수신부터 제어 모듈까지 연결해 시나리오 테스트를 진행하기도 하고, Mocking 센서 테이터를 기반으로 진행하기도 한다.
- 유닛 테스트만으로는 찾지 못하는 모듈 간 연동 문제나 예외 상황을 포착할 수 있다.
로봇 소프트웨어는 하드웨어 연동이 많아 단순 유닛 테스트만으로는 한계가 있다. 따라서 유닛 테스트로 기본 로직을 탄탄하게 검증하고, 통합 테스트를 통해 실제 운영 환경과 유사한 조건에서 시스템을 점검하는 방식이 권장된다. 추가적으로 Nightly 테스트를 관리하기도 하는데, 큰 데이터를 기반으로 한 테스트, 혹은 컴파일 시간이 매우 오래걸리는 테스트 (Memory Sanitizer, Thread Sanitizer 등)는 PR을 생성할 때마다 수행되는 CI 프로세스에서 수행하게 되면 전체적인 개발 프로세스에 지연을 야기 시킨다. 따라서 이런 테스트는 CI 프로세스와는 별개로 일정 시간에 수행되어 리포트를 생성하는 방식으로 테스트 하기도 한다.
4. TDD(Test Driven Development)
TDD(Test Driven Development)는 기능 구현보다 테스트 코드를 먼저 작성하는 개발 방법론이다. 보통은 기능을 먼저 구현한 뒤 검증용 테스트 코드를 작성하는데, TDD는 이 순서를 뒤집는다. 일반적인 TDD 흐름은 다음과 같다.
- 테스트 작성: 아직 구현되지 않은 기능에 대해, 원하는 동작과 인터페이스를 정의하는 테스트 코드를 먼저 작성한다.
- 테스트 실행: 당연히 구현되지 않았으므로 테스트가 실패(Fail)한다.
- 기능 구현: 실패한 테스트를 통과시키기 위한 최소한의 코드만 작성한다.
- 리팩터링: 테스트가 통과했다면, 코드를 리팩터링하여 품질과 가독성을 개선한다.
- 테스트 재확인: 리팩터링 후에도 모든 테스트가 통과하는지 다시 확인한다.
이 과정을 반복하면서 기능 요구사항이 더욱 명확해지고, 필요 이상의 코드를 작성하지 않게 된다. 다만 TDD를 적극적으로 적용하려면 팀원들이 테스트 작성에 익숙해져야 하며, 명확한 설계와 테스트 전략이 뒷받침되어야 한다. 로봇 개발 기준으로 데이터의 입력과 출력이 명확한 특정 기능을 갖춘 라이브러리를 작성할때는 이러한 TDD 개발 방식이 적합한 경우가 많다. 반면에 외부 센서 혹은 내부 프로세스들과의 통신이 빈번이 발생하는 인터페이스 프로세스는 개발 초기에 각 함수별로 입력과 출력을 나누기 어려운 경우가 많다. 이런 경우에는 기능을 먼저 개발하고 추후에 테스트 코드를 작성하는 방식으로 하기도 한다.
5. 테스트 코드를 작성하면 개발 속도가 느려질까?
테스트 코드를 작성하면 당장 눈앞의 개발 속도가 느려지는 것처럼 느껴질 수 있다. 특히 빠른 릴리스를 요구하는 환경에서는 “기능 구현만으로도 시간이 모자란데, 테스트 코드까지 작성해야 하느냐”는 불만이 나올 수도 있다.
그러나 장기적인 관점에서 보면, 테스트 코드가 없을 때 발생하는 디버깅과 재배포, 문제 파악에 드는 막대한 비용을 고려해야 한다. 버그가 뒤늦게 발견될수록 고치는 데 더 많은 자원과 시간이 투입된다. 반면 테스트 코드가 잘 마련되어 있으면 수정사항을 적용한 뒤 자동 테스트를 돌려 잠재적인 문제를 빠르게 발견할 수 있다. 이는 “빠른 실패(Fail Fast)”를 가능하게 하며, 전체 프로젝트의 안정성과 생산성을 모두 높이는 결과로 이어진다. 새로운 기능을 빠르게 개발하는 것도 중요하지만, 장기적인 관점에서 개발 속도가 조금은 느릴 수 있지만 안정적인 시스템을 갖추고 테스트 코드를 함께 개발하는 것이 올바른 개발 방법이다. 속도보다 중요한 것은 올바른 방향으로 가는 것이다.
6. 테스트 자동화
테스트 코드의 가치를 극대화하기 위해서는, 작성된 테스트를 자동으로 돌려주는 환경(CI, Continuous Integration)을 구축하는 것이 중요하다. 대표적인 CI 툴로는 GitHub Actions, GitLab CI/CD, Jenkins 등이 있으며, 대체로 Pull Request가 올라오면 빌드와 테스트를 실행하고 결과를 알려준다.
특히 로봇 소프트웨어는 하드웨어 시뮬레이션 환경이 필요할 때가 많다. 이를 위해 Docker를 이용하거나 시뮬레이션 툴(ROS 시뮬레이터 등)과 연동해 자동 테스트를 구성한다. 이렇게 하면 실제 로봇 없이도 비교적 빠르게 코드가 의도대로 동작하는지 검증할 수 있다.
TIM Robotics의 CI 프로세스에서는 일반적으로 새로운 PR이 생성될 때마다 다음의 항목들을 CI 과정을 통해 자동으로 테스트 한다. (위 그림은 PR 생성 시 모든 테스트가 통과한 모습)
- Linter: 각 언어 (c++, python, shell script, xml) 에 대해 정해진 포멧을 지키고 있는지 확인.
- Code compile 및 테스트 : 정상적으로 컴파일이 수행되는지 확인하고, 테스트 코드가 모두 성공하는지 확인. 로봇 내부에서 동작하는 코드의 경우에는 X86 시스템 뿐만 아니라 ARM Architecture CI 서버에서도 함께 테스트 진행.
- 테스트 커버리지 확인 : 유닛 테스트 수행 완료 후 테스트 코드의 커버리지를 확인하고 수정된 파일에 대한 커버리지를 리포트 해준다. 커버리지가 특정 값 (일반적으로 80%)를 넘지 못하는 경우 CI가 실패하여 Merge를 수행할 수 없다.
- File consistency 체크 : 로봇의 경우 여러개의 Repository를 관리하는 경우가 많기 때문에 동일한 역할을 하는 파일들이 각 Repository에 분산되어 있는 경우가 많다. 각 Repository에 있는 파일들이 master file과 동일하게 유지되고 있는지 확인
사람은 누구나 실수를 할 수 있기 때문에 최대한의 자동화를 통해 이러한 휴먼 에러를 최소화 해야 한다.
7. 테스트 커버리지 체크
테스트 코드가 있다고 해도, 실제로 얼마나 많은 부분을 테스트하는지는 또 다른 문제다. 이때 도움이 되는 지표가 테스트 커버리지다. 테스트 커버리지 분석 툴(gcov, lcov, cobertura 등)을 사용하면 특정 파일 혹은 함수가 테스트에서 얼마나 커버되었는지 확인할 수 있다. 일반적으로 다음과 같은 항목을 살펴본다.
- 라인 커버리지(Line Coverage): 전체 코드 라인 중 테스트를 통해 실행된 라인의 비율
- 함수 커버리지(Function Coverage): 전체 함수 중 테스트를 통해 실행된 함수의 비율
- 분기 커버리지(Branch Coverage): 조건문(if/else 등)의 각 분기가 실제 테스트 과정에서 모두 실행되었는지를 측정한 비율
테스트 커버리지가 높다고 해서 모든 오류를 자동으로 검출할 수 있는 것은 아니다. 그러나 커버리지를 확인하면 테스트되지 않은 부분을 명확히 파악할 수 있어, 테스트의 사각지대를 줄이는 데 큰 도움이 된다. 테스트 커버리지를 확인하는 주된 목적은 각 유닛 테스트가 함수 내부의 로직을 다양하게 검증했는지를 평가하는 데 있다. 단 하나의 테스트 케이스만 존재하더라도 해당 함수가 실행되었다면 커버리지 리포트에는 포함되므로, 커버리지 수치만으로 테스트 코드의 질적 수준(즉, 얼마나 다양한 케이스를 검증했는지)을 평가하는 것은 어렵다. 따라서 유닛 테스트의 품질은 테스트를 작성하는 개발자의 역량에 크게 의존하며, 이를 위해 개발 문화가 중요한 역할을 한다. 테스트 코드를 작성할 때 중요한 점은 유닛 테스트와 통합 테스트를 분리하는 것이다. 통합 테스트가 커버리지 체크 프로세스에 포함되면 어떤 문제가 발생할까? 통합 테스트는 여러 함수가 연결된 상태에서 실행되므로, 특정 함수를 테스트하는 과정에서 해당 함수가 호출하는 모든 함수들까지 커버리지에 포함된다. 이로 인해 유닛 테스트에서 개별적으로 검증되지 않은 함수가 무엇인지 명확히 파악하기 어려워진다. 따라서 통합 테스트는 커버리지 확인 프로세스에서 제외하는 것이 바람직하다고 생각한다.
TIM Robotics에서는 테스트 작성 시 유닛 테스트와 통합 테스트를 명확히 분리하며, CI를 구성할 때 유닛 테스트의 실행 결과만 커버리지 리포트에 포함되도록 설정하였다. 이를 통해 생성된 커버리지 리포트는 PR 생성자가 확인할 수 있도록 자동으로 업로드되며, 커버리지가 사전에 설정한 기준에 미달할 경우 CI가 실패하도록 구성되어 있다. 또한, 상세 리포트를 다운로드하여 코드 라인별로 커버리지 미충족 부분을 확인할 수 있도록 하여, 어떤 코드에 대한 유닛 테스트가 부족한지 쉽게 파악할 수 있는 시스템을 구축하였다.
마치며
테스트 코드 작성은 코드 품질을 개선하는 도구를 넘어, 지속 가능한 개발 문화를 위한 필수 요소라고 할 수 있다. 특히 로봇 소프트웨어처럼 하드웨어와 맞물려 안전성과 신뢰도가 중요한 분야에서는 더욱 강조될 수밖에 없다.
처음에는 테스트 코드를 작성하는 데 드는 시간이 부담스러울 수 있다. 그러나 테스트가 존재함으로써 얻게 되는 조기 오류 발견, 협업 효율성 향상, 기술 부채 감소 등은 장기적인 관점에서 절대적인 가치를 지닌다. 또한 코드 리뷰, 스타일 가이드, CI/CD 파이프라인과 결합하면 그 효과가 배가된다.
결국 테스트 코드는 “필요하면 추가로 작성하는 옵션 사항”이 아니라, 프로젝트의 토대를 견고하게 만드는 핵심적인 도구다. 로봇 소프트웨어 개발에서도 테스트 코드 작성 문화를 정착시키고, 이를 자동화와 결합해 장기적으로 안정된 개발 환경을 구축하기를 권장한다. 테스트 코드가 제공하는 안정성과 예측 가능성은, 하드웨어와 깊이 연동되는 로봇 소프트웨어 개발에서 더욱 빛을 발하게 된다.