소프트웨어 개발 과정에서 처음부터 완벽한 코드를 작성하는 것은 어렵기 때문에,
우리는 지속적으로 리펙토링을 수행해야 한다.
과연 나는 제대로된 리펙토링을 수행한 것일까..?
전체적인 코드의 개선이나 불필요한 코드의 제거가 아닌 경우에도 코드가 수정되었으면
Reractor라는 커밋 타이블을 무분별하게 사용하고 있었다. 🥲
"리펙토링 어떻게 하는거지?", "리펙토링 멋지게 하는 방법" 등을 알기 전에
"왜 리펙토링이 필요하지?"
"테스트코드는 왜 필요하지? 언제 사용하는거지?"
필요성
에 대해서 먼저 이해하고 리펙토링과 테스트코드에 대해 학습해보자.
리펙토링이란 간단히 말하면 코드를 변경하여 가독성
, 유지 보수성
, 확장성
등을 향상시키는 과정이다.
즉, 새로운 코드를 작성하는게 아닌 기존 코드를 개선하여 좀 더 효율적으로 유지 보수가 쉬운 형태로 만드는 것이다.
리펙토링을 수행하면 코드의 가독성이 높아지고, 버그를 찾기 쉬워지며, 변경 사항을 적용하는 것이 쉬워진다.
이러한 이유로 리펙토링은 코드의 품질을 향상시키는 중요한 작업이다.
여기서 중요한 점은 리펙토링은 코드의
동작
을 변경하지 않는 다는 것이다.
즉, 새로운 기능을 추가하거나 기능을 수정하는 것이 아니다.
그렇다면 방대한 양의 코드를 리펙토링하는 과정에서 기존의 코드 로직
즉, 기존의 동작이 변경되거나 오류가 발생하면 안된다는 것이다. (🚫 side-effect 방지)
코드의 품질을 향상 시키는 중요한 리펙토링, 하지만 방대한 양의 코드안에서
동작의 변경이나 예상치 못한 부작용을 피하기 위해서는 어떻게 해야할까?
테스트코드가 필요한 이유다.
리펙토링을 수행하기 전에 기존 코드의 테스트 커버리지
를 확인하고,
변경 후에도 동일한 테스트
를 수행하여 코드 변경의 영향을 확인하는 것이 중요하다.
리펙토링과 테스트코드는 떨어질 수 없으며 떨어져서도 안되는 "한몸" 이다.
테스트코드는 이름 그대로 소프트웨어의 기능을 테스트하고 검증하기 위해 작성된 코드다.
주로 자동화된 테스트를 수행하며, 특정 함수 또는 모듈의 작동 여부를 확인하거나,
전체 시스템의 동작을 검증하는데 사용된다.
소프트웨어 테스트에는 여러 가지 종류와 수준이 있다.
그 중 대표적인 것이 The Four Levels of Software Testing 이다.
참고자료 : The Four Levels of Software Testing
Unit Testing (단위 테스트)
소스 코드의 각각의 단위(주로 함수
또는 메소드
)를 분리된 환경에서 테스트하는 것이다.
일반적으로 빠르고 자동화된 테스트이며, 코드의 버그를 빠르게 찾아내어 개발 생산성을 높여준다.
Integration Testing (통합 테스트)
단위 테스트에서 통합된 코드의 상호작용
을 테스트하는 것이다.
서로 다른 단위 테스트를 통합하여 전체적인 기능이 동작하는지 확인한다.
통합 테스트는 종종 모의 객체(Mock Object)와 같은 도구를 사용하여 다른 시스템 또는 구성 요소와의 상호작용을 시뮬레이트한다.
System Testing (시스템 테스트)
전체 시스템
의 기능과 성능을 테스트하는 것이다.
시스템의 명세서와 요구사항을 기반으로 테스트 케이스를 작성한다.
기능적, 비기능적인 측면에서 테스트를 수행한다.
Acceptance Testing (인수 테스트)
최종 사용자가 시스템이 요구사항과 명세서에 따라 적합한지 테스트하는 것이다.
이 테스트는 주로 고객과 사용자의 요구사항을 충족하는지 확인하는 데 사용된다.
인수 테스트는 사용자 시나리오와 실제 데이터를 사용하여 수행될 수 있다.
이러한 네 가지 수준의 테스트는 소프트웨어 개발 과정에서 각각 다른 시기와 목적에 맞추어 적용된다.
이를 통해 소프트웨어의 품질을 개선하고, 버그를 발견하여 수정하는 등의 작업을 수행할 수 있다.
우선 프로그래밍의 최소단위(함수, 메서드)를 테스트 하는 Unit Test
를 연습해보자.
테스트 코드 구현을 잘하기 위해서 아래 두가지의 기본적인 방법을 먼저 연습하자.
1. 최소단위 함수부터 테스트하기.
반환값이 명확히 존재하고, 다른 함수를 호출하지 않는 함수부터 테스트 한다.
즉, dependency 가 없는 함수.
const coffeeMakingMap = {
americano: () => "물" + "," + "커피원두",
cappuccino: () => "우유" + "," + "커피원두",
};
// 최소 단위의 함수
function makeCoffee(orderMenu, orderCount) {
const result = [];
for (let i = 0; i < orderCount; i++) {
result.push(coffeeMakingMap[orderMenu]());
}
return result;
}
// 테스트 코드
let expected = ["물,커피원두"];
console.log(expected.toString() === makeCoffee("americano", 1).toString());
// 성공(true)
expected = ["우유,커피원두", "우유,커피원두", "우유,커피원두"];
console.log(expected.toString() === makeCoffee("cappuccino", 3).toString());
// 성공(true)
expected = ["우유,커피원두"];
console.log(expected.toString() === makeCoffee("americano", 1).toString());
// 실패(false)
🧐 더 알아가기
위 예제에서 expected.toString()을 사용한 이유는
===
연산자의 경우 두 피연산자가 같은 객체의 경우에만 true를 반환하기 때문이다.
배열은 객체이므로 두 객체의 값이 같더라도 다른 객체일 수 있다. (객체의 값은 참조에 의한 전달이기 때문에)var person1 = { name: 'Lee'}; var person2 = { name: 'Lee'}; // person1 변수와 person2 변수가 가리키는 객체는 비록 내용은 같지만 다른 메모리에 저장된 별개의 객체다. // 즉, person1 변수와 person2 변수의 참조 값은 전혀 다른 값이다. console.log(person1 === person2); // false
위 예제 처럼 테스트 코드를 작성 및 호출하여 확인이 가능하지만,
사람이 직접 모든 함수를 호출하는 것은 불가능하다.
라이브러리를 통해 테스트코드를 조금 더 쉽게 구현할 수 있다.
아래 예제는 Node.js
에서 기본으로 제공되는 내장 모듈인
node:test
와 node:assert
를 사용하여 테스트 코드를 작성한 내용이다.
// ES module를 사용하기위해 package.json을 깔아준다.
npm init -y
node:test 모듈은 Node.js의 내장 테스트 프레임워크인 assert 모듈과 연동하여 테스트 코드를 작성하도록 도와주는 도구다.
assert 모듈은 테스트에서 사용되는 assert 함수를 제공하여, 예상되는 값과 실제 값이 일치하는지 비교할 수 있게 해준다.
2. given -> when- > then 패턴 사용하기.
일관된 방식의 테스트 코드 구현을 위해서 가장 많이 사용되는
given(테스트에 필요한 값 셋팅) -> when(실행) -> then(테스트)
방식으로 테스트를 수행해보자.
// index.js
const coffeeMakingMap = {
americano: () => "물" + "," + "커피원두",
cappuccino: () => "우유" + "," + "커피원두",
};
export function makeCoffee(orderMenu, orderCount) {
const result = [];
for (let i = 0; i < orderCount; i++) {
result.push(coffeeMakingMap[orderMenu]());
}
return result;
}
// index.test.js
import test from "node:test";
import assert from "node:assert";
import { makeCoffee } from "./index.js";
test("makeCoffee 커피를 잘 만드는지 확인", () => {
//given
const orderCount = 3;
const orderMenu = "americano";
//when
const result = makeCoffee(orderMenu, orderCount);
//then
const expected = ["물,커피원두", "물,커피원두", "물,커피원두"];
assert.deepEqual(expected, result);
});
// Node.js의 watch 옵션으로 테스트 코드 실행이 가능하다.
node --watch index.test.js
index.js 내용이 변경(리펙토링)될 때마다 자동으로 함수를 호출하여 테스트 결과를 보여준다.
위에서 연습한 코드를 리펙토링 해보자.
const coffeeMakingMap = {
americano: () => "물" + "," + "커피원두",
cappuccino: () => "우유" + "," + "커피원두",
};
export function makeCoffee(orderMenu, orderCount) {
const result = [];
for (let i = 0; i < orderCount; i++) {
result.push(coffeeMakingMap[orderMenu]());
}
return result;
}
orderMenu
, orderCount
)의 매개변수 순서가 잘못되는 경우가 있을 수 있다.orderMenu
, orderCount
}로 파라미터를 전달하고 받으면 순서의 오류를 피할 수 있다.구조 분해 할당(destructuring)
이라 한다. // index.js
const coffeeMakingMap = {
americano: () => "물" + "," + "커피원두",
cappuccino: () => "우유" + "," + "커피원두",
};
// 테스트를 위해 파리미터 순서 변경
export function makeCoffee({ orderCount, orderMenu }) {
const result = [];
for (let i = 0; i < orderCount; i++) {
result.push(coffeeMakingMap[orderMenu]());
}
return result;
}
// index.test.js
import test from "node:test";
import assert from "node:assert";
import { makeCoffee } from "./index.js";
test("makeCoffee 커피를 잘 만드는지 확인", () => {
//given
const orderCount = 3;
const orderMenu = "americano";
//when
const result = makeCoffee({ orderMenu, orderCount });
//then
const expected = ["물,커피원두", "물,커피원두", "물,커피원두"];
assert.deepEqual(expected, result);
});
객체를 이용한 파라미터 전달로 순서가 달라도 오류가 없다.
리펙토링과 테스트코드에 대해서 간단히 알아보고 왜 필요한지
그리고 왜 "한몸"
인지 확인해 보았다.
개발자에게 리펙토링은 필수 과정이며 이를 위해서는 테스트 코드가 함께 해야 한다.
그리고 테스크 코드를 구현하기 위해서는 코드가 간결하고 최소단위여야 하며 순수함수들로 이루어 질수록 구현이 용이함을 알 수 있었다.
순수함수
, 함수형 프로그래밍
, 클린코드
들이 왜 중요한지도 알게되었다.
코드를 작성 할때는 항상 아래의 내용들을 고려하며 작성하도록 노력해보자.
참고 - 코드스쿼드 마스터즈 수업