BackEnd/Refactoring

[Refactoring] 가변 데이터 (Mutable Data)

샤아이인 2023. 1. 3.

백기선 님의 리팩터링 강의를 들으며 요약한 내용입니다.

6. 가변 데이터

함수형 프로그래밍에서는 데이터를 변경할 때 복사본을 전달한다.

하지만 Java와 같은 언어에서는 데이터의 변경을 허용한다. Call By Value를 생각해보면 주소값을 전달하기에 변경여파가 크다.

따라서 데이터가 변경될 시 발생할 수 있는 여파를 관리할 방법을 적용해야 한다.

 

6 - 1) 변수 쪼개기

어떤 변수에 할당이 여러번 되고 있다 생각해 보자. 과연 적합한 상황일까?

 

다음 Rectangle 코드를 살펴보자!

▶ Rectangle

public class Rectangle {

    private double perimeter;
    private double area;

    public void updateGeometry(double height, double width) {
        double temp = 2 * (height + width);
        perimeter = temp;

        temp = height * width;
        area = temp;
    }

    public double getPerimeter() {
        return perimeter;
    }

    public double getArea() {
        return area;
    }
}

 

눈에 거슬리는 temp라는 변수가 보일 것 이다. temp가 사실상 permieter, area값을 임시로 저장하고 있기 때문에 의미가 매우 중복적이라 할 수 있다.

 

이를 좀더 의미 있는 permieter, area라는 지역변수 2개로 나누어 보자.

public void updateGeometry(double height, double width) {
    final double perimeter = 2 * (height + width);
    this.perimeter = perimeter;

    final double area = height * width;
    this.area = area;
}

값이 한번 설정되면 재할당될 일 이 없기 때문에 final키워드를 추가하였다.

또한 의미에 적합하게 변수가 사용 중이다.

 

예시를 하나만 더 살펴보자.

▶ Order

public class Order {

    public double discount(double inputValue, int quantity) {
        if (inputValue > 50) inputValue = inputValue - 2;
        if (quantity > 100) inputValue = inputValue - 1;
        return inputValue;
    }
}

지금 Order 클래스의 discount를 보면 inputValue라는 파라미터를

1) 인자를 받는대도 사용하고 있고

2) 계산 로직에서도 사용하고 있고

3) 심지어 반환값으로도 사용되고 있다.

 

거의 시작에서 끝까지 변수 하나로 계속 우려먹고 있다. 좀 더 의미 있는 변수로 나누어 보자.

public class Order {

    public double discount(double inputValue, int quantity) {
        double result = inputValue;
        if (inputValue > 50) result -= 2;
        if (quantity > 100) result -= 1;
        return result;
    }
}

이전보다 어떤 흐름인지 한눈에 더 잘 들어온다.

 

6 - 2) 질의 함수와 변경 함수 분리하기

CQRS원칙을 지키자는 의미이다.

 

명령-조회 분리 (command-query separation) 규칙:

어떤 값을 리턴하는 함수는 사이드 이팩트가 없어야 한다.

 

또한 눈에 띌만한 사이드 이팩트는 없어야 한다.

예를 들어 케시는 중요한 객체 상태변화는 아니다. 따라서 어떤 메서드 호출로 인해 캐시 데이터를 변경하더라도 분리할 필요는 없다.

 

예를 들어 다음 코드를 살펴보자.

public class Billing {

    private Customer customer;

    private EmailGateway emailGateway;

    public Billing(Customer customer, EmailGateway emailGateway) {
        this.customer = customer;
        this.emailGateway = emailGateway;
    }

    public double getTotalOutstandingAndSendBill() {
        double result = customer.getInvoices().stream()
                .map(Invoice::getAmount)
                .reduce((double) 0, Double::sum);
        sendBill();
        return result;
    }

    private void sendBill() {
        emailGateway.send(formatBill(customer));
    }

    private String formatBill(Customer customer) {
        return "sending bill for " + customer.getName();
    }
}

getTotalOutstandingAndSendBill()을 살펴보면 이름부터 벌써 2가지 역할을 하고 있음이 느껴진다.

1) invoice의 총함을 구하고

2) 이메일을 보낸다

 

이를 2개의 메서드로 분리해 보자.

public double getTotalOutstanding() { // 조회용
    return customer.getInvoices()
            .stream()
            .map(Invoice::getAmount)
            .reduce((double) 0, Double::sum);
}

public void sendBill() { // 커멘드 용
    emailGateway.send(formatBill(customer));
}

 

6 - 3) Setter 제거하기

개인적인 내 생각에는, 그냥 Setter는 악의 근원에 가깝다.

Setter는 지양해야 할 대상이다.

 

객체 생성 후 처음 설정된 값이 변경될 여지가 없다면, 생성자를 통해서 값을 설정하고, 기존의 Setter는 지워버리자!

 

6 - 4) 파생 변수를 질의 함수로 바꾸기

변경할 수 있는 데이터를 최대한 줄이도록 노력해야 한다.

즉, 다른 값들로부터 계산하여 알아낼 수 있는 변수는 제거하고 그 식 자체를 대입하면 된다.

 

1) 계산식 자체가 데이터의 의미를 더 잘 표현하는 경우도 있다.

2) 해당 변수가 어디선가 잘못된 값으로 수정될 수 있는 가능성을 제거할 수 있다. 변수가 하나라도 더 줄었으니!

 

예를 들어 다음 코드를 살펴보자.

public class Discount {

    private double discountedTotal;
    private double discount;

    private double baseTotal;

    public Discount(double baseTotal) {
        this.baseTotal = baseTotal;
    }

    public double getDiscountedTotal() {
        return this.discountedTotal;
    }

    public void setDiscount(double number) {
        this.discount = number;
        this.discountedTotal = this.baseTotal - this.discount;
    }
}

discountedTotal이라는 변수는 baseTotal - discount로 알아낼 수 있는 변숫값이다.

 

따라서 discountedTotal변수 자체를 없애고, 식으로 바꿔버리자.

public class Discount {

    private double discount;

    private double baseTotal;

    public Discount(double baseTotal) {
        this.baseTotal = baseTotal;
    }

    public double getDiscountedTotal() {
        return this.baseTotal - this.discount;
    }

    public void setDiscount(double number) {
        this.discount = number;
    }
}

 

6 - 5) 여러 함수를 변환 함수로 묶기

변환함수

변환함수는 원본 데이터를 입력받아서 필요한 정보를 모두 도출하고, 각각을 출력 데이터의 필드에 넣어서 보관한다.

 

관련 있는 여러 파생 변수를 만들어내는 동일한 로직의 함수가 여러 곳에서 만들어지고 사용된다면 그러한 파생 변수를 변환 함수 (transform function)를 통해 한 곳으로 모아둘 수 있다.

 

여러 함수를 한데 묶는 이유는 도출 로직이 중복되는 것을 피하기 위해서다.

 

이러한 리팩토링 방식 대신에 여러 함수를 "클래스로 묶기"로 처리해도 된다.

 

1) 원본 데이터가 변경될 수 있는 경우 -> “여러 함수를 클래스로 묶기 (Combine Functions into Class)”를 사용하는 것이 적절하다.

2) 원본 데이터가 변경되지 않는 경우 -> 변환 함수를 통해 새롭게 생성된 불변 레코드를 통해 사용하게 된다.

 

변환함수의 장점

  • 데이터를 입력 받고 여러 정보를 내보낸다고 가정하자. 그럴 경우 이 정보가 사용되는 곳마다 같은 도출 로직이 반복될 수 있다.
  • 한곳에 모아두게 되면 검색, 갱신을 한 곳에서 처리 가능하고, 로직 중복도 막을 수 있다.

 

음... 글만 보면 잘 이해가지 않는다, 다음 그림을 보면 이해가 갑니다?

https://refactoring.com/catalog/combineFunctionsIntoTransform.html

같은 source를 기준으로 여러 파생 변수(세모, 네모)를 만드는 f(), g()가 있다.

이러한 파생 변수를 반환하는 함수 자체를 한 곳에 모은다면?, 이는 결국 변수가 한곳에 모이는 것과 일맥 상통하다!

const base(aReading) = {...}
const taxableCharge(aReading) = {...}

function enrichReading(aReading){
	const result = _.cloneDeep(original);
	result.baseCharge = calculateBaseCharge(result);
	result.taxableCharge = Math.max(0, result.baseCharge - taxThreshold(result.year);
	return result;
}

 

▶ 과정

  1. 변환할 레코드를 입력받아서 값을 그대로 반환하는 변환 함수를 만든다.
  2. 묶을 함수 중 함수 하나를 골라서 본문 코드를 변환 함수로 옮기고, 처리 결과를 레코드에 새 필드로 기록한다. 그런 다음 클라이언트 코드가 이 필드를 사용하도록 수정한다.
  3. 테스트한다.
  4. 나머지 관련 함수도 위 과정에 따라 처리한다.

 

다음으로 에제 코드로 살펴보자!

우선 Client1, 2, 3의 코드를 살펴보자.

 

▶ Client1

public class Client1 {

    double baseCharge;

    public Client1(Reading reading) {
        this.baseCharge = baseRate(reading.month(), reading.year()) * reading.quantity();
    }

    private double baseRate(Month month, Year year) {
        return 10;
    }

    public double getBaseCharge() {
        return baseCharge;
    }
}

 

▶ Client2

public class Client2 {

    private double base;
    private double taxableCharge;

    public Client2(Reading reading) {
        this.base = baseRate(reading.month(), reading.year()) * reading.quantity();
        this.taxableCharge = Math.max(0, this.base - taxThreshold(reading.year()));
    }

    private double taxThreshold(Year year) {
        return 5;
    }

    private double baseRate(Month month, Year year) {
        return 10;
    }

    public double getBase() {
        return base;
    }

    public double getTaxableCharge() {
        return taxableCharge;
    }
}

client1, 2의 경우 base를 계산하는 똑같은 로직을 하나의 함수로 묶어서 사용하는 것이 좋은데.

문제는 이런게 이미 만들어져 있다고 할때 어디에 있는지도 모를때가 많다는 것이다.

 

그래서 찾아보니 정말 예전 작업자가 남겨둔 함수가 있다고 가정하자. 다음과 같이 Client3의 calculateBaseCharge가 예전에 만들어 둔 것 이다...

 

▶ Client3

public class Client3 {

    private double basicChargeAmount;

    public Client3(Reading reading) {
        this.basicChargeAmount = calculateBaseCharge(reading);
    }

    private double calculateBaseCharge(Reading reading) { // 예전에 만들어둔 함수...
        return baseRate(reading.month(), reading.year()) * reading.quantity();
    }

    private double baseRate(Month month, Year year) {
        return 10;
    }

    public double getBasicChargeAmount() {
        return basicChargeAmount;
    }
}

 

3개의 클래스 모두 baseRate를 구하는 로직이 거의 동일하게 중복되고 있다.

또한 인자로 Reading을 전달받는다. 이를 개선해 보자.

 

▶ EnrichReading

우선 위 그림에서 파생변수를 하나로 묶은 EnrichReading이라는 record타입이 있다.

public record EnrichReading(Reading reading, double baseCharge) {}

record는 Class이긴 한데, 모든 필드가 final이며 값의 변경되지 않는다고 생각하면 된다.

 

Client1, 2, 3에서 공통적으로 Reading이라는 변수를 받아서

Client1 에서는 baseChage를

Client2 에서는 base를

Client3 에서는 basicChargeAmount를

생성한다.

 

https://refactoring.com/catalog/combineFunctionsIntoTransform.html

위 그림으로 생각하면 네모(Reading)를 받아 서로 다른 변수를 생성하고 있는 것이다.

이를 ReadingClient를 통해 EnrichReading으로 tranform과정을 거치자!

 

단계 1. 입력 객체를 그대로 반환하는 불변객체를 반환한다.

public class ReadingClient {
    // 생략...

    protected EnrichReading enrichReading(Reading reading) {
        return new EnrichReading(reading);
    }
}

 

 

단계 2. 변경하려는 계산 로직 중 하나를 변경한다.

public class Client3 extends ReadingClient {

    private double basicChargeAmount;

    public Client3(Reading reading) {
        EnrichReading read = enrichReading(reading); // 중간삽입
        this.basicChargeAmount = calculateBaseCharge(read);
    }

    private double calculateBaseCharge(Reading reading) { // 예전에 만들어둔 함수...
        return baseRate(reading.month(), reading.year()) * reading.quantity();
    }

    private double baseRate(Month month, Year year) {
        return 10;
    }

    public double getBasicChargeAmount() {
        return basicChargeAmount;
    }
}

 

 

단계 3. 예전 만들어둔 함수를 변환함수로 옮긴다.

public class ReadingClient {
    // 생략...

    protected EnrichReading enrichReading(Reading reading) {
        return new EnrichReading(reading, calculateBaseCharge(reading));
    }

    private double calculateBaseCharge(Reading reading) {
        return baseRate(reading.month(), reading.year()) * reading.quantity();
    }
}

예전에 만들어 둔 함수는 calculateBaseCharge이다.

 

단계 4. 이 함수를 사용하던 클라이언트가 부가 정보를 담은 필드를 사용하도록 변경한다.

public class Client3 extends ReadingClient {

    private double basicChargeAmount;

    public Client3(Reading reading) {
        this.basicChargeAmount = enrichReading(reading).baseCharge(); // 변경
    }

    public double getBasicChargeAmount() {
        return basicChargeAmount;
    }
}

 

 

▶ ReadingClient

public class ReadingClient {

    protected double taxThreshold(Year year) {
        return 5;
    }

    protected double baseRate(Month month, Year year) {
        return 10;
    }

    protected EnrichReading enrichReading(Reading reading) {
        return new EnrichReading(reading, calculateBaseCharge(reading));
    }

    private double calculateBaseCharge(Reading reading) {
        return baseRate(reading.month(), reading.year()) * reading.quantity();
    }
}

ReadingClient의 EnrichReading을 보면 Reading을 전달받아서 EnrichReading이라는 불변객채로 변환하여 반환하게 된다.

 

변경된 client 1, 2, 3는 다음과 같다.

public class Client1 extends ReadingClient {

    double baseCharge;

    public Client1(Reading reading) {
        this.baseCharge = enrichReading(reading).baseCharge(); // 변환
    }

    public double getBaseCharge() {
        return baseCharge;
    }
}

public class Client2 extends ReadingClient {

    private double base;
    private double taxableCharge;

    public Client2(Reading reading) {
        this.base = enrichReading(reading).baseCharge(); // 변환
        this.taxableCharge = Math.max(0, this.base - taxThreshold(reading.year()));
    }

    public double getBase() {
        return base;
    }

    public double getTaxableCharge() {
        return taxableCharge;
    }
}

public class Client3 extends ReadingClient {

    private double basicChargeAmount;

    public Client3(Reading reading) {
        this.basicChargeAmount = enrichReading(reading).baseCharge(); // 변환
    }

    public double getBasicChargeAmount() {
        return basicChargeAmount;
    }
}

 

6 - 6) 참조를 값으로 바꾸기

▶ 레퍼런스 객체 vs 값 객체

“Objects that are equal due to the value of their properties, in this case their x and y coordinates, are called value objects.” - martin fowler

값 객체는 객체가 가진 필드의 값으로 동일성을 확인한다. 값 객체는 변하지 않는다.

어떤 객체의 변경 내역을 다른 곳으로 전파시키고 싶다면 레퍼런스 객체가 아니라면 값 객체를 사용한다.

 

예를 들어 다음과 같이 getter/setter가 남발하는 코드를 살펴보자!

public class TelephoneNumber {

    private String areaCode;
    private String number;

    public String areaCode() {
        return areaCode;
    }

    public void areaCode(String areaCode) {
        this.areaCode = areaCode;
    }

    public String number() {
        return number;
    }

    public void number(String number) {
        this.number = number;
    }
}

 

이를 다음과 같이 값객체로 변경해 보자!

public class TelephoneNumber {

    private final String areaCode;
    private final String number;

    public TelephoneNumber(String areaCode, String number) {
        this.areaCode = areaCode;
        this.number = number;
    }

    public String areaCode() {
        return areaCode;
    }

    public String number() {
        return number;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        TelephoneNumber that = (TelephoneNumber) o;
        return Objects.equals(areaCode, that.areaCode) && Objects.equals(number, that.number);
    }

    @Override
    public int hashCode() {
        return Objects.hash(areaCode, number);
    }
}

이때 꼭 equals&hashCode를 구현해야만 한다.

 

아니면 record타입으로 간단하게 다음과 같이 처리할 수도 있다.

public record TelephoneNumber(String areaCode, String number) { }

댓글