[데브툰] 다양한 정책을 쉽게 등록하고 삭제하기

2024. 5. 7. 13:23Project/데브툰

목차

현재 상황

  정책 등록 api 구현

  팀원의 코드 리뷰

리팩토링

  추가 고려 사항, 코드 구현

  문제 발생

  대안

  해결

  테스트

회고

  만족한 점

  아쉬운 점 

  다음에는 이렇게

 

현재 상황

데브툰 프로젝트를 진행하면서 나는 확장성 있는 설계 부분을 맡았다. 현재 프로젝트에는 쿠키 가격 정책과 비속어 정책이 존재하고 기획 단계에서는 쿠키 정책만 등록하기로 했었다. 하지만 추후 더 다양한 정책이 등록될 가능성이 있으며, 이를 효과적으로 관리하기 위해 처음부터 쉽게 등록하고 교체할 수 있는 프로그램을 만드는 것을 목표로 하고 기획을 수정했다. 

 

정책 등록 api 구현

나는 어떻게 하면 확장성 있는 코드를 만들고, 유지보수성 또한 높일 수 있을까 고민을 하면서 구현에 들어갔다. 

1. 정책 인터페이스(Policy) 

다양한 정책 유형이 존재할 수 있고 각 정책이 동일한 방식으로 처리 있도록 공통 인터페이스를 제공했다.

새로운 정책 유형이 추가되더라도 기존 시스템과의 호환성을 유지할 수 있다.

 

 2. 정책 팩토리(PolicyFactory) 

다양한 유형의 정책 객체를 생성할 수 있는 기능을 제공하면서 정책 생성 로직을 한 곳에 모아 관리하고자 했다. 지원하지 않는 타입의 요청이 들어올 경우 예외를 발생시킨다.

새로운 정책 유형이 추가되더라도 PolicyFactory 수정하면 된다. 

🔽 코드

더보기
public class PolicyFactory {
    public static Policy registerPolicy(PolicyCreateRequest request) {
        switch (request.getType()) {
            case "cookie":
                return new CookiePolicyEntity(request.getDetails());
            case "bad_words":
                return new BadWordsPolicyEntity(request.getDetails());
            default:
                throw new DevtoonException(ErrorCode.NOT_FOUND, ErrorMessage.getResourceNotFound("Policy", request.getType()));
        }
    }
}

 

3. 정책 관리 클래스 (PolicyManager) 

한 곳에서 모든 정책을 관리하고, 정책 등록 및 활성 정책 관리를 용이하도록 했다.

🔽 코드

더보기
@Slf4j
@Component
public class PolicyManager {

    private List<Policy> policies = new ArrayList<>(); 
    
    // 정책 등록: 정책을 리스트에 추가합니다.
    public void addPolicy(Policy policy) {
        policies.add(policy);
    }

    // 활성 정책 조회: 현재 활성 상태인 정책들의 리스트를 반환합니다.
    public List<Policy> getActivePolicies() {
        LocalDateTime now = LocalDateTime.now();
        return policies.stream()
                .filter(policy -> isActive(policy, now))
                .collect(Collectors.toList());
    }
    
    // 정책 활성 상태 확인: 주어진 정책이 현재 활성 상태인지 확인합니다.
    private boolean isActive(Policy policy, LocalDateTime now) { 
        return !now.isBefore(policy.getStartDate()) && (policy.getEndDate() == null || now.isBefore(policy.getEndDate()));
    }
    
    // 모든 활성 정책 적용: 현재 활성 상태인 모든 정책을 적용합니다.
    public void applyAllActivePolicies() {
        List<Policy> activePolicies = getActivePolicies();
        for (Policy policy : activePolicies) {
            policy.applyPolicy();
        }
    }

}

공통적으로 고려한 부분은 한 곳에서 관련 로직을 관리토록 해서 유지보수를 쉽도록 했고, 추후 여러 정책이 들어와도 쉽게 갈아 끼울 수 있도록 하는 것을 목표로 작성했다. 정상적으로 동작하는 것까지 확인하고 자신 있게 PR을 날렸다....

 

코드를 본 팀원의 반응

 

코드만 봐서는 이해가 가지 않는다며 그림을 그려서 알려달라고 요청했고, 그때 나는 뭔가 잘못되었음을 감지했다. 

실제로는 나이스하게 코드리뷰를 해주셨다. 😂😂

01

나름 커밋 메세지도 자세하게 작성하였는데, 그럼에도 팀원이 바로 이해하지 못할 정도면 코드가 깔끔하지 않다고 생각했다.  그래서 나는 코드 리뷰를 보면서 어떤 것들을 수정해야 할지 추리고 코드도 전반적으로 손을 보기로 했다. 

 

리팩토링 시작

추가 고려 사항,  코드 구현

Policy 인터페이스는 그대로 가져가되 추가적으로 고려한 부분은 다음과 같다.

현재 객체 생성 과정에서 간단한 분기 판단 논리가 존재한다. 이를 깔끔하게 리팩터링 하기 위해 객체를 동적으로 생성하는 코드 패턴이 필요하다. 또한, 정책 객체를 동적으로 생성할 때 enum 타입과 추상 메서드를 활용해 코드 가독성과 유지보수성을 개선토록 했다.

 

첫 번째, 단순 팩토리 패턴 적용

다양한 유형의 객체 생성 과정을 돕는 코드 구현 방법이 있을까 조사를 하다가 [팩토리 패턴]이 적합하다고 판단했고 그중에서 단순 팩토리 패턴을 적용했다.

 

팩토리 패턴은 생성 패턴으로 생성 과정을 팩토리 클래스로 추출해 재사용할 수 있고, 복잡한 생성 과정을 캡슐화해서 호출자는 객체 생성을 알 필요가 없다는 이점이 있다. (생성과정과 사용과정을 분리)

 

그렇다면 어떤 상황일 때 객체 생성 과정이 복잡하다고 할 수 있을까?

크게 두 가지로 나눠 볼 수 있는데, 

  •  if 분기 판단 논리가 있으며, 유형에 따라 다른 객체를 동적으로 생성하는 경우 => 우리 프로젝트에 해당!

  •  유형에 따라 다른 객체를 생성할 필요는 없지만, 단일 객체 자체의 생성 프로세스가 비교적 복잡한 경우로 나뉠 수 있다. 

 

팩토리 패턴 종류에는 크게 3가지로

  • 단순 팩토리 패턴

  • 팩토리 메서드 패턴

  • 추상 팩토리 패턴이 있다. 여기서 추상 팩토리 패턴은 복잡하여 실제 프로젝트 개발에 잘 사용되지 않는다고 한다. 

1) 단순 팩토리 패턴
하나의 팩토리 클래스가 다양한 타입의 객체를 생성할 책임을 갖는다.

2) 팩토리 메서드 패턴 
객체 생성을 서브 클래스에 위임한다. 기본 클래스에는 객체를 생성하는 메서드의 시그니처만 정의되어 있고, 실제 객체의 생성은 서브 클래스의 구현에 의존한다.

복잡한 팩토리 메서드 패턴을 적용하는 건 과도한 설계가 될 수 있다고 판단해 단순 팩터리 패턴을 사용하여 코드 복잡성을 낮추기로 결정했다. 

 

두 번째, enum 타입 & 추상 메서드 적용

현재 정책 타입을 문자열로 사용하고 있으며, 이를 switch문으로 처리하고 있다. 이를 개선하기 위해 enum 타입과 추상 메서드를 적용하기로 결정했다.

 

enum 타입 적용

enum을 사용하면서 정책 타입을 하드코딩하지 않고, 미리 정의된 상수를 사용하여 특정 정책 유형에 대한 객체를 생성하도록 했다.

 

추상 메서드 적용의 이점

다형성을 통해 다양한 정책 객체를 생성할 수 있으며, 객체 생성 로직을 명확히 분리할 수 있다.
추상 메서드는 반드시 구현해야 하므로 일관된 인터페이스를 유지할 수 있다.
새로운 정책 유형을 PolicyFactory enum에 추가하고 해당 추상 메서드를 구현함으로써 쉽게 확장할 수 있다.

 

적용 코드

/**
 * 정책 타입에 맞게 정책 객체를 생성 및 반환
 */
@Getter
public enum PolicyFactory {

    COOKIE_POLICY("COOKIE_POLICY", PolicyType.COOKIE_POLICY) {
        @Override
        public Policy createPolicy(PolicyCreateRequest request){
            CookiePolicyEntity cookiePolicy = CookiePolicyEntity.create(
                    request.getCookiePrice(),
                    request.getCookieQuantityPerEpisode(),
                    request.getStartDate(),
                    request.getEndDate()
            );
            return cookiePolicy;
        }
    },
    BAD_WORDS_POLICY("BAD_WORDS_POLICY", PolicyType.BAD_WORDS_POLICY) {
        @Override
        public Policy createPolicy(PolicyCreateRequest request){
            BadWordsPolicyEntity badWordsPolicy = BadWordsPolicyEntity.create(
                    request.getWarningThreshold(),
                    request.getStartDate(),
                    request.getEndDate()
            );
            return badWordsPolicy;
        }
    };

    private final String policyName;
    private final PolicyType policyType;

    PolicyFactory(String policyName, PolicyType policyType) {
        this.policyName = policyName;
        this.policyType = policyType;
    }
   
    // 추상 메서드
    public abstract Policy createPolicy(PolicyCreateRequest request);

}

 

프로젝트에 필요한 부분을 먼저 생각하고 해결 방법을 모색하였다. 팩토리 패턴과 이를 구현하기 위해 enum 타입과 추상 메서드를 활용하여 정책 객체를 동적으로 생성하는 코드를 구현했다.

 

이렇게 리팩토링 후 발생한 문제가 있었고, 이 또한 문제를 정확히 확인하고 해결방안을 모색하는 과정으로 해결하였다.

문제 발생

그림은 현재 구조를 나타내고, 빨간색 글씨는 발생한 두 가지 문제를 나타낸다. 

 

문제 1. PolicyFactory에서 정책에 맞게 객체를 생성하고자 할 때 createPolicy()메서드 파라미터에 공통으로 사용할 requestdto를 넣어줘야 하는지 아니면 정책별 requestdto를 넣어줘야 하는지 고민이다.

/**
 * 정책 타입에 맞게 정책 객체를 생성 및 반환
 */
@Getter
public enum PolicyFactory {

    COOKIE_POLICY("COOKIE_POLICY", PolicyType.COOKIE_POLICY) {
        @Override
        public Policy createPolicy(PolicyCreateRequest request) {
            // 정책에 맞게 객체를 생성(엔티티 내 create메서드 사용) 
            // -> 서비스로 리턴
            // [문제] 그럼 request dto를 정책별로 가져가야하나? 
            // 아니면 대표 dto에 필드로 다 넣어야 하나?
            CookiePolicyEntity cookiePolicy = 
               CookiePolicyEntity.create(request);

            return new CookiePolicyEntity();
        }
    },
    BAD_WORDS_POLICY("BAD_WORDS_POLICY", PolicyType.BAD_WORDS_POLICY) {
        @Override
        public Policy createPolicy(PolicyCreateRequest request) {
            return new BadWordsPolicyEntity();
        }
    };

    private final String policyName;
    private final PolicyType policyType;

    PolicyFactory(String policyName, PolicyType policyType) {
        this.policyName = policyName;
        this.policyType = policyType;
    }

    // [문제]어떤 요청dto를 파라미터로 받아야 할까?
    // 1. 공통dto 사용해서 안에 필드 다 넣기 -> 현재 이 방법 적용
    // 2. 정책별dto사용 시 파라미터로 어떻게 넘겨줘야하는가?
    public abstract Policy createPolicy(PolicyCreateRequest request);
}

 

문제 2. PolicyFactory가 Policy타입을 서비스 단으로 반환하면

→ Jpa레포지토리가 2개(추후 여러 개)인데 저걸 보고 어떻게 구별해서 저장할 것인가?

 

대안 및 해결 

문제가 어떤 건지 정확히 알고 있어서 해결이 가능했다.

결론적으로는 공통 DTO 사용 + 레지스트리 패턴으로 해결했다. 다음은 고민과정에서 생각한 대안과 최종 선택한 이유를 소개하려 한다.

 

문제 1: PolicyFactory에서 createPolicy() 메서드의 파라미터에 어떤 dto를 넘겨줘야 할까?

두 가지 대안

1. 공통 DTO: 모든 정책 유형에서 필요한 필드를 포함

  • 장점 : 한 곳에서 관리해서 유지 관리 쉬움

  • 단점: 각 정책이 필요로 하지 않는 데이터까지 포함할 수 있어 메모리 낭비가 생길 수 있음. DTO가 지나치게 커짐

 

2. 정책별 DTO: 각 정책에 따라 맞춤형 DTO를 사용

  • 장점: 각 정책의 요구사항에 따라 필요한 데이터만을 포함 가능

  • 단점: 설계 복잡해질 수 있음

 

선택과 이유 - 공통 DTO을 선택

1. 현재 상황에서 정책별 DTO를 선택하는 것은 장점보다 복잡성이 증가한다고 판단했다.

정책별 DTO를 구현하기 위해서는 CookiePolicyFactory와 CookiePolicyFactory를 추상 클래스로 생성한 후, PolicyFactory로 상속해야 한다. 또한, CookiePolicyRequest, BadWordsPolicyRequest를 생성한 후, PolicyCreateRequest를 상속해야 한다.

 

2. 정책 패키지는 특별한 기능이 많지 않다. 초기에 데이터를 등록하고, 이후에 데이터를 변경하고 싶다면 변경 대신 삭제를 진행한다. 따라서 정책 패키지는 데이터 기록을 위한 히스토리 성격이 강하며 현재로서는 쿠키정책과 비속어정책정도 관리할 생각이다.

 

🔽코드 적용

PolicyFactory

더보기
/**
 * 정책 타입에 맞게 정책 객체를 생성 및 반환
 */
@Getter
public enum PolicyFactory {

    COOKIE_POLICY("COOKIE_POLICY", PolicyType.COOKIE_POLICY) {
        @Override
        public Policy createPolicy(PolicyCreateRequest request) {
            // 정책에 맞게 객체를 생성(엔티티 내 create메서드 사용) -> 서비스로 리턴
            CookiePolicyEntity cookiePolicy = CookiePolicyEntity.create(
                    request.getCookiePrice(),
                    request.getCookieQuantityPerEpisode(),
                    request.getStartDate(),
                    request.getEndDate()
            );
            return cookiePolicy;
        }
    },
    BAD_WORDS_POLICY("BAD_WORDS_POLICY", PolicyType.BAD_WORDS_POLICY) {
        @Override
        public Policy createPolicy(PolicyCreateRequest request) {
            BadWordsPolicyEntity badWordsPolicy = BadWordsPolicyEntity.create(
                    request.getWarningThreshold(),
                    request.getStartDate(),
                    request.getEndDate()
            );
            return badWordsPolicy;
        }
    };

    private final String policyName;
    private final PolicyType policyType;

    PolicyFactory(String policyName, PolicyType policyType) {
        this.policyName = policyName;
        this.policyType = policyType;
    }

    public abstract Policy createPolicy(PolicyCreateRequest request);
}

PolicyCreateRequest

더보기
@AllArgsConstructor
@Getter
public class PolicyCreateRequest { // 정책에 공통적으로 사용하는 요청 dto

    private String policyName;
    private LocalDateTime startDate;
    private LocalDateTime endDate;

    // 쿠키 정책
    private BigDecimal cookiePrice;
    private Integer cookieQuantityPerEpisode;

    // 비속어 정책
    private int warningThreshold;

}

 

문제 2: 서비스단으로 Policy타입 리턴이 올 텐데, 여기서 어떻게 정책별 리포지토리에 저장할까?

두 가지 대안

1. 타입 검사: instanceof를 사용해 Policy 객체의 타입을 검사하고, 해당 타입에 맞는 리포지토리에 저장

  • 장점: 단순하고 직관적

  • 단점: 새로운 Policy 타입 정책이 추가될 때마다 if-else 또는 switch 문을 수정해야 함.

 

2. 레지스트리 패턴: 객체를 전역적으로 저장하고 검색 가능하게 하는 디자인 패턴

  • 장점: 새로운 Policy 타입 정책이 추가될 때마다 코드 수정 없이 레지스트리에 새로운 리포지토리를 등록만 하면 됨

  • 단점: 타입 검사 방법보다 구현 복잡

 

선택과 이유 - 레지스트리 패턴 선택

이번 프로젝트는 확장성 있는 설계 및 구현을 목표로 했다. 이는 새로운 요구사항이 추가될 때 기존 코드를 최소한으로 수정하고, 새로운 기능을 쉽게 적용할 수 있는 구조를 선택하는 것을 의미한다. 기존에 채택한 타입 검사 및 if-else, switch 문 방식은 새로운 Policy 타입이 추가될 때마다 수정이 필요하여 유지보수성이 떨어지며, 개방-폐쇄 원칙(OCP)을 위반하는 한계가 있었다.

 

반면, 레지스트리 패턴은 새로운 Policy 타입이 추가될 때 기존 코드를 수정할 필요 없이 레지스트리에 새로운 리포지토리를 등록하기만 하면 된다. 레지스트리를 통해 모든 리포지토리를 중앙에서 관리하여 유지보수성이 높아지고, 객체 생성 및 검색 로직을 캡슐화할 수 있다.

 

🔽코드 적용

PolicyService

더보기
@Slf4j
@RequiredArgsConstructor
@Service
public class PolicyService {

    private final PolicyRepositoryRegistry policyRepositoryRegistry;

    /**
     * 정책 등록
     */
    @Transactional
    public void register(PolicyCreateRequest request) {
        // 1. request로 들어온 정책 확인 -> PolicyType 객체를 반환 -> 예: COOKIE_POLICY
        PolicyType policyType = PolicyType.create(request.getPolicyName());
        log.info("등록시 policyType : {}", policyType);

        // 2. 정책 타입에 맞게 정책 객체(Policy)를 생성 및 반환 <- 팩토리에서 진행
        // 2-1. 객체 생성 : 쿠키정책이면 쿠키 필드 값 채워서 객체를 생성
        Policy policy = PolicyFactory.valueOf(policyType.getPolicyName()).createPolicy(request);
        log.info("등록시 policy : {}", policy);

        // 3. 정책 별 리포지토리에 저장(여기서 처리)
        JpaRepository<Policy, Long> repository =
                (JpaRepository<Policy, Long>) policyRepositoryRegistry.getRepository(policy.getClass());
        repository.save(policy);
    }

}

PolicyRepositoryRegistry

더보기
package yjh.devtoon.policy.infrastructure;

@Component
public class PolicyRepositoryRegistry {

    private final Map<Class<? extends Policy>, JpaRepository<? extends Policy, Long>> registry =
            new HashMap<>();

    public PolicyRepositoryRegistry(
            CookiePolicyRepository cookiePolicyRepository,
            BadWordsPolicyRepository badWordsPolicyRepository) {
        registry.put(CookiePolicyEntity.class, cookiePolicyRepository);
        registry.put(BadWordsPolicyEntity.class, badWordsPolicyRepository);
    }

    @SuppressWarnings("unchecked")
    public <T extends Policy> JpaRepository<T, Long> getRepository(Class<T> policyClass) {
        return (JpaRepository<T, Long>) registry.get(policyClass);
    }

}

 

테스트로 확인

postman - api 테스트

01

회고

만족한 점

 • 너무 생각만 하지 말고 우선 돌아가게 작성하자

막상 확장성 있는 설계를 하려고 하니 막막했다. 많은 방법이 있을 것 같고 내가 놓친 부분도 분명 많을 것 같았다. 하지만 우선 코드를 정상적으로 돌아가게 만들자는 생각으로 결론을 지었고, pr을 올리고 코드리뷰 및 추가적인 모색을 통해 리팩토링을 진행하게 되었다. 절대 처음부터 완벽을 생각하지 말고 빠르게 개발 후 리팩토링을 진행한 점이 만족스럽다.

 

 • 소위 있어 보이는 해결 방법을 찾으려 하지 않고 현재 내 코드에서 정확하게 문제가 어떤 것인지 확인한 것

위에서는 문제를 2가지로 압축했다. 그랬더니 해결방안을 모색하고 결정하는 데 크게 어렵지 않았다. 

 

  커밋 메시지를 꼼꼼하게 작성한 것

처음에는 팀원의 이해를 돕기 위해 커밋 메시지를 꼼꼼하게 작성했다. 나의 경우 해당 코드를 하루종일 며칠간 생각해서 머릿속에 그려지는 반면 팀원의 경우 텍스트로만 이해를 해야 하니 더더욱 자세히 적으려 했다. 하지만 이는 추후 해당 코드를 보고 리팩토링을 진행할 때 나에게 더 도움이 되었다.

 

아쉬운 점

기획을 하면서 이번 프로젝트를 통해 시도하고 싶은 것들을 정할 때, 코드 설계까지 구상했어야 했다.

이번 프로젝트에서 기획자, 개발자로 활동 했다. 전반적인 기획을 진행하며 구현하고자 하는 것들을 정리했지만, 개발자 입장에서 구체적인 설계까지 생각하지 않은 점이 아쉽다. 물론 그림을 통해 대략적인 흐름을 잡았었지만, 확장성 있는 설계를 원했다면 좀 더 자세히 흐름을 잡았어야 했다.

 

다음에는 이렇게

기획과 코드 설계 과정을 같이 하자

아쉬운 점을 다음 프로젝트에서는 만족한 점으로 적을 수 있도록 처음부터 코드 설계를 꼼꼼하게 구상하고 들어갈 것이다. 

 

 일급 컬렉션 사용해서 리팩토링을 해보고 싶다.

코드 리뷰 중에 '현재 적용가능한 정책들을 모아두는 리스트를 일급 컬렉션을 사용해서 구현해 볼 수도 있지 않을까요?'라는 리뷰 또한 받았었다. 컬렉션을 하나의 객체로 취급해 해당 클래스에서 관련된 모든 작업을 책임지도록 하면 코드 결합도도 낮출 수 있고 코드 가독성 또한 좋아질 것 같아 추가적으로 리팩토링을 진행한다면 도전해보고 싶다.