기술아티클

Android 단위 테스트 도입기_feat.시행착오 끝에 찾은 길

2024.10.22






안녕하세요, 연구소 개발 1팀 김민수 입니다. 

 

저는 안드로이드 서비스 개발과 유지보수를 담당하고 있습니니다.

이 글에는 제가 지란지교시큐리티에서 겪었던 안드로이드 단위 테스트의 도입 시도와 시행착오에 대한 내용이 담겨있습니다. 그간 제가 했던 치열한 고민과 선택들이 이 글을 읽는 독자들에게 도움이 되었으면 좋겠습니다.

 

 

 

1. Android 단위 테스트의 첫 시작, 오류 로그

 

안드로이드 개발자로 지란지교시큐리티에 입사했던 2014년, 당시의 안드로이드를 회상해 보면 혼돈의 시절이었습니다. 아키텍처에 대한 필요성이 언급되었지만 MVC가 적합하지 않다는 말들이 오고 갔고, MVP, MVVM 등이 언급되었지만 명확하게 정립된 패턴은 없었습니다. 


디바이스 파편화 이슈를 해결하는 것만으로도 가슴이 벅차오르던 시절이었습니다.

 

 

기기 파편화, 2015(One Signal)

 

개인적으로는 이 일을 시작하기 전, 이전 직장에서의 2년간의 SI 웹 개발 경험에서, 전임자들의 코드로 인생의 쓴맛을 경험한 제게 Spring MVC는 큰 감동이었고, 안드로이드에도 이 아키텍처를 적용하여 동료나 후임자들에겐 이런 고통을 공유하지 않겠다는 큰 포부를 가지고 있었습니다.

 

‘나는 후임들에게 내가 겪은 고통을 물려주지 않겠다!’

 

그렇게 시작한 프로젝트 초기엔 많은 기능이 없었기에 좋은 아키텍처를 적용하여 유지 보수성을 향상시키는 것만으로도 충분하다고 생각했습니다. 하지만 프로젝트도 나이를 먹어가면서 기능 추가/변경, 클라이언트 별 커스텀 반영, OS 업데이트 대응 등 프로젝트 규모가 점점 커져가기 시작했고, 사소한 코딩 실수로 인한 크래시 로그(Crash Log)가 미세하게 늘어나기 시작했습니다.

 

그러던 어느 날 QA 팀 동료로부터 이런 피드백을 받게 됩니다.

 

“전반적으론 잘 만든 거 같은데, 개발자 선에서 검증이 된 건지 의문이 가는 버그들이 있다.”

 

무언가 조치를 필요하겠다는 생각이 들기 시작했습니다.

 

 

 

 

2. 단위 테스트를 통해 얻고자 했던 것

 

공장에서 정비반으로 일하시던 아버지께서 빨리 고치는 사람보다, 오래 걸려도 앞으로 문제없게 고치는 사람이 더 좋다는 말씀을 하시곤 했습니다.

 

‘시간이 걸려도 문제없게 고치는 것이 가장 중요’

 

수많은 기능 추가와 변경, 개선 작업이 있었고, 그 과정에서 기존 기능의 영향도를 판별해 내야 했습니다. 인간의 뇌(?)로 판단하기엔 문제없어 보였지만, 체크하지 못했던 실수들은 사용자들에게 닿은 후 에러를 뿜어내었습니다. 

 

작은 코드 수정이라도 예외는 없었습니다.

 

당시에도 크래시 리포트 서비스(지금의 Crashlytics 같은)를 사용하였기에, 수집된 오류 로그들이 확인될 때마다 빠르게 패치하였지만 이 과정이 좋다고 할 순 없었습니다. 특히나 ‘Null’ 체크 같은 에러들은 개발자에겐 사소하지만, 사용자에겐 크리티컬 해 보였으니까요.

 

원인의 사소함은 중요하지 않은 OS 오류 팝업


팀에서 단위 테스트 도입에 대한 얘기가 언급되기 시작했었습니다. 

단위 테스트 도입을 통해 얻고자 했던 것들은 다음과 같습니다.

 

  • 기능 추가/수정/개선 영향도로 발생하는 결함 사전 체크
  • 늘어가는 기능 수와 비례하여 증가하는 수기 테스트 소요 시간들을, 단위 테스트를 통해 일정 부분 자동화하고 소요 비용(내 시간) 절감
  • 사용자들에게 닿기 전 결함 사전 체크를 통해 버그 확인/수정 → 안정성 개선 → 사용자들의 신뢰도 상승 기대

 

기존 대비 개발 단계와 비교하면 테스트 코드 작성 절차가 추가되어야 했고, 이로 인해 공수가 더 필요해지는 것 아니냐는 우려도 있었습니다. 하지만 앞으로도 프로젝트엔 많은 기능들이 추가될 것이고 그와 비례해 늘어날 테스트 시간을 생각하니 고민할 시간에 더 빨리 도입하는 게 낫겠다는 생각이 들었습니다.

 

 

 

 

3. 첫 단위 테스트 도입 실패, 그리고 깨달은 것

 

I. 리팩토링 필요성 인지
 

단위 테스트를 처음 도입하려던 시점엔 막막했습니다. 
메일/일정/연락처 등을 관리하는 현 애플리케이션 대비 간단한 더하기, 빼기 같은 사칙 연산 수준의 단위 테스트 샘플들은, 비동기 처리, 블록 코딩 등 수많은 코딩 기법이 도입된 현 프로젝트의 가이드가 되진 못했습니다. 수일 간의 고민 끝에 무엇이라도 검증해 보자는 마음으로 단위 테스트 함수를 선언해 놓으니 바로 고민이 이어졌습니다.

 

MVC를 지향하였으나, Android 특성상 Controller 역할을 하는 Activity에 많은 작업이 위임되는 걸 피할 수 없었던 코드

 

■ 검증하려고 했던 항목

  • 메일 갱신 요청 시, 기본 설정 개수만큼 목록에 표시되는가?
     

■ 검증 목표 함수의 구현 현황

  • 로딩 표시
  • 서버로 메일 목록 정보 요청 및 응답 수신
  • 수신된 응답 파싱
  • 중복 메일이 있는 경우 제거
  • 메일 최신순 정렬
  • Android의 ListAdapter를 통해 리스트 표시
  • 로딩 표시 제거

 

서버로 메일 목록 요청 후, 앱 내에 목록이 올바르게 설정되는지 테스트하고 싶었을 뿐이었습니다. UI가 섞인 현 구현 상태를 테스트하려면 에뮬레이터 등의 환경 구성이 필요하다고 판단되었으나, 이는 유닛 테스트의 범주를 넘는 것으로 보였습니다. 에뮬레이터가 아니더라도 단위 테스트에서 이 코드를 동작하게 하려면 Context, Activity, Fragment 등의 플랫폼 API의 역할을 대체해 줄 Mock 설정이 필요했습니다. 수 백 줄의 코드 라인에 말이죠.

 

쉬울 줄 알았던 단위 테스트 도입을 위해, 현재 프로젝트엔 아래의 작업들이 필요하다고 깨닫게 되었습니다.

 

 

II. 지나고 보니 보이는 것들

 

  • 현 구현 상태를 한 번에 테스트하는 건 Use-Case Test에 가까움.
  • 네트워크 API 등이 올바르게 동작하는지를 확인하는 것은, 별도의 테스트로 분리하는 것이 나은 테스트 구성으로 판단됨. (ex: Rest API Test)
  • ProgressBar, ListView 등은 계측 테스트 환경(실 디바이스, 에뮬레이터 등)을 필요로 함. 이는 UI 테스트로 보는 게 옳고, 단위 테스트를 넘어서는 범주라고 판단함.
  • Android Context 같은 API는 에뮬레이터 등과 같은 환경 구성을 필요로 함. 또한 Activity, Fragment 등은 Lifecycle과 환경적 요소의 영향을 받음.
  • Mock으로 역할 대체가 가능하나, 테스트를 위해 필요한 사전 작업이 너무 많음. (Mock 설정 등)
     

정리해 놓고 나니 현 구현 상태로는 Unit Test 도입이 어렵겠다는 결론에 도달하게 되었고, 아래와 같은 항목들을 개선할 필요가 보였습니다.

 

 

 

 

4. 단위 테스트 도입 방안 재설정, 다시 시작

 

III.    프로젝트가 나아가야 할 길
 

현 상태로는 단위 테스트를 도입하기 어렵다는 것을 깨닫고, 어떻게 개선해야 할지를 먼저 고민하게 되었습니다.

 

  • 현 아키텍처인 MVC는 Controller(Activity)에 많은 역할이 위임되어 있음.
  • 특정 컴포넌트에 많은 역할이 위임되는 건 유지 보수성이 떨어지는 문제를 가짐.
  • View와 비즈니스 로직의 강한 결합도로 테스트 코드 작성이 어려움.
  • 목표했던 Unit Test를 도입하려면 더 작은 단위로 처리(함수)를 구분해야 함.
  • 테스트 코드 작성에 유리하려면, Android 플랫폼 의존적인 API 들에 대한 의존성이 분리(Weak)되어야 함.

 

위와 같이 정리해 보면, 결론적으로는 단위 테스트에 유리한 아키텍처로 구조를 리팩토링하는 것이 필요하다는 결론에 도달하였습니다.

 

 

IV. 테스트 범주 정의


최종적인 목표는 전체 애플리케이션의 테스트(UI, Use-Case, Monkey Test 등등)가 되겠지만, 한 번에 모든 걸 도입하기엔 테스트의 종류가 너무 많았습니다. 특히 UI 테스트 같은 것들은 디스플레이 파편화, OS 버전 별 다른 디스플레이 설정 등으로 테스트에 고려할 사항이 너무 많았기 때문에, 구현 단위를 최소화하고 핵심 비즈니스 로직을 위주로 단위 테스트를 도입하는 것을 우선 목표로 설정하였습니다. 가장 작은 단위부터 검증할 수 있는 상황을 만들어놓고, 차후 데이터 액세스 계층(DAO), UI 등의 영역으로 테스트 영역을 넓혀가는 게 리스크가 적은 방법이라고 판단하였습니다.
 

 

V. 아키텍처 선택 과정

 

대중적으로 알려진 안드로이드 아키텍처 3가지

 

사실 아키텍처는 고민을 시작하는 시점에서 MVVM으로 어느 정도 결심이 선 상태였습니다. 이미 많은 프로젝트를 통해 MVC, MVP의 한계나 아쉬운 점을 느낀 상태였기 때문입니다. 안드로이드 아키텍처에 대해선 인터넷으로 많은 자료들을 검색할 수 있기에, 제가 느낀 핵심 부분에 대해서만 요약하였습니다.
 

■ MVC (Model-View-Controller) 

  • 웹에서는 뷰와 컨트롤러의 영역이 분리가 가능했으나, 안드로이드는 View 역할(인터랙션, 뷰 조작 등)과, 다른 화면으로의 이동까지 결정하는 Controller의 역할을 Activity가 같이하게끔 설계되어 있어 비즈니스 로직의 분리가 어려움.
  • 결론적으로 단위 테스트를 도입하기엔 적합하지 않은 모델이라고 판단.
     

■ MVP (Model-View-Presenter) 

  • 뷰의 추상화를 통해 비즈니스 로직과 분리가 용이하지만, 규모가 커져갈수록 Presenter의 복잡성이 증가하는 문제가 있음.
  • 추상화를 통해 뷰 의존성을 완화(weak)할 수 있으나, 결과적으론 Presenter가 뷰 조작을 명령(Method Call)하는 형태를 취하고 있어, 단순 값 비교 형태의 테스트 코드를 작성하기엔 불리하다고 느낌.
     

■ MVVM (Model-View-ViewModel) 

  • MVVM에서 필수 기술로 간주되는 Data Binding을 통해, View가 ViewModel의 값을 관찰(observe)하는 형태로 구현.
  • 이러한 Data Binding의 특성을 통해 ViewModel은 상태 값만으로도 뷰의 상태를 표현 가능하며, View 의존성을 완전히 배제/분리한 형태로 구현할 수 있음. 
  • 위와 내용들을 토대로 MVVM은 MVP보다 더 명확하게 View와 비즈니스 로직의 분리가 가능하며, 이러한 점들은 단위 테스트 작성에 매우 용이함.
  • 단점으로 다른 디자인 패턴(MVC, MVP)와 비교하였을 때, MVVM 패턴의 핵심이라 할 수 있는 Data Binding을 비롯, LiveData나 Flow, RxJava 등과 같은 반응형 프로그래밍에 대한 이해, 의존성 주입(DI) 등 기본 지식으로 요구하는 것들이 많아 초기 러닝 커브가 높음.

 

여담이지만 간단한 프로젝트라면 MVC나 MVP로도 충분할 수 있고, 오히려 MVVM이 과한 아키텍처가 될 수 있습니다. 많은 추상화와 의존성 설정 등으로 초기 러닝 커브가 높기 때문입니다. 다만 현 프로젝트는 앞으로도 계속 개발/유지 보수될 프로젝트기 때문에 명확하게 레이어를 구분할 수 있는 아키텍처를 필요로 했고 위와 같은 장점들을 누리기 위해 MVVM을 선택하게 되었습니다.

 

 

VI. 차세대 vs 리팩토링

 

1개 프로젝트에 포함된 모듈 목록


 

Jira, Git, Confluence 등으로 누락된 이슈 없이 꼼꼼하게 형상 관리된 프로젝트라고 자부하지만, 이미 방대한 기능을 포함하고 있는 프로젝트를 차세대로 개발하는 건, 많은 리스크가 있었습니다. 시간적 부분은 차지하고서라도, 세세하게 구현된 수많은 예외 처리와 일부 고객사들의 커스텀 기능들을 누락 없이 반영한다는 것과, 신규 구현이기 때문에 모든 것을 새로이 검증해야 하는 상황이 큰 부담으로 다가왔습니다. 결론적으로는 점진적 리팩토링을 선택했습니다. Git-Flow 브랜치 전략을 이용하여 화면 단위로 리팩토링을 진행하고, 리팩토링된 구현체에 단위테스트를 적용, 검증된 기능은 병합하는 방식으로 진행하는 것이 기능 안정성에 더 유리하다고 판단하였습니다.

 

 

VII. 리팩토링에서 고려했던 것들
 

이번 리팩토링은 단위 테스트 도입이 목적으로, 단위 테스트의 목표인 비즈니스 로직을 최대한 독립적인 모듈로 구성하는 것을 핵심으로 두었습니다. Android API와 같은 플랫폼 의존성은 최대한 분리해 내고, Mock 처리는 최대한 배제하면서 구현된 코드를 테스트에서도 그대로 사용할 수 있도록 리팩토링하는 것이 목표였습니다. Mock Framework의 사용을 완전히 배제할 순 없었지만, 구현된 코드를 최대한 그대로 사용하는 것이 테스트와 실 환경에서의 갭을 줄이는 요소라고 판단하였기 때문입니다.

 

 

 

 

4. 리팩토링 된 프로젝트의 모습

 

비즈니스 로직이 분리된 View의 모습

 

뷰를 비롯한 Android Component 의존성이 분리된 ViewModel의 모습

 

ViewModel 구현 함수를 타겟으로 구현한 단위 테스트

 

MVVM로의 리팩토링을 통해 단위 테스트가 용이하도록 코드를 개선하였습니다. Android View Component였던 ProgressBar 표시 여부를 ViewModel에서는 MutableLiveData<Boolean>로 상태를 나타낼 수 있도록 대체하고, View Layer에서는 해당 값을 참조(Observe) 하여 뷰를 조작하는 형태로 변경되었습니다. 이러한 방식으로 View의 영역을 ViewModel에서 분리해 내면서 Android Component에 대한 의존성을 제거하고, 비즈니스 로직에 더 집중함과 동시에 단위 테스트에 더 용이한 코드로 개선할 수 있었습니다.

 

 

 

 

 

5. 단위 테스트 적용 사례


단위 테스트의 실제 적용 사례를 소개해보려 합니다.

 

사용자의 메일 읽음 상태를 처리하는 함수

 

위 함수는 사용자의 메일 읽음 상태를 변경 요청하는 함수입니다. 해당 함수가 호출되면 타겟팅 된 메일(mailId)은 인자로 받은 isRead로 읽음 상태가 변경되어야 합니다. 코드 의도대로 정상 동작하는지 단위 테스트를 구현해 보겠습니다.

 

메일 읽음 상태 변경 요청에 대한 단위 테스트

 

해당 함수를 테스트하려면 메일 목록 로드가 완료되어 있어야 합니다.  메일 목록을 불러오는 사전 작업(requestMailsRefresh() 호출) 후, 메일 목록에 포함되어 있는 특정 메일의 상태를 읽음(isRead=true)로 변경하는 테스트 코드를 작성한 후 실행하였습니다.

 

단위 테스트가 실패한 모습

 

단위 테스트에서 메일 상태를 읽음(isRead=true)으로 변경 요청하였기 때문에, 기대값에 true를 설정하였지만, false가 반환된 테스트 결과를 확인합니다. 메일 읽음 상태를 변경하는 코드가 잘못 작성되었다는 뜻이겠죠. 작성된 코드를 다시 확인합니다.

 

코드 리뷰로 누락된 로직 확인 후 보완



코드를 확인해 보니, 메일 상태를 읽음으로 변경하는 네트워크 요청은 추가되어 있습니다. 그러나 요청이 성공한 후, 앱에서 메모리 캐시 된 메일 정보를 읽음 상태로 변경하는 처리가 누락되어 있음을 확인할 수 있었습니다. 요청 성공이 확인되면 메모리 캐시 된 메일 목록 정보에서 해당 메일을 찾아 읽음 상태를 요청된 isRead와 동일하게 변경하는 처리를 추가합니다.

 

단위 테스트에 성공한 모습

 

누락된 처리를 보완한 후, 다시 단위 테스트를 실행합니다. 이번에는 단위 테스트가 성공(passed) 되었다는 화면을 확인할 수 있습니다.

 

 

단위 테스트 실패 시, Gitlab에서 확인 가능한 리포트

 

단위 테스트 성공 이후, 배포까지 완료된 Gitlab에서의 리포트

 

현재 단위 테스트는 비즈니스 로직을 중심으로 이루어진 상태입니다. ViewModel에서 핵심 기능에 대한 단위 테스트는 거의 대부분 구현된 상태이며, 현재는 일부 고객만을 위한 커스텀 기능을 위한 단위 테스트도 작성하고 있습니다. 

 

덧붙여 현재 사내에선 Gitlab-Runner를 통해 CI/CD 환경을 제공하고 있어, 단위 테스트를 도입과 동시에 빌드 자동화도 같이 연동하였습니다. 현재는 Git에서 Merge Request가 발행되면 아래와 같은 절차를 통해 배포가 준비됩니다.

 

■ 현재 애플리케이션의 배포 절차

단위 테스트 실행 → PMD(코드 정적 검증)→ 패키지 빌드→ 빌드 된 패키지를 배포 서버로 업로드

 

큰 틀은 구성되었다고 생각합니다만, 아직 남은 과제들이 있습니다. 
 

원격(Server)에서 데이터를 조회하는 부분은 현재 Mocking을 통해 구현된 상태로, 해당 부분과 UI 테스트 도입은 앞으로 해야 할 과제로 남아있습니다.
 

 

 

 

7. 단위 테스트 도입 후기

 

버전이 상향되고, 기능과 함께 추가되는 테스트 케이스들은 점차 더 많은 시간 소요를 요구하였습니다. 기능 수정의 영향도에서 포함되지 않는다고 판단한 부분에서 결함이 나는 경우도 종종 있었습니다. 

 

단위 테스트 도입 후, 여러가지를 얻었습니다. 직접 기능을 테스트하고 수동으로 결함 확인 후 수정해야 했던 이전과 비교해본다면, 자동화된 테스트를 통해 기능 추가나 수정 후에도 전체 기능이 어떻게 영향을 받을지 빠르게 확인할 수 있었고, 이는 작업 소요 시간 단축과 더불어 코드 배포 속도와 안정성을 향상시킬 수 있었습니다.

 

하지만 아직 끝난 건 아닙니다. 
사용자들이 기능에 대해 결함 경험이 반복된다면 사용자들의 신뢰를 잃을 수 있으므로, UI 테스트와 통합 테스트도 도입하면서 프로젝트의 품질을 향상 시켜야 할 것입니다. 

 

이러한 과정들을 거쳐 애플리케이션 안정성을 향상시킨다면 사용자들의 신뢰도에 긍정적인 영향을 줄 수 있을 것이고, 결과적으로는 회사의 이익에도 영향을 미칠 수 있다고 생각합니다. 덧붙여 개발자로서는 자부심을 더 가질 것 같고요 .😊

 

 

김민수

개발팀

정답보다 더 최선의 답을 찾기 위해서 늘 고민하고 있습니다

추천하는 영감

지금 바로 개발 해보는 Apple-Intelligence

보안 전시회 방문객은 어떤 고민이 가장 클까?

Windows 커널에서 C++ 사용하기 (상)