이번 시간부터는 게시판 기능을 구현해보도록 하겠습니다.
그 전에 잠깐 게시판에 대한 요구사항을 가볍게 정리해보도록 하겠습니다.
- 계층형 카테고리
- 물품 판매 게시글 CRUD
- 게시글 조건 검색
- 물품 주문 요청
- 계층형 대댓글
- 매매 내역 조회
- 게시글 별 쪽지 송수신
- 페이지 번호를 이용한 페이징 처리(게시글 조회)
- 무한 스크롤을 이용한 페이징 처리(쪽지 및 매매 내역 조회)
우리의 기능적인 최종 목표는, 간단한 물품 판매 게시판을 구축하는 것입니다.
갈 길은 멀지만, 하나씩 차분히 구현해보도록 하겠습니다.
먼저, 계층형 카테고리를 만들어보도록 하겠습니다.
위와 같이 각 카테고리마다 하위 카테고리를 계속해서 추가해나갈 수 있습니다.
선택된 각 카테고리 별로 게시글을 작성할 수 있도록 할 것입니다.
이제 바로 코드를 작성해보겠습니다.
엔티티 작성을 위해, entity.category 패키지에 Category를 작성해줍니다.
package kukekyakya.kukemarket.entity.category;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import javax.persistence.*;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Category {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "category_id")
private Long id;
@Column(length = 30, nullable = false)
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
@OnDelete(action = OnDeleteAction.CASCADE) // 1
private Category parent;
public Category(String name, Category parent) {
this.name = name;
this.parent = parent;
}
}
Category 엔티티는, 자신의 id 값과 카테고리 명, 부모 카테고리를 가지고 있습니다.
카테고리 테이블은 category(id(primary key), name, parent_id(foreign key)) 와 같은 형태로 생성될 것이며, 외래 키를 통해 테이블을 셀프 참조하고 있습니다.
만약 부모 카테고리가 없다면, 해당 카테고리는 루트 카테고리가 되고, parent는 null의 값을 가지게 됩니다.
이를 이용한다면, 카테고리가 어떻게 계층을 이루고 있는지 파악할 수 있을 것입니다.
1. 어떤 부모 카테고리가 삭제된다면, 해당 카테고리의 모든 하위 카테고리는 연달아서 제거됩니다. @OneToMany 관계로 자식 카테고리를 참조해서 cascade타입을 REMOVE로 설정하거나, @OnDelete의 action 설정을 CASCADE로 설정하면, 해당 카테고리의 하위 카테고리를 연달아서 제거할 수 있게 됩니다. 두 방식의 차이와 이유는, 바로 아래의 토막글로 간단히 정리해보도록 하겠습니다.
예를 들어, "스마트폰" 카테고리의 하위 카테고리가 "안드로이드 폰", "아이폰" 일 때,
"스마트폰" 카테고리를 제거하게 되면, 하위 카테고리인 "안드로이드 폰"과 "아이폰"도(하위 카테고리의 하위 카테고리도 포함) 모두 제거될 것입니다.
***
- cascade = CascadeType.REMOVE와 @OnDelete(action = OnDeleteAction.CASCADE)의 차이?
* 내용이 길어져서 위 게시글에 별도로 정리하였습니다.
***
이제 repository.category.CategoryRepository를 작성해주겠습니다.
package kukekyakya.kukemarket.repository.category;
import kukekyakya.kukemarket.entity.category.Category;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface CategoryRepository extends JpaRepository<Category, Long> {
@Query("select c from Category c left join c.parent p order by p.id asc nulls first, c.id asc")
List<Category> findAllOrderByParentIdAscNullsFirstCategoryIdAsc();
}
JPQL로 새로운 쿼리가 작성되어있습니다.
부모의 아이디로 오름차순 정렬하되 NULL을 우선적으로 하고, 그 다음으로 자신의 아이디로 오름차순 정렬하여 조회합니다.
이에 대해 설명하기 전에, 우리가 어떻게 계층형 카테고리를 구현해나갈지 먼저 살펴보도록 하겠습니다.
앞으로 계층형 카테고리를 구현하기 위해, 다음과 같은 예제를 자주 사용하겠습니다.
id는 카테고리의 id 컬럼, p_id는 카테고리의 부모 id 컬럼입니다.
id p_id
1 NULL
2 1
3 1
4 2
5 2
6 4
7 3
8 NULL
데이터베이스에 위와 같은 형태로 카테고리가 저장되어 있다고 가정해보겠습니다.
만약 어떤 카테고리를 생성한 뒤에, 그에 대한 하위 카테고리를 생성하려면, 그 상위 카테고리는 이미 데이터베이스에 저장되어 있을 것입니다.
하위 카테고리는 상위 카테고리보다 먼저 생성될 수 없습니다.
우리는, 상위 카테고리를 지정하여 하위 카테고리를 생성할 수 있습니다.
위와 같은 예시 데이터를, CategoryRepository에 작성된 findAllOrderByParentIdAscNullsFirstCategoryIdAsc로 조회해보겠습니다.
id p_id
1 NULL
8 NULL
2 1
3 1
4 2
5 2
7 3
6 4
조회된 결과는 위와 같습니다.
우리는 조회된 카테고리를 계층형으로 만들어서 응답해줘야합니다.
위와 같은 결과를 어떻게 계층형으로 변환할 수 있을까요?
위 데이터는 p_id로 오름차순 정렬되어있습니다.
또한, 반드시 상위 카테고리가 만들어진 뒤에 하위 카테고리가 생성될 수 있기 때문에,
p_id에 나타나는 카테고리의 실제 데이터는, 하위 카테고리보다 반드시 더 먼저 조회되어있습니다.
예를 들어,
id=2 카테고리의 부모 카테고리 id=1은 첫번째로 조회되었고,
id=3 카테고리의 부모 카테고리 id=1은 첫번째로 조회되었고,
id=4 카테고리의 부모 카테고리 id=2는 세번째로 조회되었고,
...
id=6 카테고리의 부모 카테고리 id=4는 다섯번째로 조회되었습니다.
카테고리의 부모 카테고리는, 하위 카테고리보다 반드시 먼저 조회되어 있는 것입니다.
부모 카테고리의 id를 오름차순으로 정렬했기 때문에 당연한 결과입니다.
이를 이용하면,
조회된 데이터를 순차적으로 탐색하면서도, 각 상위 카테고리의 하위 카테고리가 무엇인지 판별해낼 실마리가 보이는 것입니다.
부모 카테고리가 없다면 루트 카테고리임을 의미하므로, p_id가 NULL인 카테고리는 우선적으로 나타나야합니다.
또한, 우리가 지정한 id 생성 전략에 의해서, 카테고리의 id는 순차적으로 증가합니다.
즉 id는 데이터가 생성된 순서를 의미하기 때문에, 두번째 정렬 조건으로 id 오름차순을 지정해준다면,
같은 부모 카테고리를 가지는 하위 카테고리들은, 생성된 순서로 정렬될 수 있습니다.
* 우리는 부모 카테고리의 id 값을 첫번째 정렬 조건으로 사용하고 있습니다. 부모 카테고리 id 값은 외래 키로 지정되어있고, 외래 키에 대한 인덱스 자동 생성 여부는 데이터베이스 종류에 따라 다르지만, 카테고리의 개수는 그리 많지 않을 것이기 때문에 인덱스가 없더라도 큰 지장이 없을 것입니다. 만약 인덱스 생성이 되어있지 않다면, 상황에 따라 외래 키에도 인덱스를 직접 생성해주면 될 것입니다.
***
- 계층형 구조를 만드는 또 다른 방법?
사실 계층형 구조는 다음과 같은 방법으로도 만들 수 있습니다.
하위 카테고리를 @OneToMany 관계로 만들어서, 루트 카테고리(p_id=NULL)만 조회해둔 뒤에, 하위 카테고리를 재귀적으로 조회해나가면 됩니다.
하지만 이런 방식을 취할 경우, 하위 카테고리를 호출할 때마다 SELECT 쿼리를 반복적으로 수행해야하며, 중첩된 계층이 깊어질수록 성능 상의 문제가 생길 수 있습니다.
그렇다고 복잡함을 감수하고 단일한 쿼리로 조회하자니, 쿼리 작성에 어려움이 있습니다. 우리는 쿼리와 자바 코드 모두를 이용하겠습니다. 단일 쿼리를 통해서 모든 카테고리를 조회한 뒤, 자바 코드로 직접 계층형 구조로 변환해주도록 하겠습니다. 확장이 어려운 데이터베이스가 복잡한 쿼리를 수행하는 것에 비해, 확장이 용이한 서버 애플리케이션에서 직접 변환해주는 것이 더욱 효율적일 것입니다.
@OneToMany 관계를 이용할 때의 반복적인 children SELECT 쿼리에 대해서 하나 더 언급하자면, 초기에는 루트 카테고리만 조회해둔 뒤에, children을 조회하는 유사한 SELECT 쿼리를, default_batch_fetch_size 설정을 통해 IN 쿼리로 한 번에 처리할 수 있지 않을까 싶었지만, 의도한대로 동작하지 않았습니다.
각 루트의 children을 조회했을 때, 특정 child의 children을 다시 조회하려면, 미리 쿼리를 날려보지않고서는 확인할 수 없으므로 당연한 것이었습니다.
그럼에도 불구하고 IN 쿼리를 하려면, 일단 있는 모든 카테고리들을 미리 컨텍스트에 조회해둔 뒤에, 그 후 하위 카테고리들을 다시 조회해나가는 방법도 있겠습니다.
어차피 모든 하위 카테고리들은 이미 조회된 상태이므로, 특정 child는 이미 알고 있는 상태입니다.
이렇게 하면 IN 쿼리는 분명 수행되지만, 모든 카테고리를 조회하는 쿼리도 포함되어야하므로, 두 번의 쿼리가 생성됩니다.
또한, 두 번의 쿼리만으로 수행된다하더라도, 동일한 카테고리들을 중복해서 받아야하는 문제점이 있습니다.
구현이나 코드 측면에서는 더욱 간결해지겠지만, 동일한 데이터를 두 번이나 요청하면서 대역폭을 낭비할 이유는 없습니다.
***
이제 CategoryRepository.findAllOrderByParentIdAscNullsFirstCategoryIdAsc를 통해 계층형 구조로 변환시킬 실마리를 얻었습니다.
이에 대한 자세한 내용은 뒤에서 살펴보도록 하고, CategoryRepository에 대한 테스트를 수행해보겠습니다.
test 디렉토리에서 동일한 패키지 경로 내에 CategoryRepositoryTest를 작성해줍니다.
전체 소스코드는 다음과 같습니다.
package kukekyakya.kukemarket.repository.category;
import kukekyakya.kukemarket.entity.category.Category;
import kukekyakya.kukemarket.exception.CategoryNotFoundException;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.dao.EmptyResultDataAccessException;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.List;
import static java.util.stream.Collectors.*;
import static kukekyakya.kukemarket.factory.entity.CategoryFactory.createCategory;
import static kukekyakya.kukemarket.factory.entity.CategoryFactory.createCategoryWithName;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DataJpaTest
class CategoryRepositoryTest {
@Autowired CategoryRepository categoryRepository;
@PersistenceContext EntityManager em;
@Test
void createAndReadTest() {
// given
Category category = createCategory();
// when
Category savedCategory = categoryRepository.save(category);
clear();
// then
Category foundCategory = categoryRepository.findById(savedCategory.getId()).orElseThrow(CategoryNotFoundException::new);
assertThat(foundCategory.getId()).isEqualTo(savedCategory.getId());
}
@Test
void readAllTest() {
// given
List<Category> categories = List.of("name1", "name2", "name3").stream().map(n -> createCategoryWithName(n)).collect(toList());
categoryRepository.saveAll(categories);
clear();
// when
List<Category> foundCategories = categoryRepository.findAll();
// then
assertThat(foundCategories.size()).isEqualTo(3);
}
@Test
void deleteTest() {
// given
Category category = categoryRepository.save(createCategory());
clear();
// when
categoryRepository.delete(category);
clear();
// then
assertThatThrownBy(() -> categoryRepository.findById(category.getId()).orElseThrow(CategoryNotFoundException::new))
.isInstanceOf(CategoryNotFoundException.class);
}
@Test
void deleteCascadeTest() {
// given
Category category1 = categoryRepository.save(createCategoryWithName("category1"));
Category category2 = categoryRepository.save(createCategory("category2", category1));
Category category3 = categoryRepository.save(createCategory("category3", category2));
Category category4 = categoryRepository.save(createCategoryWithName("category4"));
clear();
// when
categoryRepository.deleteById(category1.getId());
clear();
// then
List<Category> result = categoryRepository.findAll();
assertThat(result.size()).isEqualTo(1);
assertThat(result.get(0).getId()).isEqualTo(category4.getId());
}
@Test
void deleteNoneValueTest() {
// given
Long noneValueId = 100L;
// when, then
assertThatThrownBy(() -> categoryRepository.deleteById(noneValueId))
.isInstanceOf(EmptyResultDataAccessException.class);
}
@Test
void findAllWithParentOrderByParentIdAscNullsFirstCategoryIdAscTest() {
// given
// 1 NULL
// 2 1
// 3 1
// 4 2
// 5 2
// 6 4
// 7 3
// 8 NULL
Category c1 = categoryRepository.save(createCategory("category1", null));
Category c2 = categoryRepository.save(createCategory("category2", c1));
Category c3 = categoryRepository.save(createCategory("category3", c1));
Category c4 = categoryRepository.save(createCategory("category4", c2));
Category c5 = categoryRepository.save(createCategory("category5", c2));
Category c6 = categoryRepository.save(createCategory("category6", c4));
Category c7 = categoryRepository.save(createCategory("category7", c3));
Category c8 = categoryRepository.save(createCategory("category8", null));
clear();
// when
List<Category> result = categoryRepository.findAllOrderByParentIdAscNullsFirstCategoryIdAsc();
// then
// 1 NULL
// 8 NULL
// 2 1
// 3 1
// 4 2
// 5 2
// 7 3
// 6 4
assertThat(result.size()).isEqualTo(8);
assertThat(result.get(0).getId()).isEqualTo(c1.getId());
assertThat(result.get(1).getId()).isEqualTo(c8.getId());
assertThat(result.get(2).getId()).isEqualTo(c2.getId());
assertThat(result.get(3).getId()).isEqualTo(c3.getId());
assertThat(result.get(4).getId()).isEqualTo(c4.getId());
assertThat(result.get(5).getId()).isEqualTo(c5.getId());
assertThat(result.get(6).getId()).isEqualTo(c7.getId());
assertThat(result.get(7).getId()).isEqualTo(c6.getId());
}
void clear() {
em.flush();
em.clear();
}
}
테스트는 이미 익숙해지고 있으므로, 자세한 설명은 생략하겠습니다.
CategoryRepository를 이용한 CRUD와, 상위 카테고리를 제거했을 때 모든 하위 카테고리도 함께 삭제되는지 테스트해보았습니다.
또, 위에서 언급했던 예시 데이터를 통해,
CategoryRepository.findAllOrderByParentIdAscNullsFirstCategoryIdAsc의 수행 결과 또한 테스트해봤습니다.
Category를 생성하는 팩토리 클래스는 다음과 같습니다.
package kukekyakya.kukemarket.factory.entity;
import kukekyakya.kukemarket.entity.category.Category;
public class CategoryFactory {
public static Category createCategory() {
return new Category("name", null);
}
public static Category createCategory(String name, Category parent) {
return new Category(name, parent);
}
public static Category createCategoryWithName(String name) {
return new Category(name, null);
}
}
자세한 설명은 생략하겠습니다.
이제 서비스 로직을 작성할 시간입니다.
service.category.CategoryService를 작성해주겠습니다.
@RequiredArgsConstructor
@Service
@Transactional(readOnly = true)
public class CategoryService {
private final CategoryRepository categoryRepository;
public List<CategoryDto> readAll() {
List<Category> categories = categoryRepository.findAllOrderByParentIdAscNullsFirstCategoryIdAsc();
return CategoryDto.toDtoList(categories);
}
@Transactional
public void create(CategoryCreateRequest req) {
categoryRepository.save(CategoryCreateRequest.toEntity(req, categoryRepository));
}
@Transactional
public void delete(Long id) {
if(notExistsCategory(id)) throw new CategoryNotFoundException();
categoryRepository.deleteById(id);
}
private boolean notExistsCategory(Long id) {
return !categoryRepository.existsById(id);
}
}
모든 카테고리를 조회하고, 카테고리를 생성 및 삭제할 수 있습니다.
생성 및 삭제는 익숙하니 넘어가도록 하고, 모든 카테고리를 조회하는 CategoryService.readAll을 조금 더 세부적으로 살펴보겠습니다.
// CategoryService.java
public List<CategoryDto> readAll() {
List<Category> categories = categoryRepository.findAllOrderByParentIdAscNullsFirstCategoryIdAsc();
return CategoryDto.toDtoList(categories);
}
CategoryRepository를 통해 플랫한 구조의 카테고리를 조회하였습니다.
우리는 이를 계층형 구조로 변환해야합니다.
CategoryDto의 스태틱 메소드 toDtoList에서 categories를 인자로 받고, 그 결과를 반환해주고 있습니다.
dto.category.CategoryDto 를 자세히 살펴보겠습니다.
package kukekyakya.kukemarket.dto.category;
import kukekyakya.kukemarket.entity.category.Category;
import kukekyakya.kukemarket.helper.NestedConvertHelper;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.*;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CategoryDto {
private Long id;
private String name;
private List<CategoryDto> children;
public static List<CategoryDto> toDtoList(List<Category> categories) {
NestedConvertHelper helper = NestedConvertHelper.newInstance(
categories,
c -> new CategoryDto(c.getId(), c.getName(), new ArrayList<>()),
c -> c.getParent(),
c -> c.getId(),
d -> d.getChildren());
return helper.convert();
}
}
플랫한 구조의 카테고리 목록을, 계층형 구조로 어디서 변환 하나 했더니,
CategoryDto.toDtoList에서 categories를 인자로 받아다가 계층형 구조로 변환하여 반환해주고 있었습니다.
NestedConvertHelper라는 헬퍼 클래스를 통해 카테고리를 중첩 구조로 변환하고 있던 것입니다.
그렇다면 이제 플랫한 구조의 카테고리 목록을, 계층형으로 변환하기 위한 실질적인 작업을 수행하는 helper.NestedConvertHelper를 살펴보도록 하겠습니다.
package kukekyakya.kukemarket.helper;
import kukekyakya.kukemarket.exception.CannotConvertNestedStructureException;
import java.util.*;
import java.util.function.Function;
public class NestedConvertHelper<K, E, D> {
private List<E> entities;
private Function<E, D> toDto;
private Function<E, E> getParent;
private Function<E, K> getKey;
private Function<D, List<D>> getChildren;
public static <K, E, D> NestedConvertHelper newInstance(List<E> entities, Function<E, D> toDto, Function<E, E> getParent, Function<E, K> getKey, Function<D, List<D>> getChildren) {
return new NestedConvertHelper<K, E, D>(entities, toDto, getParent, getKey, getChildren);
}
private NestedConvertHelper(List<E> entities, Function<E, D> toDto, Function<E, E> getParent, Function<E, K> getKey, Function<D, List<D>> getChildren) {
this.entities = entities;
this.toDto = toDto;
this.getParent = getParent;
this.getKey = getKey;
this.getChildren = getChildren;
}
public List<D> convert() {
try {
return convertInternal();
} catch (NullPointerException e) {
throw new CannotConvertNestedStructureException(e.getMessage());
}
}
private List<D> convertInternal() {
Map<K, D> map = new HashMap<>();
List<D> roots = new ArrayList<>();
for (E e : entities) {
D dto = toDto(e);
map.put(getKey(e), dto);
if (hasParent(e)) {
E parent = getParent(e);
K parentKey = getKey(parent);
D parentDto = map.get(parentKey);
getChildren(parentDto).add(dto);
} else {
roots.add(dto);
}
}
return roots;
}
private boolean hasParent(E e) {
return getParent(e) != null;
}
private E getParent(E e) {
return getParent.apply(e);
}
private D toDto(E e) {
return toDto.apply(e);
}
private K getKey(E e) {
return getKey.apply(e);
}
private List<D> getChildren(D d) {
return getChildren.apply(d);
}
}
복잡해보이지만, 실상은 그렇게 복잡하지 않습니다.
세부적으로 살펴보겠습니다.
public class NestedConvertHelper<K, E, D> {
...
NestedConvertHelper는 세 개의 제너릭 파라미터를 받습니다.
K : 엔티티의 key 타입
E : 엔티티의 타입
D : 엔티티가 변환된 DTO의 타입.
public class NestedConvertHelper<K, E, D> {
private List<E> entities;
private Function<E, D> toDto;
private Function<E, E> getParent;
private Function<E, K> getKey;
private Function<D, List<D>> getChildren;
NestedConvertHelper는 다음과 같은 네 개의 필드를 가지고 있습니다.
entities : 플랫한 구조의 엔티티 목록입니다. 우리가 약속했던 방법(CategoryRepository.findAllOrderByParentIdAscNullsFirstCategoryIdAsc)으로 정렬된 엔티티 목록을 전달받으면, 각 엔티티가 자식 엔티티를 나타내는 계층형 구조의 DTO 목록으로 변환시켜줄 것입니다.
toDto : 엔티티를 DTO로 변환해주는 Function입니다.
getParent : 엔티티의 부모 엔티티를 반환해주는 Function입니다.
getKey : 엔티티의 Key(우리는 id)를 반환해주는 Function입니다.
getChildren: DTO의 children 리스트를 반환해주는 Function입니다.
우리는 기존에 작성된 엔티티와 DTO는 전혀 건드리지 않을 것입니다.
엔티티가 변환되어야 할 DTO 타입은, 이미 엔티티에 대해서 알고 있습니다.
엔티티의 수정 없이 entities를 계층형 DTO 구조로 변환하기 위해, 필요한 함수들을 DTO를 통해서 주입받아서 사용할 것입니다.
public static <K, E, D> NestedConvertHelper newInstance(List<E> entities, Function<E, D> toDto, Function<E, E> getParent, Function<E, K> getKey, Function<D, List<D>> getChildren) {
return new NestedConvertHelper<K, E, D>(entities, toDto, getParent, getKey, getChildren);
}
private NestedConvertHelper(List<E> entities, Function<E, D> toDto, Function<E, E> getParent, Function<E, K> getKey, Function<D, List<D>> getChildren) {
this.entities = entities;
this.toDto = toDto;
this.getParent = getParent;
this.getKey = getKey;
this.getChildren = getChildren;
}
NestedConvertHelper의 인스턴스를 생성하는 스태틱 팩토리 메소드 newInstance입니다.
스태틱 메소드이기 때문에 메소드 레벨에 제너릭 타입 파라미터를 지정하였습니다.
전달받는 함수를 이용한 타입 추론에 의해 각각의 제너릭 타입이 추론될 것입니다.
내부적으로 private 생성자를 이용하여 인스턴스를 생성하고 초기화합니다.
public List<D> convert() {
try {
return convertInternal();
} catch (NullPointerException e) {
throw new CannotConvertNestedStructureException(e.getMessage());
}
}
인스턴스를 생성했다면, 퍼블릭 메소드 convert를 통해 계층형 변환 작업을 수행할 수 있습니다.
단, 우리는 입력받은 entities에 대해 사전 조건이 필요합니다.
부모 카테고리의 id를 오름차순으로 정렬되어있어야하기 때문에,
entities를 순차적으로 탐색하면서, 어떤 카테고리의 부모 카테고리 id는 반드시 해당 카테고리보다 앞서서 탐색된 상황이어야합니다.
만약 그렇지 않다면, NullPointerExceptino이 발생할 것이고, CannotConvertNestedStructureException 예외가 발생하여 우리는 entities를 계층형 구조로 변환할 수 없습니다.
private List<D> convertInternal() {
Map<K, D> map = new HashMap<>();
List<D> roots = new ArrayList<>();
for (E e : entities) { // 1
D dto = toDto(e);
map.put(getKey(e), dto); // 2
if (hasParent(e)) { // 3
E parent = getParent(e);
K parentKey = getKey(parent);
D parentDto = map.get(parentKey);
getChildren(parentDto).add(dto);
} else {
roots.add(dto); // 4
}
}
return roots;
}
실질적인 변환 작업을 수행합니다.
roots에는 자식 엔티티가 없는 루트 엔티티가 담기게 되고, 최종적인 반환 값은 roots가 될 것입니다.
그리고 roots에 담긴 DTO의 children들은, 자신의 자식들을 담고 있습니다.
이를 구현하기 위해 Map을 이용하였습니다.
1. 우리의 사전 조건에 의해 정렬된 entities를 순차적으로 탐색합니다.
2. 탐색된 엔티티를 DTO로 변환하여 map에 넣어줍니다. 이미 탐색된 부모 엔티티의 DTO는, 어떤 자식 엔티티를 탐색할 때 반드시 Map에 담겨있어야합니다. 그렇지 않다면 NullPointerException 예외가 발생하여 변환 작업이 실패하는 것입니다.
3. 부모가 있다면, Map에서 부모의 DTO를 찾아줍니다. entities는 우리의 의도에 맞게 정렬된 상태일 것이기 때문에, 반드시 부모 엔티티의 DTO는 Map에 이미 있을 것입니다. 부모 DTO의 children으로, 지금 탐색하는 엔티티의 DTO를 넣어줍니다.
4. 부모가 없다면, 루트 엔티티입니다. 해당 DTO를 roots에 넣어줍니다.
위 알고리즘에 의해 NestedConvertHelper가 전달받은 entites는 계층형 구조로 변환되는 것입니다.
private boolean hasParent(E e) {
return getParent(e) != null;
}
private E getParent(E e) {
return getParent.apply(e);
}
private D toDto(E e) {
return toDto.apply(e);
}
private K getKey(E e) {
return getKey.apply(e);
}
private List<D> getChildren(D d) {
return getChildren.apply(d);
}
주입받은 Function을 통해 다양한 메소드를 작성할 수 있습니다.
그렇다면 이제 다시 CategoryDto.toDtoList로 돌아가보도록 하겠습니다.
// CategoryDto.java
public static List<CategoryDto> toDtoList(List<Category> categories) {
NestedConvertHelper helper = NestedConvertHelper.newInstance(
categories,
c -> new CategoryDto(c.getId(), c.getName(), new ArrayList<>()),
c -> c.getParent(),
c -> c.getId(),
d -> d.getChildren());
return helper.convert();
}
이제 어떤 코드인지 알게 되었습니다.
NestedConvertHelper의 인스턴스를 생성하기 위해,
첫번째 인자로 계층형 구조로 변환할 엔티티 목록을,
두번째 인자로 엔티티를 DTO로 변환하는 함수를,
세번째 인자로 엔티티의 부모를 반환하는 함수를,
네번째 인자로 엔티티의 ID를 반환하는 함수를,
다섯번째 인자로 DTO의 자식 목록을 반환하는 함수를 지정해준 것입니다.
이를 이용하여 NestedConvertHelper.convert는 계층형 구조로 변환 작업을 수행하고, toDtoList는 계층형 구조의 CategoryDto 리스트를 반환하는 것입니다.
응답된 리스트에는 루트 DTO들이 있을 것이고, 각 루트 DTO는 children에 하위 DTO를 재귀적으로 소유하고 있을 것입니다.
* 우리는 요구 사항에 의해, 카테고리 뿐만 아니라 대댓글도 계층형으로 만들어야합니다. 이를 위해 계층형 구조 변환을 도와주는 헬퍼 클래스를 별도로 작성한 것이고, 대댓글 기능을 구현할 때도 재사용할 수 있을 것입니다.
이제 이렇게 작성된 NestedConvertHelper를 테스트 해보겠습니다.
test 디렉토리에서 동일한 패키지 경로 내에 NestedConvertHelperTest를 작성해줍시다.
package kukekyakya.kukemarket.helper;
import kukekyakya.kukemarket.exception.CannotConvertNestedStructureException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class NestedConvertHelperTest {
private static class MyEntity { // 1
private Long id;
private String name;
private MyEntity parent;
public MyEntity(Long id, String name, MyEntity parent) {
this.id = id;
this.name = name;
this.parent = parent;
}
public MyEntity getParent() {
return parent;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
private static class MyDto { // 2
private Long id;
private String name;
private List<MyDto> children;
public MyDto(Long id, String name, List<MyDto> children) {
this.id = id;
this.name = name;
this.children = children;
}
public List<MyDto> getChildren() {
return children;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
@Test
void convertTest() {
// given
// 1 NULL
// 8 NULL
// 2 1
// 3 1
// 4 2
// 5 2
// 7 3
// 6 4
MyEntity m1 = new MyEntity(1L,"myEntity1", null);
MyEntity m8 = new MyEntity(8L,"myEntity8", null);
MyEntity m2 = new MyEntity(2L,"myEntity2", m1);
MyEntity m3 = new MyEntity(3L,"myEntity3", m1);
MyEntity m4 = new MyEntity(4L,"myEntity4", m2);
MyEntity m5 = new MyEntity(5L,"myEntity5", m2);
MyEntity m7 = new MyEntity(7L,"myEntity7", m3);
MyEntity m6 = new MyEntity(6L,"myEntity6", m4);
List<MyEntity> myEntities = List.of(m1, m8, m2, m3, m4, m5, m7, m6);
NestedConvertHelper helper = NestedConvertHelper.newInstance(
myEntities,
e -> new MyDto(e.getId(), e.getName(), new ArrayList<>()),
e -> e.getParent(),
e -> e.getId(),
d -> d.getChildren()
);
// when
List<MyDto> result = helper.convert();
// then
// 1
// 2
// 4
// 6
// 5
// 3
// 7
// 8
assertThat(result.size()).isEqualTo(2);
assertThat(result.get(0).getId()).isEqualTo(1);
assertThat(result.get(0).getChildren().size()).isEqualTo(2);
assertThat(result.get(0).getChildren().get(0).getId()).isEqualTo(2);
assertThat(result.get(0).getChildren().get(0).getChildren().size()).isEqualTo(2);
assertThat(result.get(0).getChildren().get(0).getChildren().get(0).getId()).isEqualTo(4);
assertThat(result.get(0).getChildren().get(0).getChildren().get(0).getChildren().size()).isEqualTo(1);
assertThat(result.get(0).getChildren().get(0).getChildren().get(0).getChildren().get(0).getId()).isEqualTo(6);
assertThat(result.get(0).getChildren().get(0).getChildren().get(1).getId()).isEqualTo(5);
assertThat(result.get(0).getChildren().get(1).getId()).isEqualTo(3);
assertThat(result.get(0).getChildren().get(1).getChildren().size()).isEqualTo(1);
assertThat(result.get(0).getChildren().get(1).getChildren().get(0).getId()).isEqualTo(7);
assertThat(result.get(1).getId()).isEqualTo(8);
assertThat(result.get(1).getChildren().size()).isEqualTo(0);
}
@Test
@DisplayName("어떤 자식의 부모는, 반드시 자식보다 앞서야 한다.")
void convertExceptionByNotOrderedValueTest() {
// given
// 1 NULL
// 8 NULL
// 3 1
// 4 2
// 2 1
// 5 2
// 7 3
// 6 4
MyEntity m1 = new MyEntity(1L,"myEntity1", null);
MyEntity m8 = new MyEntity(8L,"myEntity8", null);
MyEntity m2 = new MyEntity(2L,"myEntity2", m1);
MyEntity m3 = new MyEntity(3L,"myEntity3", m1);
MyEntity m4 = new MyEntity(4L,"myEntity4", m2);
MyEntity m5 = new MyEntity(5L,"myEntity5", m2);
MyEntity m7 = new MyEntity(7L,"myEntity7", m3);
MyEntity m6 = new MyEntity(6L,"myEntity6", m4);
List<MyEntity> myEntities = List.of(m1, m8, m3, m4, m2, m5, m7, m6);
NestedConvertHelper helper = NestedConvertHelper.newInstance(
myEntities,
e -> new MyDto(e.getId(), e.getName(), new ArrayList<>()),
e -> e.getParent(),
e -> e.getId(),
d -> d.getChildren()
);
// when, then
assertThatThrownBy(() -> helper.convert())
.isInstanceOf(CannotConvertNestedStructureException.class);
}
@Test
@DisplayName("부모가 없는 루트는, 항상 제일 앞에 있어야 한다.")
void convertExceptionByNotOrderedValueNullsLastTest() {
// given
// 2 1
// 3 1
// 4 2
// 5 2
// 7 3
// 6 4
// 1 NULL
// 8 NULL
MyEntity m1 = new MyEntity(1L,"myEntity1", null);
MyEntity m8 = new MyEntity(8L,"myEntity8", null);
MyEntity m2 = new MyEntity(2L,"myEntity2", m1);
MyEntity m3 = new MyEntity(3L,"myEntity3", m1);
MyEntity m4 = new MyEntity(4L,"myEntity4", m2);
MyEntity m5 = new MyEntity(5L,"myEntity5", m2);
MyEntity m7 = new MyEntity(7L,"myEntity7", m3);
MyEntity m6 = new MyEntity(6L,"myEntity6", m4);
List<MyEntity> myEntities = List.of(m2, m3, m4, m5, m7, m6, m1, m8);
NestedConvertHelper helper = NestedConvertHelper.newInstance(
myEntities,
e -> new MyDto(e.getId(), e.getName(), new ArrayList<>()),
e -> e.getParent(),
e -> e.getId(),
d -> d.getChildren()
);
// when, then
assertThatThrownBy(() -> helper.convert())
.isInstanceOf(CannotConvertNestedStructureException.class);
}
}
1. 테스트를 위해 임의의 엔티티 클래스를 작성해주었습니다.
2. 테스트를 위해 임의의 DTO 클래스를 작성해주었습니다.
이렇게 작성된 엔티티와 DTO 클래스로, 위 예시에서 살펴봤던 데이터로 테스트를 진행하였습니다.
자신의 부모만 알고 있던 플랫한 구조의 엔티티 목록이, 중첩된 계층형 구조의 DTO 목록으로 변환되었습니다.
NestedConvertHelper로 주입받은 entities이 조건에 맞게 정렬되어있지 않다면,
변환은 올바르게 수행될 수 없어서 NullPointerException이 발생하게 되고, 결국 CannotConvertNestedStructureException이 발생하게 됩니다.
각 테스트에 대한 내용은 코드를 참조 바랍니다.
내용이 길어져서 한 번 끊고 가겠습니다.
이번 시간에는 단일한 쿼리로 카테고리 목록을 조회하여, 직접 정의한 NestedConvertHelper라는 헬퍼 클래스를 통해, 자신의 부모만 알고 있던 엔티티 목록을, 자신의 자식들을 알고 있는 계층형 구조의 DTO 목록으로 변환할 수 있었습니다.
다음 시간에는, 계층형 카테고리를 이어서 살펴보겠습니다.
* 질문 및 피드백은 환영입니다.
* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.
https://github.com/SongHeeJae/kuke-market
'Spring > 게시판 만들기' 카테고리의 다른 글
스프링부트 게시판 API 서버 만들기 (17) - 게시판 - 계층형 카테고리 - 3 (4) | 2021.12.08 |
---|---|
스프링부트 게시판 API 서버 만들기 (16) - 게시판 - 계층형 카테고리 - 2 (0) | 2021.12.08 |
스프링부트 게시판 API 서버 만들기 (14) - Swagger로 API 문서 만들기 (0) | 2021.12.07 |
스프링부트 게시판 API 서버 만들기 (13) - 코드 리팩토링 (0) | 2021.12.06 |
스프링부트 게시판 API 서버 만들기 (12) - 로그인 - 8 - 인증 및 인가 - 4(마무리) (4) | 2021.12.05 |