[디자인 패턴] Spring 다담다 프로젝트에 전략 패턴 적용하기

다나·2023년 9월 16일
0

다담다 프로젝트

목록 보기
17/28
post-thumbnail

0️⃣ 서론

📄 현재 프로젝트 구조

현재 Scrap이라는 부모 클래스로 Video, Product, Article, Place, Other 카테고리의 자식 클래스가 있습니다.

따라서 각각의 자식 클래스는 각각의 Entity로 되어 있습니다.

하지만, 상속 관계에 있는 만큼 조회 기능, 검색 기능, 수정 기능 등 각각의 Video, Product, Article, Place, Other 카테고리는 기능이 거의 유사합니다.

그러나, 각각은 다른 Repository와 Service, Controller를 가지고 있기 떄문에 ArticleController, OtherController, ProductController, VideoController에서는 동일한 기능이어도 아래와 같이 따로 각자 분리되어 있습니다.

@Operation(summary = "아티클 스크랩 개수 조회", description = "아티클 스크랩 개수 정보를 조회할 수 있습니다.")
@GetMapping("/v1/scraps/articles/count")
public ApiResponse<GetArticleCountResponse> getArticleCount(Authentication authentication) {
    String email = authentication.getName();
    return ApiResponse.success(
            GetArticleCountResponse.of(articleService.getArticleCount(email)));
}

@Operation(summary = "기타 스크랩 개수 조회", description = "기타 스크랩 개수 정보를 조회할 수 있습니다.")
@GetMapping("/v1/scraps/others/count")
public ApiResponse<GetOtherCountResponse> getOtherScrap(Authentication authentication) {
    String email = authentication.getName();
    return ApiResponse.success(GetOtherCountResponse.of(otherService.getOtherCount(email)));
}

@Operation(summary = "상품 스크랩 개수 조회", description = "상품 스크랩 개수 정보를 조회할 수 있습니다.")
@GetMapping("/v1/scraps/products/count")
public ApiResponse<GetProductCountResponse> getProductScrap(Authentication authentication) {
    String email = authentication.getName();
    return ApiResponse.success(
            GetProductCountResponse.of(productService.getProductCount(email)));
}

@Operation(summary = "비디오 스크랩 개수 조회", description = "비디오 스크랩 개수 정보를 조회할 수 있습니다.")
@GetMapping("/v1/scraps/videos/count")
public ApiResponse<GetVideoCountResponse> getVideoCount(Authentication authentication) {
    String email = authentication.getName();
    return ApiResponse.success(GetVideoCountResponse.of(videoService.getVideoCount(email)));
}

이 때의 단점은 동일한 기능을 나눠서 작성했기 때문에 코드의 길이가 길어진다는 단점이 있습니다.

저희 서비스는 카테고리가 점차 늘어날 수도 있기 때문에 그럴 때마다 동일한 로직이 계속되어서 추가됩니다.

그러나, 제가 생각한 가장 큰 단점은 "테스트 코드 작성시 동일한 기능을 테스트하게 된다"라는 점입니다.

즉, 테스트 코드 작성시에 카테고리의 스크랩 개수를 조회하는 같은 기능도 Controller의 메소드로 나누어져 있기 때문에 각각의 테스트 코드를 작성해줘야 합니다.

그러나, 한 개의 Controller를 테스트 완료하면 다른 Controller의 메소드도 동일한 로직이기 때문에 동일한 결과가 나올지 알 수 있지만 커버리지 및 테스트 코드를 위해서는 동일한 로직도 테스트 코드를 작성해야 한다는 단점이 있었습니다.

따라서 이 구조를 refactoring하는 과정을 소개해드리겠습니다!


1️⃣ 본론 1

🖍️ if 문 사용하기

🥹 여기서 잠깐, 여기에서 if문을 사용해서 하나의 Controller method를 사용하면 안될까요??

@RequiredArgsConstructor
@RestController
public class ScrapController {

    @GetMapping("/v1/scraps/{category}/count")
    public ApiResponse<GetScrapCountResponse> getScrapCount(
            @NotNull @PathVariable Category category,
            Authentication authentication) {
        String email = authentication.getName();

        if(category == "video") {
            return ApiResponse.success(GetScrapCountResponse.of(videoService.getVideoCount(email)));
        } else if (category == "product") {
            return ApiResponse.success(GetScrapCountResponse.of(productService.getProductCount(email)));
        } else if (category == "other") {
            return ApiResponse.success(GetScrapCountResponse.of(otherService.getOtherCount(email)));
        }

        return ApiResponse.success(GetScrapCountResponse.of(scrapCount));
    }
}

그러면 카테고리가 늘어날 때마다 Controller의 코드를 수정해줘야 합니다 🥲

🔎 SOLID 객체 지향 5가지 설계 원칙

혹시 SOLID라는 객체지향설계의 원칙을 들어보셨나요??

여기에서 O는 OCP로 개방 폐쇄 원칙입니다.
확장에 대해는 열려 있고, 수정에 대해서는 닫혀 있어야 합니다.

그런데 위의 코드와 같이 if문을 사용하면, 수정에는 닫혀 있는 구조가 아닌 계속해서 if문을 추가해야 하는 수정에 열려있는 구조가 됩니다.

그리고 DIP 의존 관계 역전 원칙을 여기게 됩니다.
DIP는 추상화에 의존해야 하고, 구체화에 의존하면 안된다는 것을 의미합니다.

즉, if문으로 구체화 되어 있는 videoService, productService 클래스를 의존하게 됩니다!!


2️⃣ 본론 2

🧚‍♀️ 프로젝트에 전략 패턴 적용하기

그러면 과연 이러한 문제를 해결할 수 있는 방법은 없을까?? 라는 고민을 하다가 전략 패턴을 떠올리게 되었습니다.

이전에 디자인 패턴으로 전략 패턴을 게시글로 올렸던 적이 있습니다.

https://handayeon-coder.github.io/posts/디자인-패턴-06.-Strategy-패턴/

이때, 알고리즘의 동작이 런타임에 실시간으로 교체 되어야 하는 경우에 사용될 수 있다고 했습니다.

아래는 전략 패턴 다이어그램입니다!

따라서 전략 패턴처럼 Client가 Controller에 PathVariable로 입력한
@GetMapping("/v1/scraps/{category}/count") category에 따라서 videoService.getVideoCount, productService,getProductCount와 같이 실시간으로 교체되면 될 것 같다는 생각을 했습니다.

전략 패턴 다이어그램을 저희 서비스에도 적용해보았습니다.

1. Strategy 인터페이스 생성하기

먼저 Strategy를 의미하는 ScrapService라는 인터페이스를 만들어주도록 하겠습니다.

public interface ScrapService {
    Long getScrapCount(String email);
}

2. ConcreteStrategy를 정의하기

각각의 ConcretStrategy가 되는 VideoService, ProductService, ArticleService에 getScrapCount 전략 메소드를 @Override해서 나타내줍니다.

  • (이때, 다른 PlaceService, OtherService는 동일한 로직이라서 아래의 코드에서 생략하였습니다.)
@Service
@RequiredArgsConstructor
public class ProductService implements ScrapService {

    private final ProductRepository productRepository;
    private final UserService userService;

    @Override
    @Transactional
    public Long getScrapCount(String email) {
        User user = userService.validateUser(email);
        return productRepository.countByUserAndDeletedDateIsNull(user);
    }
}
@Service
@RequiredArgsConstructor
public class ArticleService implements ScrapService {

    private final ArticleRepository articleRepository;
    private final UserService userService;

    @Override
    @Transactional
    public Long getScrapCount(String email) {
        User user = userService.validateUser(email);
        return articleRepository.countByUserAndDeletedDateIsNull(user);
    }
}
@Service
@RequiredArgsConstructor
public class VideoService implements ScrapService {

    private final VideoRepository videoRepository;
    private final UserService userService;

    @Override
    @Transactional
    public Long getScrapCount(String email) {
        User user = userService.validateUser(email);
        return videoRepository.countByUserAndDeletedDateIsNull(user);
    }
}

3. Context 클래스에서 Strategy 선택하기

@RequiredArgsConstructor
@RestController
public class ScrapController {

    private final Map<String, ScrapService> scrapServices;

    @GetMapping("/v1/scraps/{category}/count")
    public ApiResponse<GetScrapCountResponse> getScrapCount(
            @NotNull @PathVariable Category category,
            Authentication authentication) {
        String email = authentication.getName();

        ScrapService scrapService = scrapServices.get(category.getServiceName());
        Long scrapCount = scrapService.getScrapCount(email);
        return ApiResponse.success(GetScrapCountResponse.of(scrapCount));
    }
}
  • 이때, scrapServices로 선택할 전략을 필드에 나타내줍니다.
  • @GetMapping("/v1/scraps/{category}/count")
    • @PathVariable로 원하는 Category를 입력받아줍니다.
  • ScrapService scrapService = scrapServices.get(category.getServiceName());
    • 여러 전략(VideoService, ProductService, ArticleService, PlaceService, TotalService)에서 입력된 카테고리에 해당하는 서비스 로직을 선택해줍니다.
@Getter
public enum Category {
    product("productService"),
    article("articleService"),
    place("placeService"),
    video("videoService"),
    other("otherService");

    private final String serviceName;

    Category(String serviceName) {
        this.serviceName = serviceName;
    }
}

🌱 Spring 빈에 대해서 알아보자!

  • 어떻게 전략 패턴을 사용해서 원하는 서비스 전략을 선택할 수 있는 건지 알아보겠습니다!!

따라서 scrapServices에는 어떠한 것들이 담겨있는지 확인해보겠습니다.

이를 위해서 System.out.println("scrapServiceMap: " + scrapServiceMap); 출력하는 문장을 추가했습니다.

@RequiredArgsConstructor
@RestController
public class ScrapController {

    private final Map<String, ScrapService> scrapServices;

    @GetMapping("/v1/scraps/{category}/count")
    public ApiResponse<GetScrapCountResponse> getScrapCount(
            @NotNull @PathVariable Category category,
            Authentication authentication) {
        String email = authentication.getName();


        for (ScrapService scrapServiceMap : scrapServices.values()) {
            System.out.println("scrapServiceMap: " + scrapServiceMap);
        }

        ScrapService scrapService = scrapServices.get(category.getServiceName());

        Long scrapCount = scrapService.getScrapCount(email);
        return ApiResponse.success(GetScrapCountResponse.of(scrapCount));
    }
}

  • 즉, 여기에는 ScrapService 인터페이스를 구현한 ArticleService, OtherService, PlaceService, ProductService, VideoService이 담아졌음을 알 수 있습니다.
  • private final Map<String, ScrapService> scrapServices;
    • map의 키에 스프링 빈의 이름을 넣어주고, 그 값으로 ScrapService 타입으로 조회한 모든 스프링 빈을 담아줍니다.
  • 이 중에서 동일한 이름의 Service를 scrapServices.get(category.getServiceName())해서 가져오고 사용해줍니다.

🤖 테스트 코드 작성하기

@Test
@WithCustomMockUser
public void should_it_returns_the_number_of_saved_videos_When_getting_the_number_of_videos() throws Exception {
    // 비디오 개수 조회시 저장된 비디오의 개수를 반환하는지 확인
    mockMvc.perform(get("/v1/scraps/video/count")
                    .header("X-AUTH-TOKEN", "aaaaaaa"))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.jsonPath("$.data.count").value(2L));
}

위의 작성하여 카테고리별 스크랩 개수 조회하는 Controller를 테스트할 수 있습니다.

따라서, 동일한 기능의 핵심 로직을 하나의 테스트만으로 기능이 정상적으로 작동하는지 확인할 수 있고, 앞으로는 같은 기능과 로직에 대해서는 동일한 로직의 검증 테스트 코드를 여러 개 작성하지 않고 다른 에러 처리와 같은 테스트 되지 않은 부분을 더 구체적으로 테스트 코드 작성할 수 있게 되었습니다.


🔎 SOLID 객체 지향 5가지 설계 원칙 살펴보기

if문 적용하기에서 if문을 사용하면 SOLID 원칙 중 O와 D에 해당하는 원칙을 어기게 된다고 이야기했습니다.

그러면 전략 패턴을 적용했을 때에는 과연 원칙을 잘 지키고 있는지 살펴보겠습니다!

  1. ScrapController가 ScrapService 인터페이스를 의존하고 있기 때문에 DIP 의존관계 역전 원칙을 지킬 수 있게 되었습니다.
  1. 카테고리가 추가되어도 아래의 코드는 변화되지 않기 때문에 OCP 개발 폐쇄 원칙을 지키게 됩니다.
@RequiredArgsConstructor
@RestController
public class ScrapController {

    private final Map<String, ScrapService> scrapServices;

    @GetMapping("/v1/scraps/{category}/count")
    public ApiResponse<GetScrapCountResponse> getScrapCount(
            @NotNull @PathVariable Category category,
            Authentication authentication) {
        String email = authentication.getName();

        ScrapService scrapService = scrapServices.get(category.getServiceName());

        Long scrapCount = scrapService.getScrapCount(email);
        return ApiResponse.success(GetScrapCountResponse.of(scrapCount));
    }
}

3️⃣ 결론

  • 현재 배우고 있는 GoF 디자인 패턴 중 하나인 전략 패턴을 직접 프로젝트에 적용해보게 될 수 있어서 뜻깊은 시간이었습니다.
  • 이러한 리팩토링을 시작하기 전에는 어떠한 서비스를 의미하는지 모르게 되고 코드에 대한 가독성이 떨어지게 되며 디자인 패턴을 적용하고자 하는 나의 욕심이 아닐까라는 생각이 들게 되었습니다. 그러나, 이전까지는 스크랩의 카테고리가 상품, 영상, 아티클, 기타까지만 있었는데 이번에 상품 카테고리를 늘리게 되면서 앞으로 카테고리가 늘어날 수 있다는 생각을 하게 되었고 그러면 앞으로 기능 추가유지 보수를 위해서라도 전략 패턴을 사용해야겠다는 다짐을 하게 되었습니다. 그리고 실제로 리팩토링을 하면서 테스트 코드와 유지보수 측면 등 여러 가지의 장점이 있는 것을 느끼고 리팩토링을 하기를 잘 했다는 생각이 들었습니다.
  • 그리고 이 블로그를 작성하는 이유 중 하나가 팀원들에게 해당 리팩토링 과정을 소개하고 장점에 대해서 소개한 뒤, 실제 프로젝트에도 도입하자고 제안하기 위해서 작성된 이유도 있는 만큼 실제 프로젝트에서 리팩토링을 도입되었으면 좋겠다는 바램으로 이 글을 작성합니다 🌸
profile
컴퓨터공학과 학생이며, 백엔드 개발자입니다🐰

0개의 댓글