2023. 12. 3. 14:49ㆍ리팩토링
내 코드가 그렇게 이상한가요? 를 참고하여 쓴 글 입니다.
리팩터링이란 실질적인 동작은 유지하면서, 구조만 정리하는 작업이다.
리팩터링을 하기 위해 코드를 변경할 때 실질적인 동작까지 바뀌어 버린다면, 이는 리팩터링이라 할 수 없다.
그렇기 위해서 단위 테스트를 통해 실질적인 동작이 변하지 않았는지 확인해야한다.
단위 테스트는 리팩토링 중에 동작을 변경하는 실수를 줄일 수 있는 방법이다.
단위 테스트는 작은 기능 단위로 동작을 검증하는 테스트로, 테스트 코드를 활용해서 메서드 단위로 동작을 검증하는 방법이라고 생각해도 괜찮다.
먼저 예시코드를 살펴보자.
class DeliveryManager {
static getDeliveryCharge(products) {
let charge = 0;
let totalPrice = 0;
products.forEach((product) => {
totalPrice += product.price;
});
if (totalPrice < 20_000) {
charge = 5000;
} else {
charge = 0;
}
return charge;
}
}
이 코드에서 살펴볼 수 있는 몇 가지 문제점들이 있다.
- 메서드가 static으로 정의되어 있기 때문에 데이터와 데이터를 조작하는 로직을 분리해서 정의할 수 있게된다. 이렇게 되면 응집도가 낮아질 가능성이 높다.
- 배송비는 금액을 나타내는 개념이므로
값 객체
를 활용할 수 있다. 이때 주의할 점은 단순히 배송비를 외부에서 getter를 통해 가져와 계산하는 로직을 외부에서 정의하지 않아야한다. - 상품 합계 금액은 다양한 상황에서 사용될 수 있다. 이 말은 즉, 합계 금액을 계산하는 로직이 각각의 클래스에서 따로 구현되어 중복될 가능성이 높아질 수 있다는 말이다. 때문에 별도의 클래스로 관리하는 것이 좋다.
🔨테스트 코드를 사용한 리팩터링 흐름
꼭 이 방법만이 정답은 아니다.
안전하게 리팩터링하기 위한 테스트 코드 추가 방법은 여러가지이다.
이 방법은 이상적인 구조를 어느 정도 알고 있을 때 유용한 방법이다.
- 이상적인 구조의 클래스 기본 형태를 어느 정도 잡는다.
- 이 기본 형태를 기반으로 테스트 코드를 작성한다.
- 테스트를 실패시킨다.
- 테스트를 성공하기 위한 최소한의 코드를 작성한다.
- 기본 형태의 클래스 내부에서 리팩터링 대상 코드를 호출한다.
- 테스트가 성공할 수 있도록, 조금씩 로직을 이상적인 구조로 리팩터링한다.
이상적인 구조의 클래스 기본형태 잡기
class ShoppingCart {
#products;
constructor(products) {
if (!Array.isArray(products)) {
this.#products = [];
} else {
this.#products = products;
}
}
getProducts() {
return this.#products;
}
add(product) {
const adding = this.#products;
adding.push(product);
return new ShoppingCart(adding);
}
}
module.exports = ShoppingCart;
class Product {
#id;
#name;
#price;
constructor(id, name, price) {
this.#id = id;
this.#name = name;
this.#price = price;
}
getPrice() {
return this.#price;
}
}
module.exports = Product;
class DeliveryCharge {
#amount;
constructor(shoppingCart) {
this.#amount = -1;
}
getAmount() {
return this.#amount;
}
}
우선 다음과 같은 이상적인 구조의 클래스를 만들어본다.
아직 완성된 상태가 아니기 때문에 하나씩 DeliveryManager
의 로직을 옮겨주면 된다.
테스트 코드 작성하기
테스트해야할 항목은 다음과 같다.
- 상품 합계 금액이 20,000원 미만이면 배송비는 5,000원
- 상품 합계 금액이 20,000원 이상이면 배송비는 무료
const Product = require('../src/shopping/Product');
const ShoppingCart = require('../src/shopping/ShoppingCart');
const DeliveryCharge = require('../src/shopping/DeliveryCharge');
describe('배송비 테스트', () => {
test('상품 합계 금액이 20,000원 미만이면 배송비는 5,000원', () => {
const shoppingCart = new ShoppingCart();
const oneProductAdded = shoppingCart.add(new Product(1, '상품A', 5000));
const twoProductAdded = oneProductAdded.add(new Product(2, '상품B', 10000));
expect(new DeliveryCharge(twoProductAdded).getAmount()).toBe(5000);
});
test('상품 합계 금액이 20,000원 이상이면 배송비는 무료', () => {
const shoppingCart = new ShoppingCart();
const oneProductAdded = shoppingCart.add(new Product(1, '상품A', 10000));
const twoProductAdded = oneProductAdded.add(new Product(2, '상품B', 10000));
expect(new DeliveryCharge(twoProductAdded).getAmount()).toBe(0);
});
});
테스트 실패시키기
단위 테스트는 프로덕션 코드를 구현하기 전에, 실패와 성공을 확인해야 한다. 기대한 대로 실패 혹은 성공하지 않는다면 테스트 코드나 프로덕션 코드 중 어딘가에 오류가 있다는 방증이기 때문이다.
그래서 우선은 테스트를 실패시킨다. 배송비를 계산하는 로직이 구현되지 않았으므로 현재 위 테스트 코드는 실패한다.
테스트 성공시키기
처음부터 본격적으로 구현하는 것이 아닌 테스트를 성공시키기 위한 최소한의 코드만 구현한다.
테스트 코드를 작성하는 이유 중 하나는 구현한 기능에 대한 빠른 피드백을 받는 것이기도 하다.
class DeliveryCharge {
#amount;
constructor(shoppingCart) {
const totalPrice = shoppingCart.getProducts()[0].getPrice() + shoppingCart.getProducts()[1].getPrice();
if (totalPrice < 20_000) {
this.#amount = 5000;
} else {
this.#amount = 0;
}
}
getAmount() {
return this.#amount;
}
}
module.exports = DeliveryCharge;
리팩터링하기
테스트 코드가 의도대로 잘 동작한다면 리팩터링을 한다.
우선 리팩토링 대상인 DeliveryManager
의 로직을 DeliveryCharge
생성자에 호출해서 정상 작동하는지 확인한다.
const DeliveryManager = require('./DeliveryManager');
class DeliveryCharge {
#amount;
constructor(shoppingCart) {
this.#amount = DeliveryManager.getDeliveryCharge(shoppingCart.getProducts());
}
getAmount() {
return this.#amount;
}
}
module.exports = DeliveryCharge;
그리고 상품의 총 금액을 계산하는 로직은 ShoppingCart
에게 책임을 맡기자.
class ShoppingCart {
#products;
constructor(products) {
if (!Array.isArray(products)) {
this.#products = [];
} else {
this.#products = products;
}
}
getProducts() {
return this.#products;
}
calculateTotalPrice() {
let totalPrice = 0;
this.#products.forEach((product) => {
totalPrice += product.getPrice();
});
return totalPrice;
}
add(product) {
const adding = this.#products;
adding.push(product);
return new ShoppingCart(adding);
}
}
module.exports = ShoppingCart;
그런 다음 DeliveryManager
에서 ShoppingCart
의 calculateTotalPrice
를 호출하도록 변경한다.
이제 DeliveryManager
에서 직접 총 주문금액을 계산하지 않게 된다.
class DeliveryManager {
static getDeliveryCharge(products) {
let charge = 0;
const totalPrice = products.calculateTotalPrice();
if (totalPrice < 20_000) {
charge = 5000;
} else {
charge = 0;
}
return charge;
}
}
module.exports = DeliveryManager;
그런 다음 배송비 계산로직을 DeliveryCharge
로 옮긴다.
생성자에서 파라미터로 shoppingCart
를 받기 때문에 이에 맞게 변경한다.
class DeliveryCharge {
#amount;
constructor(shoppingCart) {
const totalPrice = shoppingCart.calculateTotalPrice();
if (totalPrice < 20_000) {
this.#amount = 5000;
} else {
this.#amount = 0;
}
}
getAmount() {
return this.#amount;
}
}
module.exports = DeliveryCharge;
그 외에도 매직 넘버를 피하기 위해 상수로 변경하거나 더 좋은 리팩터링을 이어나갈 수 있다.
테스트 코드가 있으니 리팩터링 중간에 실수로 로직을 잘못 작성하면, 테스트가 실패하기 때문에 곧바로 알아차릴 수 있다.
따라서 안전하게 로직을 변경할 수 있다.