Skip to content

영양성분 계산 로직 리팩토링

koo995 edited this page Sep 22, 2024 · 12 revisions

요구사항 분석

위의 요구사항에 따르면 등록된 식품에 대한 섭취 다이어리를 작성할 때, 섭취식품의 정보를 무게(g)로 선택하거나 식품 저장 시 등록된 기본 단위(?개, ?컵, ?잔, ?인분)를 선택하냐에 따라서 영양정보를 알맞게 계산해 줘야 한다. 예를 들어, 사용자가 사과를 섭취했을 때, 사과 1개 단위로 섭취할 수 있거나 257g처럼 무게단위로 섭취할 수 있다.
또한, 영양성분의 알맞은 계산을 위해서 소수점 2자리 까지 표현하더라도 불필요한 소수점 표현은 없애야 할 필요가 있다. 예를 들어, 칼로리는 245.55kcal, 250kcal 와 같이 표현되어야지 250.10kcal, 251.0 처럼 불필요한 소수점 자리가 있으면 보기 안 좋다.

클래스 정의 V1

등록된 식품의 영양성분은 NutritionFacts 라는 클래스가 나타낸다. 그리고 등록된 기본 단위로 섭취했을 때 계산된 영양성분을 전달하는 NutritionFactsPerOneServing, gram단위로 섭취했을 때 계산된 영양성분을 전달하는 NutritionFactsPerGram클래스를 정의하였다.

@ToString
@Getter
@NoArgsConstructor
public class NutritionFacts {
    // 새로운 식품 등록 페이지에서 입력된 정보들
    private BigDecimal productTotalCalories; // 식품의 총 칼로리
    private BigDecimal productTotalCarbohydrate;
    private BigDecimal productTotalProtein;
    private BigDecimal productTotalFat;
    
    // 식품의 서빙사이즈. 제품에 따라서 3인분 or 6개가 합쳐진 영양성분이 표시될 수 있으니까 서빙 제공량을 저장한다.
    // 기본 단위로 계산할 때 이 값을 나누어 1에 해당하는 기본 단위에서 사용자의 섭취 양을 계산해줘야한다.
    private BigDecimal productServingSize; 
    private String productServingUnit; // 식품의 기본 섭취 단위
    private BigDecimal productTotalWeightGram; // 전체 중량

    @Builder
    public NutritionFacts(BigDecimal productTotalCalories, BigDecimal productTotalCarbohydrate, BigDecimal productTotalProtein, BigDecimal productTotalFat, BigDecimal productServingSize, String productServingUnit, BigDecimal productTotalWeightGram) {
        this.productTotalCalories = productTotalCalories;
        this.productTotalCarbohydrate = productTotalCarbohydrate;
        this.productTotalProtein = productTotalProtein;
        this.productTotalFat = productTotalFat;
        this.productServingSize = productServingSize;
        this.productServingUnit = productServingUnit;
        this.productTotalWeightGram = productTotalWeightGram;
    }
    
    // 전체 영양성분에서 그램 당 영양성분을 전달함
    public NutritionFactsPerGram calculateNutritionFactsPerGram() {
        return NutritionFactsPerGram.builder()
                .productCaloriesPerGram(stripIfNecessary(
                        productTotalCalories.divide(productTotalWeightGram, SCALE, ROUNDING_MODE)))
                .productCarbohydratePerGram(stripIfNecessary(
                        productTotalCarbohydrate.divide(productTotalWeightGram, SCALE, ROUNDING_MODE)))
                .productProteinPerGram(stripIfNecessary(
                        productTotalProtein.divide(productTotalWeightGram, SCALE, ROUNDING_MODE)))
                .productFatPerGram(stripIfNecessary(
                        productTotalFat.divide(productTotalWeightGram, SCALE, ROUNDING_MODE)))
                .build();
    }

    // 전체 영양성분에서 기본 단위 당 영양성분을 전달함.
    public NutritionFactsPerOneServing calculateNutritionFactsPerOneServingUnit() {
        return NutritionFactsPerOneServing.builder()
                .productCaloriesPerOneServing(stripIfNecessary(
                        productTotalCalories.divide(productServingSize, SCALE, ROUNDING_MODE)))
                .productCarbohydratePerOneServing(stripIfNecessary(
                        productTotalCarbohydrate.divide(productServingSize, SCALE, ROUNDING_MODE)))
                .productProteinPerOneServing(stripIfNecessary(
                        productTotalProtein.divide(productServingSize, SCALE, ROUNDING_MODE)))
                .productFatPerOneServing(stripIfNecessary(
                        productTotalFat.divide(productServingSize, SCALE, ROUNDING_MODE)))
                .build();
    }

    // 불필요한 소수점을 제거하기 위한 메서드
    private BigDecimal stripIfNecessary(BigDecimal value) {
        BigDecimal strippedValue = value.stripTrailingZeros();
        return strippedValue.scale() <= 0 ? strippedValue.setScale(0) : strippedValue;
    }
}
@Getter
@ToString
public class NutritionFactsPerGram {
    private BigDecimal productCaloriesPerGram;

    private BigDecimal productCarbohydratePerGram;

    private BigDecimal productProteinPerGram;

    private BigDecimal productFatPerGram;

    @Builder
    private NutritionFactsPerGram(BigDecimal productCaloriesPerGram, BigDecimal productCarbohydratePerGram, BigDecimal productProteinPerGram, BigDecimal productFatPerGram) {
        this.productCaloriesPerGram = productCaloriesPerGram;
        this.productCarbohydratePerGram = productCarbohydratePerGram;
        this.productProteinPerGram = productProteinPerGram;
        this.productFatPerGram = productFatPerGram;
    }
}
@Getter
@ToString
public class NutritionFactsPerOneServing {
    private BigDecimal productCaloriesPerOneServing;

    private BigDecimal productCarbohydratePerOneServing;

    private BigDecimal productProteinPerOneServing;

    private BigDecimal productFatPerOneServing;

    @Builder
    private NutritionFactsPerOneServing(BigDecimal productCaloriesPerOneServing, BigDecimal productCarbohydratePerOneServing, BigDecimal productProteinPerOneServing, BigDecimal productFatPerOneServing) {
        this.productCaloriesPerOneServing = productCaloriesPerOneServing;
        this.productCarbohydratePerOneServing = productCarbohydratePerOneServing;
        this.productProteinPerOneServing = productProteinPerOneServing;
        this.productFatPerOneServing = productFatPerOneServing;
    }
}

이제 다이어리 작성시 계산기 클래스를 통해 섭취한 단위와 양에 따른 영양성분을 계산한다.

먼저, 사용자가 선택한 단위의 연산을 위한 전략 인터페이스를 정의한다.

public interface NutritionCalculationStrategy {
    CalculatedNutrition calculate(NutritionFacts nutritionFacts, ProductIntakeInfo productIntakeInfo);
}

계산된 영양성분을 표현할 dto 클래스.

@EqualsAndHashCode
@Getter
@ToString
public class CalculatedNutrition {
    private BigDecimal calories;
    private BigDecimal carbohydrate;
    private BigDecimal protein;
    private BigDecimal fat;

    @Builder
    public CalculatedNutrition(BigDecimal calories, BigDecimal carbohydrate, BigDecimal protein, BigDecimal fat) {
        this.calories = calories;
        this.carbohydrate = carbohydrate;
        this.protein = protein;
        this.fat = fat;
    }
}

그램 단위로 영양성분을 계산할 전략을 구현.

public class GramBasedNutritionCalculationStrategy implements NutritionCalculationStrategy {

    @Override
    public CalculatedNutrition calculate(NutritionFacts nutritionFacts, ProductIntakeInfo productIntakeInfo) {
        // NutritionFacts 에서 그램단위의 영양성분을 가져온다.
        // productIntakeInfo 에서 섭취한 양을 가져온다.
        NutritionFactsPerGram nutritionFactsPerGram = nutritionFacts.calculateNutritionFactsPerGram();
        BigDecimal quantity = productIntakeInfo.getQuantity();
        return CalculatedNutrition.builder()
                .calories(stripIfNecessary(
                        nutritionFactsPerGram.getProductCaloriesPerGram().multiply(quantity)))
                .carbohydrate(stripIfNecessary(
                        nutritionFactsPerGram.getProductCarbohydratePerGram().multiply(quantity)))
                .protein(stripIfNecessary(
                        nutritionFactsPerGram.getProductProteinPerGram().multiply(quantity)))
                .fat(stripIfNecessary(
                        nutritionFactsPerGram.getProductFatPerGram().multiply(quantity)))
                .build();
    }

    private BigDecimal stripIfNecessary(BigDecimal value) {
        BigDecimal strippedValue = value.stripTrailingZeros();
        return strippedValue.scale() <= 0 ? strippedValue.setScale(0) : strippedValue;
    }
}

기본 단위로 영양성분을 계산할 전략을 구현.

public class DefaultNutritionCalculationStrategy implements NutritionCalculationStrategy {

    @Override
    public CalculatedNutrition calculate(NutritionFacts nutritionFacts, ProductIntakeInfo productIntakeInfo) {
         // NutritionFacts 에서 기본 단위의 영양성분을 가져온다.
        // productIntakeInfo 에서 섭취한 양을 가져온다.
        NutritionFactsPerOneServing nutritionFactsPerOneServing = nutritionFacts.calculateNutritionFactsPerOneServingUnit();
        BigDecimal quantity = productIntakeInfo.getQuantity();
        return CalculatedNutrition.builder()
                .calories(stripIfNecessary(
                        nutritionFactsPerOneServing.getProductCaloriesPerOneServing().multiply(quantity)))
                .carbohydrate(stripIfNecessary(
                        nutritionFactsPerOneServing.getProductCarbohydratePerOneServing().multiply(quantity)))
                .protein(stripIfNecessary(
                        nutritionFactsPerOneServing.getProductProteinPerOneServing().multiply(quantity)))
                .fat(stripIfNecessary(
                        nutritionFactsPerOneServing.getProductFatPerOneServing().multiply(quantity)))
                .build();
    }

    private BigDecimal stripIfNecessary(BigDecimal value) {
        BigDecimal strippedValue = value.stripTrailingZeros();
        return strippedValue.scale() <= 0 ? strippedValue.setScale(0) : strippedValue;
    }
}

선택된 단위에 맞는 전략을 만들어내는 팩토리 클래스.

@RequiredArgsConstructor
@Component
public class NutritionCalculationStrategyFactory {
    private static final String GRAM = "gram";

    public NutritionCalculationStrategy getStrategy(String servingUnit) {
        if (GRAM.equals(servingUnit)) {
            return new GramBasedNutritionCalculationStrategy();
        } else {
            return new DefaultNutritionCalculationStrategy();
        }
    }
}

영양성분을 계산하는 계산기 클래스.

@RequiredArgsConstructor
@Component
public class NutritionCalculator {
    private final ProductRepository productRepository;
    private final NutritionCalculationStrategyFactory strategyFactory; // 전략 팩토리

    public CalculatedNutrition calculate(ProductIntakeInfo productIntakeInfo) {
        Product product = productRepository.findById(productIntakeInfo.getProductId())
                .orElseThrow(() -> new BusinessException(INVALID_PRODUCT_ID));
        NutritionFacts nutritionFacts = product.getNutritionFacts();
        
        // productIntakeInfo 안에 있는 사용자가 선택한 단위를 가져온다.
        NutritionCalculationStrategy strategy = strategyFactory.getStrategy(productIntakeInfo.getServingUnit());
        
        // 선택한 단위에 맞는 전략으로 영양성분을 계산한다.
        return strategy.calculate(nutritionFacts, productIntakeInfo);
    }
}

V1 코드의 문제점.

  • 불필요한 소수점을 없애기 위한 stripIfNecessary메서드가 여러 클래스에 중복되어있다.
  • 만약 영양성분을 표시할 단위가 추가되거나 식품의 영양성분 중에서 식이섬유와 같은 항목이 추가된다면, 변경에 유연하지 못한 구조로 인해 수정해야 할 클래스가 너무 많은 문제점을 가지고 있다.
  • 사용자의 섭취단위에 따른 영양성분을 계산하는 단순한 로직인데 NutritionCalculator 가 이용할 클래스만 NutritionCalculationStrategyFactory, DefaultNutritionCalculationStrategy, GramBasedNutritionCalculationStrategy, CalculatedNutrition, NutritionCalculationStrategy 총 5개의 클래스가 필요하며, CalculatedNutrition, NutritionFactsPerOneServing, NutritionFactsPerGram 클래스들은 비슷한 정보를 담는 구조인데 3개의 클래스로 나뉘어져 있다.
    따라서 하나로 추상화를 적용할 필요가 있다.

클래스 정의 V2

영양성분을 저장하는 Nutrition VO(Value Object) 도입.

해당 클래스에서 소수점 처리, 필요한 영양성분, 그리고 영양성분간의 계산을 위한 모든 로직을 관리한다.
따라서 영양성분이 추가되더라도 Nutrition 클래스 안에서 변경이 이루어진다.
그리고 CalculatedNutrition, NutritionFactsPerOneServing, NutritionFactsPerGram 와 같은 비슷한 클래스들을 하나의 클래스로 추상화를 하였다.

@Getter
@EqualsAndHashCode
@ToString
public class Nutrition {
    public static final int SCALE = 2;
    public static final RoundingMode ROUNDING_MODE = RoundingMode.HALF_UP;

    private final BigDecimal calories;
    private final BigDecimal carbohydrate;
    private final BigDecimal protein;
    private final BigDecimal fat;

    @Builder
    private Nutrition(BigDecimal calories, BigDecimal carbohydrate, BigDecimal protein, BigDecimal fat) {
        this.calories = stripIfNecessary(calories);
        this.carbohydrate = stripIfNecessary(carbohydrate);
        this.protein = stripIfNecessary(protein);
        this.fat = stripIfNecessary(fat);
    }

    public Nutrition add(Nutrition nutrition) {
        return Nutrition.builder().
                calories(stripIfNecessary(this.calories.add(nutrition.calories))).
                carbohydrate(stripIfNecessary(this.carbohydrate.add(nutrition.carbohydrate))).
                protein(stripIfNecessary(this.protein.add(nutrition.protein))).
                fat(stripIfNecessary(this.fat.add(nutrition.fat))).
                build();
    }

    public Nutrition multiply(BigDecimal amount) {
        return Nutrition.builder().
                calories(stripIfNecessary(this.calories.multiply(amount))).
                carbohydrate(stripIfNecessary(this.carbohydrate.multiply(amount))).
                protein(stripIfNecessary(this.protein.multiply(amount))).
                fat(stripIfNecessary(this.fat.multiply(amount))).
                build();
    }

    public Nutrition divide(BigDecimal amount) {
        return Nutrition.builder().
                calories(stripIfNecessary(this.calories.divide(amount, SCALE, ROUNDING_MODE))).
                carbohydrate(stripIfNecessary(this.carbohydrate.divide(amount, SCALE, ROUNDING_MODE))).
                protein(stripIfNecessary(this.protein.divide(amount, SCALE, ROUNDING_MODE))).
                fat(stripIfNecessary(this.fat.divide(amount, SCALE, ROUNDING_MODE))).
                build();
    }

    public Nutrition subtract(Nutrition nutrition) {
        return Nutrition.builder().
                calories(stripIfNecessary(this.calories.subtract(nutrition.calories))).
                carbohydrate(stripIfNecessary(this.carbohydrate.subtract(nutrition.carbohydrate))).
                protein(stripIfNecessary(this.protein.subtract(nutrition.protein))).
                fat(stripIfNecessary(this.fat.subtract(nutrition.fat))).
                build();
    }

    private BigDecimal stripIfNecessary(BigDecimal value) {
        BigDecimal scaledValue = value.setScale(SCALE, ROUNDING_MODE);
        BigDecimal strippedValue = scaledValue.stripTrailingZeros();
        return strippedValue.scale() <= 0 ? strippedValue.setScale(0) : strippedValue;
    }

    public static Nutrition empty() {
        return Nutrition.builder()
                .calories(BigDecimal.ZERO)
                .carbohydrate(BigDecimal.ZERO)
                .protein(BigDecimal.ZERO)
                .fat(BigDecimal.ZERO)
                .build();
    }

    public static Nutrition of(BigDecimal calories, BigDecimal carbohydrate, BigDecimal protein, BigDecimal fat) {
        return Nutrition.builder().
                calories(calories).
                carbohydrate(carbohydrate).
                protein(protein).
                fat(fat).
                build();
    }
}

불필요한 전략과 팩토리 클래스 제거, 기존 클래스 리팩토링.

  • NutritionCalculationStrategyFactory, NutritionCalculationStrategy, GramBasedNutritionCalculationStrategy, DefaultNutritionCalculationStrategy 제거.
  • NutritionFacts, NutritionCalculator 리팩토링
@ToString
@Getter
@NoArgsConstructor
public class NutritionFacts {
    private static final String GRAM = "gram";

    // Nutrition VO을 활용하여 칼로리, 탄수화물, 단백질, 지방정보를 포함한다.
    private Nutrition totalNutrition;

    private BigDecimal productServingSize;

    private String productServingUnit;

    private BigDecimal productTotalWeightGram;

    @Builder
    public NutritionFacts(Nutrition totalNutrition, BigDecimal productServingSize, String productServingUnit, BigDecimal productTotalWeightGram) {
        this.totalNutrition = totalNutrition;
        this.productServingSize = productServingSize;
        this.productServingUnit = productServingUnit;
        this.productTotalWeightGram = productTotalWeightGram;
    }
    
    // 매개변수로 받은 servingUnit의 값이 gram이면 그램당 계산하고 아니면 기본단위로 계산한다.
    public Nutrition calculate(String servingUnit, BigDecimal quantity) {
        if (servingUnit.equals(GRAM)) {
            return totalNutrition.divide(productTotalWeightGram).multiply(quantity);
        }
        return totalNutrition.divide(productServingSize).multiply(quantity);
    }
}
@RequiredArgsConstructor
@Component
public class NutritionCalculator {
    private final ProductRepository productRepository;

    public Nutrition calculate(ProductIntakeInfo productIntakeInfo) {
        Product product = productRepository.findById(productIntakeInfo.getProductId())
                .orElseThrow(() -> new BusinessException(INVALID_PRODUCT_ID));

        // 식품의 NutritionFacts을 가져와서 선택한 단위와 양에 맞게 계산한다.
        NutritionFacts nutritionFacts = product.getNutritionFacts();
        return nutritionFacts.calculate(productIntakeInfo.getServingUnit(), productIntakeInfo.getQuantity());
    }
}

V2 코드의 문제점.

  • NutritionFacts 클래스의 calculate 메서드에서 동일한 계산 로직이 if문으로 분기처리되어 있다.
    "gram으로 연산할 경우 총 중량을 나누고 섭취량을 곱해준다."
    "기본단위로 연산할 경우 식품의 서빙 제공량을 나누고 사용자의 섭취량을 곱해준다."의 과정은 매우 유사하다.
  • gram이 아니면 기본단위로 계산한다는 로직이 비즈니스적으로도 어색하다.
  • 허용되지 않은 단위가 들어오더라도 기본단위로 계산되는 만큼 유효성검증에 있어서도 부족한 부분이 존재한다.

클래스 정의 V3

ServingUnit Value Object 도입.

ServingUnit 클래스에서 단위를 관리하며 단위에 따른 연산에 필요한 비율을 미리 계산해 놓는다.
허용되지 않은 단위가 입력되면 예외를 발생시켜준다.

@EqualsAndHashCode
@ToString
@Getter
public class ServingUnit {
    public static final String GRAM = "gram";
    private String description;
    private BigDecimal unitConversionRate;

    private ServingUnit(String description, BigDecimal unitConversionRate) {
        this.description = description;
        this.unitConversionRate = unitConversionRate;
    }

    public Boolean isSupport(String servingUnitDescription) {
        return this.description.equals(servingUnitDescription);
    }

    // 입력받은 값(description)에 해당하는 단위를 정의.
    public static ServingUnit asOneServingUnit(String description) {
        return new ServingUnit(description, BigDecimal.ONE);
    }

    // 그램에 해당하는 단위를 정의.
    public static ServingUnit ofGram(BigDecimal productDefaultServingSize, BigDecimal productTotalWeightGram) {
        return new ServingUnit(GRAM, productDefaultServingSize.divide(productTotalWeightGram, SCALE, ROUNDING_MODE));
    }
}
@ToString
@Getter
@NoArgsConstructor
public class NutritionFacts {
    private Nutrition nutritionPerOneServingUnit;
    
    // 식품에 따라서 허용된 단위를 가지고 있는다. 현 상황에서는 2개의 단위를 허용한다. 기본단위 or gram
    private List<ServingUnit> allowedProductServingUnits;

    @Builder
    public NutritionFacts(Nutrition nutritionPerOneServingUnit, List<ServingUnit> allowedProductServingUnits) {
        this.nutritionPerOneServingUnit = nutritionPerOneServingUnit;
        this.allowedProductServingUnits = allowedProductServingUnits;
    }

    public Nutrition calculate(String clientChoiceServingUnitDescription, BigDecimal quantity) {

        // 반복문을 돌며 입력받은 단위(clientChoiceServingUnitDescription)가 식품의 허용된 단위에 해당하는 지 확인한다.
        // 만약 입력받은 단위가 허용된 단위 중 하나라면 미리 정의된 비율에 따라서 영양성분을 계산한다.
        for (ServingUnit productServingUnit : allowedProductServingUnits) {
            if (productServingUnit.isSupport(clientChoiceServingUnitDescription)) {
                BigDecimal ratioFactor = productServingUnit.getUnitConversionRate();
                return nutritionPerOneServingUnit.multiply(ratioFactor).multiply(quantity);
            }
        }
        throw new BusinessException(NOT_ALLOWED_SERVING_UNIT);
    }
}

결론

리팩토링을 통해 중복된 코드를 줄일 수 있었고, 추상화와 Value Object을 정의하는 것으로 단위가 추가되거나 영양성분이 추가되더라도 범위를 최소한으로 유연하게 변경이 가능하게 하였다.