본문 바로가기
개발~/Spring-Data-JPA

[Spring] 하위 메뉴 구현하기

by 보배곰 2021. 2. 9.

1. 목표

상위 메뉴, 하위 메뉴 까지만 존재하는 메뉴를 만들려고 합니다. 

메뉴의 순서를 정할 수 있고, 하위 메뉴는 같은 상위 메뉴 아래에서만 순서를 변경할 수 있습니다. 또한 항상 전체 메뉴 목록을 보여줘야하기 때문에 페이징은 하지 않습니다. 

2. 개발 환경

Spring boot, JPA, h2 database, querydsl 를 이용해서 개발합니다.

 

3. 데이터베이스 구조

create table menu
(
  id bigint generated by default as identity,
  parent_id bigint,
  name varchar(255),
  list_order int,
  primary key(id),
  foreign key (parent_id) references menu(id)
)

id는 자동으로 올라가게 되고, 하위 메뉴는 parent_id에 부모 id 를 저장합니다. 

name 에는 메뉴 이름이 입력되고, list_order 를 통해 메뉴의 순서를 알 수 있습니다. 

 

메뉴는 위와 같은 구조로 데이터를 입력했습니다. 

 

4. 도메인 

Menu 는 아래와 같이 구성합니다. 

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Menu {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_id")
    private Menu parent;

    private String name;

    private int listOrder;

    @OneToMany(mappedBy = "parent")
    private List<Menu> children = new ArrayList<>();
}

한 테이블에서 자식 메뉴까지 모두 관리하고, 메뉴 조회시 항상 자식 메뉴를 같이 조회하기 위해 chilren도 같이 넣어줬습니다. 

 

 

API 결과로 내려주는 데이터는 MenuResult 로 감싸서 내려주려고 합니다. Entity 자체를 결과로 내리지 않도록 주의합니다. 

MenuResult를 결과로 내려주려고 할때, 그 안에 있는 children 역시 MenuResult로 감싸야 합니다. 

@Getter
public class MenuResult {

    private Long id;

    private String name;

    private int listOrder;

    private List<MenuResult> children;

    public MenuResult(final Menu menu) {
        this.id = menu.getId();
        this.name = menu.getName();
        this.listOrder = menu.getListOrder();
        this.children = menu.getChildren().stream().map(MenuResult::new).collect(Collectors.toList());
    }

}

 

5. 시도

5-1. Spring Data JPA findAll() 사용 (v1)

우선 Spring data jpa 의 findAll() 를 이용해서 전체 메뉴를 조회해보겠습니다. 

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MenuService {

    private final MenuRepository menuRepository;

    public List<MenuResult> getV1Menus() {
        final List<Menu> all = menuRepository.findAll();
        return all.stream().map(MenuResult::new).collect(Collectors.toList());
    }

}

 

물론 Json 결과는 List가 아닌 Object로 내려주어야 하지만.. 예제이니 그냥 이렇게 내리도록 하겠습니다~ 

@RestController
@RequiredArgsConstructor
public class MenuController {

    private final MenuService menuService;

    @GetMapping("/v1/menus")
    public ResponseEntity<List<MenuResult>> getV1Menus() {
        final List<MenuResult> menus = menuService.getV1Menus();
        return ResponseEntity.ok(menus);
    }

}

 

아래가 /v1/menus 를 호출한 결과입니다. 

 

데이터가 잘 나오는 것처럼 보이지만, 하위 메뉴들이 상위 메뉴와 같은 레벨에서 한 번씩 더 나오고 있습니다. 

그리고 호출된 sql을 보면 아래와 같습니다.

menu 를 조회하는 select 쿼리 1번, parent id를 가지고 자식을 조회하는 쿼리가 6번 즉, 상위, 하위 메뉴 포함한 전체 메뉴 갯수만큼 호출되고 있습니다. 

 

해당 방법은 원하는 결과도 나오지 않고, SQL이 비효율적으로 호출되는 모습을 확인할 수 있습니다. 

 

5-2. Spring Data JPA findAllByParentIsNull() 사용 (v2)

그러면 이번에는 Parent가 null인 상위 부모만 출력해보면 하위 메뉴는 상위 메뉴가 보이는 곳에서 안나오지 않을까요? 

v2로 만들어보겠습니다. 

public List<MenuResult> getV2Menus() {
	final List<Menu> all = menuRepository.findAllByParentIsNull();
	return all.stream().map(MenuResult::new).collect(Collectors.toList());
}

Service 에 getV2Menus() 메서드를 추가했습니다. v1 과 다른 점은 menuRepository에 추가한 findAllByParentIsNull() 부분입니다. 

Controller 도 작성하여 해당 메서드를 호출하면, 원하는 결과로 나오게 됩니다! 

 

결과 SQL도 같이 확인해보면 아래와 같습니다. 

Hibernate: select menu0_.id as id1_0_, menu0_.list_order as list_ord2_0_, menu0_.name as name3_0_, menu0_.parent_id as parent_i4_0_ from menu menu0_ where menu0_.parent_id is null

Hibernate: select children0_.parent_id as parent_i4_0_0_, children0_.id as id1_0_0_, children0_.id as id1_0_1_, children0_.list_order as list_ord2_0_1_, children0_.name as name3_0_1_, children0_.parent_id as parent_i4_0_1_ from menu children0_ where children0_.parent_id=?
Hibernate: select children0_.parent_id as parent_i4_0_0_, children0_.id as id1_0_0_, children0_.id as id1_0_1_, children0_.list_order as list_ord2_0_1_, children0_.name as name3_0_1_, children0_.parent_id as parent_i4_0_1_ from menu children0_ where children0_.parent_id=?
Hibernate: select children0_.parent_id as parent_i4_0_0_, children0_.id as id1_0_0_, children0_.id as id1_0_1_, children0_.list_order as list_ord2_0_1_, children0_.name as name3_0_1_, children0_.parent_id as parent_i4_0_1_ from menu children0_ where children0_.parent_id=?
Hibernate: select children0_.parent_id as parent_i4_0_0_, children0_.id as id1_0_0_, children0_.id as id1_0_1_, children0_.list_order as list_ord2_0_1_, children0_.name as name3_0_1_, children0_.parent_id as parent_i4_0_1_ from menu children0_ where children0_.parent_id=?
Hibernate: select children0_.parent_id as parent_i4_0_0_, children0_.id as id1_0_0_, children0_.id as id1_0_1_, children0_.list_order as list_ord2_0_1_, children0_.name as name3_0_1_, children0_.parent_id as parent_i4_0_1_ from menu children0_ where children0_.parent_id=?
Hibernate: select children0_.parent_id as parent_i4_0_0_, children0_.id as id1_0_0_, children0_.id as id1_0_1_, children0_.list_order as list_ord2_0_1_, children0_.name as name3_0_1_, children0_.parent_id as parent_i4_0_1_ from menu children0_ where children0_.parent_id=?

실제로 Spring data jpa 를 사용해서 호출한 SQL이 맨 윗줄에 표시되고, 메뉴 갯수만큼 다시 SQL 이 호출됩니다. 

 

원하는 결과대로 나오는 것 같긴 하지만 아직 해결되지 않은 문제가 있습니다. 

첫 번째는 SQL이 많이 호출되는 문제가 있고, 두 번째는 listOrder 를 바꾸면, listOrder 순서대로 나오지 않는다는 문제가 있습니다. 

 

그러면 SQL 호출부터 줄여보도록 하겠습니다. 

5-3. default_batch_fetch_size 추가

많이 호출되는 SQL을 줄이는 간단한 방법은 properties에 hibernate.default_batch_fetch_size를 추가해주는 것입니다. 그러면 설정한 size만큼 연관 데이터를 in 쿼리를 이용해서 조회합니다. 

spring.jpa.properties.hibernate.default_batch_fetch_size=10

 

기존 SQL과 달리 IN 쿼리를 이용해서 조회합니다.

Hibernate: select menu0_.id as id1_0_, menu0_.list_order as list_ord2_0_, menu0_.name as name3_0_, menu0_.parent_id as parent_i4_0_ from menu menu0_ where menu0_.parent_id is null
Hibernate: select children0_.parent_id as parent_i4_0_1_, children0_.id as id1_0_1_, children0_.id as id1_0_0_, children0_.list_order as list_ord2_0_0_, children0_.name as name3_0_0_, children0_.parent_id as parent_i4_0_0_ from menu children0_ where children0_.parent_id in (?, ?, ?)
Hibernate: select children0_.parent_id as parent_i4_0_1_, children0_.id as id1_0_1_, children0_.id as id1_0_0_, children0_.list_order as list_ord2_0_0_, children0_.name as name3_0_0_, children0_.parent_id as parent_i4_0_0_ from menu children0_ where children0_.parent_id in (?, ?, ?)

? 안에 데이터를 확인하기 위해 properties에 아래 내용을 추가하고 확인합니다. 

 

Hibernate: select menu0_.id as id1_0_, menu0_.list_order as list_ord2_0_, menu0_.name as name3_0_, menu0_.parent_id as parent_i4_0_ from menu menu0_ where menu0_.parent_id is null

 

위 결과로 나온 1,2,3 id 를 토대로 in 쿼리로 조회합니다. 

Hibernate: select children0_.parent_id as parent_i4_0_1_, children0_.id as id1_0_1_, children0_.id as id1_0_0_, children0_.list_order as list_ord2_0_0_, children0_.name as name3_0_0_, children0_.parent_id as parent_i4_0_0_ from menu children0_ where children0_.parent_id in (?, ?, ?)

또 in (1,2,3) 결과로 나온 4,5,6 id 를 가지고 select를 마저합니다. 

Hibernate: select children0_.parent_id as parent_i4_0_1_, children0_.id as id1_0_1_, children0_.id as id1_0_0_, children0_.list_order as list_ord2_0_0_, children0_.name as name3_0_0_, children0_.parent_id as parent_i4_0_0_ from menu children0_ where children0_.parent_id in (?, ?, ?)

 

5-4. listOrder 순서로 출력

listOrder 에 맞게 출력하도록 여러 시도를 해보겠습니다. 

우선 listOrder 를 변경하도록 하겠습니다. 

list_order 를 변경하여 아래와 같은 결과가 나와야 합니다. 

위에서 만든 메뉴 조회 API 를 호출하면 이전과 같은 순서로 나오나 listOrder 는 변경된 데이터로 나오게 됩니다. 

 

5-4-1. listOrder 순서로 출력. Sort 이용 (v3)

우선 처음에 간단하게 시도해볼 수 있는 방법은 repository를 통해 조회할 때 Sort 를 넣어보는 방법입니다. 

controller에 /v3/menus를 만들고, 호출해보도록 하겠습니다.

아래는 Serivce에 추가한 코드입니다! 

public List<MenuResult> getV3Menus() {
	final List<Menu> all = menuRepository.findAll(Sort.by(Sort.Direction.ASC, "listOrder"));
	return all.stream().map(MenuResult::new).collect(Collectors.toList());
}

 

SQL은 아래와 같이 나옵니다. 

Hibernate: select menu0_.id as id1_0_, menu0_.list_order as list_ord2_0_, menu0_.name as name3_0_, menu0_.parent_id as parent_i4_0_ from menu menu0_ order by menu0_.list_order asc
Hibernate: select children0_.parent_id as parent_i4_0_1_, children0_.id as id1_0_1_, children0_.id as id1_0_0_, children0_.list_order as list_ord2_0_0_, children0_.name as name3_0_0_, children0_.parent_id as parent_i4_0_0_ from menu children0_ where children0_.parent_id in (?, ?, ?, ?, ?, ?)

첫 번째 전체 메뉴를 조회하는 SQL 에 order by menu0_.list_order asc 가 적용된 모습을 볼 수 있습니다. 하지만 이는 원하는 결과가 아닙니다! 우리는 먼저 부모가 정렬되고, 자식이 맞게 배치되어야 하니까요.

실제 호출된 결과도, 부모 메뉴 , 자식 메뉴 관련 없이 listOrder 순서대로 나오고 있습니다. 

 

5-4-2. listOrder 순서로 출력. Sort 이용 (v4)

public List<MenuResult> getV4Menus() {
	final List<Menu> all = menuRepository.findAllByParentIsNull(Sort.by(Sort.Direction.ASC, "listOrder"));
	return all.stream().map(MenuResult::new).collect(Collectors.toList());
}

v2에서 이용한 방법을 이용해서 조회해보도록 하겠습니다. 

 

Hibernate: select menu0_.id as id1_0_, menu0_.list_order as list_ord2_0_, menu0_.name as name3_0_, menu0_.parent_id as parent_i4_0_ from menu menu0_ where menu0_.parent_id is null order by menu0_.list_order asc
Hibernate: select children0_.parent_id as parent_i4_0_1_, children0_.id as id1_0_1_, children0_.id as id1_0_0_, children0_.list_order as list_ord2_0_0_, children0_.name as name3_0_0_, children0_.parent_id as parent_i4_0_0_ from menu children0_ where children0_.parent_id in (?, ?, ?)
Hibernate: select children0_.parent_id as parent_i4_0_1_, children0_.id as id1_0_1_, children0_.id as id1_0_0_, children0_.list_order as list_ord2_0_0_, children0_.name as name3_0_0_, children0_.parent_id as parent_i4_0_0_ from menu children0_ where children0_.parent_id in (?, ?, ?)

부모 메뉴에서는 listOrder 순서대로 잘 나오는 것 같지만 자식 메뉴는 listOrder 가 적용되지 않았습니다. 자식 메뉴도 순서대로 출력되도록 적용해봅시다. 

 

5-4-3. 자식 메뉴도 listOrder 순서로 출력. @OrderBy 이용 (v4)

자식 메뉴도 순서대로 출력하기 위해 @OrderBy를 이용해보겠습니다. Menu Enttiy 에 @ToMany 인 연관 객체에 @OrderBy를 붙여주고 정렬할 필드명을 명시해줍니다. 우리는 children 메뉴들이 listOrder 순서에 따라 정렬되기를 원하니 아래와 같이 명시해주면 됩니다. 

그러면 하위메뉴도 listOrder 순서대로 정렬되게 됩니다. 

SQL은 아래와같이 나오게 됩니다.

Hibernate: select menu0_.id as id1_0_, menu0_.list_order as list_ord2_0_, menu0_.name as name3_0_, menu0_.parent_id as parent_i4_0_ from menu menu0_ where menu0_.parent_id is null order by menu0_.list_order asc

자식메뉴를 조회하는 쿼리에 in 쿼리와 Order by 가 들어갔네요.~ 

Hibernate: select children0_.parent_id as parent_i4_0_1_, children0_.id as id1_0_1_, children0_.id as id1_0_0_, children0_.list_order as list_ord2_0_0_, children0_.name as name3_0_0_, children0_.parent_id as parent_i4_0_0_ from menu children0_ where children0_.parent_id in (?, ?, ?) order by children0_.list_order asc

Hibernate: select children0_.parent_id as parent_i4_0_1_, children0_.id as id1_0_1_, children0_.id as id1_0_0_, children0_.list_order as list_ord2_0_0_, children0_.name as name3_0_0_, children0_.parent_id as parent_i4_0_0_ from menu children0_ where children0_.parent_id in (?, ?, ?) order by children0_.list_order asc

 

 

원하는 대로 출력이 되는 것 같지만,  Entity에 표기함으로써 해당 Entity를 사용하는 모든 곳에 영향이 가게 됩니다. 또한 변수를 넣을 수 없게 됩니다. 또 다른 방법을 사용해서 메뉴를 정렬해보도록 하겠습니다. 

@OrderBy("listOrder asc") 는 주석처리하고 다음 내용을 진행하겠습니다.

5-5. 자식 메뉴도 listOrder 순서로 출력. JPQL 이용 (v5)

JPQL을 이용해서도 해보겠습니다. 결론부터 말하면 자식 메뉴는 listOrder 순서로 안되더라구요~ 

@Query("select parentMenu from Menu parentMenu" +
	    " left join parentMenu.children child " +
        " where parentMenu.parent is null" +
        " order by parentMenu.listOrder asc, child.listOrder asc")
List<Menu> findAllWithJpql();

자식 메뉴에 listOrder 가 적용되려면 in 쿼리가 있는, 자식 메뉴를 호출하는 sql에 order by 가 적용되어야 하는데 안되더라구요! 

그럼 다음 방법을 알아보겠습니다~ 

 

5-6. 자식 메뉴도 listOrder 순서로 출력. Querydsl 이용 (v6) - 채택!

querydsl 을 이용하려면 querydsl 설정을 해야합니다. 

설정 방법은 여기를 참고하셔요~

jojoldu.tistory.com/372

 

Spring Boot Data Jpa 프로젝트에 Querydsl 적용하기

안녕하세요? 이번 시간에는 Spring Boot Data Jpa 프로젝트에 Querydsl을 적용하는 방법을 소개 드리겠습니다. 모든 코드는 Github에 있습니다. Spring Data Jpa를 써보신 분들은 아시겠지만, 기본으로 제공해

jojoldu.tistory.com

Querydsl 을 이용하면, children 에도 조건을 설정해줄 수 있습니다. 

@Repository
@RequiredArgsConstructor
public class MenuQuerydslRepository {

    private final JPAQueryFactory query;

    public List<Menu> findAllWithQuerydsl() {
        QMenu parent = new QMenu("parent");
        QMenu child = new QMenu("child");

        return query.selectFrom(parent)
                .distinct()
                .leftJoin(parent.children, child)
                .fetchJoin()
                .where(
                        parent.parent.isNull()
                )
                .orderBy(parent.listOrder.asc(), child.listOrder.asc())
                .fetch();
    }

}

Querydsl을 이용해서 쿼리를 작성할 때, 같은 객체를 구분하기 위해서 아래와 같이 new QMenu 를 이용해서 QClass인스턴스를 구분해줍니다. 

querydsl에서의 distinct()는 애플리케이션에서 컬렉션 페치 조인으로 중복된 데이터를 제거해주는 역할을 합니다. 

 

이렇게 설정해서 API를 호출하면 원하는 결과로 나오게 됩니다. 

SQL도 같이 살펴보도록 하겠습니다.

Hibernate: select distinct menu0_.id as id1_0_0_, children1_.id as id1_0_1_, menu0_.list_order as list_ord2_0_0_, menu0_.name as name3_0_0_, menu0_.parent_id as parent_i4_0_0_, children1_.list_order as list_ord2_0_1_, children1_.name as name3_0_1_, children1_.parent_id as parent_i4_0_1_, children1_.parent_id as parent_i4_0_0__, children1_.id as id1_0_0__ 
from menu menu0_ 
left outer join menu children1_ on menu0_.id=children1_.parent_id 
where menu0_.parent_id is null 
order by menu0_.list_order asc, children1_.list_order asc

SQL도 잘 나오는 걸 확인할 수 있습니다. 

Hibernate: select children0_.parent_id as parent_i4_0_1_, children0_.id as id1_0_1_, children0_.id as id1_0_0_, children0_.list_order as list_ord2_0_0_, children0_.name as name3_0_0_, children0_.parent_id as parent_i4_0_0_ from menu children0_ where children0_.parent_id in (?, ?, ?)

6. 결론

저는 Querydsl을 이용한 방법(v6)으로 메뉴를 조회하는 방법을 선택했습니다. 메뉴 조회 API를 개발하기 위해서 이것저것 시행착오를 많이 겪었었는데, 필요하신 분들께도 도움이 되었으면 좋겠습니다~ 

 

소스는 Github에서 확인가능합니다! 

github.com/treasureBear94/blog/tree/master/spring-menu

 

treasureBear94/blog

https://treasurebear.tistory.com/ 예제 코드. Contribute to treasureBear94/blog development by creating an account on GitHub.

github.com

 

'개발~ > Spring-Data-JPA' 카테고리의 다른 글

[JPA] Method 기반 Query 생성  (0) 2020.12.01

댓글