본문 바로가기
프로젝트

SpringBoot 전략패턴 + IF 분기 없애기

by 멍멍구리 2022. 6. 8.

전략패턴을 사용한 이유

 

여러 웹툰 플랫폼의 정보를 한번에 확인할 수 있는 종합 웹툰 플랫폼을 만들고 있었는데 한 가지 고민이 있었다. 

 

네이버 웹툰, 카카오 웹툰, 레진코믹스 등 각각의 웹툰 플랫폼의 정보를 크롤링 해와야 하는데 플랫폼의 종류가 워낙 많기 때문에 확장성에 대한 고려가 필요했던 것. 확장성을 고려하지 않고 작업할 경우 각각의 웹툰 플랫폼마다 서비스 계층의 메소드를 추가한다고 생각하면... 끔찍하다. 

 

확장성을 고려한 설계를 생각하다가 떠올린 게 전략 패턴(Strategy Pattern)이다. 각기 다른 요청을 처리하는 메소드 자체는 하나로 두고 들어온 요청을 파악해서 적절한 로직으로 분기할 수 있다면 추가할 플랫폼이 생겼을 때 개발이 수월하리라 판단했다.

 


전략패턴이란?

전략패턴은 행위를 인터페이스화 하고 사용 시에 구체적인 행위를 생성하는 방식이다. 전략패턴의 대략적인 구성도는 아래와 같다.

 

https://howtodoinjava.com/design-patterns/behavioral/strategy-design-pattern/

 

내가 생각한 전략패턴의 핵심은, 하위 클래스를 상위 클래스로 Upcasting이 가능하다는 다형성이다. 이러한 다형성을 통해 사용자(클라이언트)는 인터페이스에만 의존하면 된다. 실제 구현은 로직이 포함되어 있는 하위클래스가 맡아서 하므로 쉽게 전략을 갈아끼울 수 있을 뿐더러 확장 시에는 구체적인 하위 클래스(인터페이스를 상속한)를 추가만 해주면 되기 때문에 유연한 구조로 설계가 가능하다. 또한 클라이언트가 인터페이스에만 의존한다는 성질을 활용해서 실행 코드를 수정하지 않고 유지보수 할 수 있다. 

 

전략패턴에 대한 설명은 Headfirst-DesignPattern에 쉽게 서술되어 있다.

 

Head First Design Patterns

Chapter 1. Intro to Design Patterns: Welcome to Design Patterns Someone has already solved your problems. In this chapter, you’ll learn why (and how) you can exploit the wisdom and … - Selection from Head First Design Patterns [Book]

www.oreilly.com


전략패턴 구현

 

public interface ParseStrategy {
    List<Webtoon> webtoons = new ArrayList<>();
    List<Webtoon> parse(String url);
}

 

ParseStrategy 인터페이스를 생성해서 parse() 추상 메소드를 선언한다. parse()의 구현은 구체 클래스에게 맡긴다. List는 이후에 구현할 parse()에서 공통적으로 사용해야하기 때문에 인터페이스에 선언해 두었다.

 

public class NaverWebtoon implements ParseStrategy {

    @Override
    public List<Webtoon> parse(String url) {
        Document naverDocument = UrlConnector.getHtml(url);
        Elements select = naverDocument.select(".thumb img");
        for (Element element : select) {
            Webtoon webtoon = new Webtoon(element.attr("title"), element.attr("src"));
            webtoons.add(webtoon);
        }
        return webtoons;
    }
}

 

네이버웹툰의 파싱 전략을 구현한 NaverWebtoon 클래스이다. 아직 깡통 프로젝트여서 단순한 로직을 가지고 있다.

 

  • UrlConnector 클래스에서 Jsoup을 이용한 크롤링
  • CSS Selecting
  • 요소들을 순회하며 인터페이스에 선언된 리스트에 추가
  • 리스트를 반환

 

public class LezhinWebtoon implements ParseStrategy {
    @Override
    public List<Webtoon> parse(String url) {
        doSomething();
    }
}

 

public class KakaoWebtoon implements ParseStrategy {
    @Override
    public List<Webtoon> parse(String url) {
        doSomething();
    }
}

 

추가적인 구체 클래스들은 아직 구현하지 않았으나 각 클래스들만의 고유한 파싱 전략을 구현할 수 있음을 알 수 있다. 이 과정을 통해서 이제 확장할 수 있는 구조가 되었는데, 여기까지는 아주 단순한 전략패턴의 케이스이기 때문에 어려움이 없었다. 이렇게 구성된 전략패턴은 MVC 구조를 가진 SpringBoot 에서는 어떤 방식으로 사용할 수 있을까?

 

@Slf4j
@RequiredArgsConstructor
@RestController
public class WebtoonParserController {

    private final WebtoonParserService webtoonParserService;

    private String naverWebtoonURL = "https://comic.naver.com/webtoon/weekday";
    private String kakaoWebtoonURL = "https://webtoon.kakao.com/original-webtoon";
    private String lezhinWebtoonURL = "https://www.lezhin.com/ko/scheduled";

    @GetMapping("/naver")
    public ResponseEntity<List<Webtoon>> naverWebtoonParse(){
        List<Webtoon> webtoons = webtoonParserService.parse(naverWebtoonURL);
        return ResponseEntity.ok(webtoons);
    }

    @GetMapping("/kakao")
    public ResponseEntity<List<Webtoon>> kakaoWebtoonParse(){
        List<Webtoon> webtoons = webtoonParserService.parse(kakaoWebtoonURL);
        return ResponseEntity.ok(webtoons);
    }

    @GetMapping("/lezhin")
    public ResponseEntity<List<Webtoon>> lezhinWebtoonParse(){
        List<Webtoon> webtoons = webtoonParserService.parse(lezhinWebtoonURL);
        return ResponseEntity.ok(webtoons);
    }
}

 

HTTP 요청에 따라 컨트롤러들은 분리되어 있지만 서비스 계층의 동일한 parse() 메소드를 가르키고 있다. 

 

@Slf4j
@RequiredArgsConstructor
@Service
public class WebtoonParserService {

    private final ParseFactory parseFactory;

    /** 전략패턴 & 팩토리로 입력 URL에 적절한 로직 실행  */
    public List<Webtoon> parse(String url){
        ParseStrategy parseStrategy = parseFactory.createParseStrategy(url);
        List<Webtoon> webtoons = parseStrategy.parse(url);
        return webtoons;
    }
}

 

전략패턴의 클라이언트(사용자)에 해당하는 서비스 계층이다. 위의 코드에서 ParseFactory 클래스를 의존하고 있는데, 이 팩토리 클래스는 파라미터로 들어온 URL을 조건으로 필터링하고 적절한 객체를 생성해주는 클래스다.

 

만약 이 팩토리 클래스 없이 일반적인 전략패턴만을 사용한다면 서비스 계층의 코드는 아래와 같을 것이다.

 

@Slf4j
@RequiredArgsConstructor
@Service
public class WebtoonParserService {
    public List<Webtoon> naverParse(String url){
        ParseStrategy parseStrategy = new NaverWebtoon();
        List<Webtoon> webtoons = parseStrategy.parse(url);
        return webtoons;
    }

    public List<Webtoon> kakaoParse(String url){
        ParseStrategy parseStrategy = new KakaoWebtoon();
        List<Webtoon> webtoons = parseStrategy.parse(url);
        return webtoons;
    }

    public List<Webtoon> lezhinParse(String url){
        ParseStrategy parseStrategy = new LezhinWebtoon();
        List<Webtoon> webtoons = parseStrategy.parse(url);
        return webtoons;
    }
}

 

메소드 자체는 분리되어 있지만 생성해주는 구체 클래스만 다를 뿐, 로직이 모두 동일하다. 이때, "조건을 판별해서 적절한 객체를 생성해주는 클래스"가 필요한데, 사용할 수 있는 것이 팩토리 클래스다.

 

@Component
public class ParseFactory {
    public ParseStrategy createParseStrategy(String url) {
        if(url.contains("naver")) return new NaverWebtoon();
        else if(url.contains("kakao")) return new KakaoWebtoon();
        else if(url.contains("lezhin")) return new LezhinWebtoon();
        else return null;
    }
}

 

ParseFactory 클래스는 파라미터로 넘어온 URL의 조건을 필터링해서 적절한 객체를 생성한다. 

 

@Slf4j
@RequiredArgsConstructor
@Service
public class WebtoonParserService {

    private final ParseFactory parseFactory;

    /** 전략패턴 & 팩토리로 입력 URL에 적절한 로직 실행  */
    public List<Webtoon> parse(String url){
        ParseStrategy parseStrategy = parseFactory.createParseStrategy(url);
        List<Webtoon> webtoons = parseStrategy.parse(url);
        return webtoons;
    }
}

 

팩토리를 이용하면 세 갈래로 나누어진 메소드의 분리를 하나로 합쳐서 깔끔하고 유지보수 및 확장에 편리한 구조로 만들 수 있다. 관리포인트가 팩토리 하나로 줄어들기 때문이다.

 

하지만 ...


나는 IF가 싫어요...

전략패턴을 도입했던 가장 큰 이유는 서비스에 추가될 웹툰 플랫폼이 많아서, 확장성을 고려했기 때문이다. 그렇다면 최소 열 가지 이상의 플랫폼이 들어오고 그때마다 무한의 계단 마냥 증식할 팩토리 클래스 내부의 IF 분기를 생각해보면, 아찔해졌다. 사실 팩토리 클래스 내부의 IF 분기는 유지보수나 확장성에 큰 영향을 미치지는 않을 것 같았지만, 개인적으로 IF가 증식하고 있는 모양이 싫어서 꽤 고민을 했다.

 

  • Switch로 가독성을 높일까?
  • IF 분기를 없앨 수 있는 방법은 없을까?
  • 별도의 클래스로 관리하는 것이 오히려 유지보수성을 해치지 않을까?

한참 고민하다 IF 분기를 없애기로 했다. 우선 분기처리가 20 라인 이상 되는 것도 부담스러웠고 전략들의 모음을 등록하고 관리하는 클래스를 추가한다면 좀 더 역할에 맞는 클래스 설계가 맞지 않을까 생각했다.

 

여기서 IF 분기를 없애는 과정은 살짝 확신이 없으니 참고만 바란다. 더 깔끔한 방법이 있을 것 같은데 조금 아쉽다.

 

/** 파싱 전략 등록을 관리 */
public class ParseStrategies {
    public static List<ParseStrategy> parseStrategies = new ArrayList<>(){{
        add(new NaverWebtoon());
        add(new KakaoWebtoon());
        add(new LezhinWebtoon());
    }};
}

 

먼저 전략들의 등록을 관리하는 ParseStrategies 클래스를 생성한다. 이제부터 새로운 구체 전략 클래스를 생성하면 이곳에 등록해주어야 한다.

 

/** 전략의 구체 클래스를 생성하는 팩토리 클래스 */
@Component
public class ParseFactory {
    public ParseStrategy createParseStrategy(String url){
        for (ParseStrategy parseStrategy : ParseStrategies.parseStrategies) {
            if(parseStrategy.isSupport(url)) return parseStrategy;
        }
        return null;
    }
}

 

팩토리 클래스에서는 ParseStrategies에 등록된 전략들을 순차적으로 탐색하면서 적절한 전략을 가진 객체들을 생성한다. IF문이 사라지고 아주 깔끔한 코드만 남았다. 여기서 각각의 parseStrategy 객체들은 isSupport() 메소드를 통해서 본인이 URL을 처리하는 적절한 객체인지 판단한다.

 

public interface ParseStrategy {
    List<Webtoon> webtoons = new ArrayList<>();
    List<Webtoon> parse(String url);
    boolean isSupport(String url);
}

 

적절한 객체인지를 판별하는 isSupport() 메소드는 각 구체 전략 클래스에서 구현하도록 인터페이스에 선언한다.

 

@Override
public boolean isSupport(String url) {
    if(url.contains("naver")) return true;
    else return false;
}

 

NaverWebtoon 클래스의 경우에는 네이버 URL을 포함한 경우 true를 반환한다. 만약 URL로 kakao가 전달되었을 경우에는 false를 응답하기 때문에 ParseFactory는 다음 전략 객체를 탐색하게 된다.

 

여기까지의 과정을 통해 이제 서비스에 새로운 웹툰 플랫폼의 추가할 때 두 가지의 장점을 가지게 된다. 

 

  • 쉽게 새로운 구현 클래스를 만들 수 있다(확장성)
  • 클라이언트 코드를 수정하지 않아도 된다(유지보수성)

트레이드오프를 생각하면...

IF 분기를 사용했을 때는 확장이나 유지보수가 필요할 때 관리포인트는 팩토리 클래스 하나다. 하지만 별도의 클래스를 만들어서 IF 분기를 없애면, 관리 포인트는 ParseStrategies와 isSupport() 두 곳이 된다. 

 

하지만 전략들을 모아서 관리하는 단 하나의 역할만 수행하는 클래스를 추가로 빼서 관리하는 것이 조금 더 객체지향적이라는 생각이 들었는데, 쉽지 않은 문제다. 내가 구현한 방법이 미숙했을 수도 있겠다는 생각이 든다.

댓글