본문으로 바로가기

클린 코드 정리

category 개발/인프라, CS 2022. 11. 29. 14:48

이번 글에서는 클린 코드라는 유명한 기술 서적에 대해 다뤄볼것이다. 클린 코드는 좋은 코드에 대한 정의를 내려주는 책이라고 볼 수 있다. 단순히 좋은 변수명, 함수명을 작성하는것에서 멈추지 않고 코드형식이나 경계, 라이브러리에 대한 깊은 고민을 했다는 것을 느낄수 있는 내용으로 구성되어 있다.

 

지금은 거의 개발에 있어 기준이 되어버린 방식과 기법에 대한 배경설명과 역사를 서술하고 있으며 모호하게만 알고 있었던 개념을 명확하게 알려준다는 점에서 유용한 책이다. 그 중에서도 가장 실용적이고, 실제로 개발할때 접목하여 사용할 수 있는 개념들을 정리해보았다.

1장) 깨끗한 코드란?

깨끗한 코드란 무엇일까? 클린 코드에서는 깨끗한 코드가 지켜야 할 법칙에 대해 다음과 같이 명시하고 있다.

  • 잘 쓴 문장처럼 쉽게 읽힐 것
  • 보기에 즐거울 것
  • 가독성이 좋을 것
  • 주의 깊게 짰다는게 느껴질 것

이러한 것들을 위해서는 코드가 중복이 없어야 하고, 적절한 이름을 지어야 하고, 한번에 한가지 기능만을 수행해야 하며, 작게 추상화 되어야 한다. 이를 지키면 코드는 읽기 쉬워지고, 무엇보다 중요한것은 예상한대로 동작하는 코드를 작성할 수 있게된다.

 

깔끔한 코드를 쓰기위해서는 한번의 시도만으로는 부족하다. 꾸준히 코드를 개선하려는 전문가다운 노력이 있어야만 하고, 이를 뒷받침해줄 만한 개발 문화와 환경이 있어야 한다. 앞으로 기술할 글에서는 이러한 코드 깔끔하게 유지하기 위한 법칙에 대해서 이야기 해보겠다.

2장) 이름 짓기

소프트웨어에서 이름은 모든곳에서 쓰인다. 변수나 함수뿐만 아니라 소스 파일, 디렉터리도 붙히는 것이 이름이다. 이름 짓기는 개발에 있어서 모든것이라고 할 수 있을정도로 큰 영향을 끼치기 때문에 이름을 잘 짓는것은 정말 중요하다.

 

게다가 개발할때 사용하는 이름은 기본적으로 영어이다. 영어가 모국어가 아닌이상 맥락적으로 적합한 이름을 짓는것은 더욱 어렵다. 이 문서에서는 이름을 잘 짓는 몇가지 규칙에 대해서 기술하겠다.

의도를 분명히 밝혀라

의도를 명확하게 밝혔을 경우에는 쓸데없는 짓을 할 필요가 없어진다. 좋은 이름을 짓기까지는 시간이 걸리지만 좋은 이름을 사용함으로써 절약할 수 있는 시간이 훨씬 더 많다. 좋은 이름은 다음과 같은 질문에 모두 답할 수 있어야 한다.

  • 변수(또는 함수)의 존재 이유는?
  • 수행 기능은?
  • 사용 방법은?

변수 또는 함수의 이름이 이에 답하지 못하고 따로 주석이 필요하다면 좋은 이름이라고 볼 수 없다. 좋은 이름이란 기능적 맥락이 이름에 드러나야만 한다. 예를 들어서 유저가 작성한 게시물을 불러오는 함수가 있다면, getPosts대신 getUserUploadedPosts와 같은 이름이 더 좋을것이다.

그릇된 정보를 피해라

개발자는 코드에 그릇된 단서를 남겨서는 안된다. 이는 코드가 가지는 의미를 흐린다. 예시를 들자면

1) 축약어를 쓸때는 한번 더 생각해라

parameter대신 pt, collapes대신 cls, horizontal대신 ht와 같은 축약어는 코드를 작성한 사람 외에는 이해하기도 힘들고 효율적이지도 않다. 이러한 축약어의 진짜 의미를 알기 위해서는 주석이 따로 필요하므로 좋은 이름이라고 할 수 없다. 이름의 길이가 길어진다고 하더라도 읽는 사람에게 정확한 의미를 전달하는것이 가장 중요하다.

2) 그릇된 분류를 사용하지 말아라

여러 계정을 그룹으로 묶을때, list의 개념에 속하지 않는데도 accountList라고 작성해서는 안된다. 개발자에게 list란 특수한 의미로 쓰인다. 그 대신 accountGroup이 더 적절하다.

3) 흡사한 이름을 사용하지 말아라

어떤 함수에서 userCommnetHandler을 사용하고 그 근처에 있는 함수에서 userCommentHandlerAuthorityDefault를 사용한다면 두 함수간에 차이를 알 수 있을까? 두 함수가 유사한 이름을 사용하고 있음에도 불구하고 완전히 다른 기능을 제공하고 있다면 일관성이 떨어진다고 볼 수 있다.

의미있게 구분해라

이름에 연속된 숫자를 덧붙히거나 불용어를 추가하는 방식은 적절하지 못하다. 특히 의미없이 숫자만 붙은 이름인 a1, a2, aN과 같은 이름은 아무런 정보도 제공하지 못한다. 또한 불용어를 추가한 이름 역시 아무런 정보를 제공하지 못한다. 다른 역할을 하는 함수를 productData 또는 productInfo와 같이 개념을 구분하지 않은채 이름만 달리 하는 경우를 뜻한다. info나 data는 a, an, the와 같이 의미가 불분명한 불용어다.

 

불용어란 중복을 의미한다. 변수 이름에 variable이라는 이름을 써서는 안된다. 표에 table라는 단어, nameString과 같은 것들이다. 또 다른 예를 들어보겠다. getActiveAccount(); getActiveAccounts(); getActiveAccountInfo(); 가 있을때, 개발자는 어떤 함수를 호출해야 하는지 이름만 보고 알 수 있을까?

발음하기 쉬운 이름을 사용해라

발음하기 쉬운 이름은 커뮤니케이션에 있어서 더 유리하다. customerManegementcstmrMgement, 둘중 무엇이 더 말하기 쉬운가? 전자의 경우 “커스터머 매니지먼트”라고 발음할 수 있지만 후자는 “시스템알 엠 지멘트”라고 발음해야 한다. 사람의 뇌는 축약어나 불완전한 단어보다 완전한 단어에 더 익숙하기 때문에 더 길더라도 완전한 단어로 된 이름을 사용하는것이 좋다.

검색하기 쉬운 이름을 사용해라

문자 하나를 사용하는 이름은 코드를 볼때 쉽게 눈에 띄지 않는다는 단점이 있다. maxPaymentAmount는 ide내의 검색으로 찾기 쉽지만, 숫자 7은 꽤나 까다롭다. 7이 들어가는 파일 이름이나 수식이 모두 검색되기 때문이다. 문자 e또한 e로 시작하는 코드는 매우 많기 때문에 찾기 어렵다. 이러한 관점에서 보았을때 긴 이름은 짧은 이름보다 좋으며 검색하기 쉬운 이름이 상수(숫자)보다 좋다.

함수 이름 짓기

함수 이름은 동사나 동사구가 적합하다. postPayment, deletePage, saveDocument등이 좋은 예이다. javabean표준에 따르면 접근자(accessor)는 get, 변경자(mutator)는 set, 조건자(predicate)는 is를 붙혀야 한다고 말한다.

의미있는 맥락을 추가하라

firstName, lastName, state라는 변수들이 있을때 이들을 같이 보면 주소라는 사실을 금방 알아챌 수 있다. 그렇다면 state만 사용할때는 어떨까? state는 주소 말고도 상태를 나타낼때에도 사용된다. 여기에 addr이라는 접두어를 추가하여 addrFirstName, addrLastName, addrState로 사용하면 변수가 좀더 큰 구조에 속한다는 사실을 전할 수 있게된다.

3장) 함수

함수는 프로그램의 가장 기본적인 단위이다. 이 문서에서는 함수를 잘 만드는 법에 대해서 설명하겠다.

작게 만들어라

함수를 만드는 첫번째 규칙은 ‘작게’이다. 그리고 두번째 규칙은 ‘더 작게’이다. 저자는 if문, else문, for문등에 들어가는 블록은 ‘한줄’이여만 한다고 말하고 있다. 이 말의 진짜 의미는 중첩 구조가 생길만큼 함수가 커져서는 안된다는 뜻으로 함수에서 들여쓰기가 1단 또는 2단을 넘어서면 안 된다고 말하고 있다.

한가지만 해라

다음 문장은 30년간 여러가지 표현으로 개발자들에게 주어진 가장 귀한 충고이다. “함수를 한가지를 해야 한다. 그 한가지를 잘해야 한다. 그 한가지만을 해야 한다.” 여기에서 그 한가지란 함수가 추상화 수준이 하나인 단계만 수행한다면 한가지 작업만 한다고 할 수 있다.

함수를 보았을때, 코드 내에서 의미 있는 이름으로 다른 함수를 추출해낼 수 있다면 그 함수는 여러 작업을 하는것이다. 따라서 한가지 작업만 해야 한다는 규칙을 위반한다. 한가지 함수 내에 추상화 수준을 섞으면 코드를 읽기 어렵다. 특정 표현이 근본 개념인지 세부사항인지 구분하기 어려운 탓이다. 추상화 수준에 따른 예를 들자면

 

추상화 수준이 높은 코드
getHtml()

 

추상화 수준이 중간인 코드
let html = readIntorducePageHtml();

 

추상화 수준이 낮은 코드

let url = '1.5.2/user/uploaded/html';
let html = await fetch(url);

위에서 아래로 코드 읽기(내려가기 규칙)

코드는 위에서 아래로 이야기처럼 읽히는 코드가 좋은 코드이다. 한가지 함수 다음에는 추상화 수준이 한 단계 낮은 함수가 와야 한다. 즉 위에서 아래로 코드를 읽으면 함수 추상화 한 단계 낮은 함수가 오게 되는 것이다(내려가기 규칙).

 

추상화 수준이 하나인 함수를 구현하는 것은 쉽지 않은 일이지만, 그만큼 중요한 규칙이다. 핵심은 짧으면서도 한가지만 하는 함수이다.

서술적인 이름을 사용하라

좋은 이름이 주는 가치는 아무리 강조해도 지나치지 않다. “코드를 읽으면서 짐작했던 기능을 각 루틴이 수행한다면 깨끗한 코드라고 불러도 될 것이다.” 한 가지만 하는 작은 함수에 좋은 이름을 붙힌다면 이런 원칙을 이미 달성한 것이라고 볼 수 있다.

 

함수가 작고 단순할수록 서술적인 이름을 붙히기도 쉬워진다. 만약 이름이 길다고 하더라도 괜찮다. 길고 서술적인 이름이 짧고 어려운 이름보다 좋고, 길고 서술적인 이름이 길고 서술적인 주석보다 좋다. 서술적인 이름을 사용하면 개발자 머리 속 에서도 설계가 뚜렷해지므로 코드를 개선하기 쉬워진다.

함수 인자

함수에서 이상적인 인자 개수는 0개(무항)이다. 다음은 1개(단항)이고, 다음은 2개(이항)이다. 3개(삼항)은 가능한 피하는 편이 좋다. 인자는 많을수록 복잡해지고, 개념을 이해하기 어렵게 만든다. includeSetupPageInto(pageConent)보다 includeSetupPage()가 더 이해하기 쉽다. 함수에 인자가 있을 경우 함수를 읽을때마다 코드를 뜯어 그 의미를 해석해야 한다. 따라서 개발자가 현 시점에서 별로 중요하지 않은 세부사항을 알아야 한다는 점에서 비효율적이다.

 

테스트 관점에서 보면 인자는 더 어렵다. 갖가지 인자 조합으로 함수를 검증해야 한다면 그만큼 테스트 코드또한 복잡해지게 된다. 만약 인자가 3개를 넘어간다면 인자마다 유효한 값으로 모든 조합을 구성해야 하기에 테스트하기가 상당히 부담스러워지게 된다.

 

최선은 입력 인자가 없는 함수이며, 차선은 입력 인수가 1개뿐인 함수이다. accountInfoOnVisiterRender(pageData)는 이해하기 쉽다. pageData를 렌더링 하겠다는 뜻이다.

동사와 키워드

함수의 의도를 제대로 표현하려면 좋은 함수 이름이 필수이다. 단항 함수는 함수와 인자가 동사/명사 쌍을 이뤄야 한다. 예를 들자면 write(name)은 누구나 바로 이해한다. 이름(name)이 무엇이든 쓴다(wirte)한다는 의미이다. 좀더 나은 이름은 writeField(name)이다.

명령과 조회를 분리해라

함수는 뭔가를 수행하거나 뭔가에 답하거나 둘중 하나만 해야 한다. 둘다 해서는 안된다. 객체 상태를 변경하거나, 아니면 객체 정보를 반환하거나. 둘중 하나다. 함수 하나에서 둘다 할 경우에는 혼란을 초래한다

if (set("username", "dylan")) ...

위 코드는 무슨 의미로 읽히는가? 해당 코드를 작성한 개발자는 “username을 dylan으로 설정하는데 성공하면…”의 의미로 작성하였지만 코드를 처음 보는 사람은 “username의 석성이 dylan으로 설정되어 있다면…”으로 오해할 소지가 있다. 이에 대한 해결책은 아래와 같이 명령과 조회를 분리하는 것이다.

if (attributeExist("username")) {
  setAttribute("username", "dylan");
}

5장) 코드 형식

좋은 개발자라면 형식을 깔끔하게 맞춰 코드를 작성해야 한다. 만약 팀으로 일한다면 모두가 합의해 코드 규칙을 정하고 모두가 그 규칙을 따라야 한다. 필요하다면 규칙을 자동으로 적용하는 도구를 활용해야 한다. 코드 형식은 의사소통의 일환으로 가독성 높은 코드만큼 중요한것은 없다. 오랜 시간이 지나 원래 코드의 흔적을 알아보기 어려울정도로 코드가 바뀌더라도, 맨 처음 잡아놓은 구현 스타일과 가독성 수준은 계속해서 영향을 끼친다. 그렇다면 원활한 소통을 장려하는 코드 형식은 무엇일까?

적절한 행 길이를 유지해라.

JUnit, FitNesse, Time and Money와 같은 거대한 시스템에 작성된 코드를 보면 500줄을 넘어가는 파일이 없으며 대부분 200줄 미만이다. 이는 결과적으로 200줄 정도의 파일로도 견고한 초대형 시스템을 구축할 수 있다는 사실을 알 수 있다. 일반적으로 큰 파일보다 작은 파일이 이해하기 더 쉽다. 이는 반드시 지켜야 할 규칙은 아니지만, 바람직한 규칙 정도로 삼으면 좋을것이다.

세로 밀집도

줄바꿈이 개념을 분리한다면 세로 밀집도는 연관성을 의미한다. 즉 서로 밀접한 코드 행은 세로로 가까이 놓여야 한다는 뜻이다.

수직 거리

함수 연관 관계와 동작 방식을 이해하려고 이 함수에서 저 함수로 옮기며 파일을 위아래로 뒤져봤던 경험이 있는가? 이는 결코 달갑지 않은 경험일 것이다. 시스템이 무엇을 하는지 이해하기 위해서 이 조각 저 조각이 어디에 있는지 찾느라 에너지를 소모하게 된다. 이를 방지하기 위해 서로 밀접한 개념은 세로로 가까이 둬야 한다. 물론 두 개념이 같은 파일에 속한다는 가정 하에 성립되는 법칙이다.

 

같은 파일에 속할 정도로 밀접한 두 개념은 세로 거리로 연관성을 표현한다. 여기서 연관성이란 한 개념을 이해하는데 다른 개념이 중요한 정도를 뜻한다. 연관성이 깊은 두 개념이 멀리 떨어져 있으면 코드를 읽는 사람이 소스 파일을 여기저기 뒤지게 된다.

변수 선언

변수는 사용하는 위치에서 최대한 가까운곳에 선언해야 한다.

종속 함수

한 함수가 다른 함수를 호출한다면 두 함수는 세로로 가까이 배치한다. 가능하다면 호출하는 함수를 호출되는 함수보다 먼저 배치한다. 그러면 소스 코드가 자연스럽게 읽힌다. 만약 이 규칙을 일관적으로 적용한다면 개발자는 방금 호출한 함수가 바로 다음에 정의되리라고 예측할 수 있게 되고, 모듈 전체의 가독성도 높아진다.

7장) 오류 처리

오류 처리는 중요하다. 하지만 오류 처리 코드로 인해 프로그램의 논리를 이해하기 어려워진다면 깨끗한 코드라고 부르기 어렵다. 이 문서에서는 깨끗한 코드에 한걸음 더 다가가는 단계로 오류를 아름답게 처리하는 기법과 고려사항 몇가지를 소개하겠다.

Try-Catch 문부터 작성하라

try블록에 들어가는 코드를 작성하면 어느 시점에서든 실행이 중단된 후 catch 블록으로 넘어갈 수 있다. try블록에서 무슨 일이 생기든 catct 블록은 프로그램 상태를 일관성 있게 유지해야 한다. 이같은 구조에서 코드를 작성하면 try블록에서 무슨일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬워진다.

 

try-cath 구조로 범위를 정의했으므로 tdd를 사용하면 나머지 논리를 추가할 수 있다. 먼저 강제로 예외를 일으키는 테스트 케이스를 작성한 후 테스트를 통과하게 코드를 작성하는 방법을 권장한다. 그러면 자연스럽게 try 블록의 트랜잭션 범위부터 구현하게 되므로 트랜지션의 본질을 유지하기 쉬워진다.

예외에 의미를 제공하라

예외를 던질 때에는 전후 상황을 충분히 덧붙인다. 그러면 오류가 발생한 원인과 위치를 찾기가 쉬워진다. 오류 메시지에 정보를 담아 예외와 함께 던지고 실패한 연산의 이름과 실패 유형도 함께 언급하는것이 좋다.

특수 사례 패턴

특수 사례 패턴이란 catch 블록에서 객체를 조작해 특수 사례를 처리하는 방식이다. 그러면 클라이언트 코드에서 예외적인 상황을 처리할 필요가 없어지기 때문에 하위 함수가 예외적인 상황을 캡슐화 시켜 처리할 수 있게 된다.

null을 반환하지 말아라

null을 반환하지 말고 특수 사례 패턴을 활용하여 처리하는것이 더 낫다. 만약 null을 반환해야 하는 상황이라면, null 대신 빈 배열을 반환한다면 코드가 훨씬 깔끔해진다. 대부분의 프로그래밍 언어(javascript 포함)는 호출자가 실수로 넘기는 null을 적절히 처리하는 방법이 없다. 그렇다면 애초부터 함수에서 null을 넘기지 못하도록 금지하는게 더 합리적이다.

8장) 경계

시스템에 들어가는 모든 소프트웨어를 내부 개발자가 직접 구현하는 경우는 드물다. 많은 경우에 개발자는 패키지와 오픈소스를 이용하여 개발을 진행한다. 이때 외부 코드를 내부 코드에 깔끔하게 통합해야 하는데, 이 문서에서는 소프트웨어 경계를 깔끔하게 처리하는 기법에 대해서 설명하도록 하겠다.

외부 코드 사용하기

라이브러리와 같은 외부 코드는 언제라도 인터페이스가 변경될 여지가 있다. 이해를 위해 javascript환경을 지원하는 쿠키 라이브러리를 보면서 알아보도록 하자.

import Cookies from 'js-cookie';

Cookies.set('brand', 'lotus');

위의 코드는 js-cookie라는 라이브러리를 불러와서 사용하고 있는 코드이다. 여기서 js-cookie의 라이브러리 버전이 업데이트 되며 인터페이스가 다음과 같이 변경되었다고 가정해보겠다.

import setCookies from 'js-cookie';

setCookies.setName('brand').setValue('lotus');

이렇게 인터페이스가 변경되었다면 기존 코드를 리팩토링 해줘야 하게 된다. 그런데 js-cookie를 모든 컴포넌트에서 직접 호출하고 있다면 어떻게 될까? 호출하는 코드가 100개면 100개의 코드를 모두 리팩터링해줘야 하는 상황이 발생한다. 이것이 바로 라이브러리의 경계가 제대로 구분되어 있지 않았을때 발생할 수 있는 문제 상황이다. 내부 코드가 외부 코드(라이브러리)에 종속되어 있으므로, 외부 코드가 조금만 변경되더라도 수정점이 수없이 많이 발생하는 것이다.

 

라이브러리의 인터페이스가 변경되는것은 생각보다 흔한 일이다. js-cookie같은 오픈소스 라이브러리 뿐만 아니라 Java와 같은 프레임워크의 내장 함수 인터페이스가 통째로 바뀌는 일도 있다. 이런 변경 가능성은 위험 부담에 속하기 때문에 통제할 수 있는 방법으로 리스크를 관리해야 한다. 다음 코드를 보도록 하자.

import Cookies from 'js-cookie';

const setCookie = (key, value) => {
  Cookies.set(key, value)
};

export default setCookie;

setCookie라는 자체 함수를 만들어 그 안에서 Cookies를 호출해 사용하도록 작성하였다. 이렇게 구현할 경우 js-cookie의 인터페이스가 변경되더라도 setCookie 함수의 스코프에서만 작업해주면 리팩터링이 끝난다. setCookie는 소스코드 전체에서 사용하고 있지만, 실제로 js-cookie를 호출해주는건 함수 스코프에 작성한 코드밖에 없다. 이처럼 외부 코드와 캡슐화 시켜 경계를 나누는것은 소프트웨어 안정성면에서도 큰 영향을 끼치게 된다.

테스트 코드 활용하기

라이브러리라고 해도 엄연한 외부 코드이다. 이는 사람이 작성한 것으로, 100% 신뢰할 수 없는 경우도 꽤나 있다. 그렇다면 외부 코드를 학습하는 단계에서 테스트 코드를 작성한다면 어떨까? 곧바로 내부 코드에서 외부 코드를 호출하는 대신 먼저 간단한 테스트 케이스를 작성하여 외부 코드를 익히는것을 학습 테스트 라고 부른다.

 

외부 코드를 익히는데 테스트 코드를 사용했을때 얻을 수 있는 이점은 상상 이상으로 크다. 코드를 익히는 과정에서 얻은 지식을 단위 테스트로 표현할 수 있으며, 이는 라이브러리의 무결성을 증명할뿐만 아니라, 다른 개발자가 라이브러리를 익혀야 하는 상황이 생겼을때 이미 내부 코드에 적합하게 작성된 테스트 코드가 존재하기 때문에 학습할때 효율적이다.

 

라이브러리를 사용하기 위해서는 어쨌든 라이브러리를 배워야 하기 때문에 학습 테스트에 드는 비용은 없는거나 마찬가지다. 이 과정에서 학습 테스트는 이해도를 높혀주는 정확한 실험과도 같다. 만약 라이브러리의 새 버전이 나와서 새로 패키지를 설치했다고 가정해보자. 이 경우 그냥 학습 테스트를 돌려보기만 하면 내부 코드와 호환되는지를 알 수 있게 된다. 통합 테스트 과정에서 해당 학습 테스트를 등록해놓기만 하면 라이브러리가 내부 코드와 호환되는지의 여부를 즉각적으로 알 수 있게 된다. 이러한 장점은 위에서 애기한 외부 코드의 경계를 나누고 내부 코드에서 캡슐화 시키는 방법과 시너지를 일으켜 외부 코드를 신뢰할 수 있게 만든다.

9장) 단위 테스트

테스트는 코드를 신뢰할 수 있도록 한다. 테스트를 작성하고 자동화 하는 것에서 멈추지 않고, 아예 테스트 주도 개발(TDD)라는 개념이 소프트웨어 개발에서 주류를 차지한지 오래이다. 테스트 코드를 제대로 작성하는 것은 서비스 코드를 제대로 작성하는것 만큼이나 중요하다. 이번 문서에서는 이에 대해 기술하겠다.

TDD의 법칙 세가지

TDD는 실제 코드를 작성하기 전에 단위 테스트부터 작성하는 방법론이다. 책에서는 이를 위한 세가지 법칙에 대해 설명하고 있다.

  1. 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.
  2. 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
  3. 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.

위 규칙을 따르다 보면 실제 코드 만큼이나 거대한 테스트 케이스가 생기게 되는데, 이는 종종 심각한 관리 문제를 유발하기도 한다.

깨끗한 테스트 코드 유지하기

테스트 코드는 실제 코드 못지않게 중요하다. 관리되지 않은 테스트 코드는 실제 코드에 대한 신뢰성을 떨어트리며 무엇보다 개발 효율성을 떨어트릴 위험성이 있다. 테스트 코드를 작성할때는 주의깊게 설계하고 작성해야 하며, 실제 코드 못지 않게 구조적으로 짜야 한다. 그 이유는 다음과 같다.

테스트는 유연성, 유지보수성, 재사용성을 제공한다

테스트 코드를 깨끗하게 유지하지 않으면 결국은 잃어버리게 된다. 그리고 테스트 케이스가 없다면 실제 코드의 유연성과 신뢰성을 인증받을 수 없다. 코드의 유연성, 유지보수성, 재사용성을 제공하는 버팀목이 바로 단위테스트다. 왜냐면 테스트 케이스가 있으면 변경이 두렵지 않기 때문이다.

 

만약 테스트 케이스가 적용되어 있지 않는 실제 코드가 있다면, 모든 변경이 잠정적인 버그라고 할 수 있다. 하지만 테스트 케이스가 있다면 버그의 공포는 사실상 사라진다. 부실하고 무질서한 코드를 리팩터링 할때도 걱정없이 변경할 수 있다. 이처럼 테스트 케이스는 실제 코드의 유연성, 유지보수성, 재사용성을 제공하며 무엇보다 코드를 쉽게 변경할 수 있게 된다.

 

따라서 테스트 코드가 지저분하다면 코드를 변경하는 능력이 떨어지게 되고, 이에 따라 실제 코드를 개선하는 능력도 떨어지게 된다. 결과적으로 테스트를 포기하게 되고 실제 코드도 망가지게 된다.

깨끗한 테스트 코드

깨끗한 테스트 코드를 작성하기 위해서는 세가지가 필요하다. 가독성, 가독성, 그리고 가독성이다. 가독성은 실제 코드보다 테스트 코드에 더더욱 중요하다. 테스트 코드는 최소한의 표현으로 많은 것을 나타내야 하며 이를 위해서는 명료성과 단순성, 풍부한 표현력이 필요하다.

 

테스트 코드 구조로 적합한 방식으로 Build-Operate-Check 패턴이 있다. 첫 부분은 테스트 자료를 만들고, 두번째 부분은 테스트 자료를 조작하며, 세번째 부분은 조작한 결과가 올바른지 확인한다. 예시를 위해 다음의 코드를 보도록 하자.

import React from "react";
import { render, screen } from "@testing-library/react";
import Profile from "./Profile";

describe("<Profile/>", () => {
  it("username값에 admin이 포함되어있다", () => { 👈 1번
    render(<Profile username="admin" name="어드민" />); 👈 2번
    expect(screen.getByText("dylan")).toBeInTheDocument(); 👈 3번
  });
});

(1)에서는 테스트 케이스를 선언(Build)하고 있고, (2)에서는 Profile에 prop을 주입하여 렌더링 하고 있으므로 Operate에 해당된다. 그리고 (3)에서는 렌더링 결과값이 올바른지 검사하고 있으므로 Check에 해당된다. 이 예시는 너무 간단한 구조라서 완벽한 예시가 되지는 못하겠지만, 복잡한 테스트 케이스를 작성할때 이런 규칙을 지켜야 한다는것을 알고 있으면 좋을 것 같다.

 

이 패턴에서 가장 중요한 부분은 if, else, try, catch와 같은 잡다하고 세세한 코드를 거의 다 없앤다는 것이다. 테스트 코드는 곧바로 본론으로 돌입해 정말 필요한 함수만 사용한다. 모두가 알다싶이 예외처리나 조건문 같은 코드는 독자(개발자)의 몰입을 해친다. 따라서 필요 함수 외에는 테스트 케이스에 포함시켜서는 안된다. 이러한 규칙을 지키고 테스트 코드를 작성한다면 코드를 읽는 사람이 잡다하고 세세한 코드에 헷갈릴 필요 없이 함수만 보고서 해당 테스크 코드가 수행하는 기능을 재빨리 이해할 수 있게 된다.

테스트당 개념 하나

테스트 케이스 한개는 오로지 한가지 개념만을 테스트해야 한다. 이것저것 잡다한 개념을 연속으로 테스트하는 긴 함수는 지양해야 한다. 만약 한가지 테스트 함수에서 독자적인 개념 세개를 테스트하고 있다면, 이는 테스트 함수 세개로 쪼개야 마땅하다.

F.I.R.S.T 규칙

깨끗한 테스트 코드는 다음 다섯가지 규칙을 따르는데 , 각 규칙에서 첫글자를 따오면 FIRST가 된다.

빠르게(Fast)

테스트는 빠르게 작동해야 한다. 테스트 속도가 느리다면 자주 돌릴 엄두가 나지 않는다. 자주 돌리지 않는다면 초반에 문제를 감지하지 못하고, 코드를 마음껏 정리하기도 어렵다. 이는 코드 품질 하락의 계기가 될 수 있다.

독립적으로(Independent)

각 테스트는 서로 의존해서는 안된다. 각 테스트는 독립적이여야 하며, 어떤 순서로 실행하더라도 정상적으로 작동해야 한다. 테스트가 서로에게 의존하면 하나가 실패할때 나머지도 잇달아 실패하므로 원인을 진단하기 어려워진다.

반복가능하게(Repeatable)

테스트는 어떤 환경에서도 반복 가능해야 한다. 개발 환경, QA 환경, 심지어는 퇴근길 지하철에서도 실행할 수 있어야 한다. 테스트가 돌아가지 않는 환경이 하나라도 있다면 테스트가 실패한 이유를 둘러댈 변명이 생기기 때문이다.

자가검증하는(Seft-Validating)

테스트의 결과값은 Boolean으로 나와야 한다. 성공 아니면 실패인 것이다. 통과 여부를 아려면 로그 파일을 읽어야 해서는 안된다. 테스트가 스스로 성공과 실패 여부를 가늠하지 않는다면 판단은 주관적이게 되며, 지루한 수작업 평가가 필요하게 된다.

시기 적절하게(Timely)

테스트는 적시에 작성해야 한다. TDD의 방식에 따르면 단위 테스트는 실제 코드를 구현하기 직전에 작성해야만 한다. 이를 어길시 실제 코드가 너무 테스트하기 어려울수도 있고, 이로인해 테스트하지 않는 실제 함수가 탄생할 수 도 있다. 따라서 언제나 실제 코드를 작성하기 전에 테스트 코드를 작성하는것이 필요하다.