dev-wish.com

프론트엔드 테스트 - (1) 테스트의 필요성

2024.03.17

#테스트
why-frontend-testing-needs

입사한 지 8개월, 실무를 시작한 지 7개월 차에 접어드는 시점에서 스스로를 판단해보자면,
입사 초반에 가졌던 열정만큼은 가지지 못하고, 업무의 익숙함은 분명히 늘어났으나 여전히 만족할만큼 전문적이지는 못하는 스스로에 대한 권태가 커지는 시점이다.

작성하는 코드에 대한 만족보다는 당장 눈앞의 태스크를 해치우는 데 급급한 모습에 대한 싫증이 커지면서, 문제점이 무엇인가 고민해보았을 때
테스트코드, TDD, 리팩토링, 디자인패턴... 등 알지 못해도 업무를 수행하는 데에는 지장이 없는, 코드를 작성하는 것을 넘어 더 '좋은' 코드를 작성하는 데 필요한 기반들이 부족함을 느낀다.

자신의 코드에 대한 확신은 스스로 얻어낼 수밖에 없다고 생각하기에, 이를 극복해보고자 그동안 미뤄왔던 부분들을 채워보고자 한다. (현실에 치이다 보면 또 미뤄질 수도 있으나...)


처음부터 하지 못한 이유

입사 이후로 지금까지 이러한 공부를 미뤄온 것에 대한 변명을 시작해보면,

명확한 데드라인

회사에서 맡게 되는 업무는 데드라인이 비교적 명확하다.

정말 칼같이 프로덕션 환경에 반영되어야 하는 시점이 정해져 있는 이슈도 있고, 비교적 개발 쪽에서 일정을 가져갈 수 있는 이슈도 있다.
하지만 후자의 경우라도 얽혀있는 이해당사자들이 많기 때문에, 예상되는 데드라인에 대해 서로 소통하고 조율하게 된다.

그렇기 때문에 일단 기한을 지키는 것이 1순위가 되고, 기능을 위한 코드 외에 추가적인 비용을 요구하는 리팩토링이나 테스트코드 작성은 일단 제쳐놓고 생각하게 된다.
물론 숙련된 개발자라면... 이 비용까지 합해서 데드라인을 예상하고 커뮤니케이션하겠지만, 아직까지 일정 산출에 익숙하지 않은 나에게는 힘든 부분이다.

어디까지 내려가야하는 거예요?

실제로 업무 초반에 테스트 코드를 작성해본 적이 있다. 이때 겪었던 어려움을 되새겨보면,

  • 어느 범위까지 테스트를 작성해야 할까? 작은 단위로 쪼개면 비용이 크고, 큰 단위로 테스트하자니 테스트 결과를 신뢰하기 어렵다.
  • 테스트할 때 실제 데이터는 어디까지 사용하고, 모킹은 어디까지 해야할까?

전반적으로 테스트의 범위와 깊이를 어디까지 설정해야 하는지가 애매했다.

즉 테스팅에 쏟는 비용과 테스팅으로 얻는 이점의 trade off 사이에서 어느 정도 균형을 유지하는 게 실무에서 적절한지 판단하기 어려웠다. 이 부분은 꾸준한 공부와 경험으로 극복할 수 있는 부분이긴 하다.


그럼에도 필요한 이유

이러한 어려움에도 지금 상황에서 다시 실무에서 테스팅의 필요성을 느끼는 이유는 아래와 같다.

업무 범위의 증가

올해부터 부서에서 맡게 될 페이지들이 엄청나게 늘어난다.

배포를 수행할 때마다 코드가 변경된 부분들에서 이상이 없는지 확인하는데, 확인해야 할 페이지들이 갑작스레 늘어나면서 앞으로는 빠르게 모든 부분을 체크하기 힘들어질 것 같다.

또한 변경한 부분에서만 이슈가 발생하는 것도 아니다. A 구간을 변경했지만 어이없게도 Z 구간에서 에러가 발생할 수 있고, 이런 케이스는 더욱더 빠르게 알아채기 어렵기 때문에 실제 유저에게 노출될 가능성이 높아지게 된다.

이러한 위험성을 안고 개발하는 것은 상당한 피로감을 준다. 난 운이 좋게(?) 입사 초반에 장애를 몇 번 겪었는데, 그래서 장애에 대한 두려움이 좀 있었다. (물론 신입이라 장애가 발생해도 처리할 수 있었던 건 아무것도 없었다)

새벽에 장애가 발생할까 봐 핸드폰 알림 소리를 켜놓고 자기도 했고, 내 코드가 새롭게 나가야 하면 배포 전날부터 스트레스를 받으며 두 번 세 번 확인하곤 했다. 이렇게 언제 장애가 발생할지 모른다는 생각은 개발자의 수명을 엄청나게 단축시키기 때문에... 이에 대한 대비가 필요할 것 같다.

이미지 출처 : @waterglasstoon
이미지 출처 : @waterglasstoon

필연적인 협업

회사는 협업하는 공간이다. 여러 개발자가 하나의 코드를 계속해서 수정해나간다.

코드리뷰를 통해 서로의 암묵적인 확인을 거치지만 일에 치이다 보면 놓치는 경우도 있고, 또 내가 작성하지 않은 코드를 100% 이해하기는 어렵다.
심지어 예상하지 못한 부분에서 코드들이 의존관계를 가지면서 얽혀있을 수도 있다.

코드의 모든 변경사항을 이해하고 그 변경사항에 대한 사이드 이펙트를 예상한다는 것은 불가능하다고 생각하기 때문에, 나 대신 테스트코드가 이러한 의존성을 파악하도록 책임을 전가하고자 한다.

업무 속도 가속화

개발자는 하루종일 코드만 작성하면 되는거 아닌가 했지만, 실제 회사에서는 코드 작성보다 커뮤니케이션하는데 더 많은 시간을 쓰고있다.

보통 나는 이슈 하나에 대해 아래와 같은 절차를 밟는다.

  1. 컨텍스트를 이해하고 구체화한다.

    보통 기획서를 읽으면서 이 이슈가 어떤 목적을 가지는지 파악하고, 텍스트로된 요구사항들을 코드 단에서 어떻게 수행할지 변환하여 생각해본다. 그리고 명확하지 않은 기획사항들을 구체화하거나, 개발단에서 어려움이 있는 부분들을 해소하기 위해 기획자분과 커뮤니케이션하면서 조율해나가고, 최종적인 세부테스크들을 도출한다.

  2. 실제 개발을 진행한다. 개발자하면 떠오르는 코드작성 시간이 여기에 해당한다.

  3. 개발한 부분에 대한 검증을 수행한다.

    테스트환경에 코드를 반영하여 기능을 점검한다. 이 과정에서도 기획자분 또는 QA 담당자분과 커뮤니케이션이 필요하다.

검증 과정에서 문제가 발생하면 가장 먼저 어느 코드에서 이 문제를 발생시키는가를 파악하게 되는데, 이 과정에서 많은 시간을 소모하게 된다.
이를 세세하게 분리된 테스트코드에게 이관함으로써 전반적인 업무 시간을 단축시킬 수 있다고 생각했다.


TDD, 소프트웨어 공학, 리팩토링의 관계

지금까지 테스팅이라 하면 여러가지 테스트 프레임워크를 사용하여 테스트코드를 작성하는 것에 국한되어 생각해왔다.
또한 여전히 TDD는 이상적인 이야기라고 느껴졌는데, TDD의 기본 원칙인 Red-Green-Refactor 사이클 때문이다.

TDD Cycle

이미지 출처 : https://marsner.com/blog/why-test-driven-development-tdd/

  • Red. 실패하는 테스트 작성
  • Green. 실패한 테스트를 통과하도록 최소한의 코드 작성
  • Refactor. 작성한 코드를 리팩토링

TDD에서는 코드 작성보다 먼저 테스트를 작성한다. 동작하는 코드가 없으므로 이 테스트는 실패한다. 그 이후에 실패한 테스트를 통과시키는 코드를 작성한다. 이 사이클이 다소 허무맹랑한 이야기라고 느꼈는데, 특히나 실제 동작하는 코드보다 테스트를(그것도 100% 실패하는) 먼저 작성하는게 낯설었다.

하지만... TDD 사이클에는 더 깊은 뜻(?)이 숨겨져 있었다.

테스트를 먼저 작성함으로써 전체 애플리케이션을 어떤 단위로 분리하고 설계할지 한 번 생각하게 만든다.
그리고 테스트 코드 작성 후 실제 코드를 작성할 때 중요한 점은 테스트하기 쉬운 코드를 작성해야 한다는 점인데, 이를 따르기 위해서는 결국 소프트웨어 공학 원칙을 준수하는 코드를 작성하게 된다. 간단한 예시로 살펴보자.

function register(user) {
  // 1. 유저 validation
  if (!user.name || !user.password) {
    throw new Error("인증이 실패했습니다.");
  }

  // 2. 유저 정보를 서버로 전송 (간략화)
  fetch("http://application.com/user", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(user),
  });

  // 3. 결과를 UI에 표시
  const result = document.getElementById("register-result");
  result.innerText = `${user.name}님의 가입이 성공적으로 이루어졌습니다.`;
}

위의 register 함수는 3가지 일을 직접 수행하고 있다. 이 register 함수에 대한 테스트 코드를 작성한다고 생각해보자. 테스트해야할 조건을 나열해보면 아래와 같다.

  • user.name이 유효하지 않으면 에러를 발생시킨다.
  • user.name이 유효하지 않으면 서버로 전송하지 않는다.
  • user.name이 유효하지 않으면 결과를 UI에 표시하지 않는다.
  • user.password이 유효하지 않으면 에러를 발생시킨다.
  • user.password이 유효하지 않으면 서버로 전송하지 않는다.
  • user.password이 유효하지 않으면 결과를 UI에 표시하지 않는다.
  • user.nameuser.password가 유효하면 유저 정보를 서버로 전송한다.
  • fetch에 성공하면 ${user.name}님의 가입이 성공적으로 이루어졌습니다.가 UI에 업데이트된다.
  • fetch에 실패하면 UI가 업데이트되지 않는다.
  • ....

register 함수가 제대로 동작하는지 확인하기 위해서 필요한 테스트 조건이 지나치게 많다. 이제 단일 책임 원칙에 맞게 관심사를 분리해보자.

function register(user) {
  // 1. 유저 validation
  if (!validateUser(user)) {
    throw new Error("인증이 실패했습니다.");
  }

  // 2. 유저 정보를 서버로 전송
  registerUser(user);

  // 3. 결과를 UI에 표시
  showRegister(user);
}

register 함수에서 모두 수행했던 기능들을 다른 함수에서 수행하도록 전가했다. 이제 다시 register 함수에서 테스트해야할 조건을 나열해보자.

  • validateUser의 결과가 유효하지 않으면 에러가 발생한다.
  • validateUser의 결과가 유효하지 않으면 registerUser가 실행되지 않는다.
  • validateUser의 결과가 유효하지 않으면 showRegister가 실행되지 않는다.
  • validateUser의 결과가 유효하면 registerUser가 실행된다.
  • registerUser가 성공하면 showRegister가 실행된다.
  • registerUser가 실패하면 showRegister가 실행되지 않는다.

테스트 조건이 훨씬 단순해졌다. 이처럼 테스트 효율을 높이려면 단일 책임 원칙, 의존성 주입과 같은 소프트웨어 공학 원칙을 잘 써먹는게 중요하다.


결국 좋은 애플리케이션을 만들기 위해서는 안전한 코드가 필요하고, 안전한 코드에는 테스트 코드가 필요하고, 좋은 테스트 코드를 작성하기 위해서는 소프트웨어 공학 원칙을 이해하고 있어야하며 꾸준한 리팩토링이 수반되어야 한다. 좋은 코드를 작성하기 위해서 언급되는 모든 컨셉들이 결국에는 다 맞물려있다는 생각이 든다... 공부해야할게 산더미다

여전히 일을 하다보면 빠르게 해치우고 다음 이슈를 처리하고 싶은 욕망이 크지만, 궁극적인 목표인 1) 개발 속도를 늘리고 2) 안전한 코드를 작성하는 것에 가까워지기 위해서는 올바르지 않은 방향이라는 것을 느끼기에 이를 극복해보고자 한다.


Reference