ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SQL][QueryDSL] LEFT JOIN, RIGHT JOIN의 차이점 비교하기 (Feat. 상속 관계의 Entity 쿼리하기)
    Today I Learned 2023. 6. 27. 15:50

    채팅 기록 조회

    백엔드 애플리케이션에서 특정 사용자가 자신이 참가하는 경기 게시글에 생성된 채팅방 페이지로 이동할 때, 해당 경기 식별자를 갖는 모든 채팅 내역을 조회하는 기능이 있었다. 해당 기능은 JPQL을 직접 작성해 Repository의 쿼리 메서드에 부여하는 방식으로 구현했었고, 이때까지는 사용자가 실제로 입력한 메시지와 특정 이벤트가 발생했을 때의 메시지를 구분해서 조합한 뒤 클라이언트에 DTO로 반환하고 있었다.
     
    해당 기능을 위한 Entity인 ChattingMessage를 추상 클래스화하고, 채팅 메시지와 이벤트 메시지를 ChattingMessage를 상속받은 구체 클래스로 정의해 모든 메시지를 하나의 List에 담아 반환하도록 하는 것을 리팩터링의 목표로 두고 작업을 진행하던 중, LEFT JOIN과 RIGHT JOIN의 차이를 비교할 수 있었던 상황이 발생했다. 쿼리 결과를 직접 실험해보면서 둘의 차이점에 대해 정리했다.
     
     
    ChattingMessage Entity의 정의를 살펴보자. 추상 클래스인 ChattingMessage는 해당 클래스를 상속받는 자식 클래스들이 공통적으로 가져야 할 값들인 식별자, 연관된 경기 식별자 (각 경기 별로 채팅방은 하나만 존재하도록 정책을 설정했기 때문에 채팅방은 경기의 식별자로 구분된다.), 작성한 사용자 식별자, 메시지 타입, 메시지 생성 시각을 필드 값으로 갖는다. 상속을 데이터베이스 테이블 구조에서 나타내기 위한 전략으로는 JOINED를 선택했다.
     

    // models/chatting/message/ChattingMessage.java
    
    @Entity
    @Table(name = "chatting_messages")
    @Inheritance(strategy = InheritanceType.JOINED)
    public abstract class ChattingMessage {
        @Id
        @GeneratedValue
        private Long id;
    
        private Long gameId;
    
        private Long userId;
    
        @Enumerated(EnumType.STRING)
        private ChattingMessageType type;
    
        @CreationTimestamp
        private LocalDateTime createdAt;
    }

     

    ChattingMessage를 상속받은 구체 클래스인 ChattingEventMessage와 ChattingTextMessage를 각각 살펴보자. 해당 클래스들은 자신에게 필요한 필드 값들을 개별적으로 갖는다. ChattingEventMessage는 명시적으로 갖는 값은 존재하지 않지만, 다른 종류의 메시지들과의 분류를 위해 정의했다. ChattingTextMessage는 사용자가 채팅으로 입력한 내용을 갖는다.
     

    // models/chatting/message/ChattingEventMessage.java
    
    @Entity
    @Table(name = "chatting_event_messages")
    public class ChattingEventMessage extends ChattingMessage {
    
    }
    // models/chatting/message/ChattingTextMessage.java
    
    @Entity
    @Table(name = "chatting_text_messages")
    public class ChattingTextMessage extends ChattingMessage {
        private String content;   
    }

     
    특정 경기 식별자를 포함하는 모든 채팅 메시지들을 쿼리하기 위한 쿼리문을 살펴보도록 하자. 테이블에서 상속 관계를 나타내기 위해 JOINED 전략을 취했기 때문에, Entity들을 상속하기 위해서는 추상 클래스의 테이블과 필요한 구체 클래스들의 테이블들이 모두 JOIN되어야 한다. 우선 다음과 같이 쿼리문을 작성했다.
     

    // repositories/ChattingMessageRepositoryImpl.java
    
    @Repository
    public class ChattingMessageRepositoryImpl implements ChattingMessageRepository {
        private final JPAQueryFactory jpaQueryFactory;
        
        public ChattingMessageRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
            this.jpaQueryFactory = jpaQueryFactory;
        }
    
        @Override
        public List<ChattingMessageQueryDto> findAllByGameId(Long gameId) {
            return jpaQueryFactory
                // User, ChattingMessage 타입의 Entity를
                // 정의한 DTO에 Projection해 반환받는다. ChattingMessage의 경우,
                // 추상 클래스 타입으로 Entity들을 가져올 것이므로 DTO의 생성자에 타입을
                // 구체 클래스 타입이 아닌 추상 클래스 타입으로 정의한다.
                .select(Projections.constructor(
                    ChattingMessageQueryDto.class,
                    user, chattingMessage
                ))
                // Game 테이블을 기준으로 쿼리한다.
                .from(game)
                // 추상 클래스를 구현하는 모든 구현 클래스 객체들을 쿼리할 것이기 때문에,
                // 추상 클래스와 구현 클래스에 대한 모든 테이블들을 join한다.
                .rightJoin(chattingMessage).on(chattingMessage.gameId.eq(game.id))
                .rightJoin(chattingEventMessage)
                .on(chattingEventMessage.id.eq(chattingMessage.id))
                .rightJoin(chattingTextMessage)
                .on(chattingTextMessage.id.eq(chattingMessage.id))
                .rightJoin(user).on(user.id.eq(chattingMessage.userId))
                .where(game.id.eq(gameId)
                    .and(chattingMessage.type.eq(ChattingMessageType.ENTER)
                        .or(chattingMessage.type.eq(ChattingMessageType.QUIT))
                        .or(chattingMessage.type.eq(ChattingMessageType.TEXT))
                    )
                )
                .orderBy(chattingMessage.createdAt.asc())
                .fetch();
        }
    }

     
    해당 쿼리를 테스트하기 위해 다음과 같은 데이터셋을 삽입했다.
     

    - 박석민 님이 채팅방에 입장했습니다.
    - 권희동 님이 채팅방에 입장했습니다.
    - 이명기 님이 채팅방에 입장했습니다.
    - 박석민: 음료수는 누가 사오시는 걸로 할까요?
    - 권희동: 제가 오는 길에 사올께요!
    - 이명기: 저 지금 왔는데 여기 지금 다른 팀이 점거중인데요???
    - 권희동: 아니 걔네들은 한두번도 아니고 진짜 왜들 그런대요??
    - 박석민: 이거는 제가 봤을 때 진짜 강하게 항의를 하던지 해야지 진짜; 얘기하고 올께요.
    - 박석민 님이 채팅방에서 나갔습니다.
    - 박민우 님이 채팅방에 입장했습니다.
    - 박민우: 저 지금 도착했어요! 오... 잠깐만요 쟤네들 싸우는데요?
    - 이명기: 와; 난리도 아니네;

     

    테스트 코드에서는 쿼리한 추상 클래스 타입의 인스턴스들이 어떤 구체 타입인지 식별하고, 실제로 각 구체 타입의 개수와 일치하는지 확인하도록 했다.
     

    @Test
    void findAllByGameId() {    
        List<ChattingMessageQueryDto> chattingMessageQueryDtos
                = repository.findAllByGameId(gameId);
        List<Long> chattingEventMessageIds = List.of(
            // 1-3, 9-10
        );
            List<Long> chattingTextMessageIds = List.of(
            // 4-8, 11-12
        );
        assertThat(chattingMessageQueryDtos).hasSize(
            chattingEventMessageIds.size() + chattingTextMessageIds.size()
        );
        
        List<ChattingEventMessage> chattingEventMessagesQueried = new ArrayList<>();
        List<ChattingTextMessage> chattingTextMessagesQueried = new ArrayList<>();
        
        chattingMessageQueryDtos.forEach(dto -> {
            ChattingMessage chattingMessage = dto.getChattingMessage();
            if (chattingMessage instanceof ChattingEventMessage) {
                chattingEventMessagesQueried.add((ChattingEventMessage) chattingMessage);
                return;
            }
            chattingTextMessagesQueried.add((ChattingTextMessage) chattingMessage);
        });
        
        assertThat(chattingEventMessagesQueried)
            .hasSize(chattingEventMessageIds.size());
        assertThat(chattingTextMessagesQueried)
            .hasSize(chattingTextMessageIds.size());
    }

     
    데이터셋에서 확인할 수 있는 ChattingEventMessage 인스턴스는 5개, ChattingTextMessage 인스턴스는 7개이다. 테스트는 성공했을까? 테스트를 수행한 결과는 다음과 같다.
     

     
    개별 인스턴스들의 타입을 식별하기는커녕 단 하나의 Entity도 쿼리가 되지 않은 채 테스트가 실패했다.
     
    혹시 JOIN을 다르게 해줘야 하는 것인가 싶어 ChattingMessage와 관련된 테이블들을 JOIN하는 동작에 대해서만 RIGHT JOIN에서 LEFT JOIN으로 변경해준 뒤, 테스트를 다시 수행해보았다.
     

    @Override
    public List<ChattingMessageQueryDto> findAllByGameId(Long gameId) {
        return jpaQueryFactory
            .select(Projections.constructor(
                ChattingMessageQueryDto.class,
                user, chattingMessage
            ))
            .from(game)
            // ChattingMessage와 관련된 테이블들을 JOIN하는 방식을
            // RIGHT JOIN에서 LEFT JOIN으로 변경했다.
            .leftJoin(chattingMessage).on(chattingMessage.gameId.eq(game.id))
            .leftJoin(chattingEventMessage)
            .on(chattingEventMessage.id.eq(chattingMessage.id))
            .leftJoin(chattingTextMessage)
            .on(chattingTextMessage.id.eq(chattingMessage.id))
            .rightJoin(user).on(user.id.eq(chattingMessage.userId))
            .where(game.id.eq(gameId)
                .and(chattingMessage.type.eq(ChattingMessageType.ENTER)
                    .or(chattingMessage.type.eq(ChattingMessageType.QUIT))
                    .or(chattingMessage.type.eq(ChattingMessageType.TEXT))
                )
            )
            .orderBy(chattingMessage.createdAt.asc())
            .fetch();
    }

     
    테스트가 정상적으로 수행되는 것을 확인할 수 있었다.
     

     
    도대체 RIGHT JOIN과 LEFT JOIN 간에 어떤 차이가 있기 때문에 이렇게 다른 결과가 도출되었을까?
     

    로우 쿼리를 직접 실행시켜 결과 비교하기

    쿼리를 조금씩 바꿔가면서 직접 실행시켜보면서 차이를 살펴보도록 하자. 쿼리에 사용될 테이블들과 삽입되어 있는 내용들은 각각 다음과 같다.
     

    Game 테이블
    User 테이블
    ChattingMessage 테이블
    ChattingEventMessage 테이블
    ChattingTextMessage 테이블

     
    우선 Game 테이블과 ChattingMessage 테이블만을 JOIN시켜보았다.

    SELECT
        games.id AS game_id,
        chatting_messages.id AS chatting_message_id, chatting_messages.user_id, chatting_messages.type, chatting_messages.created_at
    FROM
        games
            RIGHT JOIN chatting_messages ON chatting_messages.game_id = games.id
    WHERE
        games.id = 1
            AND (chatting_messages.type = 'ENTER'
                OR chatting_messages.type = 'QUIT'
                OR chatting_messages.type = 'TEXT')
    ORDER BY
        chatting_messages.created_at ASC;

     
    모든 ChattingMessage 테이블의 Column들이 조건에 부합하는 Game 테이블의 Column에 연결되어 출력된 것을 확인할 수 있다.
     
    이제 여기에 ChattingEventMessage 테이블을 RIGHT JOIN한 뒤, ChattingEventMessage의 Column들에 대해서도 조회를 수행해보자.
     

    SELECT
        games.id AS game_id,
        chatting_messages.id AS chatting_message_id, chatting_messages.user_id, chatting_messages.type, chatting_messages.created_at,
        chatting_event_messages.id AS chatting_event_messages_id
    FROM
        games
            RIGHT JOIN chatting_messages ON chatting_messages.game_id = games.id
            RIGHT JOIN chatting_event_messages ON chatting_event_messages.id = chatting_messages.id
    WHERE
        games.id = 1
            AND (chatting_messages.type = 'ENTER'
                OR chatting_messages.type = 'QUIT'
                OR chatting_messages.type = 'TEXT')
    ORDER BY
        chatting_messages.created_at ASC;

     
    ChattingEventMessage에 존재하는 Tuple들은 모두 쿼리되었고, Game과 ChattingMessage 테이블에 존재하는 Tuple들은 ChattingEventMessage에 존재하는 Tuple들과 JOIN 조건을 충족하는 Tuple들만이 쿼리되었다.
     
    그러면 이번에는 ChattingEventMessage을 LEFT JOIN시켜보자.
     

    SELECT
        games.id AS game_id,
        chatting_messages.id AS chatting_message_id, chatting_messages.user_id, chatting_messages.type, chatting_messages.created_at,
        chatting_event_messages.id AS chatting_event_messages_id
    FROM
        games
            RIGHT JOIN chatting_messages ON chatting_messages.game_id = games.id
            LEFT JOIN chatting_event_messages ON chatting_event_messages.id = chatting_messages.id
    WHERE
        games.id = 1
            AND (chatting_messages.type = 'ENTER'
                OR chatting_messages.type = 'QUIT'
                OR chatting_messages.type = 'TEXT')
    ORDER BY
        chatting_messages.created_at ASC;

     
    결과가 조금 다른데, ChattingEventMessage의 Column에 NULL이 존재하는 Tuple들이 나타나고 있다. 이번에는 Game과 ChattingMessage 테이블의 Tuple들은 모두 쿼리되었다. ChattingEventMessage에서 JOIN 조건을 충족하는 Tuple에는 존재하는 값들이 포함되었고, 조건을 충족하지 못하는 경우에는 NULL이 채워진 채로 쿼리되었다.
     
    이 결과들을 LEFT JOIN과 RIGHT JOIN을 밴 다이어그램으로 나타낸 그림을 보면서 살펴보도록 하자. 두 JOIN의 차이에서 가장 눈여겨볼 차이점은 결과 테이블에 반드시 모두 포함시켜야 하는 쪽이 기준 테이블이냐, JOIN으로 붙는 테이블이냐이다.
     

    LEFT JOIN

    [Game RIGHT JOIN ChattingMessage]에 ChattingEventMessage를 LEFT JOIN할 경우, JOIN된 테이블에서 모든 요소들이 반드시 존재해야 하는 쪽은 기준 테이블인 [Game RIGHT JOIN ChattingMessage]이다. 만약 JOIN하는 테이블 쪽의 특정 Tuple이 기준 테이블 쪽의 요소와 JOIN 조건을 충족하지 못한다면, 해당 Tuple은 JOIN 결과 테이블에 도출될 수 없다. (예시에서는 모든 ChattingEventMessage의 id가 ChattingMessage의 id들 중 하나와 일치했기 때문에 그런 결과가 도출되지는 않았다.)
     
    대신 기준 테이블의 Tuple이 JOIN하는 테이블 쪽의 Tuple보다 더 많아 JOIN하는 테이블 쪽의 요소들과 연결되지 못한 경우, 비어 있는 Column에는 NULL을 채워서 나타낸다. ChattingMessage의 4-8, 11, 12번 식별자를 갖는 ChattingEventMessage는 없기 때문에, 해당 Tuple들과 연결되는 ChattingEventMessage Column에는 NULL이 삽입되었다.
     

    RIGHT JOIN

    반대로 [Game RIGHT JOIN ChattingMessage]에 ChattingEventMessage를 RIGHT JOIN할 경우, JOIN된 테이블에서 모든 요소들이 반드시 존재해야 하는 쪽은 JOIN으로 붙는 테이블인 ChattingEventMessage이다. 만약 기준 테이블 쪽의 특정 Tuple이 JOIN으로 붙는 테이블 쪽의 요소와 JOIN 조건을 충족하지 못한다면, 해당 Tuple은 JOIN 결과 테이블에 도출될 수 없다.
     
     

     

     

     

     

    연속 RIGHT JOIN을 했을 때 단 하나의 Tuple도 쿼리될 수 없었던 이유

    살펴본 원리를 바탕으로 문제에 다시 접근해보자. [[Game RIGHT JOIN ChattingMessage] RIGHT JOIN chattingEventMessage] 결과 테이블(뒤에서부터는 결과 테이블이라고 지칭하겠다.)에 ChattingTextMessage 테이블을 RIGHT JOIN시키는 경우를 생각해보자.
     
    결과 테이블이 갖고 있는 chatting_message_id 값은 다음과 같이 1, 2, 3, 9, 10이다.
     


    그리고 ChattingTextMessage 테이블이 갖고 있는 id 값은 다음과 같이 4, 5, 6, 7, 8, 11, 12이다.
     

     
    ChattingTextMessage 테이블을 결과 테이블에 RIGHT JOIN시킨다는 것은 최종 결과 테이블에 JOIN으로 붙는 테이블인 ChattingTextMessage 테이블의 Tuple들이 결과 테이블에 반드시 포함되어야 한다는 것이다. 문제는, ChattingTextMessage의 테이블에 포함된 Tuple들에는 결과 테이블에 존재하는 Tuple들과 JOIN 조건을 만족하는 Tuple들이 하나도 존재하지 않는다. 따라서 쿼리의 결과에 어떤 Tuple도 쿼리되지 않았던 것이다.
     

    SELECT
        games.id AS game_id,
        chatting_messages.id AS chatting_message_id, chatting_messages.user_id, chatting_messages.type, chatting_messages.created_at,
        chatting_event_messages.id AS chatting_event_messages_id,
        chatting_text_messages.id AS chatting_text_messages_id, chatting_text_messages.content
    FROM
        games
            RIGHT JOIN chatting_messages ON chatting_messages.game_id = games.id
            RIGHT JOIN chatting_event_messages ON chatting_event_messages.id = chatting_messages.id
            RIGHT JOIN chatting_text_messages ON chatting_text_messages.id = chatting_messages.id
    WHERE
        games.id = 1
            AND (chatting_messages.type = 'ENTER'
                OR chatting_messages.type = 'QUIT'
                OR chatting_messages.type = 'TEXT')
    ORDER BY
        chatting_messages.created_at ASC;

     
     

    LEFT JOIN을 했을 때 의도한 대로 쿼리되었던 이유

    마지막으로 모든 자식 Entity 테이블들을 LEFT JOIN으로 JOIN시켰을 때 정상적으로 모든 Tuple들이 도출된 이유를 살펴보자.
     

    SELECT
        games.id AS game_id,
        chatting_messages.id AS chatting_message_id, chatting_messages.user_id AS chatting_message_user_id, chatting_messages.type, chatting_messages.created_at,
        chatting_event_messages.id AS chatting_event_messages_id,
        chatting_text_messages.id AS chatting_text_messages_id, chatting_text_messages.content,
        users.id AS user_id, users.name
    FROM
        games
            LEFT JOIN chatting_messages ON chatting_messages.game_id = games.id
            LEFT JOIN chatting_event_messages ON chatting_event_messages.id = chatting_messages.id
            LEFT JOIN chatting_text_messages ON chatting_text_messages.id = chatting_messages.id
            RIGHT JOIN users ON chatting_messages.user_id = users.id
    WHERE
        games.id = 1
            AND (chatting_messages.type = 'ENTER'
                OR chatting_messages.type = 'QUIT'
                OR chatting_messages.type = 'TEXT')
    ORDER BY
        chatting_messages.created_at ASC;

     
    위의 쿼리에서 ChattingTextMessage 테이블이 LEFT JOIN되는 과정까지를 순차적으로 살펴보자.
     

    Game 테이블에 ChattingMessage 테이블을 LEFT JOIN한다.

     
    기준 테이블인 Game 테이블의 Tuple들은 쿼리 결과에 반드시 포함되고, ChattingMessage 테이블의 모든 Tuple들은 JOIN 조건을 만족한다. 따라서 ChattingMessage의 모든 Tuple들에 대해 Game의 동일한 Tuple이 연결된 상태의 테이블이 도출된다.
     

     

    결과 테이블에 ChattingEventMessage 테이블을 LEFT JOIN한다.

     
    마찬가지로 기준 테이블인 [Game LEFT JOIN ChattingMessage]의 Tuple들은 쿼리 결과에 반드시 포함되고, ChattingEventMessage 테이블의 모든 Tuple들은 JOIN 조건을 만족한다. 따라서 기존 결과 테이블의 Tuple에 JOIN 조건을 만족하는 ChattingEventMessage의 Tuple이 각각 연결된 테이블이 형성된다. 이때 기존 결과 테이블과 연결될 ChattingEventMessage의 Tuple이 없는 경우, 해당 Column에는 NULL이 채워진다.
     

     

    결과 테이블에 ChattingTextMessage 테이블을 LEFT JOIN한다.

     
    이번에도 마찬가지로 기준 테이블인 [[Game LEFT JOIN ChattingMessage] LEFT JOIN ChattingEventMessage]의 Tuple들은 쿼리 결과에 반드시 포함되고, ChattingTextMessage 테이블의 모든 Tuple들은 JOIN 조건을 만족한다. 따라서 기존 결과 테이블의 Tuple에 JOIN 조건을 만족하는 ChattingTextMessage의 Tuple이 각각 연결된 테이블이 형성된다. 이번에도 기존 결과 테이블과 연결될 ChattingTextMessage의 Tuple이 없는 경우에는 해당 Column에 NULL이 채워지게 된다.
     

     
    결과적으로 의도한 대로 ChattingMessage 테이블과 모든 구현체 테이블들이 JOIN된 결과 테이블이 구성되는 것을 확인할 수 있다.
     
     

    References

    - Mysql Join 해부(Left, Right, Outer, Inner Join)
    - INNER JOIN / OUTER JOIN / LEFT OUTER JOIN 차이 및 예제
    - ChatGPT
     
     
     
     

    댓글

Designed by Tistory.