테스트 하는게 더 생산성이 높을 때가 있다니까요 정말로?

Youth·2025년 1월 28일
1

고찰 및 분석

목록 보기
22/23

안녕하세요 킴스캐슬입니다
오랜만에 기술적인 내용의 글을 작성하네요!

Unit test에 관한 영상이나 글들을 최대한 많이 봤고 프로젝트에 적용을 해본 경험으로 쓰는글이다보니까 제 개인적인 주관이 많이 들어있는 글이 되지 않을까 싶네요 ㅎㅎ 새로운 의견이나 반박은 늘 환영입니다

이번 글은 정말 다양한 영상이나 글들이 많았지만 unit test관련해 가장 큰 영감을 준 The Pragmatic Programmer 2th Anniversary Edition을 기반으로 작성된 글입니다

그럼 시작해보겠습니다!

테스트에 대해 생각해보기

제가 구글링을 하면서 봤던 unit test글을 보면 대부분? 거의 모든 글에서 이렇게 말합니다

unit test는 필요하다!! 좋다!! 해야한다!! must do it!!!

(테스트 해야해!!! 뺴애애액!!)

근데 제 주변에서도 실제로 unit test를 적용해본 사람보다는 아닌사람이 더 많긴했습니다
저만해도 지금까지 해왔던 프로젝트에서 가장 최근프로젝트를 제외하고는 unit test를 적용해보지 않았으니까요...ㅎㅎ

그런데 요즘와서는 unit test에 대한 중요도는 다들 한번쯤은 들어봤을거라고 생각됩니다
여전히 테스트코드를 작성하지 않는 개발자는 있을수있지만 적어도 원래는 테스트코드를 작성해야한다는건 알고있습니다

자, 그러면 여기서 중요한 질문이 나옵니다
우리가 생각하는 테스트의 중요한 가치는 무엇일까요???

이 질문을 던지면 많은 사람들은 이렇게 대답할수도있습니다 버그를 찾기위해서요!!

하지만 제가 조사를 해보면서 느낀점은 테스트는 버그를 찾기위한것이 아니라고 생각합니다

저자의 말을 빌리자면

저는 테스트의 주요한 이득이 테스트를 실행할때가 아닌 테스트에 대해 생각하고 작성할때 생긴다고 믿습니다

자 그러면 이제부터 이게 무슨 소리인지 한번 고민해보겠습니다

우리는 지금 비디오서비스를 만들고있는데 우리 서비스를 자주이용하는사람들에게 이벤트를 해주고싶어서 user정보를 조회해서 유저의 정보를 얻어오고 싶습니다

그래도 싱글톤 패턴으로 전역적으로 네트워킹을 할 수있는(실제로 네트워킹을 하진 않지만 네트워킹을 한다고 가정해보겠습니다ㅎㅎ) 객체를 가지고 있습니다

class UserManager {
    static let shared = UserManager()    
    func getUserFromOpenVideoField() async -> [String] {
        return ["민수", "철수"]
    }
    func getUserFromCompletedVideo() async -> [String] {
        return ["영희", "짱구"]
    }
}

그리고 예를들어서 비디오를 열어본(openVideoField)사람들의 목록을 가져오기 위해서는 viewModel같은곳에서 아래와 같이 호출하면 되겠죠

func getAvidViewers() {
    return UserManager.shared.getUserFromOpenVideoField()
}

근데 만약에 여기에 테스트에 대해 생각해야한다면 어떤 문제점이 있을까요
우선 첫번째 문제는 싱글톤으로 인해 실제로 API를 호출하는 객체를 전역적으로 사용한다면 API를 테스트하는 부분에서만 바꿀수가 없습니다

API테스트를 할때는 보통 API를 실제로 호출하는 부분을 다른 가짜 데이터를 보내주는 부분으로 바꿔껴서 실제 API를 호출하지 않고 원하는 데이터를 온것처럼 시뮬레이션해서 테스트를 하는 경우가 많습니다

두번째문제는 저희가 지금 원하는게 우리서비스를 많이 이용하는 유저를 찾는건데 많이 이용하는 유저의 기준이 영상을 다본 횟수기준일까요 아니면 영상을 열어본 횟수기준일까 잘 모르겠습니다

기획자에게 전화를 걸어보니 열심히 놀고있는건지 연락을 안받을수도있죠

그럴때는 약간의 꼼수를 써서 함수의 input으로 field의 이름을 받으면 됩니다

protocol Manager {
    func getUser(from fieldName: String) async -> [String]
}

class UserManager2: Manager {
    func getUser(from fieldName: String) async -> [String] {
        if fieldName == "CompletedVideo" {
            return ["영희", "짱구"]
        } else if fieldName == "OpenVideo" {
            return ["민수", "철수"]
        } else {
            return []
        }
    }
}

func getAvidViewers(manager: Manager, fieldName: String) async -> [String] {
    return await manager.getUser(from: fieldName)
}

이렇게 하면 manager라는 프로토콜을 통해서 전역적으로 네트워크 호출을 하지 않고 네트워킹 호출을 하지않고도 데이터를 넘겨줄 수 있는 객체를 받을수있기때문에 manager자체를 테스트할 수 있게되었습니다 그리고 field이름이 갑자기 바뀌더라도 함수의 인자만 바꾸면 원하는 결과를 얻을 수 있게됩니다

테스트에 대한 생각을 하는것만으로도 기존 코드보다 여러 나쁜 가능성을 해결할 수 있는 코드를 짤 수 있습니다

테스트가 코딩을 주도한다

이전 예시가 이야기하는 부분이 크게 두가지라고 생각합니다

  1. 결합도를 낮추고 확장성을 높여준다
  2. 유연성을 높힐 수 있다

이 두가지를 좀 설명을 해보면 swift에서는 결합도를 낮추고 확장성을 높이기 위해서는 protocol이라는 추상화를 사용하게됩니다 그리고 protocol을 타입으로 사용하면 결합도를 낮추고 확장성을 높여줄수있습니다

protocol에 대한 내용이지만 약간 덧붙이자면 어떤 protocol을 바라보고있으면 protocol을 채택한 객체가 올수있다는 의미지 객체가 온다는 의미가 아니기에 객체자체와 약한결합으로 주입받을수있습니다 그리고 확장성의 경우엔 protocol을 채택하는 객체를 런타임에서 바꾸면서 실행시킬수있는 부분이라고 설명드릴수있을것같습니다

기존 싱글톤 객체와 강하게 결합되고 확장성이없는(객체를 바꾸기위해서는 코드자체를 바꿔야함, protocol의경우에는 런타임에 객체만 따로 바꿔끼워주면된다) 상태에서 Manager라는 protocol을 활용해 결합도를 낮추고 확장성을 높힐 수 있었습니다

두번째의 경우엔 field이름을 함수의 인자로 받으면서 조금더 유연하게 함수를 사용할 수 있었던겨죠

단순히 테스트에대해서 생각함으로써 코드를 작성자가 아닌 사용자인것처럼 메서드를 외부의 시선으로 보게되는겁니다

약간의 설명을 덧붙여보면 메서드를 만들때 테스트를 생각하며 만든다면 이런상황일때는 어떡하지 이런상황일때는 어떡하지같은 생각들이 모여서 함수를 사용하는 관점과 시각에서 로직을 구성할수있다는 이야기로 해석하시면 좋을것같습니다:)

책에 이런 말이 있습니다 테스트가 코드의 첫번째 사용자다
저는 이 말이 약간은 와닿았던것같습니다 테스트를 하게되면서 내가만든 메서드가 실제 사용되었을때의 관점과 시선을 입혀줄수있다고 생각합니다 그리고 그시선을 통해 조금더 잘 작동하고 발전된 로직을 가진 메서드로 만들어낼수있는거죠

그리고 결합도이야기가 나와서 한가지 더 이야기를 해보면 다른 코드와 긴밀하게 결합된 함수나 메서드는 테스트하기 힘들다고 생각합니다

결국은 테스트라는게 독립된 상황에서 내가 만든 로직, 더 확장해보면 하나의 모듈이 잘 동작하는지를 확인하는 절차라고생각하는데 싱글톤처럼 다른 객체와 강하게 결합되어있다면 내 로직이 문제가 생겼을 때 다른 객체에 영향을 받는 객체때문에 내로직이 문제인건지 아니면 내가 강하게 바라보고있는 저 객체가 문제인건지 찾기가 어려울수있습니다 그리고 그말은 내 로직에대해 100% 확신이 불가능하다는 말이기도 합니다

그렇기때문에 내 객체에 대한 100%의 확신을 가지기 위해서는 객체와 객체간의 결합도가 낮아야한다고 생각합니다

(자신있어?)

그리고 객체끼리 강하게 결합되어있다면 메서드를 테스트하기위한 온갖환경구성을 한참해야합니다
만약에 싱글톤으로 API요청을 하는 경우에 테스트를 위해서는 싱글톤대신 원하는 값을 반환해주는 mockAPI통신객체를 새로만들어줘서 넣어줘야하는 시간과 비용이 발생합니다 하지만 protocol로 결합도를 끊어놨다면 프로토콜을 채택해서 반환값만 넣어주고 객체를 넣어주기만 하면됩니다

즉, 무언가를 테스트하기 좋게만들면 결합도가 낮아지게 됩니다

왜 추상화를 하나요?

지금 진행하고 있는 프로젝트를 몇몇 분들이 github에서 보시고 추상화와 protocol의 사용에 관한 질문을 하셨는데요 핵심은 왜 추상화를 하나요?였습니다

당시에는 제가 3가지 이유를 설명드렸던것같아요

  1. 결합도가 낮아진다
  2. 확장성이 높아진다
  3. unit test를 하기 위해서

여전히 1,2번이 추상화를 하는 이유로 맞다고생각하지만 여러 글을 참고하고 책을 읽으면서 3번이 조금 애매해진 느낌이 들었습니다 약간 닭이먼저냐 알이먼저냐인거같은 느낌이들었습니다

1,2번이 목적이라면 추상화를 하는게 맞고 사실 unit test코드를 짜려면 결합도가 낮아야해서 추상화가되어있어야하지만 책에서는 테스트를 고려하다보면 추상화가 된다라고 이야기를 하니까 정말 딜레마에 빠진 느낌이 들었습니다 3번에 대해서는 추상화의 목적이라기보다는 테스트를 고려하면 돌아오는 보상같은 느낌이 조금더 강해진것같습니다

그래서 앞으로 3번에 대한 이야기를할때는 약간의 추가적인 의견을 덧붙여가며 이야기를 할것같습니다

무언가를 테스트하려면 당연한 이야기겠지만 내가 작성하려는 기능과 로직에 대한 이해가 충분히 동반되어야합니다

하지만 현실과 이상의 갭은 언제나 존재할수있습니다

(돌아는 가니까... 한잔해~)

예를들어서 제가 앱잼이라는 프로젝트를 진행할때를 생각하면 네트워킹 레이어를 구축했는데 마감이 얼마 안남은 시점에서 새로운 API가 생겨서 메서드를 만들어야할때 만약에 시간이 많거나 그랫으면 레이어의 메서드들을 수정 보완하는 방식으로 특정 기능의 API를 하나의 메서드로 통일시켜서 깔끔한 함수구조를 만들었겠지만 당장 내일이 마감이라 전체 구조를 바꾸는게 공수가 많이든다면 현실적으로

그냥 임시로 메서드 새로만들어!

라고 할수밖에없습니다 그리고 에러처리에관한 로직도

우선 print문으로 찍어! 혹은 if문 추가해!

라고할수밖에없습니다

우리의 코드도 위 짤고 크게 다르지 않네요...

특정 조건을 만족하는 코드도 나중에 추가할거교, 오류처리하는 코드도 나중에 추가할거고 그렇게 하다보면 코드는 온갖 조건절과 특수한 경우의 처리때문에 원래 그래야하나는 것보다 몇배는 더 길어지게됩니다

하지만 코딩을 시작하기전에 테스트케이스에대해 먼저생각해본다면(완벽하게 모든 케이스를 떠올릴수는 없겠지만 아얘안하는것보다는 훨씬) 함수를 단순하게 만드는 코드 패턴을 찾을 수 있을거라고 생각합니다

컴포넌트 기반 개발

컴포넌트 기반 개발이라는 말이 책에 나오는데 우선 설명을 한번 보겠습니다

컴포넌트 기반 개발은 오랫동안 소프트웨어 개발이 추구해온 고귀한 목표였다
기본 발상은 IC칩들을 조합하여 회로를 구성하는 것처럼 소프트웨어 컴포넌트들을 가져다 조립해서 쓸 수 있어야한다는 것이다 하지만 이것은 여러분이 사용할 컴포넌트가 믿을 만하고, 컴포넌트들이 동일한 전압과 연결규격, 타이밍 등을 갖추어야 가능한 일이다. 칩은 테스트할수있도록 설계된다. 공장이나 회로에 꼽았을때뿐만아니라 제품으로 설치된 현장에서도 테스트할수있도록 설게뙨다
⇒ 소프트웨어도 똑같이 할 수 있다. 하드웨어쪽과 마찬가지로 소프트웨어를 만들 때 맨 처음부터 테스트가 가능하도록 만들고, 서로 연결하기 전에 코드를 하나하나 철저하게 테스트해야만 한다

혹시 이 글을 보고 떠오르는게 있으신가요?

저는 모듈화가 떠올랐습니다 앱을 만들때 새로운 feature를 생성해야한다면 기존에 기능별로 나눠논 모듈을 조합해서 새로운 기능을 만드는 매커니즘이 이와비슷하다고 생각했습니다

하드웨어의 칩 차원 테스트는 대체로 소프트웨어의 ‘단위테스트’에 해당합니다
두 경우 모두 각 모듈의 동작을 검증하기 위해 다른것들로부터 isolate시켜 놓고 테스트를 수행하죠

모듈을 통제된(심지어는 인위적으로 민들어진) 환경에서 철저히 테스트하고 나면, 넓은 바깥세상에서 그 모듈이 어떻게 행동할지 더 잘 알게 될거라 생각합니다

나중에 이런 ‘소프트웨어IC’들을 모아서 완결된 시스템을 조립할 때에도, 우리는 개별 부분이 기대대로 잘 작동할 것이라고 믿을 수있게되는거죠

테스트의 실용성에 관하여

앞에서 이야기했던 내용은 아마도 테스트에 관한 이론적인 내용일겁니다. 아마 테스트는 이러이러하기때문에 해야된다!라는 주장정도로 생각하실분이 계시지 않을까라는 생각이듭니다. 저도 막상 회사에서 개발을 해보니 테스트를 한다는게 얼마나 많은 앞단의 작업이 필요하며 테스트를 실용적으로 사용할 수 있을까에 대한 의문을 가지고 있습니다. 테스트를 짰을 때 오히려 생산성이 높아졌던 경험을 해보지도 못했고 진짜로 그런 케이스가 있는지를 직접적으로 느껴보지 못했기 때문이었습니다

제가 경험해보지 못했기때문에

아니 테스트코드를 짠다고 생산성이 높아진다는건 그냥 탁상공론아니야?

라는 말에 반박할 수가 없었던거죠

그러다가 작년 말에 진행했던 과제전형에서 테스트코드를 적어본적이 있었는데 그 케이스가 딱 생산성을 높여주는 테스트코드였었어서 그 경험을 한번 공유해보려합니다

예를들어서 특정 유저들의 위치를 서버에서 보내주고 나서 그 유저들을 한번씩 거치는 최단거리를 알고리즘을 통해서 찾아서 맵위에 선으로 표시한다고 해보겠습니다

저는 이 문제를 외판원 순회(TSP) 알고리즘을 사용해서 풀겠다고 결정했다고 해봅시다. 외판원 순회는 무조건 정답을 찾기위해서는 꽤나 시간복잡도가 커서 20명이 넘어가게되면 몇초이상의 시간이 걸립니다

근데 처음에는 10명정도가 최대라고 해서 TSP를 사용했다고 해보겠습니다. 그런데 갑자기 20명이 넘을수도있다고 바뀌었다고 쳐봅시다. 그러면 현재 알고리즘을 기반으로 시간복잡도를 줄일 수 있도록 알고리즘을 최적화 해야합니다

이때 최적화 하기위해 코드를 수정한 알고리즘을 검증하기 위해서는 어떻게 해야할까요?

아마 몇가지 정답 case가 있을겁니다 한 10가지가 있었다고 해볼까요? 알고리즘이 뭐 어떻게 바뀌어도 그 10가지 case는 무조건 통과를 해야한다고 해봅시다. 만약에 테스트 코드가 없다면 알고리즘코드가 한줄이 바뀔때마다 10가지 case들을 하나하나 실행해보면서 10가지 case가 모두 성공하면서 최종 시간이 줄어드는지를 확인해야합니다

그런데 테스트코드가 작성되어있다면 하나하나 확인할 필요없이 테스트 한번 클릭하는것만으로 그 알고리즘이 10가지 기본 테스트 케이스를 통과하는지를 확인할 수 있게됩니다

회사에서 이러한 알고리즘은 UX적으로 굉장히 중요합니다. 옳은 경로를 알려줘야하는데 옳지않은 경로를 알려주면 그야말로 치명적이게될테니까요. 테스트케이스가 50개가 있다면? 알고리즘을 수정할때마가 50번의 수동 검증을 해야합니다. 50개의 테스트를 통과하는지를 확인하는 테스트코드가있다면 빌드 딸깍이면 내가 수정한 알고리즘이 유효한지를 한번에 확인할 수 있습니다

이런 경우라면 오히려 생산성을 증가시키는 테스트가 됩니다


마무리

오늘은 unit test를 대체 왜 해야하는가?에 대한 이야기를 주저리주저리 해봤던것같습니다

iOS기준으로는 앱이 고도화됨에따라 여러기능이 추가됨에따라 여러 모듈로 나눠지는 모듈화를 하게되고 그렇게되면 모듈간의 의존성이 발생하고 많은 코드 많은 모듈들을 유지보수하다보면 많은 문제가 발생하게되는데 그때마다 모듈간의 테스트를 믿을수있게되면 빨리 문제가 발생한 곳을 찾아서 수정하고 디벨롭할수있습니다

물론 테스트코드를작성하면 너무 느려요...라고 의견을 주시는 분들도있고 실제로 제가 테스트코드를짜보니 당연히 안짜는것보다는 느리긴한것같습니다 물론 제가 테스트코드를 작성하는데 익숙하지 않은것도 있겠지만요...

하지만 그럼에도 불구하고 한번 작성해보면 추후에 발생할 문제나 커뮤니케이션 비용이 줄어들기에 길게본다면 기획적으로 사라지고 변하지 않은 기능정도는 테스트를 적용하는것이 전체적인 비용을 줄이는 방법이라는 생각이 들었습니다. 그리고 특정 case에서는 테스트코드가 있는게 비약적인 생상성 증가를 만들어낼 수 있습니다

사실 모든 개발자분들은 어떤 로직을 짤때 이런 문제가 발생할수있으니 분기처리는 이렇게해야겠다, 만약에 이게 들어오면 이런문제가생길수있으니 예외처리를 해줘야겠다라고 생각하면서 코드를 작성합니다 그리고 이게 저는 테스트를 고려한 개발이었다고 생각을 합니다

하지만 실제로 테스트코드를 작성해보면 훨씬더 많은 상황에대한 고려를 하게되고 이 생각들과 고민들이 그대로 코드에도 녹여내진다고 생각하기에 공부와 성장이 목적인 현재상황에서는 최대한 테스트코드를 많이 작성해보면서 코드작성에도 익숙해지고 어떤 기능을 맡게되었을때 좀더 많은 상황을 그려볼수있는 시야를 갖고싶네요

그러면 이전보다 지금, 지금보단 앞으로 좀더 좋은 함수와 코드를 짤수있게되지 않을까요?

제가 이 책을 읽으면서 가장 기억에 남았던 구절을 소개해드리고 저는 이만 물러가보도록하겠습니다:)

여러분의 소프트웨어를 테스트하라. 그러지 않으면 사용자가 테스트하게 된다
(실용주의프로그래머: 데이비드 토머스, 앤드류 헌트)

profile
AppleDeveloperAcademy@POSTECH 1기 수료, SOPT 32기 iOS파트 수료

0개의 댓글

관련 채용 정보