JPA
를 사용하면 쿼리 작성에 많은 제약이 생기게 됩니다. 그 중 하나는 트리 구조의 엔티티를 조회할 때 인데, 오라클의 connect by ~
나 MySQL에서 제공하는 with ~
등의 쿼리가 그것입니다.
당연하게도 JPA는 DBMS 독립적으로 운용되어야 하기에, 위 함수를 사용할 수 없습니다.
메뉴의 깊이가 어느 정도가 될 지 모르겠다는 요구 사항으로 인해 위와 같은 상황에서 무한 depth를 지원하는 구조를 구현하기 위해서 했던 고민을 공유하고자 합니다.
최대한 단순하게 표현하자면, 메뉴의 엔티티는 다음과 같은 형태로 설계하였습니다.
@Entity
class CmsMenu : AbstractEntity() {
@Column(length = 16)
var parentId: UUID? = null // *1)
@Column(name = "menuGroup", length = 64, nullable = false)
@Convert(converter = CmsMenuGroup.Converter::class)
var group: CmsMenuGroup? = null
@Column(name = "menuName", length = 128, nullable = false)
var name: String? = null
@Column(nullable = false)
@ColumnDefault("1")
var depth: Int? = null // *2)
@Column(nullable = false)
@ColumnDefault("1")
var priority: Int? = null
@ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
@JoinColumn(
name = "parentId",
foreignKey = ForeignKey(name = "fk_cms_menu_parent"),
insertable = false,
updatable = false,
)
var parent: CmsMenu? = null // 부모 연관
protected set
@OneToMany(mappedBy = "parent", cascade = [CascadeType.ALL])
var children: MutableSet<CmsMenu> = mutableSetOf() // 자식 연관
protected set
}
QueryDSL
플러그인을 사용하고 있으니, fetchJoin
으로 하위 메뉴를 조인하는 방식으로 접근해 보았습니다.
val m1 = QCmsMenu("m1")
val m2 = QCmsMenu("m2")
val m3 = QCmsMenu("m3")
..
this.jpaQueryFactory
.selectDistinct(m1)
.from(m1)
.leftJoin(m1.children, m2).fetchJoin()
.leftJoin(m2.children, m3).fetchJoin()
...
.where(
m1.parent.isNull,
m1.depth.eq(1),
)
.orderBy(
priority.asc(),
)
fetch()
해당 방식의 문제점은 leftJoin
을 직접 명시해야 하므로 무한 depth가 아니라는 점과, 1:N 관계에서 조인을 계속 이어붙여나가다 보니, 조회되는 row가 기하급수적으로 비대해진다는 점이 있습니다.
depth 1 의 메뉴가 2개, depth 2 의 메뉴가 각 3개, depth 3의 메뉴가 각 4개라면, 조회되는 총 row는 2 x 3 x 4 = 24(개)가 되며, depth를 늘려갈수록 n^2 의 속도로 증가합니다. 이는 메모리 낭비로 이어지며, QueryDSL이 내부적으로 row를 자바 객체로 바꾸는 데 많은 비용을 소모하게 됩니다.
따라서 쿼리 레벨에서 조인을 사용하여 트리 구조를 완성하기 보다는 검색 조건에 맞는 모든 메뉴 목록을 가져온 후 코드 레벨에서 트리 구조로 변환하여 사용하기로 했습니다. 메뉴같은 경우 수정에 비해 조회가 훨씬 빈번하니, 캐시를 적용함으로써 서버의 부담을 경감시킬 수도 있다고 생각했습니다.
우선 HibernateProxy
를 단순 POJO로 변경하기 위해 CmsMenuDto를 만듭니다.
class CmsMenuDto(
entity: CmsMenu,
) : AbstractDto(entity) {
// own properties
val parentId = entity.parentId
val group = entity.group
val name = entity.name
val path = entity.path
val depth = entity.depth
val priority = entity.priority
// foreign properties
var parent: CmsMenuDto? = null
var children: List<CmsMenuDto> = emptyList()
..
}
트리 구조를 유틸 클래스로 분리하면 Repository
와 Service
단은 단순해집니다.
@Repository
class CmsMenuRepositoryImpl(
private val jpaQueryFactory: JPAQueryFactory,
) : CmsMenuRepository {
override fun findAll(
group: CmsMenuGroup,
): List<CmsMenu> {
val menu = QCmsMenu("menu")
return this.jpaQueryFactory
.select(menu)
.from(menu)
.where(
menu.parent.isNull,
menu.group.eq(group),
)
.orderBy(
menu.priority.asc(),
)
.fetch()
}
}
@Service
class CmsMenuServiceImpl(
private val entityMapper: EntityMapper,
private val menuRepository: CmsMenuRepository,
) : CmsMenuService {
@Cacheable(
cacheNames = [CacheKey.MENU_TREE],
key = "#group",
)
override fun findTree(
group: CmsMenuGroup,
): List<CmsMenuDto> {
val list = this.menuRepository.findAll(group)
.map { this.entityMapper.map(it) }
return CmsMenuUtils.toTree(list)
}
}
비즈니스 로직과 연관이 없다고 생각하여 유틸 클래스로 분리합니다.
object CmsMenuUtils {
fun toTree(
menus: Collection<CmsMenuDto>,
): List<CmsMenuDto> {
if (menus.isEmpty()) {
return emptyList()
}
// depth 를 기준으로 그룹핑합니다.
val menuMap = menus.groupBy { it.depth }
// 가장 깊은 depth 값을 구합니다.
// 가장 하위의 깊이부터 위로 올라오면서 트리를 구성하려고 합니다.
val maxDepth = menus.maxOf { it.depth ?: -1 }
if (maxDepth < 1) {
return emptyList()
}
// 가장 하위의 메뉴는 children 을 가지지 않으니, 그 위의 depth 부터
// 위로 올라오면서 children 를 찾아 트리구조로 변환합니다.
for (depth in maxDepth - 1..1) {
val subMenus = menuMap[depth]
?: continue
for (subMenu in subMenus) {
subMenu.children = menus
.filter { it.parentId == subMenu.id }
}
}
// 깊이가 1인 메뉴 목록을 반환합니다.
return menuMap[1] ?: emptyList()
}
}
메뉴를 신설하거나 수정하는 경우 캐시를 갱신하여야 하며, 복잡도를 낮추기 위해 @CachePut
사용을 지양하고 @CacheEvict
를 사용하여 일괄 삭제하는 단순한 방법으로 구현하였습니다.
또한, parentId
를 사용하여 깊이를 계산하여 추가하였습니다.
@Service
class CmsMenuServiceImpl(
private val entityMapper: EntityMapper,
private val menuRepository: CmsMenuRepository,
) : CmsMenuService {
@Transactional
@CacheEvict(cacheNames = [CacheKey.MENU_TREE], allEntries = true)
override fun save(
form: CmsMenuForm.Save,
): CmsMenuDto {
val entity = form.toEntity()
.also { entity ->
val parent = entity.parentId
?.let { this.menuRepository.findById(it) }
// 부모 메뉴가 있다면...
if (parent != null) {
// 부모 메뉴의 깊이 + 1
entity.depth = parent.depth!! + 1
}
}
.let {
this.menuRepository.save(it)
}
publishMenuCreateEvent(entity)
return this.entityMapper.map(it)
}
}
메뉴는 화면의 공통부분에서 사용하므로 HandlerInterceptor
등을 사용하여 모델에 추가해 줍니다.
컨트롤러에서 반환한 ModelAndView
를 확인하여 html 을 응답하는 경우에, 메뉴 목록을 끼워 넣어 주는 식으로 사용할 수 있습니다.
@Component
class HandlerInterceptorImpl(
private val menuService,
) : HandlerInterceptor {
override fun postHandle(
request: HttpServletRequest,
response: HttpServletResponse,
handler: Any,
modelAndView: ModelAndView?,
) {
// Accept: text/html 헤더가 포함되어 있는지 확인합니다.
if (request.isHtmlAcceptable().not()) {
return
}
// View 가 `templates/` 이하의 템플릿을 사용하는 경우에만 메뉴를 모델에 추가합니다.
if (isInternalView(modelAndView)) {
val menuList = this.menuService.findTree(CmsMenuGroup.USER)
modelAndView!!.addObject("menuList", menuList)
}
}
}