로그인 기능을 완성하였으니 이제 게시판 기능을 구현해보겠습니다.
요구 사항
먼저 게시판 기능에 대한 요구사항을 정리하고 넘어가겠습니다. 게시판 기능에는 다음과 같은 항목들이 요구됩니다.
- 계층형 카테고리
- 물품 판매 게시글 CRUD
- 게시글 조건 검색
- 계층형 대댓글
- 게시글 별 쪽지 송수신
- 페이지 번호를 이요한 페이징 처리(게시글 조회)
- 무한 스크롤을 이용한 페이징 처리(쪽지 및 매매 내역 조회)
게시판 기능의 최종 목표는 회원들이 게시글을 작성하고 댓글을 달아 소통할 수 있도록 하는 것입니다.
계층형 카테고리
카데고리는 계층형 구조 입니다. 각각의 카테고리는 하위 카테고리를 가질 수 있습니다.
Category Entity 생성
@Entity //JPA Entity임을 나타내는 어노테이션
@Getter // Category 클래스의 필드에 대한 Getter 메서드 제공
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 기본 생성자 제공 (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) // 부모 카테고리가 살제될 때 부모 카테고리에 속하는 모든 카테고리 삭제
private Category parent;
public Category(String name, Category parent) {
this.name = name;
this.parent = parent;
}
}
카테고리에는 id, name, parent 필드가 있습니다. 카테고리를 생성할 때 부모 카테고리가 없다면 null이고 있다면 부모의 id를 참고합니다. 그리고 @OnDelete 어노테이션을 설정해줌으로써 해당 카테고리를 삭제했을 때 하위 카테고리들도 모두 삭제 됩니다.
각 어노테이션에 대한 설명은 코드를 참조해주세요.
Category Repositroy
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();
}
CategoryRepository는 JpaRepository를 상속하여 Category 엔티티와 관련된 여러가지 테이터 접근 작업을 수행합니다. 추가로 @Query를 사용하여 부모카테고리의 id 순으로 정렬해주는 메소드를 추가합니다.
@Query안의 내용을 SQL문으로 바꿔서 확인해보면
SELECT c.*
FROM category c
LEFT JOIN category p ON c.parent_id = p.category_id
ORDER BY p.category_id ASC NULLS FIRST, c.category_id ASC;
먼저 부모의 id 가 NULL인 항목을 먼저 앞으로 보내고 그 다음 부모의 id를 기준으로 오름차순 정렬합니다.
Category Repository 테스트
CategoryRepositry에 대한 테스트를 진행하겠습니다. 테스트 항목은 다음과 같습니다.(설명은 생략)
- 생성 및 읽기 테스트
- 모두 읽기 테스트
- 삭제 테스트
- 삭제 Cascade 테스트(부모 카테고리를 지웠을 때 하위 카테고리들도 삭제 되는지)
- 없는 항목을 삭제 테스트
- 부모id 기준 정렬 테스트
@DataJpaTest
class CategoryRepositoryTest {
@Autowired CategoryRepository categoryRepository;
@PersistenceContext EntityManager em;
@Test
public void createAndReadTest(){
//given
Category category = createCategory();
//when
Category savedCategory = categoryRepository.save(category);
clear();
//then
Category findCategory = categoryRepository.findById(savedCategory.getId()).orElseThrow(CategoryNotFoundException::new);
assertThat(findCategory.getId()).isEqualTo(category.getId());
}
@Test
public void readAllTest(){
//given
List<Category> categories = List.of("category1", "category2", "category3")
.stream()
.map(category_name -> createCategoryWithName(category_name))
.collect(Collectors.toList());
//when
List<Category> findCategories = categoryRepository.saveAll(categories);
//then
for (int i = 0; i < categories.size(); i++) {
assertThat(findCategories.get(i).getName()).isEqualTo(categories.get(i).getName());
}
}
@Test
public 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"));
//when
categoryRepository.deleteById(category1.getId());
clear();
//then
List<Category> categories = categoryRepository.findAll();
for (Category category : categories) {
System.out.println("category.getName() = " + category.getName());
}
assertThat(categories.size()).isEqualTo(1);
assertThat(categories.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();
}
}
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);
}
}
Category Service
@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);
}
}
read
public List<CategoryDto> readAll() {
List<Category> categories = categoryRepository.findAllOrderByParentIdAscNullsFirstCategoryIdAsc();
return CategoryDto.toDtoList(categories);
}
CategoryService 클래스에는 read, create, delete 메서드가 있습니다. 먼저 read 먼저 확인해보겠습니다.
CategoryDto
Category 엔티티에 접근하기 위한 Dto를 생성하겠습니다.
@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메서드는 Category 목록을 받아서 Dto 목록을 리턴해줍니다. 먼저 NestedConvertHelper를 살펴보고 toDoList가 어떤 반환 값을 가지는지 살펴보겠습니다.
NestedConvertHelper
public class NestedConvertHelper<K, E, D> {
// K : key, E : Entity, D : DTO
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);
}
}
NestedConvertHelper 클래스는 다음 항목을 인자로 받습니다.
- entites : 엔티티 리스트
- toDto : 엔티티를 DTO로 변환하는 함수
- getParent : 부모 엔티티를 찾는 함수
- getKey : 엔티티의 키 값을 추출하는 함수
- getChildren : DTO의 자식 DTO 리스트를 추출하는 함수
NestedConvertHelper 클래스에서 convert() 함수를 통해 변환 작업을 수행합니다. 이 함수에서 엔티티들을 순회하면서 각각의 엔티티를 DTO로 변환하고 해당 dTO를 부모 DTO의 자식 리스트에 추가 합니다. 이 작업을 수행하면서 dTO를 저장하는 Map을 이용하여 이미 생성된 DTO를 재사용하도록 합니다. 마지막으로 변환된 루트 DTO 리스트를 반환합니다.
toDtoList 호출
이제 다시 CategoryDto로 돌아와서 어떻게 NestConvertHelper의 convert() 메서드를 호출하는지 알아보겠습니다.
public static List<CategoryDto> toDtoList(List<Category> categories) {
NestedConvertHelper helper = NestedConvertHelper.newInstance(
categories,
category -> new CategoryDto(category.getId(), category.getName(), new ArrayList<>()),
category -> category.getParent(),
category -> category.getId(),
categoryDto -> categoryDto.getChildren());
return helper.convert();
}
- 첫번 째 인자로 categories를 넘기면서 NestedConvertHandler<K, E, D> 의 E 타입을 알 수 있습니다. E는 Category Entity 타입입니다.
- 두 번째 인자로category -> new CategoryDto(category.getId(), category.getName(), new ArrayList<>()), 를 넘깁니다. 받는쪽의 인자를 살펴보면 Function<E, D> toDto 형태인데, E는 Category Entity 타입입니다. 다시 넘기는 인자를 타입으로 바꿔서 보면 Category -> CategoryDto 입니다. 즉 D는 CategoryDto라는 것을 확인할 수 있습니다.
- 세 번째, 네 번째 인자도 동일하게 따라가면 타입을 알 수 있고 어떻게 호출이 되는지 파악할 수 있습니다.
NestedConvertHelper 테스트
테스트를 위해 임시로 Entity와 Dto를 생성했습니다. 그 외에 테스트 설명은 생략합니다.
class NestedConvertHelperTest {
private static class MyEntity {
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 {
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);
}
}
궁금한신점이나 잘못된 부분이 있으면 자유롭게 댓글 달아주세요.
github : https://github.com/jaeyeon423/spring_board_api
'spring > 게시판 api' 카테고리의 다른 글
Spring boot 게시판 API 서버 제작 (15) - 게시판 - 카테고리 - 웹 계층 구현 (0) | 2023.02.26 |
---|---|
Spring boot 게시판 API 서버 제작 (14) - 게시판 - 카테고리 - 2 (0) | 2023.02.25 |
Spring boot 게시판 API 서버 제작 (12) - 로그인 - api 문서 만들기 (1) | 2023.02.23 |
Spring boot 게시판 API 서버 제작 (11) - 로그인 - Token 코드 리팩토링 (0) | 2023.02.23 |
Spring boot 게시판 API 서버 제작 (11) - 로그인 - 인증 refresh token으로 access token발급 (0) | 2023.02.22 |