[온라인 화훼 경매](01) 카테고리를 구현해봅시다.

프로젝트에서 식물 카테고리 기능을 맡았습니다. TDD, Spring Data JPA를 처음 적용해본 프로젝트라 개발과정에서 겪었던 문제들과 고민들에 대해 작성하려 합니다.

요구사항

요구사항은 다음과 같습니다.

  • 카테고리 이름을 입력 받아 등록 할 수 있다.
  • 카테고리를 선택하면 카테고리에 속해 있는 하위 카테고리들을 보여준다.
  • 카테고리 이름을 수정할 수 있다.
  • 카테고리 이름을 삭제할 수 있다.

가장 먼저 떠오른 방법은 level(depth)별로 카테고리 테이블을 구현하는것이었습니다. 하지만 테이블을 한개라도 줄여보고자 Self-join을 이용하기로 하였습니다.

ERD

image

위의 그림과 같이 id를 기준으로 [Self-join]형태로 erd를 작성하였습니다. active는 delete의 역할을 합니다.

카테고리 저장

TEST

@DisplayName("최상위 카테고리를 등록 성공")
    @Test
    void addRootCategory() throws Exception {
        //given
        AddCategoryDto dto = AddCategoryDto.builder()
                .name("절화")
                .level(1)
                .build();

        Category categoryEntity = dto.toEntity();

        Long fakeCategoryId = 1L;

        // private 값을 직접 넣을수 있다.
        ReflectionTestUtils.setField(categoryEntity, "id", fakeCategoryId);

        //mocking
        given(categoryRepository.save(Mockito.any()))
                .willReturn(categoryEntity);
        given(categoryRepository.findById(fakeCategoryId))
                .willReturn(Optional.of(categoryEntity));

        //when
        Long newCategoryId = categoryService.addCategory(dto);

        //then
        Optional<Category> findCategory = categoryRepository.findById(newCategoryId);

        assertThat(findCategory).isPresent();
        assertThat(findCategory.get().getName()).isEqualTo(categoryEntity.getName());
        assertThat(findCategory.get().getLevel()).isEqualTo(categoryEntity.getLevel());
        assertThat(findCategory.get().getId()).isEqualTo(categoryEntity.getId());
    }

    @DisplayName("중, 소 카테고리 등록 성공(카테고리 등록식 부모 카테고리도 설정)")
    @Test
    void addSubCategory() throws Exception {
        //given
        // 부모카데고리 등록
        Category parentCategory = Category.builder()
                .name("절화")
                .level(1)
                .build();

        Long parentId = 1L;

        ReflectionTestUtils.setField(parentCategory, "id", parentId);

        AddCategoryDto dto = AddCategoryDto.builder()
                .name("장미")
                .level(2)
                .parentId(parentId)
                .build();

        Long fakeId = 2L;
        Category subCategory = dto.toEntity();

        ReflectionTestUtils.setField(subCategory, "id", fakeId);

        given(categoryRepository.findById(parentId)).willReturn(Optional.of(parentCategory));
        given(categoryRepository.save(Mockito.any())).willReturn(subCategory);
        given(categoryRepository.findById(fakeId)).willReturn(Optional.of(subCategory));

        //when
        Long newCategoryId = categoryService.addCategory(dto);

        //then
        Optional<Category> findCategory = categoryRepository.findById(newCategoryId);

        assertThat(findCategory).isPresent();
        assertThat(findCategory.get().getId()).isEqualTo(subCategory.getId());
        assertThat(findCategory.get().getName()).isEqualTo(subCategory.getName());



    }

테스트 코드는 간단히 카테고리를 저장하는 테스트를 진행하였습니다. Service단만을 테스트 하기 위해 mock을 사용했습니다. 이 외의 값 비교는 Junit5를 사용하였습니다.

최상위 카테고리일 경우 parentId가 null이기 때문에 중,소 카테고리와 따로 분리하여 테스트를 진행하였습니다. 처음 테스트 코드를 작성하다보니 단위 테스트에 대한 개념도 없었을 뿐더러 Junit에서 지원하는 메소드들을 이해하지 못했습니다. 이 후 리팩토링한 테스트 코드와 테스트 코드 작성법에 대해서는 나중에 다루겠습니다.

Entity

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Category extends TimeBaseEntity {

    ```

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Category parent;

    @OneToMany(mappedBy = "parent")
    private List<Category> children = new ArrayList<>();

    @Builder
    private Category(String name, int level, boolean active, Category parent) {
        this.name = name;
        this.level =level;
        this.active = active;
        if (parent != null) {
            parent.addChild(this);
        }
    }

    /**
     * 연관관계 편의 메서드
     * 상위 카테고리 <-> 하위 카테고리 양방향 연관관계이므로
     * 현재 카테고리에 상위 클래스를 지정할 때 상위 클래스에도 현재 객체를 하위 카테고리 리스트에 추가한다.
     * (순수 객체 상태 고려)
     */
    public void changeParent(Category parent) {
        if (this.parent != null) {
            //기존에 설정된 상위 카테고리가 있다면 해당 카테고리의 하위 카테고리 리스트에서 현재 객체를 제거한다.
            this.parent.removeChild(this);
        }
        this.parent = parent;
        parent.addChild(this);
    }

    public void addChild(Category child) {
        this.children.add(child);
    }

    public void removeChild(Category child) {
        this.children.remove(child);
    }

    public void changeName(String name) {
        this.name = name;
    }
}

부모-자식 관계를 맺기 위해서 Entity에 위와 같은 CRUD 메소드를 작성하였습니다. 기본적으로 Builder 패턴을 사용하였고, 객체가 생성될 때 부모 객체의 하위 카테고리 리스트에 추가 하였습니다.

Service

@RequiredArgsConstructor
@Transactional
@Service
public class CategoryService {

    private final CategoryRepository categoryRepository;

    public Long addCategory(AddCategoryDto dto) {

        Category category = dto.toEntity();
        setParentCategory(category, dto.getParentId());
        return categoryRepository.save(category).getId();
    }

    private Category getCategoryEntity(Long categoryId) {
        return categoryRepository.findById(categoryId)
                .orElseThrow(() -> new EntityNotFoundException("존재하지 않는 카테고리 ID=" + categoryId));
    }

    private void setParentCategory(Category category, Long parentId) {
        if (parentId != null) {
            Category parent = getCategoryEntity(parentId);
            category.changeParent(parent);
        }
    }

}

Dto의 값으로 부모의 id와 카테고리의 이름을 받아 옵니다. 부모의 id로 객체를 받아 온 후에 카테고리의 부모를 재설정 해준 후 jpa를 통하여 저장해줍니다.

API TEST

image 위 그림과 같이 저장 되는것을 POSTMAN을 통하여 확인 하였습니다.