Computer Science/Project Management

복잡한 정책을 고려한 코드 작성법

BEOKS 2024. 12. 23. 16:44

기획자와 협의를 하다보면 다음과 같은 말을 종종 들을때가 있습니다.

  • "이 정책은 우선 이렇게 결정되긴 했는데, 담당자 확인후 확정 드리겠습니다."
  • "고객에 따라서 이 정책은 다르게 적용될 수도 있습니다."
  • "특정한 조건에 해당하는 고객은 이 정책을 적용해주세요"

물론 대부분의 경우 기획에 정확한 정책을 요구하는게 이상적이지만, 기한이 얼마 남지 않았거나, 고객 별로 딱 맞는 정책들을 적용하고 싶은 경우 등 어쩔수 없이 복잡하게 개발을 해야만하는 순간이 오게 됩니다.

오늘은 이러한 경우에 어떻게 회사에서 해결했는지 얘기해보려고 한다. 설명에서는 실제 도메인이 아닌 할인 정책 예시 도메인을 사용하겠습니다.

1. 전략 패턴

가장 처음 적용한 해결책은 전략 패턴입니다.
전략 패턴은 정책의 종류나 상태에 따라 동작이 달라지는 경우 적용하기 좋은 디자인 패턴입니다.

우선 정책 표준을 인터페이스로 정의합니다. 이 경우 입력된 값을 얼마나 할인할지가 인터페이스가 됩니다.

public interface DiscountPolicy {
    double applyDiscount(double price);
}

그런 다음, 논의된 여러 정책 고려 사항을 구현체로 작성합니다.

import org.springframework.stereotype.Component;

@Component("normalPolicy")
public class NormalDiscountPolicy implements DiscountPolicy {
    @Override
    public double applyDiscount(double price) {
        // 할인 없음
        return price;
    }
}

@Component("specialPolicy")
public class SpecialDiscountPolicy implements DiscountPolicy {
    @Override
    public double applyDiscount(double price) {
        // 10% 할인
        return price * 0.9;
    }
}

이제 기획에서 2번 정책으로 확정되었다는 연락이 오면 바로 @Primary 어노테이션을 추가해서 배포하면 매우 빠르게 기획 요구사항을 반영할 수 있습니다.

@Primary
@Component("specialPolicy")
public class SpecialDiscountPolicy implements DiscountPolicy {
    @Override
    public double applyDiscount(double price) {
        // 10% 할인
        return price * 0.9;
    }
}

2. 프록시 패턴

이제 기획에서 개인 고객은 5% 할인, 기업 고객은 10% 할인을 하도록 수정해달라는 요청이 왔습니다.
즉, 고객의 유형에 따라 다른 정책을 적용해 달라는 말이다. 이는 원래 객체에 대해 접근을 제어하는 프록시 패턴을 이용해 해결하는게 좋습니다.

@Primary
public class ProxyDiscountPolicy implements DiscountPolicy {

    private Map<UserType, DiscountPolicy> policyMap;

    public ProxyDiscountPolicy() {
        policyMap = new HashMap<>();
        policyMap.put(UserType.INDIVIDUAL, new IndividualDiscountPolicy());
        policyMap.put(UserType.CORPORATE, new CorporateDiscountPolicy());
    }

    @Override
    public double applyDiscount(User user, double price) {
        DiscountPolicy policy = policyMap.getOrDefault(user.getUserType(), price1 -> price1);
        return policy.applyDiscount(user, price);
    }
}

3. 스펙 패턴

운영을 하다 보면, 특별한 경우가 생기고 그에 맞는 정책도 새로 생깁니다. "가입한 지 3개월 이내인 개인 고객은 20% 할인을 해주세요", "개인 중 VIP 고객인 경우 30% 할인을 해주세요" 심지어 "그냥 A 고객은 2달 동안 10% 추가 할인해 주세요"와 같은 정책도 생깁니다. (B2B 기업인 경우 협상을 하면서 이런 예외 조건이 자주 생깁니다.)

이러한 복잡하고 다양한 조건을 코드로 효율적으로 관리하기 위해 스펙 패턴(Specification Pattern)을 적용할 수 있습니다. 스펙 패턴은 비즈니스 규칙을 캡슐화하여 재사용성과 조합성을 높이는 데 도움을 줍니다.

우선, 할인 적용 여부를 판단하는 스펙을 인터페이스로 정의합니다.

public interface DiscountSpecification {
    boolean isSatisfiedBy(User user);
}

각각의 조건을 구현체로 만듭니다.

public class NewUserSpecification implements DiscountSpecification {
    @Override
    public boolean isSatisfiedBy(User user) {
        return user.getJoinDate().isAfter(LocalDate.now().minusMonths(3));
    }
}

public class VipUserSpecification implements DiscountSpecification {
    @Override
    public boolean isSatisfiedBy(User user) {
        return user.isVip();
    }
}

public class SpecificUserSpecification implements DiscountSpecification {
    private String userId;

    public SpecificUserSpecification(String userId) {
        this.userId = userId;
    }

    @Override
    public boolean isSatisfiedBy(User user) {
        return user.getId().equals(userId);
    }
}

스펙들을 조합하여 복잡한 조건을 구성할 수 있습니다.

public class AndSpecification implements DiscountSpecification {
    private DiscountSpecification spec1;
    private DiscountSpecification spec2;

    public AndSpecification(DiscountSpecification spec1, DiscountSpecification spec2) {
        this.spec1 = spec1;
        this.spec2 = spec2;
    }

    @Override
    public boolean isSatisfiedBy(User user) {
        return spec1.isSatisfiedBy(user) && spec2.isSatisfiedBy(user);
    }
}

public class OrSpecification implements DiscountSpecification {
    private DiscountSpecification spec1;
    private DiscountSpecification spec2;

    public OrSpecification(DiscountSpecification spec1, DiscountSpecification spec2) {
        this.spec1 = spec1;
        this.spec2 = spec2;
    }

    @Override
    public boolean isSatisfiedBy(User user) {
        return spec1.isSatisfiedBy(user) || spec2.isSatisfiedBy(user);
    }
}

이제 다양한 스펙을 조합하여 정책을 적용할 수 있습니다.

public class SpecificationDiscountPolicy implements DiscountPolicy {
    @Override
    public double applyDiscount(User user, double price) {
        DiscountSpecification newUserSpec = new NewUserSpecification();
        DiscountSpecification vipUserSpec = new VipUserSpecification();
        DiscountSpecification specificUserSpec = new SpecificUserSpecification("A");

        DiscountSpecification specialSpec = new AndSpecification(newUserSpec, vipUserSpec);

        if (specialSpec.isSatisfiedBy(user)) {
            return price * 0.7; 
        } else if (vipUserSpec.isSatisfiedBy(user)) {
            return price * 0.8; 
        } else if (specificUserSpec.isSatisfiedBy(user)) {
            return price * 0.9; 
        } else {
            return price;
        }
    }
}

스펙 패턴을 활용하면 비즈니스 로직을 유연하게 관리하고 확장하기 쉬워집니다. 새로운 조건이 추가되더라도 기존 코드를 크게 수정하지 않고도 대응할 수 있습니다. 또한 조건들을 조합하여 복잡한 정책도 간단하게 구현할 수 있습니다.

실제 현업에서는 이 외에도 더 복잡한 요구사항들이 많이 발생합니다. 이럴 때일수록 디자인 패턴을 적절히 활용하여 코드의 유지보수성과 확장성을 높이는 것이 중요합니다.