진짜 개발자
본문 바로가기

FrameWork/Spring JPA

Spring JPA - JPA를 이용해 Commerce App 만들기 - 9.1 (무한카테고리 구현 및 Redis를 이용한 캐싱)

728x90

무한 Category 기능확장을 해보도록 하겠습니다. 이번 포스팅에서는 카테고리 기능을 별도로 구현하는 방법을 알아보고, 

다음 포스팅에서 기존 시스템과 카테고리 기능을 결합해보도록 하겠습니다.




1. 과정

1.1 카테고리 기능을 별도로 설계 및 구현합니다.

1.2 카테고리 기능을 기존 시스템에 결합합니다.




2. 카테고리 기능 설계 및 구현

2.1 Table 설계


1. 요구 사항

  1. item은 이름, 가격, 속한 카테고리를 가지고 있습니다.
  2. item은 하나의 카테고리에만 속합니다.
  3. categories는 이름, 상위 카테고리 id를 가지고 있습니다.
  4. categories는 여러 item을 가질 수 있습니다.


2. 설계 및 과정

level table

처음 생각한 방법은 level(depth) 별로 테이블을 만드는 것입니다. categories가 최상위 카테고리가 되는 곳이며, categories_l2는 두번째 깊이의 categories를 두는 곳입니다.


장점

  • 구현이 간단합니다.
  • 직관적입니다.

단점

  • 유연하지 못합니다.

  • 관리할 테이블이 점차 증가합니다.


level 별 테이블을 만드는 방법은 간단하며, 상위 카테고리와, 하위 카테고리를 찾기 쉬우며, 직관적이다는 장점이 있습니다. 하지만, 유연하지 못하며, 관리할 테이블이 점차 증가한다는 단점이 존재합니다.



self referencing table

이 방법은, parent_id 필드를 두고, category_id를 참조하도록 즉, 자기 자신을 참조하도록 하는 방법입니다.


장점

  • 유연한 설계가 가능합니다.

단점

  • 직관적이지 못합니다.
  • 테이블을 조회하기 위한 SQL이 어렵습니다.

이 방법은 다소 직관적이지는 못하지만, 유연한 설계가 가능한 장점이 있습니다. 그림에서 보이는 것처럼 외래키로 자신 테이블의 primary key를 참조하는 형태로 이루어지기 때문에, 얼마든지 더 depth를 늘려 나갈 수가 있습니다.



어떤 설계가 더 좋은가??

정답은 존재하지 않습니다. 항상 그렇듯 설계는 trade off의 연속입니다. 만약 카테고리가 더 증가할 가능성이 적으며, 어플리케이션이 직관적이길 원한다면, level table 방법을 사용하셔도 좋습니다. 반대로, 추후 카테고리가 점차 증가할 가능성이 존재하고, 유연한 설계를 원하신다면, self referencing table을 사용하시는 편이 좋습니다. 저는 두번째 방법인 self referencing table을 사용하도록 하겠습니다.

이 외에도 많은 카테고리 테이블 설계 방법이 존재합니다. (구글링 !!)




2.2 구현

1. 테이블

테이블 생성은 JPA의 auto ddl을 이용해 생성합니다.


https://gitlab.com/galid1/jpa-commerce/-/blob/master/src/sql/initCategory.sql

data를 삽입합니다.



2. Entity

@Entity
@Table(name = "categories")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CategoryEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long categoryId;
    private String categoryName;
    private Long parentId;

    public CategoryEntity(String categoryName, Long parentId) {
        this.categoryName = categoryName;
        this.parentId = parentId;
    }
}



3. Service

우리의 목표는 위와 같은 형태로 Category 목록을 보는 것 입니다.



목표 반환 형태 Dto

@Setter
@Getter
public class CategoryDto {
    private Long categoryId;
    private String categoryName;
    private Long parentId;
    private List<CategoryDto> subCategories;

    public CategoryDto(Long categoryId, String categoryName, Long parentId) {
        this.categoryId = categoryId;
        this.categoryName = categoryName;
        this.parentId = parentId;
    }
}

이 처럼 만들기 위해서는, 위와 같은 형태의 Dto가 필요할것 같습니다.



Root Category Dto가 존재하고, 하위 카테고리 목록을 List 형태로 가지며, 이러한 형태가 재귀적으로 일어나는 형태를 가지기 위해서 입니다.



목표 반환 형태로 가공하기 위한 Service

@Service
@Transactional
@RequiredArgsConstructor
public class CategoryService {
    private final CategoryRepository categoryRepository;

    public CategoryDto createCategoryRoot() {
        Map<Long, List<CategoryDto>> groupingByParent = categoryRepository.findAll()
                .stream()
                .map(ce -> new CategoryDto(ce.getCategoryId(), ce.getCategoryName(), ce.getParentId()))
                .collect(groupingBy(cd -> cd.getParentId()));

        CategoryDto rootCategoryDto = new CategoryDto(0l, "ROOT", null);
        addSubCategories(rootCategoryDto, groupingByParent); // 아직 구현되지 않음

        return rootCategoryDto;
    }
}
  1. createCategoryRoot() 메소드에서는, 첫번째로, CategoryRepository를 통해서, 모든 CategoryEntity를 조회합니다.
  2. 조회한 결과를 모두 CategoryDto로 변환합니다.
  3. 다시 이들을 parentId를 기준으로 grouping 합니다.
  4. addSubCategories()는 아래에서 설명드리겠습니다.


parentId : 0
list :  (1. 패션),  (2. 가전/디지털),  (3. 도서),  (4. 식품),  
------------ 
parentId : 1
list :  (5. 여성),  (6. 남성),  (7. 아동),  (8. 스포츠),  (9. 잡화),  
------------ 
parentId : 2
list :  (10. 컴퓨터),  (11. 냉장고),  (12. 청소기),  (13. 세탁기/건조기), 
------------ 
parentId : 3
list :  (14. 여행),  (15. 역사),  (16. 예술),  (17. 공학/과학), 
------------ 
parentId : 4
list :  (18. 과일),  (19. 채소),  (20. 생수/음료),  (21. 수산물),  (22. 축산),
------------ 
parentId : 5
list :  (23. 티),  (24. 원피스),  (25. 블라우스),  (26. 바지/레깅스), 
------------ 
parentId : 6
list :  (27. 티),  (28. 맨투맨/후드),  (29. 셔츠),  (30. 바지/청바지),  
------------ 
parentId : 7
list :  (31. 여아),  (32. 남아),  (33. 공용), 
------------ 
parentId : 8
list :  (34. 여성),  (35. 남성),  (36. 유아),  
------------ 
parentId : 9
list :  (37. 시계),  (38. 신발),  (39. 가방),  (40. 모자), 
------------ 
parentId : 10
list :  (41. 노트북),  (42. 데스크탑),  (43. 모니터),  (44. 키보드/마우스),
------------ 
parentId : 11
list :  (45. 양문형냉장고),  (46. 3/4도어냉장고),  (47. 미니냉장고),  (48. 김치냉장고),  
------------ 
parentId : 12
list :  (49. 무선/스틱청소기),  (50. 진공청소기),  (51. 로봇청소기),  (52. 스팀청소기), 
------------ 
parentId : 13
list :  (53. 세탁기),  (54. 건조기),  (55. 탈수기), 
------------ 
parentId : 14
list :  (56. 국내여행),  (57. 해외여행), 
------------ 
parentId : 15
list :  (58. 한국사),  (59. 중국사),  (60. 서양사), 
------------ 
parentId : 16
list :  (61. 건축),  (62. 미술),  (63. 음악),  (64. 무용),
------------ 
parentId : 17
list :  (65. 화학),  (66. 수학),  (67. 물리),  (68. 공학),  
------------ 
parentId : 18
list :  (69. 사과/배),  (70. 귤/감),  (71. 수박),  (72. 딸기), 
------------ 
parentId : 19
list :  (73. 콩나물),  (74. 두부),  (75. 당근),  (76. 오이), 
------------ 
parentId : 20
list :  (77. 생수/탄산수),  (78. 과일음료),  (79. 커피),  (80. 탄산/스포츠음료), 
------------ 
parentId : 21
list :  (81. 생선),  (82. 오징어),  (83. 새우),  (84. 멸치), 
------------ 
parentId : 22
list :  (85. 소고기),  (86. 돼지고기),  (87. 닭/오리고기),  (88. 양고기)

현재까지 진행한 결과로 만들어진 객체는 Map<Long, List<CategoryDto>> 이며, 부모 카테고리의 id를 기준으로 하여, 하위 카테고리가 List형태로 묶여 값으로 담겨있습니다.



이제 이 값을 가지고, 최상위 CategoryDto 객체를 만들어야 합니다. 즉 , root는 자신의 하위 카테고리를 list로 가지며, 다시 그하위 카테고리들은 그 하위의 카테고리들을 리스트로 가지는 형태로 만들어야 합니다.



addSubCategories()

private void addSubCategories(CategoryDto parent, Map<Long, List<CategoryDto>> groupingByParentId) {
  // 1. parent의 키로 subCategories를 찾는다.
  List<CategoryDto> subCategories = groupingByParentId.get(parent.getCategoryId());

  // 종료 조건
  if (subCategories == null)
        return;

  // 2. sub categories 셋팅
  parent.setSubCategories(subCategories);

  // 3. 재귀적으로 subcategories들에 대해서도 수행
  subCategories.stream()
      .forEach(s -> {
        addSubCategories(s, groupingByParentId);
      });
}

addSubCategories() 메소드는 재귀함수입니다. category의 하위 카테고리를 set하고, 다시 그 하위 카테고리에 차하위 카테고리를 set하는 식으로 동작합니다.





2.3 Test

테스트의 중요성은 아무리 강조해도 부족합니다. 일종의 비공식 문서로서의 역할도 하며, 개발자에게 자신감을 주는 원동력입니다. 또한 꾸준한 테스트는 개발시간을 단축하는 지름길이기도 합니다.

@ExtendWith(MockitoExtension.class)
class CategoryServiceTest {
    @InjectMocks
    private CategoryService categoryService;

    @Mock
    private CategoryRepository categoryRepository;

    @Test
    public void 최상위_카테고리_생성() throws Exception {
        //given
        List<CategoryEntity> categoryEntities = createCategoryEntities();
        given(categoryRepository.findAll())
                .willReturn(categoryEntities);

        //when
        CategoryDto categoryRoot = categoryService.createCategoryRoot();

        //then
        verify(categoryRepository, atLeastOnce()).findAll();
        // root
        assertThat(categoryRoot.getSubCategories().size(), is(2));
        // sub1
        assertThat(categoryRoot.getSubCategories().get(0).getSubCategories().size(), is(2));
        // sub2
        assertThat(categoryRoot.getSubCategories().get(1).getSubCategories().size(), is(2));
    }

    private List<CategoryEntity> createCategoryEntities() {
        CategoryEntity sub1 = new CategoryEntity("SUB1", 0l);
        CategoryEntity sub2 = new CategoryEntity("SUB2", 0l);
        CategoryEntity sub11 = new CategoryEntity("SUB1-1", 1l);
        CategoryEntity sub12 = new CategoryEntity("SUB1-2", 1l);
        CategoryEntity sub21 = new CategoryEntity("SUB2-1", 2l);
        CategoryEntity sub22 = new CategoryEntity("SUB2-2", 2l);
        ReflectionTestUtils.setField(sub1, "categoryId", 1l);
        ReflectionTestUtils.setField(sub2, "categoryId", 2l);
        ReflectionTestUtils.setField(sub11, "categoryId", 3l);
        ReflectionTestUtils.setField(sub12, "categoryId", 4l);
        ReflectionTestUtils.setField(sub21, "categoryId", 5l);
        ReflectionTestUtils.setField(sub22, "categoryId", 6l);

        List<CategoryEntity> categoryEntities = List.of(
                sub1, sub2, sub11, sub12, sub21, sub22
        );
        return categoryEntities;
    }
  1. given절에서 createCategoriEntities()를 통해 테스트를 위한 더미 데이터를 생성합니다.
  2. when절에서 테스트 대상 메소드를 실행합니다.
  3. then 절에서 기대하는 결과를 테스팅 합니다. root의 하위 카테고리 다시 차하위 카테고리가 존재하는지 테스트 합니다.



2.4 캐싱

Category 목록은 웹페이지에 방문하는 거의 모든 사용자들이 사용하게 됩니다. 따라서, 이 때마다, Database에 쿼리를 날려 조회한 후 알맞은 형태로 가공하여 반환하기 보다는, 캐싱을 해놓는 것이 좋을 것 같습니다.


https://galid1.tistory.com/777

캐싱을 통해 성능향상을 하는 방법은 위 링크를 참고해 주세요.