전략패턴 적용 및 스프링에서 전략패턴을 활용하는 방식

김태현·2023년 8월 25일

들어가면서

프로젝트 진행 중 발생한 중복코드에 전략패턴 적용을 시도했습니다. 토비의 스프링, goF의 디자인패턴 등 여러가지 서적들을 읽으며 공부했던 패턴이라 자신있게 PR을 올렸는데, 대표적인 전략 안티패턴이라는 피드백을 받았습니다. 해당 안티패턴을 개선하기 위해 고민하고 학습했던 내용을 정리 해보겠습니다.

문제상황

중복코드 및 flow 설명 (좋아요/싫어요 기능)

먼저 처음 개선을 시도했던 중복코드와 해당 로직에 대한 간략한 flow입니다.

class ProductService {

    public void updateLikeCount(long productId, String feedback) {
        if (feedback.equals("like")) {
            increaseLikeCount(productId);
        } else if (feedback.equals("dislike")) {
            decreaseLikeCount(productId);
        } else {
            throw new RuntimeException();
        }
    }

    private void increaseLikeCount(long productId) {
        // 변하지 않는 부분
        Product product = findProduct(productId);

        // 변하는 부분
        int likeCount = product.getLikeCount();
        int newLikeCount = likeCount + 1;
        product.setLikeCount(newLikeCount);
        productMapper.updateLikeCount(product);
    }

    private void decreaseLikeCount(long productId) {
        Product product = findProduct(productId);

        // 변하는 부분
        int dislikeCount = product.getDislikeCount();
        int newDislikeCount = dislikeCount + 1;
        product.setDislikeCount(newDislikeCount);
        productMapper.updateDislikeCount(product);
    }
    
}

Controller에서 ProductService.updateLikeCount()를 호출한다.
요청으로 받은 feedback이 like 일 때: increaseLikeCount() 메서드 호출 ("좋아요" +1)
요청으로 받은 feedback이 dislike일 때: decreaseLikeCount() 메서드 호출 ("싫어요" +1)

전략패턴의 대표 안티패턴

코드의 중복을 제거하기 위해 패턴 적용을 시도하여 아래의 코드를 작성했고, 전략안티패턴이라는 리뷰를 받게 되었습니다.

public void updateLikeCount(long productId, String feedback) {
    Product product = findProduct(productId);
    LikeStrategy strategy;

    if (feedback.equals("like")) {
        strategy = new Like(productMapper);
    } 
		else if (feedback.equals("dislike")) {
        strategy = new DisLike(productMapper);
    } 
		else {
        throw new IllegalArgumentException("Unrecognized feedback: " + feedback);
    }

    strategy.updateLikeCount(product);
}

// ==========================================
public class Like implements LikeStrategy {
	  @Override
	  public void updateLikeCount(Product product) {
			// like 알고리즘
	  }
}

public class DisLike implements LikeStrategy {
	  @Override
	  public void updateLikeCount(Product product) {
			// dislike 알고리즘
	  }
}

해당 코드가 전략패턴이라고 할 수 없는 이유는 다음과 같습니다.

  • 전략패턴이란 컨텍스트를 그대로 유지하면서 필요에 따라 전략을 바꿔쓸 수 있는(OCP) 디자인패턴인데
  • 컨텍스트 안에서 이미 구체적인 전략클래스를 사용하도록 고정(new Like())하면 OCP 원칙에도, 전략패턴에도 들어맞는다고 할 수 없다.

전략패턴 개선 시도

1. 팩토리 클래스로 객체 생성 로직 분리

안티 패턴을 개선하기 위해 팩토리 클래스에 객체를 생성하는 로직을 위임했습니다.

@Component
public class LikeFactory {

    private final ProductMapper productMapper;

    public LikeFactory(ProductMapper productMapper) {
        this.productMapper = productMapper;
    }

    public LikeStrategy createLikeStrategy(String feedback) {
        if (feedback.equals("like")) {
            return likeStrategy();
        } else if (feedback.equals("dislike")) {
            return disLikeStrategy();
        } else {}
    }

    public LikeStrategy likeStrategy () {
        return new Like(productMapper);
    }

    public LikeStrategy disLikeStrategy() {
        return new DisLike(productMapper);
    }
}

// ===============================//
public class ProductService {

    private final LikeFactory likeFactory;

	public void updateLikeCount(long productId, String feedback) {
    	Product product = findProduct(productId);
	    LikeStrategy strategy = likeFactory.createLikeStrategy(feedback);
    	strategy.updateLikeCount(product);
    }
}

기존에는 도메인 레이어의 updateLikeCount() 메서드에서 구체적인 클래스를 의존하며 객체를 생성하고 있었습니다. 따라서 객체와 객체간의 결합도가 높았습니다.

팩토리클래스는 객체를 생성하고, ProductServiceLikeFactory 클래스를 의존하도록 변경했습니다. 그리고 updateLikeCount() 메서드는 LikeFactory.createLikeStrategy() 메서드에 객체 생성역할을 위임한다.

결과적으로 객체의 생성을 캡슐화하고 생성된 객체를 클라이언트에 노출하지 않고 생성을 분리할 수 있게 되었습니다. 이전 코드에 비해 커플링은 충분히 낮아졌지만, 여전히 전략패턴과는 다소 거리가 있습니다.

클라이언트 메서드 적용

두번째로 클라이언트 메서드를 만들었습니다.

public void processFeedback(long productId, String feedback) {
    Product product = findProduct(productId);

    if (feedback.equals("like")) {
        like(product);
    } else if (feedback.equals("dislike")) {
        dislike(product);
    }
}

public void like(Product product) {
    LikeStrategy likeStrategy = new Like(productMapper);
    processLikeWithStrategyPattern(product, likeStrategy);
}

public void dislike(Product product) {
    LikeStrategy likeStrategy = new DisLike(productMapper);
    processLikeWithStrategyPattern(product, likeStrategy);
}

private void processLikeWithStrategyPattern(Product product, LikeStrategy likeStrategy) {
    likeStrategy.updateLikeCount(product);
}

// ============================================================ //

// 전략인터페이스
public interface LikeStrategy {
    void updateLikeCount(Product product);
}

// 알고리즘
@RequiredArgsConstructor
public class Like implements LikeStrategy {

    private final ProductMapper productMapper;

    @Override
    public void updateLikeCount(Product product) {
        int likeCount = product.getLikeCount();
        product.setLikeCount(likeCount + 1);
        productMapper.updateLikeCount(product);
    }
}

// 알고리즘
@RequiredArgsConstructor
public class DisLike implements LikeStrategy {
    private final ProductMapper productMapper;

    @Override
    public void updateLikeCount(Product product) {
        int dislikeCount = product.getDislikeCount();
        product.setDislikeCount(dislikeCount + 1);
        productMapper.updateDislikeCount(product);
    }
}

전략패턴의 핵심은 컨텍스트가 어떤 전략을 사용할지 컨텍스트 앞단의 클라이언트에서 결정하게 하는것 입니다.

위의 코드는 Controller에서 클라이언트인 processFeedback()메서드를 호출합니다. 이후 feedback에 따라 객체를 생성하는 메서드를 호출하며 비즈니스 로직을 타게됩니다. 하지만 해당 코드 역시 전략패턴의 장점을 제대로 활용하지 못하고 있습니다.

이유는 다음과 같습니다.

  • 조건 분기 로직이 메서드 내부에 존재한다: 컨텍스트인 processFeedback 메서드 내부에서 "like"와 "dislike"에 대한 분기 처리를 직접 하고 있다. 전략패턴은 이러한 분기 로직을 외부로 분리하여 각각의 전략 클래스에서 처리하도록 해야한다.
  • 전략객체 생성: "like"와 "dislike" 메서드 내에서 각각의 전략 객체를 생성하고 있다. 이는 전략패턴의 핵심인 알고리즘 교체의 유연성을 활용하지 못한다. 이러한 객체 생성은 클라이언트에서 이루어져야 한다.
  • 전략 객체 사용 메서드 노출: processLikeWithStrategyPattern 메서드를 호출하여 전략을 실행하고 있다. 위 메서드를 외부(Presentation Layer)로 노출시킬 필요도 없고, 사용할 필요도 없다. 결과적으로 클라이언트 코드에서 해당 메서드를 호출하게 되면 전략의 교체와 관련된 복잡성이 증가할 수밖에 없다.

전략안티패턴 개선 및 전략패턴 적용 결과

앞서 소개한 코드로 멘토님과 팀원분께 발표하는 시간을 가졌습니다. 결과적으로 해당 디자인 패턴에 대한 이해도가 확실히 높아진것 같다는 평가를 주셨습니다. 다만 전략패턴이라고 부르기엔 아직 애매한 부분이 존재하고, 그에 대한 레퍼런스를 제공 해주셨습니다.

전략패턴 적용

UML 등 제공받은 레퍼런스를 보고 개선한 결과는 아래와 같습니다.

  1. 컨트롤러는 도메인레이어(Service)의 클라이언트를 호출한다.
@PostMapping("/likes")
public void postLike(@RequestBody LikeDto likeDto) {
    productService.likeClient(likeDto.getProductId(), likeDto.getFeedback());
}
  1. 클라이언트는 피드백에 따라 어떤 전략을 선택할지 결정하고 컨텍스트를 호출한다.
public class ProductService {

    private LikeStrategy strategy;

		public void likeClient(long productId, String feedback) {
		    if (feedback.equals("like")) {
		        setStrategy(new Like(productMapper));
		    }
		    if (feedback.equals("dislike")) {
		        setStrategy(new DisLike(productMapper));
		    }
		    likeContext(productId);
		}

		public void setStrategy(LikeStrategy strategy) {
	      this.strategy = strategy;
		}

}
  1. 컨텍스트는 클라이언트에서 선택된 전략에 따라 비즈니스 로직을 수행한다.
public void likeContext(long productId) {
    Product product = findProduct(productId);
    strategy.updateLikeCount(product);
}
  • likeClient() 메서드에서 전략 객체를 생성과 설정을 담당하고 해당 전략 객체는 setStrategy()메서드를 통해 설정됩니다.
  • likeContext() 메서드가 전략객체를 활용하여 특정 알고리즘을 수행하도록 분리되었습니다.

안티패턴을 개선해 나가는 과정에서 disLike에 대한 요구사항이 없어졌습니다. 서비스 특성상 "싫어요"에 대한 기능이 필요하지 않다고 판단했기 때문입니다. 즉 위 코드는 스프링에서 제공하는 기능을 제대로 사용하고 있지 않습니다. 실제 프로젝트에 적용되었다면 각각의 전략 객체를 스프링빈으로 등록하고 feedback에 따라 주입받는 식으로 구현되어야 할 것입니다.

느낀점

어려웠던점

몇주에 걸쳐 전략패턴을 공부하고 적용을 시도해봤습니다. 무엇보다 개념을 공부하는 것과 실제 코드에 적용해보는 것에 대한 차이를 체감했던것 같습니다. 하지만 이번 경험을 통해 전략패턴에 대한 이해를 확실하게 할 수 있었습니다.

해당 패턴을 공부하면서 가장 어려웠던 부분은 대부분의 예제에서 main 메서드를 클라이언트로 사용한다는 것이었습니다. 즉 메인 메서드에서 전략을 생성하고 컨텍스트에 전달해주는 방식으로 구성 되어있습니다. 그래서 main 메서드가 없는 프로젝트에서는 어떤 컴포넌트를 클라이언트로 사용해야하는지, 전략을 어디서 생성해서 어떻게 넘겨 주어야하는지 판단하기 어려웠습니다.

스프링 프레임워크에서의 전략패턴

스프링 프레임워크의 Dependency Injection 이라는것 자체가 전략패턴을 기반으로 하는 개념입니다.

DI란, A클래스B클래스를 의존(참조)하고 있을때, 이런 의존성을 Interface를 통해 느슨하게 만들고 해당 Interface를 구현한 클래스의 인스턴스를 필요와 상황에 따라 주입(Injection)하는 것을 의미합니다. 전략패턴 역시 Interface를 통해 두 오브젝트간의 의존성을 느슨하게 하고 구현체를 바꿔감으로써 알고리즘(전략)을 상황에 맞게 적용할 수 있기 때문입니다.

또한 디자인패턴이라는게 다 그렇듯이 전략패턴 자체는 어떤 프레임워크나 언어에 종속되는 개념이 아니기 때문에 main 메서드를 통한 예제가 많을수 밖에 없는것 같습니다.

프로그램을 실행하는 main 메서드가 스프링프레임워크로 넘어왔을때, 스프링 프로그램을 실행하는 스프링 ApplicationContext가 전략 구현체들을 만들고 주입을 해주게 된다.(DI) 결론적으로 예제의 main의 역할을 스프링 프레임워크에서는 ApplicationContext가 수행하게 됩니다.

그래서 ApplicationContext를 DI컨테이너 혹은 IoC컨테이너라고 부르기도 합니다.

돌이켜보면 "좋아요"와 "싫어요"에 대한 기능은 전략패턴을 적용하기 딱 맞아 떨어지는 기능은 아니었지만, 전략패턴에 대해 깊이 있게 이해할 수 있는 기회가 되었습니다.

profile
안녕하세요. Java&Spring 기반 백엔드 개발자 김태현입니다.

0개의 댓글