클로저 테이블 방식

정말 단순하면서 직관적인 방식이다. 부모와 자식의 관계를 나타낼 테이블을 추가적으로 생성해 모든 관계에 대해 테이블에 기입하면 된다. 코드로 보면 더 직관적이다 오늘 은 피자 엔티티를 만들어서 이용해 보자.

public class Pizza {
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;
}

@Table(uniqueConstraints={
        @UniqueConstraint(columnNames = {"parents_id", "child_id"})
})
public class PizzaPaths {

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

    @ManyToOne
    @JoinColumn(name = "parents_id")
    private Pizza parents;

    @ManyToOne
    @JoinColumn(name = "child_id")
    private Pizza child;
}

사실 고민을 좀 했다. parents와 child를 복합 키로 걸어서 이용할까 도 고민을 했다. 그렇게 되면 추가적인 클래스를 만들어(IdClass 방식과 EmbededId 방식 이 있지만 idClass 방식이 보다 직관적이고 코드도 덜 친다.) 할까 했지만.

보다 명시적으로 부모 자식을 노출하기 위해서 위와 같이 작성했다.

 

대신 복합키를 두 개를 걸지 않았기 때문에 각각 아이디 합친 값의 유니크 설정이 필요하다 왜? 하나의 피자는 동일한 부모 자식을 가지는 건 중복된 데이터 이기 때문이다. 그래서 위와 같이 고민했고 유니크 설정을 추가해주기로 결정

 

지난번 에는 데이터를 commit으로 때려 박고 하는 반쪽 짜리 테스트이다 이번에는 실질적인 어느 누가 돌려도 돌아가는 테스트로 귀찮더라도 조금 길게 코드를 적어보자.

 

더보기
  @BeforeEach
    void init(){
        Pizza 토마토피자 = Pizza.builder().name("토마토피자").build();
        Pizza 마르게리타 = Pizza.builder().name("마르게리타").build();
        Pizza 살라미 = Pizza.builder().name("살라미").build();

        pizzaRepository.saveAll(List.of(토마토피자,마르게리타,살라미));
    }

    @Test
    void uniqueTest(){
        Pizza a = pizzaRepository.findByName("토마토피자").get();
        Pizza b = pizzaRepository.findByName("마르게리타").get();
        Pizza c = pizzaRepository.findByName("살라미").get();

        PizzaPaths path1 = PizzaPaths.builder()
                .parents(a)
                .child(b)
                .build();

        PizzaPaths path2 = PizzaPaths.builder()
                .parents(a)
                .child(b)
                .build();

        Assertions.assertThrows(org.springframework.dao.DataIntegrityViolationException.class,
        ()->pizzaPathsRepository.saveAll(List.of(path1,path2)));
    }

Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Unique index or primary key violation이라고 에러 중간에 나온다. 자 유니크 테스트까지 해봤으니 실질적으로 데이터를 넣어보자.

토마토 피자
마르게리타 살라미
부라타 피자 , 브루쉐타 피자 디아볼로, 하와이안 피자

이렇게 구조를 잡는 다고 하면? 우리는 패스를 전부 기입해야 한다 다시 말해

토마토 피자를 부모로 잡고 있다면 그 연관된 모든 부분을 인서트 해줘야 한다는 소리이다. 코드로 보자.

더보기
public void insertPizza(Pizza p,Pizza parents){
        PizzaPaths save = pathsRepository.save(
                PizzaPaths.builder().parents(p).child(p).build()
        );

        List<Pizza> fetch = queryFactory.select(pizzaPaths.parents)
                .from(pizzaPaths)
                .where(pizzaPaths.child.id.eq(parents.getId()))
                .fetch();

        List<PizzaPaths> list = new ArrayList<>();
        for (int i = 0; i < fetch.size(); i++) {
            list.add(PizzaPaths.builder()
                    .parents(fetch.get(i))
                    .child(p)
                    .build());
        }

        pathsRepository.saveAll(list);
    }

먼저 본인 자신을 추가하고, 부모를 자식으로 가지고 있는 모든 피자를 가지고 와서 새로 인서트를 넣어준다. 

딱 보더라도 엄청나게 큰 카테고리 혹은 계층 구조를 구성하고 있다면 이 같은 테이블 구조는 사실 많이 힘들 것으로 생각된다. insert 한번 한번 할 때마다 비용이 너무 천차만별이다.

 

테스트 코드 및 결과 값

더보기
    @Test
    void insertEnd() throws Exception{

        Pizza 살라미 = pizzaRepository.findByName("살라미").get();
        Pizza save = pizzaRepository.save(Pizza.builder().name("디아볼로").build());
        Pizza save2 = pizzaRepository.save(Pizza.builder().name("하와이안").build());
        pizzaQuery.insertPizza(save,살라미);
        pizzaQuery.insertPizza(save2,살라미);

        List<PizzaPaths> allByChild = pizzaPathsRepository.findAllByChild(save);
        for (PizzaPaths pizzaPaths : allByChild) {
            System.out.println(pizzaPaths.getParents().getName());
        }

        System.out.println("===============================================");
        List<PizzaPaths> second = pizzaPathsRepository.findAllByChild(save2);
        for (PizzaPaths pizzaPaths : second) {
            System.out.println(pizzaPaths.getParents().getName());
        }
        Assertions.assertEquals(allByChild.size(),second.size());
    }
    
디아볼로
살라미
토마토피자

하와이안
살라미
토마토피자

인서트 는 확실히 구현하는데 오래걸렸다.. ㅜ 네이티브 쿼리를 사용한다면 보다 편리하게 인서트가 가능하다.

insert into pizza_paths (parents,child)
	select p.parents, :brandNew
    from pizza_paths p
    where p.child = :parents
union all
	select :brandNew,:brandNew
    
void insertPizzaPath(@Param("parents") Long parents, @Param("barndNew") Long brandNew)

unionAll 은 본인 자신을 추가하기 위한 부분



그렇다면 사이에 들어가는 인서트는 어떻게 해야 할까? 위와 동일하게 

    public void insertBetween(Pizza brandNew,Pizza current){

        savePizzaPath(brandNew);

        List<Pizza> fetch = queryFactory.select(pizzaPaths.child)
                .from(pizzaPaths)
                .where(pizzaPaths.parents.eq(current))
                .fetch();

        List<PizzaPaths> list = new ArrayList<>();
        for (Pizza paths : fetch) {
            list.add(
                    PizzaPaths.builder()
                            .parents(brandNew)
                            .child(paths)
                            .build()
            );
        }
        updateQuery(current,brandNew);
        pathsRepository.saveAll(list);
    }
    private void updateQuery(Pizza p, Pizza n){
        queryFactory.update(pizzaPaths)
                .set(pizzaPaths.child,n)
                .where(pizzaPaths.child.eq(p),
                        pizzaPaths.child.ne(pizzaPaths.parents))
                .execute();
    }

내가 들어갈 상위로 있는 피자를 잡아서 지워주고, 그 상위 피자들 목록을 가지고 새로 인서트도 해주어야 한다.
이 아이를 자식으로 가지고 있는 모든 칼럼에 대해서 복사해서 새로 인서트를 해주면 된다.

아까 테스트에서 중간에 삽입을 하고 동일하게 돌려서 결괏값을 받아보면

은근 복잡하지만 그래도 원하는 결과 값이 나온다.

723 토마토피자 토마토피자
726 토마토피자 마르게리타
730 토마토피자 디아볼로
733 토마토피자 하와이안
738 토마토피자 중간낑겨
724 마르게리타 마르게리타
729 살라미 디아볼로
732 살라미 하와이안
728 디아볼로 디아볼로
731 하와이안 하와이안
735 중간낑겨 살라미
736 중간낑겨 디아볼로
737 중간낑겨 하와이안
734 중간낑겨 중간낑겨

보면 알겠지만 꽤 많은 쿼리가 날아간다. 물론 네이티브 쿼리로 작성하면 최적화되겠지만 

테스트 코드 

더보기
@Test
void insertEnd() throws Exception{

    Pizza 살라미 = pizzaRepository.findByName("살라미").get();
    Pizza save = pizzaRepository.save(Pizza.builder().name("디아볼로").build());
    Pizza save2 = pizzaRepository.save(Pizza.builder().name("하와이안").build());
    Pizza between = pizzaRepository.save(Pizza.builder().name("중간낑겨").build());
    pizzaQuery.insertPizza(save,살라미);
    pizzaQuery.insertPizza(save2,살라미);
    pizzaQuery.insertBetween(between,살라미);

    em.flush();
    em.clear();

    List<PizzaPaths> all = pizzaPathsRepository.findAll();
    for (PizzaPaths pizzaPaths : all) {
        System.out.println(
                pizzaPaths.getId() + " "+ pizzaPaths.getParents().getName() + " "
                + pizzaPaths.getChild().getName()
        );
    }
}

업데이트를 위해서 는 먼저 서브트리 와 연관된 부분의 삭제문부터 시작한다.

    private void deleteByPizza(Pizza target){
        QPizzaPaths q2 = new QPizzaPaths("q2");
        QPizzaPaths q3 = new QPizzaPaths("q3");
        queryFactory
                .delete(pizzaPaths)
                .where(
                        pizzaPaths.child.in(
                                select(q2.child)
                                        .from(q2)
                                        .where(q2.parents.eq(target))
                        ),
                        pizzaPaths.parents.in(
                                select(q3.parents)
                                        .from(q3)
                                        .where(q3.child.eq(target),
                                                q3.parents.ne(q3.child))
                        )
                ).execute();
    }

 

 

타깃 즉 이동할 피자를 기준으로 서브트리 그리고 상위트리와 의 관계 선을 끊어주어 고아 서브 트리로 만들어주는 것이다.

즉 이 쿼리가 나간다면 관계선에 의한 트리는 총 2개가 존재한다. 기존 트리와 관계 가 끊어진 서브트리이다.

타깃 기준으로 타깃을 부모로 가지고 있는 관계 와 타겟을 기준으로 자식으로 가지고 있는 트리를 각각 서브 쿼리로 들고 와서 in 절을 이용해 날려준다. 

쿼리 

더보기
    delete 
    from
        pizza_paths 
    where
        (
            child_id in (
                select
                    pizzapaths1_.child_id 
                from
                    pizza_paths pizzapaths1_ 
                where
                    pizzapaths1_.parents_id=?
            )
        ) 
        and (
            parents_id in (
                select
                    pizzapaths2_.parents_id 
                from
                    pizza_paths pizzapaths2_ 
                where
                    pizzapaths2_.child_id=? 
                    and pizzapaths2_.parents_id<>pizzapaths2_.child_id
            )
        )

예를 들어 우리 피자 즉 살라미를 마르게리타 하위 트리로 옮겨보자.

딜리트 쿼리를 실행하면 토마토 피자 트리 한 개 와  살라미 피자 트리 한개 이렇게 구분된다. 

("살라미 -> 디아볼로", "살라미 -> 하와이안") , ("토마토->살라미") 이후 각각 in 절을 이용해서 좌우로 삭제해 준다. 그러면

자식이 디아볼로, 하와이안 그리고 부모가 토마토인 경우 인 애들만 삭제해주는 쿼리 가 되는 것이다.

 

이후 서브트리를 이어 붙이기 위해서 쿼리를 써야 하는데 이번에는 querydsl로 insert 후에 select 절을 이용해서 넣어주려고 했는데 실패해서 차선책으로 네이티브로 작성했다.

@Modifying
@Query(
        value = "insert into pizza_paths (parents_id,child_id) " +
                "select sup.parents_id,sub.child_id from pizza_paths sup " +
                "cross join pizza_paths sub "+
                "where sup.child_id = :parentsId and sub.parents_id = :childId",
        nativeQuery = true
)
void shiftInsertData(@Param("parentsId") Long parentsId,@Param("childId") Long childId);

새로운 위치의 조상들과 서브트리 자손에 해당하는 새로운 관계를 만들어주기 위해 이렇게 작성했다. 

crossJoin을 이용해 새 위치의 조상과 서브트리의 모든 노드를 대응시키는데 필요한 행을 만들어 낼 수 있다.

쿼리

더보기
Hibernate: 
    insert 
    into
        pizza_paths
        (parents_id,child_id) select
            sup.parents_id,
            sub.child_id 
        from
            pizza_paths sup cross 
        join
            pizza_paths sub 
        where
            sup.child_id = ? 
            and sub.parents_id = ?

위의 예를 이어가면 이렇게 되면 토마토 피자 가 제일 상위 노드 이기 때문에  토마토 피자로부터 생기는 서브트리 의 관계와 부모 와 의관계가 추가적으로 들어간다.

테스트 코드와 콘솔 내역

더보기
// 테스트 함수
@Test
void moveSubTreeToOther() throws Exception{
    insert();
    // 살라미 를 마르게리타 아래로 옮길꺼야 어떻게 ? 부모 를 지워주자.
    Pizza 마르게리타 = pizzaRepository.findByName("마르게리타").get();
    Pizza 살라미 = pizzaRepository.findByName("살라미").get();

    pizzaQuery.moveWithSubTree(살라미,마르게리타);

    List<PizzaPaths> all = pizzaPathsRepository.findAll();
    for (PizzaPaths pizzaPaths : all) {
        System.out.println(pizzaPaths.getParents()
                +" "+ pizzaPaths.getChild());
    }
}

private void insert() {
    Pizza 살라미 = pizzaRepository.findByName("살라미").get();
    Pizza save = pizzaRepository.save(Pizza.builder().name("디아볼로").build());
    Pizza save2 = pizzaRepository.save(Pizza.builder().name("하와이안").build());
    pizzaQuery.insertPizza(save,살라미);
    pizzaQuery.insertPizza(save2,살라미);
}

// PizzaQuery 클래스 함수
public void moveWithSubTree(Pizza target,Pizza move){
    deleteByPizza(target); 
    pathsRepository.shiftInsertData(move.getId(),target.getId());
}

private void deleteByPizza(Pizza target){
    QPizzaPaths q2 = new QPizzaPaths("q2");
    QPizzaPaths q3 = new QPizzaPaths("q3");
    queryFactory
            .delete(pizzaPaths)
            .where(
                    pizzaPaths.child.in(
                            select(q2.child)
                                    .from(q2)
                                    .where(q2.parents.eq(target))
                    ),
                    pizzaPaths.parents.in(
                            select(q3.parents)
                                    .from(q3)
                                    .where(q3.child.eq(target),
                                            q3.parents.ne(q3.child))
                    )
            ).execute();
}

// PizzaPath Repository interface 함수
@Modifying
@Query(
        value = "insert into pizza_paths (parents_id,child_id) " +
                "select sup.parents_id,sub.child_id from pizza_paths sup " +
                "cross join pizza_paths sub "+
                "where sup.child_id = :parentsId and sub.parents_id = :childId",
        nativeQuery = true
)
void shiftInsertData(@Param("parentsId") Long parentsId,@Param("childId") Long childId);

 


역시나 조회 데이터 추가 삭제는 간편하지만 업데이트에 있어 어느 정도의 비용이 든다고 생각한다. 

 

이런 비용을 지출 을 하고서라도 계층을 구성하고 나서는 조회할 때의 이점은 이루 말할 수 없으며 모든 트리 구조 상에 관계와 노드의 데이터 정합성도 유지할 수 있어 열거형 방식보다 는 좋다고 생각한다.

다만 문제로는 테이블을 추가적으로 구성하는 것 과 하나의 카테고리를 추가할 때마다 생각보다 많은 rows 가 테이블에 쌓인다는 어느 정도의 트레이드오프 가 존재한다.

 

아 추가적으로 인접노드 구현(셀프조인)과 유사하게 자식을 보다 쉽게 조회하기 위해 level? 혹은 length 같은 필드를 추가하면 보다 쉽게 조회가 가능하다. 본인 자신 의 length는 0이고 아래 자식은 1 등등 

select p.* from pizza_paths p where p.parents.name = '마르게리타' and length = 1; 

이러면 마르게리타 의 바로 아래자식 들 만 조회가 가능하다.

 

이번 에는 클로저 테이블에 대해 알아 보았는데 확실히 개인적인 생각으로는 클로저 테이블이 열거형 방식보다는 좋다고 생각된다. 참조의 정합성 데이터 의 일치성을 보장한다는 점이 좋았지만 구현하는 데 있어 애를 먹었다.

querydsl insert 버그, jpql insert 테이블 값과 미일치 되어 생긴 오류,@Modifying 빠져서 생긴오류, @Parma 오류, Update 후에 clear flush 안 하고 조회하여 발생된 테스트 실패, uniqueKey 동시칼럼 적용된 테스트 케이스 확인 , 복합키 설정 등등 많은 삽질을 했다... ㅠ

 

다음 에는 중첩집합 모델 에 대해 알아보자. 

 

  

+ Recent posts