트리 구조의 엔티티 조회

Dierslair·2022년 9월 20일
0

jpa

목록 보기
3/4

JPA 를 사용하면 쿼리 작성에 많은 제약이 생기게 됩니다. 그 중 하나는 트리 구조의 엔티티를 조회할 때 인데, 오라클의 connect by ~ 나 MySQL에서 제공하는 with ~ 등의 쿼리가 그것입니다.

당연하게도 JPA는 DBMS 독립적으로 운용되어야 하기에, 위 함수를 사용할 수 없습니다.

메뉴의 깊이가 어느 정도가 될 지 모르겠다는 요구 사항으로 인해 위와 같은 상황에서 무한 depth를 지원하는 구조를 구현하기 위해서 했던 고민을 공유하고자 합니다.

Entity 설계

최대한 단순하게 표현하자면, 메뉴의 엔티티는 다음과 같은 형태로 설계하였습니다.

@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
}
  • 1) 현재 메뉴를 소유하고 있는 부모 메뉴의 식별자입니다.
  • 2) 깊이를 표시합니다.

첫 번째 시도

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를 자바 객체로 바꾸는 데 많은 비용을 소모하게 됩니다.

두 번째 시도

따라서 쿼리 레벨에서 조인을 사용하여 트리 구조를 완성하기 보다는 검색 조건에 맞는 모든 메뉴 목록을 가져온 후 코드 레벨에서 트리 구조로 변환하여 사용하기로 했습니다. 메뉴같은 경우 수정에 비해 조회가 훨씬 빈번하니, 캐시를 적용함으로써 서버의 부담을 경감시킬 수도 있다고 생각했습니다.

CmsMenuDto

우선 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

트리 구조를 유틸 클래스로 분리하면 RepositoryService 단은 단순해집니다.

@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)
    }
}

CmsMenuUtils

비즈니스 로직과 연관이 없다고 생각하여 유틸 클래스로 분리합니다.

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)
        }
    }
}
profile
Java/Kotlin Backend Developer

0개의 댓글