728x90
안녕하세요! 백엔드 개발자라면 한 번쯤 고민했을 **관리 시스템(Admin System)**의 핵심 기능, 바로 계층형 메뉴(Hierarchical Menu) 관리 시스템 구축 전략을 공유합니다.
효율적인 메뉴 시스템은 단순히 DB에 데이터를 저장하는 것을 넘어, 성능(N+1 문제 회피), 데이터 무결성, 그리고 코드 유지보수성을 동시에 만족시켜야 합니다.
본 글에서는 Spring Boot, JPA, QueryDSL을 사용하여 이 세 가지 목표를 달성하는 방법을 상세히 다룹니다.
1. 🏗️ 엔티티 설계: Self-Reference와 양방향 매핑
계층형 구조를 모델링하는 가장 핵심적인 방법은 엔티티가 자기 자신을 참조하는 **셀프 참조(Self-Reference)**를 사용하는 것입니다.
Menu.java Entity 핵심
// 부모 메뉴 참조 (N:1 관계)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Menu parent;
// 자식 메뉴 목록 (1:N 관계)
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("sortOrder ASC") // DB 레벨에서 정렬 보장
private List<Menu> children = new ArrayList<>();
✨ 설계 이점
- 계층 구조의 명확성: parent 필드가 null이면 루트(Root) 메뉴임을 명확히 구분할 수 있습니다.
- 성능 최적화 준비: 연관 관계에 FetchType.LAZY를 적용하여, 기본 메뉴 조회 시 불필요한 부모나 자식 데이터 로딩을 방지했습니다. 이는 Fetch Join을 사용할 때만 필요한 데이터를 가져오기 위함입니다.
- 데이터 무결성 보장: cascade = CascadeType.ALL과 orphanRemoval = true를 통해 부모 메뉴 삭제 시, 관련된 모든 자식 메뉴도 자동으로 처리되도록 설정하여 데이터의 일관성을 유지합니다.
- 자동 정렬: @OrderBy("sortOrder ASC") 어노테이션 덕분에 자식 리스트를 조회할 때마다 별도의 정렬 로직이 필요 없습니다.
2. ⚡ Repository 최적화: QueryDSL과 Fetch Join으로 N+1 회피
메뉴 시스템에서 가장 중요한 성능 이슈는 전체 메뉴 트리를 조회할 때 발생하는 N+1 문제입니다. 이를 해결하기 위해 QueryDSL의 Fetch Join을 사용합니다.
MenuRepositoryImpl.java 핵심
@Override
public List<Menu> findAllMenusWithParentAndProgram() {
return queryFactory.selectFrom(m)
// 부모 엔티티를 함께 EAGER 로딩
.leftJoin(m.parent).fetchJoin()
// 연결된 프로그램 엔티티를 함께 EAGER 로딩
.leftJoin(m.program).fetchJoin()
.orderBy(m.sortOrder.asc())
.fetch();
}
✨ 성능 이점 (가장 중요)
- DB 접근 횟수 최소화: 일반적인 JPA 조회는 메뉴가 100개일 때 부모와 프로그램을 가져오기 위해 최대 201번의 쿼리(1 + 100 + 100)를 실행할 수 있습니다.
- Fetch Join의 힘: fetchJoin()을 사용하면 100개의 메뉴 데이터와 그에 연결된 부모, 프로그램 데이터를 단 한 번의 쿼리로 가져옵니다. 이를 통해 DB 부하를 획기적으로 줄이고 조회 시간을 단축할 수 있습니다.
3. 🌲 Service 로직: 메모리 내에서 트리 구조 재구성
DB에서 모든 데이터를 단 하나의 쿼리로 가져왔다면, 이제 이 플랫(Flat)한 리스트를 메모리 상에서 트리(Tree) 구조로 재구성하는 과정이 필요합니다.
MenuService.java getMenuTree() 핵심
public List<MenuDto> getMenuTree() {
// 1. 단 하나의 쿼리로 모든 데이터 로드 (Fetch Join 사용)
List<Menu> allMenus = menuRepository.findAllMenusWithParentAndProgram();
// 2. ID → Menu 매핑을 위한 Map 생성 (O(1) 빠른 참조)
Map<Long, Menu> menuMap = allMenus.stream()
.collect(Collectors.toMap(Menu::getId, m -> m));
List<Menu> roots = new ArrayList<>();
for (Menu menu : allMenus) {
Menu parent = menu.getParent();
if (parent != null) {
// 3. Map을 이용해 부모를 찾아 자식 목록에 추가
Menu parentEntity = menuMap.get(parent.getId());
if (parentEntity != null) {
parentEntity.getChildren().add(menu);
}
} else {
// 4. 부모가 없는 메뉴는 루트 리스트에 추가
roots.add(menu);
}
}
return MenuDto.fromEntityList(roots);
}
✨ 로직의 효율성
이 로직은 전체 리스트를 두 번 순회하는 것 외에 DB 접근이 전혀 없습니다.
- toMap (O(N)): 모든 메뉴에 대한 빠른 참조 Map을 생성합니다.
- for 루프 (O(N)): 모든 메뉴를 순회하며 Map을 통해 O(1) 시간 복잡도로 부모를 조회하고 자식 관계를 맺어줍니다.
즉, O(N)의 시간 복잡도만으로 복잡한 계층 구조를 메모리 내에서 매우 빠르게 완성합니다.
4. 🌐 Controller와 DTO: 계층 분리와 표준 응답
마지막으로 API 계층에서는 DTO를 활용하여 엔티티를 보호하고, 응답 서비스(ResponseService)를 통해 결과를 표준화합니다.
MenuController.java 핵심
@GetMapping("/tree")
public MultipleResult<MenuDto> getMenuTree() {
List<MenuDto> tree = menuService.getMenuTree();
return responseService.handleListResult(tree);
}
✨ 최종 구조의 완성도
- DTO (Data Transfer Object) 사용: 엔티티(Menu)와 API 응답 포맷(MenuDto)을 분리하여 정보 은닉과 유지보수성을 확보합니다.
- ResponseService: 성공/실패 여부, 메시지, 데이터 등을 통일된 포맷(MultipleResult<T>)으로 반환하여 프론트엔드 팀이 예측 가능하고 일관된 API를 사용할 수 있도록 합니다.
'JPA & Querydsl' 카테고리의 다른 글
| Spring Data JPA와 QueryDSL에서 동적 쿼리 처리하기 (0) | 2025.03.08 |
|---|---|
| QueryDsl을 사용하면서, fetchjoin()을 사용해야 할 때는 언제인가? (0) | 2025.02.25 |
| fetchResults(), fetchCount() deprecated된 이유(Querydsl) (0) | 2024.05.20 |
| Transaction에 대하여 깊이있게.. (1) | 2023.12.28 |
| JPA 연관관계 매핑 (0) | 2023.12.05 |