계층형 구조 즉 대댓글 혹은 카테고리 같은 구조를 만들떄 어떤방식 을 사용하는가 ? 가장먼저 떠오르는건 셀프조인 방식이다. 

바로 구현해보자.

package com.example.dowhateveriwant.entity;

import lombok.*;

import javax.persistence.*;

@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class OrganizationChart {
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String department;

//    @ManyToOne
//    @JoinColumn(name = "person_id")
//    private Person person;

    @ManyToOne
    @JoinColumn(name = "parent_id")
    private OrganizationChart organizationChart;
}

 

 

이런 식으로 셀프조인 을 하면 된다.

root 데이터 부터 시작해서 도식화 해보자면

Id 값만 기입을 해본다면 

 

                         115

                    116     117

               118 119  120

              121

 

이런 방식으로 구조화 되어 있다. 구현도 쉬웠고 인서트도 쉽다 그냥 루트 먼저 넣어주고 데이터를 넣을 때 넣어주기만 하면 된다. 예제 코드를 보자.

 

OrganizationChart left = organizationChartRepository.findById(116L).get();
organizationChartRepository.save(
        OrganizationChart.builder()
                .department("두번째 레이어 왼쪽 왼쪽")
                .organizationChart(left)
                .build()
);

이런식 으로 들고 와서 넣어주기만 하면 끝이다. 뭐 별다를게 없다.

업데이트 는 어떻게 하면 될까 ? 118 번 120 번 하위로 모두 잘라서 들고가고 싶다면 ? 118 번의 부모 만 변경해주면 된다.

OrganizationChart organizationChart = organizationChartRepository
                .findById(118L).get();
OrganizationChart organizationChart2 = organizationChartRepository
        .findById(120L).get();
organizationChart.updateParents(organizationChart2);

반대로 삭제 라면 ? 아래 자식들 부터 지우면서 올라가지 않는다면 에러를 마주할것이다. 

CascadeType.Remove 를 주면 번거롭게 밑에서 부터 지우지 않아도 된다. 


말 나온김에 cascade 타입을 한번 살펴보자.

Entity 의 상태변화를 전달할 범위라고 생각하면 편할꺼 같다. default 는 아무것도 변화 시키지 않는다.

cacadeType.All => 뭐그냥 할때마다 다 따라옴 저장을 하던 지우던 업데이트를 하던 flush 로 엔티티 날리던 

cacadeType.Persist => 저장할때 자식도 따라서 저장된다. 우리의 경우 부모를 저장하고 자식을 저장하고 그다음 자시을 부모에 업데이트 해주지만 이 타입 이 걸려있다면 ? 한방에 저장해준다.

cacadeType.Merge => 트랜젝션 종료이후 관계 의 변화 및 추가가 있다면 부모 가 merge 를 수행한다.

cacadeType.Remove => 연관 된 관계를 지울때 같이 날려준다. 

cascadType.Detach => 부모 가 detach 즉 영속성 컨텍스트 에서 날아가면 자식도 같이 날아간다.

cascadeType.Refresh => 부모 엔티티 의 db 에서 새로불러온 값 이 있다면 자식도 무조건 리프레쉬 


 

타팀으로 부터 어드민 페이지 에서 특정 부서 아래로 모든 부서를 조회 하고싶다는 요청이 왔다고 해보자.

116번 의 자식을 모두 가져와 보자. 116 을 부모로 하는 이들을 조회 하면 된다.

118 119 가 나온다. 그밑에는 ? 어떻게 해야하는가 ? 나온값을 기준으로 다시 조회 하면 된다. 그러면 121 이 조회 된다. 

즉 다시 말해 원하는 깊이 만큼 조인 을 다때려박아야 한다는 소리이다. 

예시 116을 기준으로 118 119 를 위한 leftJoin , 121 레이어 를 위한 leftJoin

QOrganizationChart q2 = new QOrganizationChart("q2");
QOrganizationChart q3 = new QOrganizationChart("q3");
queryFactory.select(organizationChart,q2,q3)
        .from(q2)
        .leftJoin(q2.parent,organizationChart)
        .leftJoin(organizationChart.parent,q3)
        .where(organizationChart.id.eq(id))
        .fetch();
select o1.*,o2.*,o3.*
from ORGANIZATION_CHART  as o1
    left outer join ORGANIZATION_CHART o2
		on o2.parent_id = o1.id
	left outer join ORGANIZATION_CHART o3
		on o3.parent_id = o2.id
where o1.id = 116;

그렇다면 이런 join 의 깊이를 알수없다면 ? level 이라는 추가적인 필드 를 만들어 for 문을 돌려 그냥 네이티브 쿼리를 작성해 날려야 하지 않을까 싶다.

또한 이렇게 되면 count() 와 같은 집계수치를 계산하기 어려울 뿐더러 매번 쿼리를 날릴떄 마다 깊이를 신경 써야한다.

 

어떻게 하면 이런 조회 의 오점 을 해결할수 있을까 라는 고민을 나말고도 수많은 사람이 했고 그렇게 해왔다.

그 대안으로 Closure Table, Nested Sets, Path Enumertation 이렇게 3가지 가 있는데 하나씩 알아보자.

 

1. Path Enumeration 경로열거

심플하다 부모 의 아이디 + 내아이디 를 경로에 추가로 적어주면 된다. / 를 쓰던 @ 를 쓰던 그건 사용자 마음이기 떄문에 색다르게 @ 로 가보자.
나는 부모 의 패스 + 부모 의 아이디로  구현 하겠다 특별한 이유는 없다 그려보니 이게더 편하더라.

organiation 으로 했더니 코드가 너무길어서 category 로 변경하겠다.

path 부분 이 경로를 적어 줄 필드 이다. 

    @Test
    @Commit
    void init() throws Exception{
        Category category = Category.builder()
                .name("root")
                .build();
        categoryRepository.save(category);
    }

root 는 최상단 카테고리 이기 때문에 부모가 없다 즉 path 를 넣어줄 필요가 업다. root 아래로 사과 오렌지 딸기 를 넣어보자.

음 ㅋㅋㅋ generateValue 안넣어줘서 에러 났다 ㅋㅋ 추가하고 넣어주자 

    @Test
    @Commit
    void addFirstLayer() throws Exception{
        Category category = categoryRepository.findById(122L).get();

        List<Category> list = new ArrayList<>();
        String[] name = {"사과","오렌지","딸기"};
        for (int i = 0; i < name.length; i++) {
            list.add(
                    Category.builder()
                    .name(name[i])
                    .path(category.getPath())
                    .build());
        }
        categoryRepository.saveAll(list);
    }

                                        root         

            사과                   오렌지                 딸기

          아오리      황금향 루비향 천혜향     산딸기                   대충 이런 구조이다. 이름 은 신경 쓰지말고 트리 구조를 보자.

                          한라봉                          산산딸기

 

위에서 어려웠던 조회 를 한번 해보자 오렌지 아래로 모든 아이들을 조회 하고 싶어요 혹은 한라봉 의 모든 부모를 조회 되는 기능을 추가해주세요 이런다면 ? 정말 간편하게 쿼리를 작성할수 있다.

    public List<Category> findParents(Category cat){

        List<Long> ids = Arrays.stream(cat.getPath().split("@"))
                .map(Long::parseLong)
                .collect(Collectors.toList());

        return queryFactory.selectFrom(category)
                .where(category.id.in(ids))
                .fetch();
    }

    public List<Category> findChildren(Category cat){
        return queryFactory.selectFrom(category)
                .where(category.path.contains(String.valueOf(cat.getId())))
                .fetch();
    }

단순 자바의 스플릿 기능 과 contains 를 이용해 그냥 가져오면 된다.... 나간 쿼리를 확인해보자.

// 자식 조회시
select
    category0_.id as id1_0_,
    category0_.name as name2_0_,
    category0_.path as path3_0_ 
from
    category category0_ 
where
    category0_.path like ? escape '!'
    
// 부모 조회시
select
    category0_.id as id1_0_,
    category0_.name as name2_0_,
    category0_.path as path3_0_ 
from
    category category0_ 
where
    category0_.id in (
        ? , ? , ?
    )

셀프조인 해서 구했을 때 와는 차원이 다르게 손쉽게 생각하고 쿼리를 구상할수 있다.

단 이렇게 구상 되었을때의 단점이 존재한다. 무진장 큰 단점이 각 카테고리 별로 path 에 대한 연관관계 가 없다보니

지우거나, 업데이트, 저장 등을 할떄 존재하지 않는 카테고리 를 패스 넣어서 집어넣을수 있다는 소리이다.

 

1. 업데이트 및 하위 카테고리 의 이동 할떄 ? mysql 의 replace 함수를 이용해 구간을 집어서 추가해서 리플레이스 하면 된다.

2. 삭제 할때 는 내 아이디 기준으로 삭제 날려주면 된다. 

 

위와 같은 쿼리가 날라가는데 데이터가 없어도 ? 롤백없이 삭제되고 업데이트 된다. 이렇게 데이터의 정합성이 맞지 않을수 있다면 너무 치명적인 오류이다.

또한 varchar 의 길이 는 한정적이다. 65535 characters  라고 하는데

정말 말도 안되지만 만약 url 같은거를 넣어서 카테고리를 구성한다면 몇 뎁스 가지도 않아서 난감한 상황에 처할것이다.

 

그럼에도 불구하고 조회 에서 주는 이점이 정말 크다고 생각하기 떄문에 

이미 정해진 카테고리 이미 완성된 카테고리 에 데이터 의 변동이 적다면 이렇게 마이그레이션 하는것도 나쁘지 않다고 생각한다.

 

다음 글에서는 개인적으로 선호하는 클로저 테이블 과 중첩집합 방법에 대해 알아보자. 

 

+ Recent posts