Spring 에서 전략패턴 적용해보기

2024. 4. 14. 22:26Spring

전략 패턴이란?

전략 패턴은 GoF 디자인 패턴 중 객체의 행위를 변경할 수 있게 하는 디자인 패턴입니다.

이 패턴은 여러 알고리즘을 하나의 추상적인 접근점(인터페이스)을 통해 각각의 알고리즘을 캡슐화하여 사용할 수 있도록 합니다.

클라이언트는 런타임에 알고리즘을 선택할 수 있으므로, 같은 문제를 다른 방식으로 해결할 수 있는 유연성을 제공합니다.

전략 패턴의 구성 요소

전략 패턴은 주로 세가지 주요 구성 요소로 이루어집니다:

  • Context (문맥): 전략을 사용하는 역할을 합니다. Context는 필요에 따라 다른 Strategy를 사용할 수 있습니다.
  • Strategy (전략): 여러 알고리즘을 인터페이스로 정의합니다. 모든 전략은 이 인터페이스를 구현합니다.
  • ConcreteStrategy (구체적인 전략): Strategy 인터페이스를 구현하는 클래스로, 실제 알고리즘을 제공합니다.

출처: 김영한님의 스프링 핵심원리 - 고급편

전략 패턴의 장점

  • 확장성: 새로운 전략을 쉽게 추가할 수 있습니다.
  • 교체 용이: 런타임에 전략을 쉽게 변경할 수 있습니다.
  • 개방/폐쇄 원칙: 기존 코드를 변경하지 않고도 새로운 알고리즘을 추가할 수 있습니다.

사실 이론에서 실제로 전략 패턴을 적용할 때까지는 여러 어려움이 있습니다. 개발하면서 어떻게 시작해야 할지 막막할 때가 많죠. 그래서 제가 사이드 프로젝트로 진행한 '피플'을 통해 전략 패턴을 구현하고 적용한 경험을 공유하고자 합니다.

 

피플은 헌혈자를 찾기 위한 '사연'과 헌혈 생활을 공유하는 '피플생활' 같은 커뮤니티 서비스를 제공합니다. 피플생활에서는 일반 글과 헌혈의 집 리뷰를 작성할 수 있는 두 가지 종류의 엔티티가 있습니다.

 

이 두 엔티티로 인해 서비스의 복잡도가 증가하고, 다양한 버전의 API가 생성되면서 개발 복잡도가 상승했습니다. 더욱이, 이 엔티티들은 검색, 댓글 작성, 조회 등 공통적인 기능을 많이 포함하고 있었습니다.

 

이런 문제를 해결하기 위해 전략 패턴을 도입하여 리팩토링을 계획하였습니다. 전략 패턴을 적용함으로써, 각기 다른 엔티티에 대한 처리를 동일한 인터페이스로 관리할 수 있게 되어 코드의 일관성을 유지하며 유연성을 높일 수 있었습니다.

 

저는 전략패턴을 구현할 수 있는 수많은 방법 중에서 스프링에서 Collection으로 주입받아 처리하는 방법을 구현하였습니다.

Spring Framwork에서 활용

Spring Framework에서는 다양한 방법으로 의존성을 주입할 수 있으며, 컬렉션 타입으로 의존성을 주입하는 것도 가능합니다.

이를 통해 개발자는 여러 개의 빈(Bean)을 하나의 컬렉션으로 관리할 수 있으며, 이는 특히 같은 타입의 여러 구현체를 자동으로 주입받아야 할 때 유용합니다. (스프링에서 지원하는 컬렉션은 List, Map, Set을 지원합니다.)

 

일단 전략(Strategy) 인터페이스와 공통적으로 사용할 DTO를 작성합니다.

public interface CommunityService {
    CommunityDto getDetail(String uuid, Account account);
}
@Getter
@NoArgsConstructor
public class CommunityDto {
    protected String uuid;
    protected String title;
    protected String content;
}

이후 해당 전략을 사용할 클래스(ConcreteStrategy)를 만든 후 implements를 합니다.

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class BoardServiceV1 implements CommunityService {
    @Override
    public BoardDto getDetail(String boardUuid, Account account) {
        '''
        상세 로직
        '''
        return dto;
    }

}
@Service
@Slf4j
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class DonationServiceV1 implements CommunityService {
    @Override
    public DonationDto getDetail(String donationUUid, Account account) {
        return dto;
    }
}

이후 이러한 전략을 사용할 클라이언트를 만듭니다 여기서는 Controller가 됩니다.

@RestController
@RequiredArgsConstructor
@RequestMapping({"/api/{version}/{type}", "/api/{version}/community/{type}/"})
public class CommunityController {
    private final Map<String, CommunityService> services;

    @GetMapping("{uuid}")
    @Operation(summary = "상세보기")
    public CommunityDto getDetail(
        @PathVariable String version, @PathVariable String type, @PathVariable String uuid
    ) throws Exception {
        Account account = AccountHolder.getAccount();

        return ServiceUtil.getService(services, type, version)
            .getDetail(uuid, account);
    }

}

저는 여기서 ServiceUtil이라는 클래스를 사용 했는데 이는 아래와 같이 구현되었습니다.

@Slf4j
public class ServiceUtil {
    public static <T> T getService(Map<String, T> services, String type, String version) {
        Set<String> keys = services.keySet();
        for (String key : keys) {
            if (key.toLowerCase().startsWith(type.toLowerCase()) && key.toLowerCase()
                .endsWith(version.toLowerCase())) {
                return services.get(key);
            }
        }

        log.warn("[WARN] 지원하지 않는 서비스입니다.  요청 서비스-> {}\n지원 하는 서비스 종류-> {}", type, services.keySet());
        throw new PpleNotfoundException("지원하지 않는 서비스입니다.  요청 서비스-> " + type + "   지원 서비스-> " + services.keySet());
    }
}

여기서 서비스를 찾는 과정을 Map의 Key로 찾는 이유는 Spring은 Map Collection에 빈을 주입할 때 Key값에 빈의 이름을 넣어주고 Value에다가 해당 서비스를 주입해 주기 때문입니다.

 

💡 @Component로 선언된 Bean의 이름은 기본적으로는 구현체의 클래스의 이름을 따라가게 됩니다.

 

또 다른 곳에서 구현한 예시를 말씀드리겠습니다.

 

저희 ‘피플’에서 ‘사연’을 게시하게 되면 환자분들이 빠른 시일내에 헌혈자분들을 만날 수 있도록 네이버 카페, SNS 등에 자동 공유하는 시스템을 제공합니다.

 

하지만 이런 외부 서비스를 이용하기 위해선 인증, 게시 요청을 보내야 하고 이는 각각의 서비스마다 다른 방식으로 진행되게 됩니다.

 

이를 구현하기 위해서 계속 ‘사연’ 서비스에 대한 로직을 수정하는 것은 비효율적이라고 판단했고 이를 개선하기 위해 전략패턴을 도입하게 되었습니다.

 

일단 외부에 글이 작성될 서비스들을 정의하고 그때그때 상황에 따라 제어할 수 있게 Entity로 정의합니다

@Slf4j
@Getter
@Setter
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Where(clause = "use_yn = true")
@Table(name = "external_articles")
public class ExternalArticle {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @JsonIgnore
    private Long id;

    @Column(name = "created_at", updatable = false)
    @CreationTimestamp
    private LocalDateTime createdAt;

    @Enumerated(EnumType.STRING)
    @Column(name = "external_provider_type", nullable = false)
    private ExternalProviderType providerType;

    // 카페 이름 or 채널 이름
    @Column(name = "name", nullable = false)
    private String name;

    // 제목 양식
    @Column(name = "title_template")
    private String titleTemplate;

    // 내용 양식
    @Column(name = "content_template")
    private String contentTemplate;

    // 카페 or 채널 ID
    @Column(name = "community_id", unique = true, nullable = false)
    private String communityId;

    // 카테고리 ID (Naver 카페의 경우)
    @Column(name = "category_id", unique = true, nullable = false)
    private String categoryId;

    // 사용 여부
    @Column(name = "use_yn", nullable = false)
    private Boolean useYn;

    // 하루에 몇 개의 게시물을 올릴 수 있는지
    @Column(name = "daily_posting_limit_val", nullable = true)
    private Integer dailyPostingLimitVal;

    // 해당 채널, 카페에 올라간 게시글 목록 및 링크
    @OneToMany(mappedBy = "externalArticle")
    private List<ExternalArticleHistory> externalArticleHistories;

}

여기서 주목할 필드는 providerType입니다.

 

해당 컬럼을 통해서 어떤 서비스로 요청을 보낼지가 결정되게 됩니다.

이후 첫번째에서 진행한 것처럼 전략(Strategy) 인터페이스를 생성합니다.

public interface ExternalArticlePoster {
    Optional<String> posting(
        String cafeId, String menuId, String subject,
        List<String> imgUrls, String content, String contentUuid
    );
}

이후 이러한 전략을 실행할 서비스를 만들어 줍니다

@Slf4j
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ExternalArticleService {

    private final ExternalArticleRepository repo;
    private final Map<String, ExternalArticlePoster> articlePoster;
    private final ExternalArticleHistoryService historyService;

    @Async
    @Transactional
    public void posting(String title, List<String> imgUrls, String content,
        String contentIdentifier) {
        List<ExternalArticle> externalArticleList = repo.findAll();

        for (ExternalArticle externalArticle : externalArticleList) {
            String titleTemplate = externalArticle.getTitleTemplate();
            String contentTemplate = externalArticle.getContentTemplate();

            String externalArticleTitle = title;
            String externalArticleContent = content;

            if (titleTemplate != null) {
                externalArticleTitle = String.format(titleTemplate, title);
            }
            if (contentTemplate != null) {
                externalArticleContent = String.format(contentTemplate, content, contentIdentifier);
            }

            List<ExternalArticleHistory> todayArticles = historyService.getArticleHistoryByCreatedAt(
                externalArticle,
                LocalDate.now().atStartOfDay(),
                LocalDate.now().atTime(23, 59, 59)
            );

            if (
                externalArticle.getDailyPostingLimitVal() != null &&
                todayArticles.size() > externalArticle.getDailyPostingLimitVal()
            ) {
                continue;
            }


            ExternalArticlePoster service = ServiceUtil.getService(articlePoster,
                externalArticle.getProviderType().toString());

            Optional<String> response = service.posting(
                externalArticle.getCommunityId(),
                externalArticle.getCategoryId(),
                externalArticleTitle, imgUrls, externalArticleContent, contentIdentifier
            );

            response.ifPresent(s -> {
                ExternalArticleHistory history = historyService.save(
                    ExternalArticleHistory.builder()
                        .externalArticle(externalArticle)
                        .articleUrl(s)
                        .contentIdentifier(contentIdentifier)
                        .build()
                );
            });
        }
        log.info("[INFO] External posting complete");
    }
}

이렇게 구현을 함으로써 또 다른 외부 서비스를 이용해야 하는 상황일 때는 DB에 컬럼을 추가하고 해당 외부 서비스에 글을 올릴 로직만을 구현하게 되니 서비스 간의 결합도를 낮추는 효과를 이룰 수 있게 되었습니다.

 

이렇게 간단하게 Spring의 Collection DI를 통해서 전략패턴을 구현함으로써

서비스의 성격 및 버전에 따라 각기 다른 전략을 선택하고 각각의 애플리케이션의 유연성을 크게 향상시킬 수 있습니다.

이 방법을 사용하면 동일한 인터페이스의 다양한 구현을 쉽게 관리하고 실행할 수 있으며, 구현체를 추가하거나 제거하는 것이 간편해집니다.

'Spring' 카테고리의 다른 글

Spring Validation 정복하기  (0) 2024.05.07
Spring의 Event  (0) 2024.03.31
Annotaion  (0) 2023.06.18
Servlet과 PSA  (0) 2023.05.29
스프링의 3대 핵심 요소  (0) 2023.05.21