[리팩토링] 악취 6 : 가변 데이터

 

✍️ 악취 6 : 가변 데이터

자바는 변수의 값을 변경하거나, 레퍼런스 변수로 인스턴스의 값을 변경할 수 있다. 이처럼 데이터의 변경이 자유로운 장점도 있지만, 데이터 변경으로 문제가 생기는 경우가 발생한다. 가령 일부 메서드에선 데이터의 변경이 올바른듯하지만 다른 메서드에선 데이터의 변경이 곧 예상치 못한 에러로 이어지는 경우가 있다.

 

가변 데이터가 유발하는 잠재적 리스크를 최소화하는 여 섯 가지 리팩토링이 있다.

1. "변수 쪼개기" 여러 데이터를 하나의 변수로 관리하지 않기

2. "질의 메서드와 변경 메서드 분리하기" 사이트 이펙트가 있는 메서드와 없는 메서드를 분리

3. "세터 제거하기" 필요한 경우에만 세터를 만들기

4. "파생 변수를 질의 메서드로" 계산해서 알아낼 수 있는 값이라면 변수가 아닌 질의 메서드 사용

5. " 참조를 값으로 바꾸기" 데이터를 일부만 변경하기보단 새로운 불변 객체 생성

 

🍊 변수 쪼개기

어떤 변수가 여러 번 재할당 되어도 적절한 경우가 있다. 가령 반복문에서 순회에 사용되는 인덱스 변수 혹은 누적값에 사용되는 변수가 그러하다.

// 이런 경우 OK!
public static void main(String[] args) {

    int sum = 0;
    for (int i = 0; i < 10; i++) {
        sum += i;
    }
}

 

예외 케이스를 제외하고, 변수에 반복적으로 재할당하고 서로 다른 곳에 참조한다면 해당 변수는 여러 용도로 사용되는 변수일 확률이 높다. 변수를 여러 용도로 사용하는 것보단 하나의 변수는 하나의 책임을 지도록 만드는 것이 좋다.

초기 지불 비용인 totalPrice에 계속해서 변화를 주는 것보단

public class Order {

    public double discount(double totalPrice, int quantity) {
        // 만원 이상 구매시 10% 할인
        if (totalPrice >= 10000)
            totalPrice = totalPrice * 0.9;

        // 10개 이상 구매시에도 10% 할인
        if (quantity >= 10)
            totalPrice = totalPrice * 0.9;

        return totalPrice;
    }
}

 

반환용 변수를 만들고 초기 지불 비용인 totalPrice의 값은 메서드 내에서 유지하는 것이 좋다. 

public double discount(double totalPrice, int quantity) {
    double result = totalPrice;

    // 만원 이상 구매시 10% 할인
    if (totalPrice >= 10000)
        result *= 0.9;

    // 10개 이상 구매시에도 10% 할인
    if (quantity >= 10)
        result *= 0.9;

    return result;
}

 

🌱  질의 메서드와 변경 메서드 분리

질의 메서드와 변경 메서드 분리, 쉽게 말하면 getter와 setter를 명백히 분리하는 리팩토링 기법이다.

어떤 값을 반환하는 getter 메서드는 사이트 이펙트가 없어야 한다. 가령 단순 조회를 위해 메서드를 호출했는데 메서드 내부에서 특정 변수의 값이 변경된다면 사용하는 입장에서 메서드 호출로 인한 변수의 변경을 예측하기 어렵다.

 

여기 예외 목록을 제외한 임의의 정수를 뽑는 nextInt 메서드가 있다. 로직은 단순하다. excludes를 제외한 범위 내의 임의의 난수를 뽑고 excludes에 set하고 값을 반환한다. 한 메서드에 get과 set이 모두 포함된 케이스이다.

public class RandomService {

    public int nextInt(int bound, Set<Integer> excludes) {
        List<Integer> candidates = IntStream.range(0, bound)
                .boxed()
                .filter(i -> !excludes.contains(i))
                .collect(Collectors.toList());

        Random rand = new Random();
        int randIdx = rand.nextInt(candidates.size());
        int randValue = candidates.get(randIdx);

        excludes.add(randValue);

        return randValue;
    }
}

class RandomServiceTest {

    @Test
    public void nextInt() {
        RandomService randomService = new RandomService();
        Set<Integer> excludes = new HashSet<>();

        randomService.nextInt(10, excludes);
        randomService.nextInt(10, excludes);
    }

}

 

nextInt 메서드는 순수 getter로 만들고 excludes에 add하는 setter 코드는 외부 혹은 별도의 메서드로 정의하는 것이 변수가 변경되는 흐름을 파악하기 쉽다.

public class RandomService {

    public int nextInt(int bound, Set<Integer> excludes) {
        List<Integer> candidates = IntStream.range(0, bound)
                .boxed()
                .filter(i -> !excludes.contains(i))
                .collect(Collectors.toList());

        Random rand = new Random();
        int randIdx = rand.nextInt(candidates.size());

        return candidates.get(randIdx);
    }
}

class RandomServiceTest {

    @Test
    public void nextInt() {
        RandomService randomService = new RandomService();
        Set<Integer> excludes = new HashSet<>();

        int randValue = randomService.nextInt(10, excludes);
        excludes.add(randValue);
    }

}

 

🍃 세터 제거하기

어떤 객체가 생성된 이후 값이 변경되지 않아야 한다면 불필요한 setter를 제거해서 불변 객체(Immutable Object)로 만들 필요가 있다. 객체지향에서 불변 객체라 함은, 초깃값을 설정할 생성자와 이후에 값을 얻어올 getter 메서드만 있을 때 불변 객체라 한다.

@AllArgsConstructor
@Getter
public class Person {

    // 주민 등록 번호
    private int pid;

    // 생년월일
    private DateTime birthDate;
}

public class App {

    public static void main(String[] args) {
        Person person = new Person(1, new DateTime(2022, 1, 1, 0, 0, 0));

        // only get, no set
        person.getPid();
        person.getBirthDate();
    }
}

 

🖥️ 파생 변수를 질의 메서드로 바꾸기

가령 계산을 통해서 알아낼 수 있는 파생 변수는 메서드로 대체할 수 있다. 메서드도 충분히 그 이름을 통해서 의미를 잘 표현할 수 있고 변수가 아니기에 어디선가 잘못된 값으로 설정될 가능성을 제거할 수 있다.

 

discountedPrice는 setDiscount 메서드가 호출되기 전까진 계산되지 않는 파생 변수이다.

public class Product {

    private int price;
    private int discount;

    // 파생 변수
    private int discountedPrice;

    public Product(int price) {
        this.price = price;
    }

    public int getDiscountedPrice() {
        return discountedPrice;
    }

    public void setDiscount(int discount) {
        this.discount = discount;
        this.discountedPrice = Math.max(this.price - this.discount, 0);
    }
    
}

 

파생 변수는 소스가 되는 변수에 종속적인 경향이 있기에 아래 테스트 코드처럼 setDiscount전에 getDiscountedPrice를 호출하면 버그가 발생한다.

class ProductTest {

    @Test
    public void test_O() {
        Product product = new Product(10000);

        product.setDiscount(1000);

        assertEquals(9000, product.getDiscountedPrice());
    }

    @Test
    public void test_X() {
        Product product = new Product(10000);

        // error not equals !!
        assertEquals(10000, product.getDiscountedPrice());
    }

}

 

이처럼 다른 변수에 종속적인 파생 변수를 질의 메서드로 변경해서 가변 데이터를 제거할 수 있다.

public class Product {

    private int price;
    private int discount;

    public Product(int price) {
        this.price = price;
    }

    public int getDiscountedPrice() {
        return Math.max(this.price - this.discount, 0);
    }

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

}

 

✨ 참조를 값으로 바꾸기

객체의 변경을 다른 곳으로 전파시키고 싶다면 레퍼런스로 정의하고, 반대로 전파하지 않겠다면 값 객체로 사용할 수 있다.

 

여기 시간에 따른 주식 정보를 담는 클래스가 있고, 주식 구매자가 클래스가 있다.

@AllArgsConstructor
@Data
@ToString
public class Stock {

    private DateTime time;
    private int id;
    private String name;
    private int price;
}

public class Buyer {

    private List<Stock> purchaseList = new ArrayList<>();

    public void buy(Stock stock) {
        purchaseList.add(stock);
    }

    public void print() {
        purchaseList.forEach(System.out::println);
    }
}

 

주식 가격이 시간에 따라 변화한다고 했을 때, 가변 객체로 정의하면 구매자가 구매한 주식 레퍼런스가 참조하는 값도 함께 변경될 것이다.

public static void main(String[] args) throws InterruptedException {
    DateTime now = new DateTime(2000, 1, 1, 0, 0, 0);
    Stock samsung = new Stock(now, 1, "samsung", 100);

    Buyer buyer = new Buyer();
    buyer.buy(samsung);

    Thread.sleep(1);

    samsung.setTime(now.plusSeconds(1));
    samsung.setPrice(200);

    buyer.buy(samsung);

    buyer.print();
    // Stock(time=2000-01-01T00:00:01.000+09:00, id=1, name=samsung, price=200)
    // Stock(time=2000-01-01T00:00:01.000+09:00, id=1, name=samsung, price=200)

}

 

반면 구매자가 소유한 주식 객체가 구매한 시점의 값을 유지하길 바란다면 불변 객체로 제공해야 한다.

@EqualsAndHashCode(of = {"id", "time"})
@AllArgsConstructor
@Getter
@ToString
public class Stock {

    private DateTime time;
    private int id;
    private String name;
    private int price;
}

public static void main(String[] args) throws InterruptedException {
    DateTime now = new DateTime(2000, 1, 1, 0, 0, 0);
    Stock samsung = new Stock(now, 1, "samsung", 100);

    Buyer buyer = new Buyer();
    buyer.buy(samsung);

    Thread.sleep(1);

    samsung = new Stock(now.plusSeconds(1), samsung.getId(), samsung.getName(), 200);
    buyer.buy(samsung);

    buyer.print();
    // Stock(time=2000-01-01T00:00:00.000+09:00, id=1, name=samsung, price=100)
    // Stock(time=2000-01-01T00:00:01.000+09:00, id=1, name=samsung, price=200)
}