ETC

[Refactoring] 6. 가변 데이터

mxruhxn 2025. 1. 3. 14:23
728x90
반응형

가변 데이터(Mutable Data)

  • 데이터를 변경하다보면 예상치 못햇던 결과나 해결하기 어려운 버그가 발생하기도 함
    • 이를 방지하기 위해 함수형 프로그래밍 언어는 데이터를 변경하지 않고 복사본을 전달함 => 부수효과(Side Effect)가 없음
    • 하지만, 그밖의 프로그래밍 언어는 데이터 변경을 허용 => 변경되는 데이터 사용 시 발생할 수 있는 리스크를 관리할 수 있는 방법을 적용하는 것이 좋음
  • 관련 리팩토링
    • "변수 캡슐화하기(Encapsulate Variable)" -> 데이터를 변경할 수 있는 메서드를 제한하고 관리 가능
    • "변수 쪼개기(Split Variable)" -> 여러 데이터를 저장하는 변수를 분해
    • "코드 정리하기(Slide Statements)" -> 데이터를 변경하는 코드를 분리하고 피할 수 있음
    • "함수 추출하기(Extract Function)" -> 데이터를 변경하는 코드로부터 사이드 이펙트가 없는 코드를 분리 가능
    • "질의 함수와 변경 함수 분리하기(Seperate Query from Modifier)" -> 클라이언트가 원하는 경우에만 사이드 이펙트가 있는 함수를 호출하도록 API 개선 가능
      • 사실상 '코드 정리하기' + '함수 추출하기'임
    • "세터 제거하기(Remove Setting Method)" -> 가능하다면 세터는 제거하는 것이 좋음
    • "파생 변수를 질의 함수로 바꾸기(Replace Derived Variable with Query)" -> 계산해서 알아낼 수 있는 값에 적용 가능
    • "여러 함수를 클래스로 묶기(Combine Functions into Class)" 또는 "여러 함수를 변환 함수로 묶기(Combine Functions into Transform)" -> 변수가 사용되는 범위를 제한 가능
      • 변경 가능한 변수는 사용 범위가 넓을 수록 부수 효과가 큼
      • 변수 사용 범위를 줄이기 위해 여러 함수들을 묶는 방법들을 사용 가능
    • "참조를 값으로 바꾸기(Change Reference to Value)" -> 데이터 일부를 변경하기 보다는 데이터 전체를 교체 가능

변수 쪼개기(Split Variable)

  • 어떤 변수가 여러번 재할당 되어도 적절한 경우
    • 반복문에서 순회하는데 사용하는 변수 또는 인덱스
    • 값을 축적시키는데 사용하는 변수
  • 그밖의 경우에 재할당 되는 변수가 있다면 해당 변수는 여러 용도로 사용되는 것 => 변수를 분리해야 더 이해하기 좋은 코드 가능
    • 변수 하나 당 하나의 책임을 지도록 만들자
    • 상수를 활용하자 (ex. 자바스크립트의 const, 자바의 final)

예제 코드

public class Rectangle {

    private double perimeter;
    private double area;

    public void updateGeometry(double height, double width) {
        double temp = 2 * (height + width);
        System.out.println("Perimeter: " + temp);
        perimeter = temp;

        temp = height * width;
        System.out.println("Area: " + temp);
        area = temp;
    }

    ...

}

 

위 코드의 updateGeometry 메서드의 구현부를 보면 temp라는 변수가 여러번 재할당되는 것을 확인할 수 있다. 처음에는 지름(perimeter)를 구하는 데에 사용되고, 이후에는 넓이를 구하는 데에 사용되었다. 여기에 변수 쪼개기를 적용하면 좋을 것 같다.

public void updateGeometry(double height, double width) {
    final double perimeter = 2 * (height + width);
    System.out.println("Perimeter: " + perimeter);
    this.perimeter = perimeter;

    this.area = height * width;
    System.out.println("Area: " + this.area);
}

 

다른 예제도 보자

public class Haggis {

    private double primaryForce;
    private double secondaryForce;
    private double mass;
    private int delay;

    public Haggis(double primaryForce, double secondaryForce, double mass, int delay) {
        this.primaryForce = primaryForce;
        this.secondaryForce = secondaryForce;
        this.mass = mass;
        this.delay = delay;
    }

    public double distanceTravelled(int time) {
        double result;
        double acc = primaryForce / mass;
        int primaryTime = Math.min(time, delay);
        result = 0.5 * acc * primaryTime * primaryTime;

        int secondaryTime = time - delay;
        if (secondaryTime > 0) {
            double primaryVelocity = acc * delay;
            acc = (primaryForce + secondaryForce) / mass;
            result += primaryVelocity * secondaryTime + 0.5 * acc * secondaryTime + secondaryTime;
        }

        return result;
    }
}

 

distanceTravelled를 살펴보면 가속도에 해당하는 acc 변수가 변함에 따라 2번 재할당되는 것을 볼 수 있다. 이 경우에도 역시 첫번째 acc과 두번째 acc을 분리하고 final 키워드를 사용하는 것이 좋을 것 같다.

public double distanceTravelled(int time) {
    double result;
    final double primaryAcc = primaryForce / mass;
    final int primaryTime = Math.min(time, delay);
    result = 0.5 * primaryAcc * primaryTime * primaryTime;

    int secondaryTime = time - delay;
    if (secondaryTime > 0) {
        final double primaryVelocity = primaryAcc * delay;
        final double secondaryAcc = (primaryForce + secondaryForce) / mass;
        result += primaryVelocity * secondaryTime + 0.5 * secondaryAcc * secondaryTime + secondaryTime;
    }

    return result;
}

질의 함수와 변경 함수 분리하기(Separate Query from Modifier)

  • 질의 함수(Query): 무언가를 조회하는 함수를 의미
  • 변경 함수(Modifier): 무언가를 변경하는 함수를 의미
  • 질의 함수와 변경 함수가 같은 메서드 안에 공존할 경우, 동작을 예측하기 어렵고 테스트 하기 어려운 코드를 만든다
  • 질의 함수와 변경 함수 분리 = 명령-조회 분리 규칙
  • 명령-조회 분리(command-query separation) 규칙: 어떤 값을 리턴하는 함수는 부수 효과가 없어야 한다
  • "눈에 띌만한(observable) 부수 효과" 없이 값을 조회할 수 있는 메서드 => 테스트 쉬움, 메서드 이동 편리
    • 가령, 캐시 데이터의 수정은 중요한 상태 변화는 아니다 => 어떤 메서드 호출로 인해, 캐시 데이터를 변경하더라도 분리할 필요는 없다

예제 코드

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 메서드는 인보이스의 총계를 가져오는 작업과 bill을 보내는 작업을 하고 있다. 하나의 메서드가 2개의 책임을 갖고 있다는 의미이다. 이러한 경우 이 두 작업(조회와 변경)을 각각의 메서드로 분리할 필요가 있어보인다

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

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

세터 제거하기(Remove Setting Method)

  • 세터를 제공한다는 것은 해당 필드가 변경될 수 있다는 것을 의미
  • 객체 생성 시 처음 설정된 값이 변경될 필요가 있다면 해당 값을 설정할 수 있는 생성자를 만들고, 세터를 제거해서 변경될 수 있는 가능성을 제거해야 함 => 객체 초기화를 세터로 하지말기
  • 또한, 값을 변경하는 메서드를 무조건 세터로 사용하기보다는, 특별한 이름을 지어주는 것이 더 명확하게 의도를 드러낸 코드라고 볼 수 있다.
    • ex) 사용자의 이름을 수정하는 메서드의 경우 setUsername 보다는 updateUsername과 같은 이름이 더 적절할 수 있다

파생 변수를 질의 함수로 바꾸기(Replace Derived Variable with Query)

  • 변경할 수 있는 데이터를 최대한 줄이도록 노력해야 함
  • 계산해서 알아낼 수 있는 변수는 제거 가능 (= 파생 변수 제거)
    • 계산 자체가 데이터의 의미를 잘 표현하는 경우가 있음
    • 해당 변수가 어디선가 잘못된 값으로 수정될 수 있는 가능성을 제거 가능
  • 계산에 필요한 데이터가 변하지 않는 값이라면
    • => 계산의 결과에 해당하는 데이터 역시 불변 데이터이기 때문에 이는 굳이 리팩토링 적용 없이 변수 유지 해도 괜찮음

예제 코드

public class ProductionPlan {

    private double production;
    private List<Double> adjustments = new ArrayList<>();

    public void applyAdjustment(double adjustment) {
        this.adjustments.add(adjustment);
        this.production += adjustment;
    }

    public double getProduction() {
        return this.production;
    }
}

 

위 코드에서 applyAdjustment 메서드 구현부를 보면, adjustment가 추가될 때마다 production에 그 값을 더해주는 것을 통해 production 인스턴스 변수는 파생 변수임을 알 수 있다. 여기에 파생 변수를 질의 함수로 바꾸는 리팩토링을 적용해보자

 

먼저 production이 가져야 할 값을 계산하는 별도의 메서드를 만든 후 getProduction(질의 함수) 안에서 이를 assert 구문을 통해 유효성 검증을 진행해보자.

public class ProductionPlan {

    private double production;
    private List<Double> adjustments = new ArrayList<>();

    public void applyAdjustment(double adjustment) {
        this.adjustments.add(adjustment);
        this.production += adjustment;
    }

    public double getProduction() {
        assert this.production == calculateProduction();
        return this.production;
    }

    private double calculateProduction() {
        return this.adjustments.stream()
                .mapToDouble(Double::valueOf).sum();
    }
}

 

문제가 없다면, 기존의 파생 변수를 계산(수정)하는 부분의 코드 및 파생 변수를 제거하고, 질의 함수를 assert 구문의 내용으로 완성시키자.

public class ProductionPlan {

//    private double production; // 파생 변수 제거
    private List<Double> adjustments = new ArrayList<>();

    public void applyAdjustment(double adjustment) {
        this.adjustments.add(adjustment);
//        this.production += adjustment; // 기존 불필요한 수정 로직 제거
    }

    public double getProduction() {
        return this.adjustments.stream()
                .mapToDouble(Double::valueOf).sum();
    }
}

여러 함수를 변환 함수로 묶기(Combine Functions into Transform)

  • 관련있는 여러 파생 변수를 만들어내는 함수가 여러 곳에서 만들어지고 사용되는 경우
    • => 파생 변수를 "변환 함수(transform function)"를 통해 한 곳으로 모아둘 수 있음
    • 변환 함수: 기존 데이터를 입력으로 받아 새로운 데이터 형태로 만드는 함수
    • 여러 곳에서 파생 변수를 사용하는 것을 막기 위함
  • 소스 데이터가 변경될 수 있는 경우 (ex. 세터를 가지고 있음)
    • => "여러 함수를 클래스로 묶기(Combine Functiosn into Class)" 사용
  • 소스 데이터가 변경되지 않는 경우
    • => 두 가지 방법 모두 사용 가능하지만, 변환 함수를 사용해서 불변 데이터의 필드로 생성해 두고 재사용 가능

예제 코드

public record Reading(String customer, double quantity, Month month, Year year) {
}
public class ReadingClient {

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

    protected double baseRate(Month month, Year year) {
        return 10;
    }
}
public class Client1 extends ReadingClient {

    double baseCharge;

    public Client1(Reading reading) {
        // 기본 사용료 계산 로직
        this.baseCharge = baseRate(reading.month(), reading.year()) * reading.quantity();
    }

    public double getBaseCharge() {
        return baseCharge;
    }
}

---

public class Client2 extends ReadingClient {

    private double base;
    private double taxableCharge;

    public Client2(Reading reading) {
        // 기본 사용료 계산 로직
        this.base = baseRate(reading.month(), reading.year()) * reading.quantity();
        // 부가적으로 taxableCharge 계산
        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 = calculateBaseCharge(reading);
    }

    private double calculateBaseCharge(Reading reading) {
        // 기본 사용료 계산 로직
        return baseRate(reading.month(), reading.year()) * reading.quantity();
    }

    public double getBasicChargeAmount() {
        return basicChargeAmount;
    }
}

 

각각의 Client들이 기본 사용료를 계산하기 위해 모두 동일한 로직을 사용하는 것을 확인할 수 있다. 이를 상위 클래스로 올려 줄일 수도 있겠지만 여기서는 변환 함수를 적용해서 해결해보자!

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

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

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

    // 변환 함수를 통해 레코드 생성
    protected EnrichReading enrichReading(Reading reading) {
        // 변하지 않는 reading을 입력으로 전달하여 또 다른 변하지 않는 객체인 EnrichReading을 생성하여 반환
        return new EnrichReading(reading, calculateBaseCharge(reading));
    }

    // 기본 사용료 계산하는 로직
    private double calculateBaseCharge(Reading reading) {
        return baseRate(reading.month(), reading.year()) * reading.quantity();
    }
}
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();
        ;
        // 부가적으로 taxableCharge 계산
        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;
    }
}

 

변환 함수를 통해서 새로운 레코드(EnrichReading)을 만들었고, 이 레코드에 부가적인 '불변' 필드를 추가하여 각 클라이언트에 있던 중복된 로직을 제거할 수 있었다.


참조를 값으로 바꾸기(Change Reference to Value)

  • 레퍼런스(Reference) 객체 vs 값(Value) 객체
    • 레퍼런스 객체는 얼마든지 객체 내부 값이 변경될 수 있음
    • 값 객체는 객체가 가진 필드의 값으로 동일성을 확인
    • 값 객체는 변하지 않음 (불변)
    • 어떤 객체의 변경 내역을 다른 곳으로 전파시키고 싶다 -> '레퍼런스 객체' 사용
    • 변경 사항 전파를 최소화하고 싶다면 -> '값 객체'를 사용

예제 코드

public class Person {

    private TelephoneNumber officeTelephoneNumber;

    public String officeAreaCode() {
        return this.officeTelephoneNumber.areaCode();
    }

    public void officeAreaCode(String areaCode) {
        this.officeTelephoneNumber.areaCode(areaCode);
    }

    public String officeNumber() {
        return this.officeTelephoneNumber.number();
    }

    public void officeNumber(String number) {
        this.officeTelephoneNumber.number(number);
    }

}
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;
    }
}

Person에서는 참조하고 있는 TelephoneNumber는 현재 레퍼런스 객체로 변경 가능한 데이터이다. 이를 값 객체로 변환시켜보자.

  1. final 키워드 사용
  2. setter 삭제
  3. equals & hashCode 오버라이딩 (중요!)
    • 오버라이딩하지 않으면 값 비교시 여전히 참조 값으로 비교하기 때문에 비교 실패
  4. TelephoneNumber를 참조하고 있는 쪽(Person)에서 값을 변경하는 부분이 있다면, 새로운 값을 생성하여 반환하도록 변경
public class TelephoneNumber {

    /* final 키워드 사용 */
    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;
    }

    /* 세터 제거 */

    // equals & hashCode 오버라이딩
    @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);
    }
}
public class Person {

    private TelephoneNumber officeTelephoneNumber;

    public String officeAreaCode() {
        return this.officeTelephoneNumber.areaCode();
    }

    // 새로운 객체를 반환하여 이를 사용하도록 변경
    public void officeAreaCode(String areaCode) {
        this.officeTelephoneNumber = new TelephoneNumber(areaCode, this.officeNumber());
    }

    public String officeNumber() {
        return this.officeTelephoneNumber.number();
    }

    // 새로운 객체를 반환하여 이를 사용하도록 변경
    public void officeNumber(String number) {
        this.officeTelephoneNumber = new TelephoneNumber(this.officeAreaCode(), number);
    }

}

 

그리고 사실 값 객체로 변경된 TelephoneNumber는 record를 사용하면 매우 쉽게 구현 가능함!

public record TelephoneNumber(String areaCode, String number) {
}
728x90
반응형