와 이거 구현하기 힘들었다... 바로 가보자 생각보다 신경 쓸 것이 많았고, 놓친 부분이 많아서 테스트 엄청 돌려봤다.

중첩집합 이란 ?

우리는 지금껏 노드의 관계를 표현하기 위해 새로운 테이블 혹은 패스를 기록하거나 아니면 fk를 조인시키는 등의 과정을 지나왔다.

중첩집합 은 이런 거 없이 현재 가지고 있는 엔티티에 left right 값을 추가해주어 left right 값에 의해 노드 간의 관계를 유지하는 방법이다.

 

위와 같은 사진으로 구현하는 게 중첩 집합이다. 나름 직관적이지 않은가?  바로 가보자.

엔티티

public class Comment {

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

    private String comment;
    @Column(name ="left_node")
    private int left;
    @Column(name = "right_node")
    private int right;
    private int level;

    public void updateComment(String newComment){
        this.comment = newComment;
    }
}

left 랑 right는 join 예약어로 사용되니 에러 만나요( 테스트 돌리는데 테이블이 없다고 해서 당황스러웠다. 쿼리 가져다가 콘솔에 써보니 left right 때문이더라 ㅠ)

 

Interface Repository

@Repository
public interface CommentRepository extends JpaRepository<Comment,Long> {
    Optional<Comment> findByComment(String comment);
}

- 잡소리

사실 이거 하기 전에 테이블에 데이터가 하나라도 있다면 루트 즉 최상위 계층을 넣어주거나 아니면 아래로 넣어주는 방식으로 작성하려고 쿼리를 생각하다가 쿼리를 작성하려고 보니 궁금했다. 그래서 exsit 말고 다른방법을 찾다 보니 
select * from table / select 1 from table을 해보니 두 개의 쿼리 타는 방법이 달랐다.

왼쪽이 fullscan 오른쪽이 index scan 이였다. 뭐 때문에 이렇게 타입이 나뉘어서 타는지 궁금해서 알아보니 * 이용해서 조회를 하게 되면 index 가  없는 키도 조회해야 하기 때문에 저런 식의 타입으로 expain 조회하면 나오더라. 당연히 pk fk fk 등으로 조합해서 select 찍어오면 이런 경우는 없다. 

explain으로 검색할 때 type에서 index all 은 피해야 할 타입 중 하나 이기 때문에 이걸 개선하고 싶어서 삽질 좀 했다 

select top 1 1 from table limit 1 oracle에서는 이게 지원된다 1개의 로우가 찾는 즉시 멈추는 쿼리 다 즉 row를 일일이 다 헤짚어 다니지 않아도 된다.

이런 방식이 mysql 에는 없나 궁금해서 찾다 보니 mysql 공홈에서 제안하는 방법이 있다.

prefer_ordering_index  = off로 꺼주면 range 스캔까지 끌어올릴 수 있다 이 내용을 어디서 읽은 거 같은데 이렇게 다시 보니 반갑다 ㅋ

물론 이게 옳은 방법이 아닐지 모르나 만약 데이터가 너무 많아서 속도가 안 나온다면 이 방법을 이용해서 조회 성능을 향상할 수 있는 방법 중 하나로 기억하고 나중에 써먹자.


 

바로 인서트부터 구현해 보자.

private NumberExpression<Integer> getLeftCaseBuilder(int target) {
    return new CaseBuilder()
            .when(comment1.left.goe(target))
            .then(comment1.left.add(2))
            .otherwise(comment1.left);
}
public void insertUpdateLeftRight(int target){
    queryFactory.update(comment1)
            .set(comment1.left, getLeftCaseBuilder(target))
            .set(comment1.right,comment1.right.add(2))
            .where(comment1.right.goe(target))
            .execute();
}
    
public void insertComment(Comment parents, String comment){

    insertUpdateLeftRight(parents.getRight());
    // insert
    commentRepository.save(Comment.builder()
                    .left(parents.getRight())
                    .right(parents.getRight() + 1)
                    .level(parents.getLevel() + 1)
                    .comment(comment).build()
    );
}

 

하나의 데이터가 들어갈 때마다 right와 left를 업데이트해준다. 부모를 잡고 들어가기 때문에 부모의 오른쪽을 기준으로  오른쪽 보다 큰 경우만 업데이트해주면 된다. 단 왼쪽 노드 의 업데이트는 조심해야 한다. 부모 노드보다 작은 왼쪽 노드들은  업데이트를 해줄 필요가 없기 때문에 caseBuilder를 이용해서 위와 같이 작성했다.

 

QueryDsl에서는 update set을 그냥 체이닝 해서 쓰면 원하는 만큼 할 수 있다. 

CaseBuilder의 반환 값이 Expression 인 점을 이용해 위와 같이 작성했다.

테스트 코드 기본 세팅

더보기
class CommentTest {
    @Autowired
    private CommentRepository commentRepository;
    @Autowired
    private CommentQuery commentQuery;
    @Autowired
    private EntityManager em;

    @BeforeEach
    private void init(){
        //insert root
        Comment root = Comment.builder()
                .left(1)
                .right(2)
                .level(0)
                .comment("3 대 500 인 사람 댓글 달아봐")
                .build();

        Comment save = commentRepository.save(root);
        //insert 1st Layer
        insertCommentAndFlushClear(save,"3대 500 아래는 없어요 ?");
        insertCommentAndFlushClear(save,"3대 520 언더아머 회원임");

//        //insert 2nd Layer
        Comment comment1 = commentRepository.findByComment("3대 500 아래는 없어요 ?").get();
        insertCommentAndFlushClear(comment1,"3대 490 ㅠㅠ");
        insertCommentAndFlushClear(comment1, "300 인대요 ? 사람이세요?");

//        //insert 2nd Layer
        Comment comment2 = commentRepository.findByComment("3대 520 언더아머 회원임").get();
        insertCommentAndFlushClear(comment2,"스벤데 몇이고");
        insertCommentAndFlushClear(comment2,"3대 660");

//        //insert 3rd Layer
        Comment comment3 = commentRepository.findByComment("스벤데 몇이고").get();
        insertCommentAndFlushClear(comment3,"구라네 10 이 비는데 ?");
    }

    @AfterEach
    public void afterInit(){
        em.flush();
        em.clear();
    }

    private void insertCommentAndFlushClear(Comment parent,String comment){
        commentQuery.insertComment(parent,comment);
        em.flush();
        em.clear();
    }

코드를 한꺼번에 넣기 때문에 flush clear를 하지 않으면 right left 노드 의 숫자가 자기 멋대로다. 꼭 해주자.

 

저거 말고 중간에 끼워 넣는 걸 해보고 싶었다. 

구현

    public void insertBetween(Comment head,Comment tail,String comment){
        queryFactory.update(comment1)
                .set(comment1.left, comment1.left.add(1))
                .set(comment1.right, comment1.right.add(1))
                .set(comment1.level, comment1.level.add(1))
                .where(comment1.left.goe(tail.getLeft()),
                        comment1.right.loe(tail.getRight()))
                .execute();

        queryFactory.update(comment1)
                .set(comment1.left,getLeftCaseBuilderWithoutRoot(tail.getLeft()))
                .set(comment1.right,comment1.right.add(2))
                .where(comment1.right.gt(tail.getRight()),
                        comment1.ne(tail))
                .execute();

        commentRepository.save(Comment.builder()
                .left(head.getLeft()+1)
                .right(tail.getRight()+2)
                .level(head.getLevel() + 1)
                .comment(comment).build());
    }

쿼리를 무려 3번이나 던져야 한다. 저장을 위한 쿼리와 사이에 끼여 들어간다면 사이에 끼이는 기준으로 부모가 가지고 있는 하위 노드 와 부모 보다 큰 노드 들에 대해 다른 업데이트를 날려야 하기 때문에 위와 같이 작성했다.

 

테스트 코드

    @Test
    public void insertBetween() throws Exception{
        Comment comment = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
        Comment tail = commentRepository.findByComment("3대 520 언더아머 회원임").get();
        commentQuery.insertBetween(comment,tail,"3대 1억");
        em.clear();
        em.flush();

        List<Comment> all = commentRepository.findAll();
        for (Comment comment1 : all) {
            System.out.println(comment1);
        }

        comment = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
        Assertions.assertThat(comment.getLeft()).isEqualTo(1);
        Assertions.assertThat(comment.getRight()).isEqualTo(18);
    }

 

삭제구현

public void deleteComment(Comment comment){
        queryFactory.update(comment1)
                .set(comment1.left, subTractWithoutLeftRoot(comment.getLeft()))
                .set(comment1.right, comment1.right.subtract(2))
                .where(comment1.right.gt(comment.getRight()))
                .execute();

        queryFactory.update(comment1)
                .set(comment1.left, comment1.left.subtract(1))
                .set(comment1.right, comment1.right.subtract(1))
                .set(comment1.level, comment1.level.subtract(1))
                .where(comment1.left.gt(comment.getLeft()),
                        comment1.right.lt(comment.getRight()))
                .execute();


        queryFactory.delete(comment1).where(comment1.eq(comment)).execute();
    }

삭제도 마찬가지로  2번의 업데이트가 필요하다 중간에서 삭제되는 경우가 있기 때문에 이걸 고려해 주어야 한다.

 

테스트 코드

@Test
void deleteComment() throws Exception{
    Comment comment = commentRepository.findByComment("3대 500 아래는 없어요 ?").get();

    commentQuery.deleteComment(comment);

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

    List<Comment> all = commentRepository.findAll();
    for (Comment comment1 : all) {
        System.out.println(comment1);
    }
    Comment comment1 = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
    Assertions.assertThat(comment1.getRight()).isEqualTo(14);
}

이 중첩 모델의 장점 중 하나는 바로 조회에 있다고 생각한다. 

public List<Comment> findByLayer(int level){
    return queryFactory.selectFrom(comment1)
            .where(comment1.level.eq(level))
            .fetch();
}

public List<Comment> getAllCommentFromComment(Comment comment){
    return queryFactory.selectFrom(comment1)
            .where(comment1.left.gt(comment.getLeft()),
                    comment1.right.lt(comment.getRight()))
            .fetch();
}

처음 entity에 level 필드를 생성했다. 이런 계층 별 탐색도 추가해보고 싶어서 추가했는데 나름 조회 쿼리를 짜는데 손쉽게 된다.

인접노드 블로그 글 쓸 때 조회 할 때 정말 고민을 많이 하면서 작성했는데 이렇게 쉽게 된다. 현재 노드를 기준으로 왼쪽은 크고 오른쪽은 작은 거 그냥 긁어오면 자식(즉 서브 트리) 모두를 조회할 수 있다.

 

이 중첩모델은 조회에서 어마어마한 이점이 있으나 위에서 보는 바와 같이 업데이트 삭제 삽입 하는데 생각보다 복잡했다. 서브트리의 이동 이 라던지 루트 위에 다시 루트를 생성한다는 등 배제한 경우 가 있음에도 이렇게 코드가 생각보다 길다. 

 

중첩모델을 사용함에 있어서는 인서트가 자주 일어난다면.. 다시 한번 고려해보고 사용해야 할 거 같다. 그렇지만 조회를 자주 한다면 최고인 것 같다.

 

테스트 코드 전문

더보기
package com.example.dowhateveriwant.entity;

import com.example.dowhateveriwant.repository.commet.CommentQuery;
import com.example.dowhateveriwant.repository.commet.CommentRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.EntityManager;

import java.util.List;

@SpringBootTest
@Transactional
class CommentTest {
    @Autowired
    private CommentRepository commentRepository;
    @Autowired
    private CommentQuery commentQuery;
    @Autowired
    private EntityManager em;

    @BeforeEach
    private void init(){
        //insert root
        Comment root = Comment.builder()
                .left(1)
                .right(2)
                .level(0)
                .comment("3 대 500 인 사람 댓글 달아봐")
                .build();

        Comment save = commentRepository.save(root);
        //insert 1st Layer
        insertCommentAndFlushClear(save,"3대 500 아래는 없어요 ?");
        insertCommentAndFlushClear(save,"3대 520 언더아머 회원임");

//        //insert 2nd Layer
        Comment comment1 = commentRepository.findByComment("3대 500 아래는 없어요 ?").get();
        insertCommentAndFlushClear(comment1,"3대 490 ㅠㅠ");
        insertCommentAndFlushClear(comment1, "300 인대요 ? 사람이세요?");

//        //insert 2nd Layer
        Comment comment2 = commentRepository.findByComment("3대 520 언더아머 회원임").get();
        insertCommentAndFlushClear(comment2,"스벤데 몇이고");
        insertCommentAndFlushClear(comment2,"3대 660");

//        //insert 3rd Layer
        Comment comment3 = commentRepository.findByComment("스벤데 몇이고").get();
        insertCommentAndFlushClear(comment3,"구라네 10 이 비는데 ?");
    }

    @AfterEach
    public void afterInit(){
        em.flush();
        em.clear();
    }

    private void insertCommentAndFlushClear(Comment parent,String comment){
        commentQuery.insertComment(parent,comment);
        em.flush();
        em.clear();
    }

    @Test
    void insertTest() throws Exception{
        List<Comment> all = commentRepository.findAll();
        for (Comment comment1 : all) {
            System.out.println(comment1);
        }

        Comment comment = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
        Assertions.assertThat(comment.getLeft()).isEqualTo(1);
        Assertions.assertThat(comment.getRight()).isEqualTo(16);
    }

    @Test
    public void insertBetween() throws Exception{
        Comment comment = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
        Comment tail = commentRepository.findByComment("3대 520 언더아머 회원임").get();
        commentQuery.insertBetween(comment,tail,"3대 1억");
        em.clear();
        em.flush();

        List<Comment> all = commentRepository.findAll();
        for (Comment comment1 : all) {
            System.out.println(comment1);
        }

        comment = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
        Assertions.assertThat(comment.getLeft()).isEqualTo(1);
        Assertions.assertThat(comment.getRight()).isEqualTo(18);
    }

    @Test
    public void updateComment() throws Exception{
        String updateOne = "업데이트 된 커멘트";
        Comment comment = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
        comment.updateComment(updateOne);

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

        comment = commentRepository.findByComment(updateOne).get();
        Assertions.assertThat(comment.getComment()).isEqualTo(updateOne);
    }

    @Test
    void deleteComment() throws Exception{
        Comment comment = commentRepository.findByComment("3대 500 아래는 없어요 ?").get();

        commentQuery.deleteComment(comment);

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

        List<Comment> all = commentRepository.findAll();
        for (Comment comment1 : all) {
            System.out.println(comment1);
        }
        Comment comment1 = commentRepository.findByComment("3 대 500 인 사람 댓글 달아봐").get();
        Assertions.assertThat(comment1.getRight()).isEqualTo(14);
    }

    @Test
    void selectSpecificLayer() throws Exception{
        for (int i = 0; i < 3; i++) {
            List<Comment> byLayer = commentQuery.findByLayer(i);
            System.out.println("========= " + i + " ============");
            for (Comment comment : byLayer) {
                System.out.println(comment);
            }
        }
    }

    @Test
    void getAllChildByComment() throws Exception{
        //2번째 레이어 2개의 댓글 에 한개의 대댓글
        Comment comment = commentRepository.findByComment("3대 520 언더아머 회원임").get();
        List<Comment> allCommentFromComment = commentQuery.getAllCommentFromComment(comment);
        Assertions.assertThat(allCommentFromComment.size()).isEqualTo(3);
        for (Comment comment1 : allCommentFromComment) {
            System.out.println(comment1);
        }
    }
}

CommentQuery 클래스 

더보기
package com.example.dowhateveriwant.repository.commet;

import com.example.dowhateveriwant.entity.Comment;
import com.querydsl.core.types.dsl.CaseBuilder;
import com.querydsl.core.types.dsl.NumberExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.stereotype.Repository;

import javax.persistence.EntityManager;
import java.util.List;

import static com.example.dowhateveriwant.entity.QComment.comment1;


@Repository
public class CommentQuery {
    private JPAQueryFactory queryFactory;
    private CommentRepository commentRepository;

    public CommentQuery(EntityManager em,CommentRepository cm) {
        this.queryFactory = new JPAQueryFactory(em);
        this.commentRepository = cm;
    }

    public void insertComment(Comment parents, String comment){

        insertUpdateLeftRight(parents.getRight());
        // 데이터 인설트
        commentRepository.save(Comment.builder()
                        .left(parents.getRight())
                        .right(parents.getRight() + 1)
                        .level(parents.getLevel() + 1)
                        .comment(comment).build()
        );
    }
    public void insertUpdateLeftRight(int target){
        queryFactory.update(comment1)
                .set(comment1.left, getLeftCaseBuilder(target))
                .set(comment1.right,comment1.right.add(2))
                .where(comment1.right.goe(target))
                .execute();
    }
    public void insertBetween(Comment head,Comment tail,String comment){
        queryFactory.update(comment1)
                .set(comment1.left, comment1.left.add(1))
                .set(comment1.right, comment1.right.add(1))
                .set(comment1.level, comment1.level.add(1))
                .where(comment1.left.goe(tail.getLeft()),
                        comment1.right.loe(tail.getRight()))
                .execute();

        queryFactory.update(comment1)
                .set(comment1.left,getLeftCaseBuilderWithoutRoot(tail.getLeft()))
                .set(comment1.right,comment1.right.add(2))
                .where(comment1.right.gt(tail.getRight()),
                        comment1.ne(tail))
                .execute();

        commentRepository.save(Comment.builder()
                .left(head.getLeft()+1)
                .right(tail.getRight()+2)
                .level(head.getLevel() + 1)
                .comment(comment).build());
    }

    public List<Comment> findByLayer(int level){
        return queryFactory.selectFrom(comment1)
                .where(comment1.level.eq(level))
                .fetch();
    }

    public List<Comment> getAllCommentFromComment(Comment comment){
        return queryFactory.selectFrom(comment1)
                .where(comment1.left.gt(comment.getLeft()),
                        comment1.right.lt(comment.getRight()))
                .fetch();
    }

    public void deleteComment(Comment comment){
        queryFactory.update(comment1)
                .set(comment1.left, subTractWithoutLeftRoot(comment.getLeft()))
                .set(comment1.right, comment1.right.subtract(2))
                .where(comment1.right.gt(comment.getRight()))
                .execute();

        queryFactory.update(comment1)
                .set(comment1.left, comment1.left.subtract(1))
                .set(comment1.right, comment1.right.subtract(1))
                .set(comment1.level, comment1.level.subtract(1))
                .where(comment1.left.gt(comment.getLeft()),
                        comment1.right.lt(comment.getRight()))
                .execute();


        queryFactory.delete(comment1).where(comment1.eq(comment)).execute();
    }
    private NumberExpression<Integer> subTractWithoutLeftRoot(int target) {
        return new CaseBuilder()
                .when(comment1.left.gt(target))
                .then(comment1.left.subtract(2))
                .otherwise(comment1.left);
    }

    private NumberExpression<Integer> getLeftCaseBuilderWithoutRoot(int target) {
        return new CaseBuilder()
                .when(comment1.left.gt(target))
                .then(comment1.left.add(2))
                .otherwise(comment1.left);
    }

    private NumberExpression<Integer> getLeftCaseBuilder(int target) {
        return new CaseBuilder()
                .when(comment1.left.goe(target))
                .then(comment1.left.add(2))
                .otherwise(comment1.left);
    }
}

 

현재까지 인접노드, 열거형 모델, 클로저 테이블, 중첩모델을 알아봤다. 

인접목록 => 자식조회가 쉽고, 구현이 쉽다. 삽입과 삭제 가 쉽지만 트리를 조회하는 데 있어 문제점이 발생했다

 

그래서 제안된 방법이 열거형 클로저 중첩모델이다.

 

열거형 => 인접목록의 트리 조회를 쉽게 할 수 있었다. 다만 참조의 정합성 문제가 발생했다. 없는 노드 도 열거형에 넣을 수 있으며 얼마나 깊은 계층까지 만들어야 하는가 에 매번 신경을 써야 하는 단점이 존재했다.

 

클로저 => 두 개의 테이블을 운용해 트리의 모든 경로를 기록하기 때문에 조회가 무척 쉬웠다. 심지어 열거형, 중첩 모델의 문제인 참조의 정합성 문제도 해결해주는 완벽한 듯해 보였으나. 계층 구조를 사용하는 데 있어 많은 저장공간을 사용하는 아쉬운 점이 존재한다.

 

중첩 => 노드 의 좌우측 번호를 매겨 트리의 계층을 유지하는 모델이다. 트리를 수정하고 데이터를 집어넣는 경우 매우 효과적이지 못하다는 것을 코드를 구현하면서도 느낀다. 반면 조회에 있어서 만큼의 편리함은 이루 말할 수 없다. 

 

각각의 장단점이 존재한다. 가장 주가 되는 기준을 정하고 그 기준에 부합하는 모델을 사용하면 된다고 생각한다. 개인적으로 모든 모델을 구현하면서 중첩 모델이 가장 어려웠다.

클로저 테이블 방식

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

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 동시칼럼 적용된 테스트 케이스 확인 , 복합키 설정 등등 많은 삽질을 했다... ㅠ

 

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

 

  

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

바로 구현해보자.

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 같은거를 넣어서 카테고리를 구성한다면 몇 뎁스 가지도 않아서 난감한 상황에 처할것이다.

 

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

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

 

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

 

1965. Employees With Missing Information

이번 문제는 많이 새로웠다,  두 테이블 데이터 사이에 비어있는 아이디를 출력해야한다. 난해하다... 셀렉트 따로따로 하라그러면 하겠는데 두가지 문제의 2조건을 모두 충족할려면 셀렉트를 두번해야하지 않겠는가 ....머리가 터질듯이 아파왔다.. ㅠ
아래 관련토픽 에서 유니온 이라는 태그를 보고 검색했다.
'union'
두개이상 의 SELECT 결과를 합칠수 있습니다.  합친 결과에서 중복되는 행은 하나만 표시합니다.

단, 컬럼의 개수가 같아야하고, 각 컬럼의 데이터타입이 같아야합니다.

테이블 수십개도 유니온으로 가능한가 ? 가능하다.ㅋㅋㅋ 칼럼의 개수와 데이터 타입만 일치한다면 뭐 마음껏 써도 된다.

단 중복된 데이터를 원한다면 union all 이라는 구조를 이용해서 작성해 주어야 한다.

union all 에 대한 예제를 검색하다 재미난 문구를 발견했다.

만약 테이블 안에서 유니크 한 값을 반환한다면 union all 을 이용하는게 더 빠른 결과를 반환한다는 내용이다.
우리의 경우 각각 의 테이블에서 이름 과 급여를 놓친 부분을 반환을 하는데 있어 union all 이 될까 싶었지만 된다.
뭐 안되더라도 위에 문구 처럼 결과에 DISTINCT 를 넣어주면 되지 않은가 싶다.

select e.employee_id as employee_id from Employees e
left join Salaries s
on s.employee_id = e.employee_id
where s.employee_id is null
union all
select s.employee_id as employee_id from Employees e
right join Salaries s
on s.employee_id = e.employee_id
where e.employee_id is null
order by employee_id;

정말 단순하다.. select 두개 사이에 유니온 써주면 된다.. 호올리... 단순하게 설명하자면
left join 을 이용해 데이터를 한군데 전부 몰아주자. 여기서 join 으로 구사한다면 원하는 데이터를 얻지못한다. 양쪽 모두에 속해 있는 결과만 리턴받기 때문에.. left join 을 써주어야한다.

뭐 이것만 안다면 밑에 셀렉트 문도 크게 다를바 없다. 

추가적으로 union 이나 union all 이나 그렇게 큰 속도차이는 없었다. 각각 990ms 900ms 정도 만약 릿코드에 나온 데이터들 보다 큰 데이터를 다루게 된다면 이러한 조그마한 속도의 디테일 이 나중에 더 큰 나비효과 를 불러 일으킬수 있다고 생각한다. 기억해두자 union all 생각보다 많이 쓸것같다.

 

1795. Rearrange Products Table

보고나서 바로 유니온 떠올랐다....

각 가게 별로 찾아서 값을 넣어주면 되는게 아닌가 ?
왜냐하면 이미 가게의 종류가 칼럼안에 정의가 되어있기 때문에 이렇게 생각했다. 
위에서 유니온 테이블 2개이상 가능하다고 했으니 바로 해보자 

select product_id,"store1" as store, store1 as price
from products
where store1 is not null
union all
select product_id,"store2" as store, store2 as price
from products
where store2 is not null
union all
select product_id,"store3" as store, store3 as price
from products
where store3 is not null;

output 출력에 맞게 select 설정해주고 각 스토어 별로 눌이 아닌경우를 찾아서 뽑아주면 된다. ㅋㅋㅋ 위에서 하고 오니 뭔가좀더 쉽다. 

 

608. Tree Node

음... 트리를 알고리즘 에서 만 보던게 여기서 보니 새롭지만 문제 넘기고싶다. 전혀 읽고 싶지가 않다...

첫번쨰 미디움 난이도의 문제 이다. 

id 는 본인 자신의 숫자를 의미하고, p_id 는 자신보다 상위 노드의 트리 아이디를 가르킨다. 항상 트리의 형태가 가능한 숫자들만 주어진다고 한다. 

여기서 root 와 inner leaf 를 구분해서 작성해 주어야 한다. 트리가 나와서 그렇지 맨위 root 중간 inner leaf 꼬리 로 분류해서 작성해주면 별볼일 없는 문제다. 여기서 inner 의 체크가 중요한데 이거는 p_id 의 대상이 2개 라면 그 p_id 본인은 inner 가된다 위의 두경우를 제외 한다면 나머지는 leaf 때려주면 된다.셀렉트 3개 유니온 갈겨주자. 물론 각 노드들은 유니크한 값들이니 위에서 배운대로 union all 을하자.

 

약 4번 제출하고 통과한 코드이다. 일련의 과정을 적을 예정이니 답이 궁금하면 스크롤 내려서 확인바란다.

 제출 1번

# Write your MySQL query statement below
select id,"Root" as type from tree
where p_id is null
union all
select id,"Inner" as type from tree
where id in (select p_id from tree where p_id != 1)
union all
select id,"Leaf" as type from tree
where id not in (select p_id from tree where p_id is not null);

null 인 아이디 라면 ? 그게 root 이다 부모가 없으니깐(???), inner 의 경우는 ? 1 이 부모가 아닌 경우 를 서브쿼리로 뽑아 in 으로 확인후 inner 로 퍼올린다, 마지막 inner 와 동일 조건으로 not in 을 이용해 주었다.


실패했다... ㅋㅋㅋ 

16/19 에서 걸린다. {"headers": {"Tree": ["id", "p_id"]}, "rows": {"Tree": [[1,null]]}} 이경우 나는 leaft 1 이 다시 들어간다 왜 ?

id 에는 null 을 포함한 값들을 가지고 대조하는데 내가 퍼올린건 null 이 아닌 값들이 올라온다.  그래서 leaf 1 도 들어간다 and 연산으로 이어붙여주자.

# 수정부분
select id,"Leaf" as type from tree
where id not in (select p_id from tree where p_id is not null) and p_id is not null;

아씨 17/19 에서 걸린다 왜? root가 1이 아닌 경우가 있다..... 문제 어디에서도 1 이 항상 루트인 경우라는 말은 없었다.... 다시 고쳐주자

#수정부분
select id,"Inner" as type from tree
where id in (select p_id from tree where p_id is not null)

응 ? 이러고 제출하니 1번이 틀린다 왜 ? 1번 제출과 동일한 이유다 null 이아닌값을 퍼올리고 null 값을 들고 대조하니 당연히 중복이지..

#코드최종
select id,"Root" as type from tree
where p_id is null
union
select id,"Inner" as type from tree
where id in (select p_id from tree where p_id is not null) and p_id is not null
union
select id,"Leaf" as type from tree
where id not in (select p_id from tree where p_id is not null) and p_id is not null
order by id;

아 union all 을 이용해서 작성해도 통과된다. 위에설명한것과 같이 각 select 문은 유니크한 값들 만 리턴한다.

아유... 드디어 통과 됬다... 멀리도 돌아왔다.... 이건 디스커스 를 안볼수가 없다 보러가보자.

SELECT id,
CASE
    WHEN p_id is NULL THEN "Root"
    WHEN id NOT IN (SELECT p_id FROM Tree WHERE p_id IS NOT NULL) THEN "Leaf"
    ELSE "Inner" 
END AS type 
FROM Tree

하 씨 ? case 와 if 문을 사용할수 있는건 알고 있었다. 그런데 여기에 이렇게 활용할 생각을 1도 생각 못했다. 와.....

이사람은 뒤에 and is not null 을 또 안써도 되는이유는 for 문처럼 하나씩 들어오는데 root 의 경우 이미 위에서 걸러지기 떄문에.. 하 씨 와 ..... 멋진 코드다 내 union  코드보다 3000배 간결하고 가독성도 좋다..

오늘 은 할당량이 좀 많다. 4문제다 .. 마지막 문제 보고 가자.

 

문제는 진짜 간단하다. 2번째 높은 급여를 반환 하는 문제이다. 자바 Pq 만들어서 쓱삭 할텐데 아쉽다. 그런데 왼걸 정말 난해했다 구현하는데 있어 제출 횟수가 보이는가.... 고생 많이한 문제다..

 

어떻게 해야 두번째 높은 값을 뽑을 수 있는가 ? 느낌이 1도 안와서 스터디원(큐빈) 에게 물어 봤다. limit 과 offset 을 이용해서 짜르면 어떻냐는 제안을 받았고 바로 제출.

select 
	salary as "SecondHighestSalary"
from employee
order by salary desc
limit 1 offset 1;

음 ? null 처리 가 안되어있다. if 문으로 처리해주자.

select 
	if(salary = null ,null,salary) as "SecondHighestSalary"
#중략----

아니 ? null 값이 반영이 안된다.  null 이 되어서 아예 반영이 안되는듯 싶었다.

case 를 이용해보자..

select 
	case
        when salary is not null then salary
        else (null)
    end as SecondHighestSalary
# 중략 ----

하씨 ? null 이 반영이 안된다.  mysql 로컬 서버를 키고 이것저것 실험 을 했다. null 자체 스트링으로 반환을 하니 응 ? 모든 반환값들이 문자열로 변했다. cast 를 이용해 형변환을 salary 에 int 로 했지만 안된다. case 를 이용하면 오로지 하나의 데이터 타입 만을 반환하는것 같았다. 

select 
	case
        when count(*)<2 then null
        else cast(min(salary) as decimal)
    end as SecondHighestSalary
from (
    select distinct salary 
    from employee 
    order by salary 
    Desc Limit 2
) a

이건 통과된 코드이다. 내코드와의 차이 점이라면 from 에서 퍼올린 데이터 기준으로 셀렉트 값이 나간다. 어떤 차이가 있는것인가.. 

select 
	salary
from employee
order by salary desc
limit 1 offset 1;

이렇게 특별하게 지정이 된다면 빈값을 반환한다. 이게 문제다 ㅋㅋ ..... limit1 offset1 아무데이터도 없다면 아무것도 반환하지 않는다.. null 조차... 에초에 전제 자체가 잘못된것이다.

select case 
when count(Salary) > 1 then (select distinct Salary from Employee
    order by Salary DESC limit 1, 1) 
else NULL end
from Employee;

내가 구현하고자 했던 코드가 이런 코드 였는데 많이 돌아간것 같다.

 

max 를 이용한 간단한 풀이를 보자.

Select MAX(salary) as SecondHighestSalary 
from Employee 
where salary < (select MAX(salary) from Employee);

max 를 이용하면 null 처리도 간단하게 할수 있다. max 값이 없다면 null 을 리턴해주기 때문에 이런 방식의 코드가 가능하다.

max 를 두번이용해서 2번째 값을 찾아주는것이 참 생소했다. ㅎㅎ......

 

와 오늘 sql 은 풀이도 오래걸리고 블로그 작성도 오래걸렸다...

확실히 모르는 부분은 디스커스 를 보면서 내꺼화 시켜야 하는데 그게 참 어려운 부분이다.

1667. Fix Names in a Table

네임 들을 캐피털 화 시키는 작업이다. Sql의 서브 스트링을 알고 있다면 손쉽게 풀 수 있는 문제이다.

# Write your MySQL query statement below

select user_id,concat(upper(left(name,1)),lower(substring(name,2))) as name
from users
order by user_id;

concat을 이용해 문자열을 붙이고 , left()와 subString을 이용해 문자를 각각 나누어 주었다.

left(name,1)을 이용해 왼쪽 에서부터 1개,

substring (스트링,시작,끝) 인데 끝이 없다면 끝까지 들고 온다. 이후 각각 upper와 lower를 이용해서 이어 붙이면 끝

 

1484. Group Sold Products By The Date

이번 문제는 은근 애를 먹었다, 그룹 바이를 이용해 데이터 별로 나누어 주었지만 products 에 어떻게 나열해야 할지 생각 이 잘 나질 않아서 구글링 끝에 작성했다.

# Write your MySQL query statement below
select a.sell_date,
count(distinct a.product) as num_sold, 
group_concat(distinct product order by product asc) as products
from activities a
group by sell_date
order by sell_date

select 안의 date 부분은 뭐 문제에서 요구 되어지는 부분이고 특별할 것이 없어 넘어간다.

count(distinct a.product) 이부분은 count를 이용해서 하자니 중간에 겹치는 문자가 있다면 그걸 전부 카운트해버리는 것이 아닌가...

중복을 제거해주는 distinct 를 한번 넣어 봤는데 오류 없이 작동하더라, 저렇게 안쪽에도 선언 이 되는지는 이번에 처음 알게 되었다.

group_concat 나를 제일 애먹인 부분이다.

그룹별로 데이터를 나열할 때 사용되어지는 이 함수이다, 사용법은 간다 하게 나열하고 싶은 데이터를 함수 안에 인자로 패스해주면 된다.
문제에서 요구되어지는 순서와 중복이 없어야 하기 때문에 나는 위와 같이 추가적인 제한사항을 적어 주었다.

 

1527. Patients With a Condition

간만에 자신감 있게 풀고 틀리고 한 번 더 제출해서 통과했다. ㅋㅋ 특별할 것 없이 DIAB1 이 포함된 문자열을 찾아서 퍼올려주는 문제이다.

지난번에 배운 like를 이용해 작성했다.

# Write your MySQL query statement below
select patient_id,patient_name,conditions
from patients
where conditions like'DIAB1%' or conditions like '% DIAB1%';

처음 작성할때 like '% DIAB1%' 이런 식으로 작성하니 테스트 12번에서 걸린다 ㅋㅋㅋ 
{"headers": ["patient_id", "patient_name", "conditions"], "values": [[1, "Daniel", "SADIAB100"]]}
위의 like 로 때려버리면 이 인풋은 1개의 값을 반환하게 된다. DIAB1의 prefix 인 경우를 찾아야 하는데 내가 찾은 건 단순 한 번이라도 사용되었다면 반환하기 때문에 이렇게 에러가 발생한다.

or를 이용해서 startsWIth 느낌 나게 한 개,  공백을 이용해서 시작하는 단어로 구분을 지어 주었다.

 

알고리즘 과 는 달리 SQL 문제들은 discuss의 글들이 대부분 비슷하다... db 가 다르지 않은 이상 거의 유사하기 때문에 discuss 보는 재미가 많이 반감되었다..

1873. Calculate Special Bonus

보너스 계산해서 퍼올리는 문제이다. 대신 조건은 아이디가 홀수인 경우 , 그리고 이름이 'M'으로 시작하지 않는 경우에만 보너스가 주어지고, 아니라면 0 쿼리 결과로 띄워주어야 한다.

select 문 그리고 bonus라는 결괏값에 if 문을 넣어 적을 생각을 하였다.

# Write your MySQL query statement below
select employee_id,
if(name not like 'm%' and employee_id % 2 = 1,salary,0) as bonus
from employees
order by employee_id;

 

if( 조건, 참, 거짓) 3항 연산자가 생각나는 표현법이지 않은가 직관적이고 좋다.

문자의 시작을 검사할 때 like '문자%' 이런 기법이 있다는 것을 검색하면서 알게 되었다. 

  • like '문자 %' 문자로 시작하는 걸 검색할 때 
  • like '% 문자' 문자로 끝나는 걸 검색할 때
  • like '% 문자%' 문자가 들어 있는 걸 검색할 때

627. Swap Salary

성별을 업데이트해주는 쿼리를 날려야 한다. 

set을 이용하고, 조건이 단 2가지 이기 때문에 If를 사용할 생각을 했다, 이보다 많다면 아마 case를 쓰지 않을까 싶다.

# Write your MySQL query statement below
update 
    salary
set
    sex = if(sex='f','m','f');

심플하다 특이사항 없이 위에 작성한 그대로다.

디스커스를 둘러보던 중 케이스를 작성한 사람이 있어 코드를 보자.

UPDATE 
    Salary 
SET 
    sex = 
    (CASE 
    WHEN (sex='f') THEN 'm' 
    WHEN (sex='m') THEN 'f' 
    END);

솔직히 이렇게 case로 작성하는 것이 보다 직관적 이여서 추후 복잡한 로직이라면 이런 케이스의 경우가 더선호되지 않을까 싶다.

 

196. Delete Duplicate Emails

딜리트 쿼리를 이용해서 중복된 이메일을 지워주어야 한다, 대신 아이디가 높은 아이들이 지워져야 한다.

음 막막해서 구글링 해서 이것저것 붙여 넣어서 완성했다.. ㅋㅋ

# Please write a DELETE statement and DO NOT write a SELECT statement.
# Write your MySQL query statement below
delete p1 from person as p1
inner join person as p2
where p1.id>p2.id and p1.email = p2.email;

지난번 작성한 join을 여기서 또 작성한다. 동일한 테이블을 조인시킨다면 모든 값이 올라가고, 그중에 조건을 걸어서 지워주는 것이다.

동일한 이메일에 아이디가 크다면. 조인을 이렇게도 사용한다는 것이 신기했다.

DELETE FROM Person 
WHERE ID NOT IN
(SELECT * FROM
    (
        SELECT MIN(ID) FROM Person
        GROUP BY email
    ) as Person1
 );

그룹바이를 이용해 이메일 별로 가장 작은 아이디 값을 가져오고 그것이 person에 없다면 지워주는 방식이 새로웠다. 

 

이렇게 sql 은 알고리즘처럼 참 다양한 풀이들이 많은 것 같다.

+ Recent posts