[온라인 화훼 경매](01) 카테고리를 구현해봅시다.
프로젝트에서 식물 카테고리 기능을 맡았습니다. TDD, Spring Data JPA를 처음 적용해본 프로젝트라 개발과정에서 겪었던 문제들과 고민들에 대해 작성하려 합니다.
요구사항
요구사항은 다음과 같습니다.
- 카테고리 이름을 입력 받아 등록 할 수 있다.
- 카테고리를 선택하면 카테고리에 속해 있는 하위 카테고리들을 보여준다.
- 카테고리 이름을 수정할 수 있다.
- 카테고리 이름을 삭제할 수 있다.
가장 먼저 떠오른 방법은 level(depth)별로 카테고리 테이블을 구현하는것이었습니다. 하지만 테이블을 한개라도 줄여보고자 Self-join을 이용하기로 하였습니다.
ERD
위의 그림과 같이 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
위 그림과 같이 저장 되는것을 POSTMAN을 통하여 확인 하였습니다.