현재 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하는 과정을 소개해드리겠습니다!
🥹 여기서 잠깐, 여기에서 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라는 객체지향설계의 원칙을 들어보셨나요??
여기에서 O는 OCP로 개방 폐쇄 원칙입니다.
확장에 대해는 열려 있고, 수정에 대해서는 닫혀 있어야 합니다.
그런데 위의 코드와 같이 if문을 사용하면, 수정에는 닫혀 있는 구조가 아닌 계속해서 if문을 추가해야 하는 수정에 열려있는 구조가 됩니다.
그리고 DIP 의존 관계 역전 원칙을 여기게 됩니다.
DIP는 추상화에 의존해야 하고, 구체화에 의존하면 안된다는 것을 의미합니다.
즉, if문으로 구체화 되어 있는 videoService, productService 클래스를 의존하게 됩니다!!
그러면 과연 이러한 문제를 해결할 수 있는 방법은 없을까?? 라는 고민을 하다가 전략 패턴을 떠올리게 되었습니다.
이전에 디자인 패턴으로 전략 패턴을 게시글로 올렸던 적이 있습니다.
https://handayeon-coder.github.io/posts/디자인-패턴-06.-Strategy-패턴/
이때, 알고리즘의 동작이 런타임에 실시간으로 교체 되어야 하는 경우에 사용될 수 있다고 했습니다.
아래는 전략 패턴 다이어그램입니다!
따라서 전략 패턴처럼 Client가 Controller에 PathVariable로 입력한
@GetMapping("/v1/scraps/{category}/count")
category에 따라서 videoService.getVideoCount, productService,getProductCount와 같이 실시간으로 교체되면 될 것 같다는 생각을 했습니다.
전략 패턴 다이어그램을 저희 서비스에도 적용해보았습니다.
먼저 Strategy를 의미하는 ScrapService라는 인터페이스를 만들어주도록 하겠습니다.
public interface ScrapService {
Long getScrapCount(String email);
}
각각의 ConcretStrategy가 되는 VideoService, ProductService, ArticleService에 getScrapCount 전략 메소드를 @Override해서 나타내줍니다.
@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);
}
}
@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));
}
}
@GetMapping("/v1/scraps/{category}/count")
ScrapService scrapService = scrapServices.get(category.getServiceName());
@Getter
public enum Category {
product("productService"),
article("articleService"),
place("placeService"),
video("videoService"),
other("otherService");
private final String serviceName;
Category(String serviceName) {
this.serviceName = serviceName;
}
}
따라서 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));
}
}
private final Map<String, ScrapService> scrapServices;
@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를 테스트할 수 있습니다.
따라서, 동일한 기능의 핵심 로직을 하나의 테스트만으로 기능이 정상적으로 작동하는지 확인할 수 있고, 앞으로는 같은 기능과 로직에 대해서는 동일한 로직의 검증 테스트 코드를 여러 개 작성하지 않고 다른 에러 처리와 같은 테스트 되지 않은 부분을 더 구체적으로 테스트 코드 작성할 수 있게 되었습니다.
if문 적용하기에서 if문을 사용하면 SOLID 원칙 중 O와 D에 해당하는 원칙을 어기게 된다고 이야기했습니다.
그러면 전략 패턴을 적용했을 때에는 과연 원칙을 잘 지키고 있는지 살펴보겠습니다!
@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));
}
}