전략 패턴을 이용해서 요구사항 대응하기(feat. Enum)
프로젝트를 진행하던 중 식사 등록 시 사용하는 단위에 대한 요구사항이 추가되었습니다.
기존에는 한 식사에 대해 ‘인분(multiple)’ 단위만 사용할 수 있었지만, 추가된 요구사항에서는 각 음식마다 ‘g’ 또는 ‘인분’ 단위를 선택할 수 있도록 수정해야 했습니다. 이로 인해 먹은 음식의 영양소를 측정하는 기존 로직도 함께 변경해야 했습니다.
하지만, 이렇게 변경할 경우 OCP(Open Closed Principle, 개방-폐쇄 원칙)에 위배가 됩니다.
이를 해결하기 위해 전략 패턴(Strategy Pattern)을 적용하여 단위 변경 혹은 단위 추가가 발생하더라도 기존의 코드에 수정 없이 대응할 수 있도록 개선했습니다.
왜 전략 패턴을 사용할 수 있었을까?
전략 패턴(Strategy Pattern)은 런타임 중에 알고리즘 전략을 선택하여 객체의 동작을 실시간으로 변경할 수 있도록 돕는 디자인 패턴입니다. 즉, 특정 상황에서는 A 전략을, 또 다른 상황에서는 B 전략을 선택하여 사용할 수 있도록 만드는 것을 의미합니다.
이번 요구사항 변경도 마찬가지였습니다.
사용자가 식사를 등록할 때 음식의 섭취량을 'g' 또는 '인분' 단위로 표기할 수 있도록 변경되었고, 이에 따라 단위에 맞춰 영양소를 측정할 수 있도록 개선해야 했습니다.
- 사용자가 ‘g’ 단위로 식사를 등록한 경우 → ‘g’ 단위를 활용하여 분석
- 사용자가 ‘인분’ 단위로 식사를 등록한 경우 → ‘인분’ 단위를 활용하여 분석
어떻게 전략 패턴을 적용할까?
전략 패턴을 적용하기 전에 먼저 고려해야 할 것은 객체가 수행해야 할 역할을 정의하고, 전략을 어떻게 추상화할지 결정하는 것입니다.
여기서 객체는 전략을 사용하는 컨텍스트(Context)를 의미합니다. 컨텍스트는 식사한 음식의 영양소를 단위에 따라 올바르게 계산하는 역할을 합니다.
전략은 'g' 단위일 경우의 계산 방식과 '인분' 단위일 경우의 계산 방식을 정의합니다. 이러한 구조를 통해 클라이언트는 특정 단위에 종속되지 않고, 전략을 변경하는 것만으로 다양한 단위에 유연하게 대응할 수 있습니다.
전략 패턴을 적용한 코드
측정 전략을 의미하는 MeasureStrategy 인터페이스를 선언하였습니다.
public interface MeasureStrategy {
double measure(double nutrients, Unit unit, double servingSize);
}
public class GStrategy implements MeasureStrategy {
@Override
public double measure(double nutrients, Unit unit, double servingSize) {
return nutrients * (unit.getG() / servingSize);
}
}
public class MultipleStrategy implements MeasureStrategy {
@Override
public double measure(double nutrients, Unit unit, double servingSize) {
return nutrients * unit.getMultiple();
}
...
}
그리고, 해당 인터페이스를 구현하는 'g' 단위일 경우 계산 전략과 '인분' 단위일 경우 계산 전략을 나타내는 GStrategy 클래스와 MultipleStrategy 클래스를 만들었습니다.
그리고, 특정 상황에 따라서 전략을 실행시킬 수 있게 만드는 Context인 Unit 클래스를 만들었습니다.
Unit의 각 g, multiple 등은 반드시 둘 중 하나는 null이고 하나는 값이 있습니다.
public class Unit {
private Integer g;
private Double multiple;
protected MeasureStrategy getStrategy() {
if(Objects.nonNull(g)){
return new MultipleStrategy();
}
if(Objects.nonNull(multiple)){
return new GStrategy();
}
throw InvalidMultipleAndGException.EXCEPTION;
}
}
그리고, Client는 해당 Unit 클래스를 이용해 적절한 영양소를 계산할 수 있습니다.
public double getAdjustedCalorie() {
return this.unit.getStrategy()
.measure(this.foodEntity.getCalorie(), unit, this.foodEntity.getServingSize());
}
public double getAdjustedCarbohydrate() {
return unit.getStrategy()
.measure(this.foodEntity.getCarbohydrate(), unit, this.foodEntity.getServingSize());
}
좀 더 개선이 가능할까?
전략 패턴을 활용하여 특정 단위에 따라 알맞은 계산을 수행할 수 있도록 하였습니다. 그러나 아쉬운 점이 몇 가지 보입니다.
첫째, 현재 g과 multiple 값 중 하나가 NULL, 다른 하나가 NULL이 아닐 경우 특정 전략을 선택하는 방식으로 구현되어 있습니다. 하지만, 프로젝트를 처음 접하는 개발자가 이러한 로직을 쉽게 이해할 수 있을지 의문이 들었습니다. 코드만으로 명확한 의도를 전달하기 어렵기 때문에, 전략을 결정하는 기준을 보다 직관적으로 표현할 방법이 필요하다고 생각했습니다.
둘째, 새로운 전략이 추가될 때마다 MeasureStrategy 인터페이스를 구현한 새로운 전략 클래스(XXXStrategy)를 반드시 생성해야 한다는 점입니다. 이는 전략이 많아질수록 클래스가 계속해서 증가하는 문제를 야기할 수 있습니다
Enum을 사용해서 개선해보자.
먼저, 단위 전략을 정의하는 UnitType Enum을 만들어 G와 MULTIPLE을 명시하여 각 전략을 구분하도록 하였습니다.
또한, MEAL_FOOD_UNIT_TYPE 칼럼을 추가하여 각 식사가 어떤 단위를 사용하여 등록되었는지 저장하도록 수정하였습니다. 이를 통해, 기존처럼 NULL 여부를 기반으로 전략을 판단하는 방식이 아니라, 명확한 MEAL_FOOD_UNIT_TYPE 값을 이용하여 전략을 결정할 수 있도록 개선하였습니다.
다음으로, UnitType Enum에 추상 메서드를 정의하여 각 단위가 직접 측정 방식을 관리하도록 개선하였습니다.
이를 통해, 각 단위(G, MULTIPLE)가 자체적으로 측정 방식을 구현할 수 있도록 하여 코드의 응집도를 높이고 유지보수성을 개선하였습니다.
public enum UnitType {
G {
@Override
public double measure(double nutrients, Unit unit, double servingSize) {
return (unit.getG() / servingSize) * nutrients;
}
},
MULTIPLE {
@Override
public double measure(double nutrients, Unit unit, double servingSize) {
return nutrients * unit.getMultiple();
}
};
abstract double measure(double nutrients, Unit unit, double servingSize);
}
운영 환경에 반영
새로운 칼럼이 추가되어도 기존 코드가 돌아갈 수 있도록 하는 것이 중요합니다.
먼저, MEAL_FOOD_UNIT_TYPE 칼럼을 추가할 때 NULL을 허용하는 방식으로 추가합니다.
그리고, 기존 값에 따라서 MEAL_FOOD_UNIT_TYPE 칼럼을 G 또는 MULTIPLE로 수정해야합니다.
이후에 운영 서버를 재배포하여 적용한 후 해당 칼럼에 NOT NULL을 적용해야합니다.
지금까지 요구사항이 추가됨에 따라 "전략 패턴"을 사용해서 대응하는 방법에 대해 포스팅하였습니다. 감사합니다.