카테고리 기능을 구현하기로 했다.
처음에는 카테고리의 구조가 확정이 안 되어 있고, 객체로 변환 처리를 어떻게 할까 고민하다가 그냥 도메인 객체를 NoSQL에 저장해 버리면 어떨까 생각했다. 하지만 나중에 생각해 보니 카테고리를 수정하거나 삭제할 때, 문제가 발생할 여지가 있었고, 카테고리의 계층 간 엄격한 관리가 필요하다는 것을 반영하여 RDBMS를 사용하기로 했다. 카테고리는 추가나 수정이 빈번하게 이루어지지 않기 때문에 조회에서 발생하는 성능의 차이는 나중에 캐시를 사용해서 극복할 수도 있었다. 그래서 SQL로 카테고리를 조회하는 기능을 구현해 보았다.
준비하기
RDBMS는 MariaDB를 사용하였다.
사실 계층형 쿼리를 사용하기 쉬운 DBMS는 오라클이다. 그냥 START WITH를 사용하면 되기 때문이다.
하지만 프로젝트에서 전반적으로 사용하는 건 MariaDB였고, 생산성을 위해 통일하기로 했다.
단, MariaDB에서는 START WITH가 없고, 약간 더 복잡한 방법을 사용해야 한다.
create table categories(
id bigint primary key auto_increment,
parent_id bigint,
level integer not null,
name varchar(100),
created_at datetime not null,
updated_at datetime not null,
foreign key (parent_id) references categories(id)
)
테이블 구조는 위와 같다.
사실 level은 당장은 필요가 없지만 나중에 하위 요소의 레벨을 3단계로 제한하거나 할 때 사용할 수도 있어서 일단 넣어놓았다.
WITH RECURSIVE ct AS (
SELECT *
FROM categories
WHERE parent_id IS NULL AND id = :id
UNION ALL
SELECT c.*
FROM categories c
INNER JOIN ct on c.parent_id = ct.id
)
SELECT * FROM ct ORDER BY id;
위와 같이 재귀적인 구조를 만들어 처리할 수 있다.
먼저 최상위 카테고리는 부모 카테고리가 없을 테니 parent_id가 NULL이다.
부모 행을 먼저 조회하고, 자식 카테고리들은 부모 id를 이용해서 조회가 된다.
구현하기
public interface CategoryJpaRepository extends JpaRepository<CategoryEntity, Long> {
List<CategoryEntity> findAllByOrderByIdAsc();
List<CategoryEntity> findAllByParentIdIsNull();
@Query(value = "WITH RECURSIVE ct AS (" +
" SELECT *" +
" FROM categories" +
" WHERE parent_id IS NULL AND id = :id" +
" UNION ALL" +
" SELECT c.*" +
" FROM categories c" +
" INNER JOIN ct on c.parent_id = ct.id" +
")" +
"SELECT * FROM ct ORDER BY id;", nativeQuery = true)
List<CategoryEntity> findOneTreeById(@Param("id") Long id);
}
JPA에서는 재귀 쿼리의 임시 테이블을 사용할 수 없다고 한다. 그래서 NativeQuery를 사용하였는데 JdbcTemplate을 사용할까 고민하다가 직접 쿼리를 작성하는 것은 더 없을 것 같아서 일단 @Query로 작성해 주었다.
일단 테스트용으로 위와 같이 데이터를 넣어주었다. 그림으로 표현하면 아래와 같다.
최상위 카테고리 id 중 하나인 parent1의 id를 이용해 위의 쿼리를 실행해 보자.
일단 조회는 잘 되는 것으로 보인다.
이제 이 결과를 자바의 객체로 변환해야 한다.
그런데 어떻게 매핑을 하는 것이 좋을까? 일단 엔티티 객체와 도메인 모델을 살펴보자.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@Table(name = "categories")
@Entity
public class CategoryEntity extends BaseDateTime {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "parent_id")
private Long parentId;
private Integer level;
private String name;
public Category toModel() {
return Category.builder()
.id(id)
.parentId(parentId)
.name(name)
.level(level)
.build();
}
public static CategoryEntity from(Category category) {
return CategoryEntity.builder()
.id(category.getId())
.parentId(category.getParentId())
.name(category.getName())
.level(category.getLevel())
.build();
}
public CategoryView toView(List<CategoryView> categoryList) {
return CategoryView.builder()
.id(id)
.parentId(parentId)
.name(name)
.level(level)
.childCategoryList(categoryList)
.build();
}
}
@ToString
@Getter
@Builder
public class Category {
private final Long id;
private final Long parentId;
private final Integer level;
private final String name;
}
나는 도메인 영역을 인프라 영역이 침범하지 않도록 도메인 객체와 엔티티 객체를 분리했다.
그런데 어디를 봐도 자식 카테고리를 가져오는 부분이 보이지 않는다.
실제로 DB에도 부모가 자식 행을 모두 가지고 있지 않기 때문에 코드상에서 따로 처리를 해주어야 한다.
그래서 조회용 모델을 따로 만들었다.
@Getter
@Builder
public class CategoryView {
private final Long id;
private final Long parentId;
private final Integer level;
private final String name;
private final List<CategoryView> childCategoryList;
}
위와 같이 만들면 자식 카테고리를 담아둘 수 있기 때문에 원하던 대로 구현할 수 있다.
이제 문제는 자식들을 찾아서 저 리스트에 넣는 것이다.
@RequiredArgsConstructor
@Repository
public class CategoryRepositoryImpl implements CategoryRepository {
private final CategoryJpaRepository categoryJpaRepository;
/*
다른 메서드 생략
*/
@Override
public List<CategoryView> findOneTreeById(Long id) {
List<CategoryEntity> result = categoryJpaRepository.findOneTreeById(id);
return getCategoryViews(result);
}
private static List<CategoryView> getCategoryViews(List<CategoryEntity> categoryEntityList) {
Map<Long, CategoryView> categoryMap = new HashMap<>();
List<CategoryView> result = new ArrayList<>();
categoryEntityList.forEach(entity -> {
CategoryView category = entity.toView(new ArrayList<>());
categoryMap.put(entity.getId(), category);
// 부모 노드일 때
if (entity.getParentId() == null) {
result.add(category);
} else { // 자식 노드일 때
CategoryView parentNode = categoryMap.get(entity.getParentId());
parentNode.getChildCategoryList().add(category);
}
});
return result;
}
}
생각해 보면 카테고리의 하위 요소는 무조건 부모보다 나중에 만들어진다. 그렇기 때문에 자동 증가 id 또는 created_at과 같은 속성으로 정렬을 하고 그대로 객체로 변환하면 위에서 아래로 내려오는 형태로 구성할 수 있다.
먼저, 최상위 카테고리는 결과 리스트에 추가한다.
사실 단일 카테고리 조회라 최상위 카테고리가 하나기 때문에 리스트에 추가하지 않아도 되지만 다른 전체 카테고리 조회 메서드에서 동일한 로직을 사용하기 때문에 재사용을 위해 이렇게 사용했다.
그다음에는 Map에 id를 Key로 부모 카테고리를 추가해 놓고, 이후에 나오는 자식 카테고리의 parentId를 통해 Map에서 부모 카테고리를 찾아 부모 카테고리의 자식 리스트에 자신을 추가한다.
이렇게 하면 원하던 대로 동작하도록 할 수 있다.
테스트해 보기
@ActiveProfiles("localdb")
@Import(CategoryRepositoryImpl.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@DataJpaTest
class CategoryRepositoryImplTest {
@Autowired CategoryRepositoryImpl categoryRepositoryImpl;
Category parent1, parent2;
Category child1, child2, child3;
Category childChild1;
@BeforeEach
void init() {
parent1 = Category.builder()
.parentId(null)
.name("parent1")
.level(0)
.build();
parent2 = Category.builder()
.parentId(null)
.name("parent2")
.level(0)
.build();
Long parentId = categoryRepositoryImpl.save(parent1);
categoryRepositoryImpl.save(parent2);
child1 = Category.builder()
.parentId(parentId)
.name("child1")
.level(1)
.build();
child2 = Category.builder()
.parentId(parentId)
.name("child2")
.level(1)
.build();
child3 = Category.builder()
.parentId(parentId)
.name("child3")
.level(1)
.build();
Long child1Id = categoryRepositoryImpl.save(child1);
categoryRepositoryImpl.save(child2);
categoryRepositoryImpl.save(child3);
childChild1 = Category.builder()
.parentId(child1Id)
.name("childChild1")
.level(2)
.build();
categoryRepositoryImpl.save(childChild1);
}
/*
다른 테스트 생략
*/
@DisplayName("특정 카테고리 트리만 조회에 성공한다.")
@Test
void findOneCategoryTreeSuccess() {
// given
// when
List<Category> allParent = categoryRepositoryImpl.findAllParent();
Long parent1Id = allParent.get(0).getId();
List<CategoryView> oneTreeById = categoryRepositoryImpl.findOneTreeById(parent1Id);
CategoryView parent1View = oneTreeById.get(0);
CategoryView child1View = parent1View.getChildCategoryList().get(0);
CategoryView child2View = parent1View.getChildCategoryList().get(1);
CategoryView child3View = parent1View.getChildCategoryList().get(2);
CategoryView childChild1View = child1View.getChildCategoryList().get(0);
// then
assertThat(oneTreeById.size()).isEqualTo(1);
assertThat(parent1View.getId()).isNotNull();
assertThat(parent1View.getParentId()).isNull();
assertThat(parent1View.getName()).isEqualTo(parent1.getName());
assertThat(parent1View.getLevel()).isEqualTo(parent1.getLevel());
assertThat(parent1View.getChildCategoryList().size()).isEqualTo(3);
assertThat(child1View.getId()).isNotNull();
assertThat(child1View.getParentId()).isEqualTo(parent1View.getId());
assertThat(child1View.getName()).isEqualTo(child1.getName());
assertThat(child1View.getLevel()).isEqualTo(child1.getLevel());
assertThat(child1View.getChildCategoryList().size()).isEqualTo(1);
assertThat(child2View.getId()).isNotNull();
assertThat(child2View.getParentId()).isEqualTo(parent1View.getId());
assertThat(child2View.getName()).isEqualTo(child2.getName());
assertThat(child2View.getLevel()).isEqualTo(child2.getLevel());
assertThat(child2View.getChildCategoryList().size()).isEqualTo(0);
assertThat(child3View.getId()).isNotNull();
assertThat(child3View.getParentId()).isEqualTo(parent1View.getId());
assertThat(child3View.getName()).isEqualTo(child3.getName());
assertThat(child3View.getLevel()).isEqualTo(child3.getLevel());
assertThat(child3View.getChildCategoryList().size()).isEqualTo(0);
assertThat(childChild1View.getId()).isNotNull();
assertThat(childChild1View.getParentId()).isEqualTo(child1View.getId());
assertThat(childChild1View.getName()).isEqualTo(childChild1.getName());
assertThat(childChild1View.getLevel()).isEqualTo(childChild1.getLevel());
assertThat(childChild1View.getChildCategoryList().size()).isEqualTo(0);
}
}
처음에 데이터를 추가하는 부분에서 테스트를 해야 하는 로직이 들어가 있어서 맘에 들지는 않지만 다른 테스트에서 사용하고 있기 때문에 나중에 고치기로 하고 일단 진행했다.
참고로 테스트로 연결한 객체간 관계는 위에서 DB에 추가한 데이터와 다르다.
잘 동작하는 것 같다.
문제는 없겠지만 혹시 모르니 전체 테스트를 돌려보자.
다행히 전부 통과했다.
그런데 이렇게만 테스트하면 재미도 없고 실제로 보이는 것도 궁금하니 한번 직접 실행해서도 확인해 보자.
@RequestMapping("/category")
@RequiredArgsConstructor
@RestController
public class CategoryController {
private final CategoryService categoryService;
/*
다른 메서드 생략
*/
@GetMapping("/{id}")
public List<CategoryView> findOneTree(@PathVariable Long id) {
return categoryService.findOneTreeById(id);
}
}
서비스는 단순히 Repository의 데이터를 가져오는 역할밖에 안 해서 생략했다.
일단 전체 최상위 카테고리를 조회하고...
id를 이용해 해당 카테고리의 자식 카테고리를 확인해 보자.
생각했던 대로 잘 나왔다.
혹시 모르니 다른 카테고리도 조회해 보자.
잘 나오는 것을 확인할 수 있다.
이번에는 이렇게 카테고리 기능을 구현해 보았다.
처음에 언급했듯이 카테고리는 추가와 수정이 빈번하지 않으니 캐시를 이용해서 접근 속도를 높이는 것도 고려해 볼 수 있다.
이후에 최적화 과정에서 시도해 보아야겠다.
'공부 > Spring' 카테고리의 다른 글
[Spring Boot] 오늘 얻은 교훈... 쉽게 갈 수 있으면 그냥 쉬운 방법을 선택하자 (0) | 2024.10.26 |
---|---|
[Spring Boot] 같은 Service 타입 Bean 여러 개 등록해서 요청마다 다른 Service 호출하기 및 예외 처리 (1) | 2024.10.24 |
[Spring] STOMP 웹소켓 채팅 테스트해보기 (0) | 2024.09.20 |
[Spring] 타임리프(Thymeleaf) 알아보기 2 (1) | 2024.09.19 |
[Spring] 타임리프 알아보기 1 (1) | 2024.09.17 |