Spring

Mybatis의 nested select, nested result (Spring Boot, H2, Kotlin|Java)

seungdols 2022. 7. 19. 23:58

마지막 근무일 즈음에 팀원분이 궁금하다고 요청을 해주셔서, 보다가 사실 생소한 현재 팀에서 Kotlin 환경이다 보니, 이런 저런 이슈들이 좀 있었는데, Mybatis의 nested select 관련 질문으로 이상하게 잘 안된다고 해주셨다.

결과적으로 어렵진 않은 이슈였는데, 궁금해서 Java 버전으로도 동일하게 샘플 코드르 작성 해보았다. 

그 이유는 코틀린 언어적인 이슈인지, 순수한 Java환경에서도 이슈가 되는지 궁금 했다. 그래서 두 프로젝트를 생성 해서 테스트 해봤다. 

결과적으로 코틀린 환경에서는 data class에 대한 noarg 플러그인이 필요로 했다. 다만, 해당 플러그인을 쓰는게 맞을까? 고민스럽긴 하다.

그리고 이럴때 필요한 개념이 보통 Mybatis에서는 association/collection이 있는데, has one 관계라면, association을 쓰고, has many 관계라면, collection을 사용 한다. 

has one 관계는 특정 객체 안에 다른 타입의 객체를 가지고 있는 경우가 그런 케이스이다. 

예를 들면, 쉽게 Book - Author의 관계로 볼 수 있을까? 책은 무조건 저자가 있다. 물론, 무명작가의 책이 왕왕 있으나 대다수 1:1의 관계를 가진다. 공동 저자의 경우도 흔하지만, 단독 저자를 기준으로 예시를 들었다. 

그리고, 우선 또 다른 관계인 has many의 관계는 하나의 객체가 다수개의 리스트 형태의 데이터를 가지고 있는 경우다. 

바로, 해당 샘플에서는 Board - Comment 관계를 기준으로 하며, 데이터는 1:N의 관계를 가진다. 게시판에는 다수개의 댓글이 있을 수 있다. 

Java 버전

구성은 그렇게 특별한 것은 없고, 사실 샘플 코드 동작 시키는 용도여서 구성은 간단 하게 잡았습니다. 

vo - object를 담을 클래스 디렉토리 

package com.sample.seungdols.domain.vo;

import java.util.List;

public class Board {

  Integer boardId;
  String title;
  String content;
  List<Comment> comments;

  public Board() {
  }

  public Board(Integer boardId, String title, String content,
      List<Comment> comments) {
    this.boardId = boardId;
    this.title = title;
    this.content = content;
    this.comments = comments;
  }

  public Integer getBoardId() {
    return boardId;
  }

  public void setBoardId(Integer boardId) {
    this.boardId = boardId;
  }

  public String getTitle() {
    return title;
  }

  public void setTitle(String title) {
    this.title = title;
  }

  public String getContent() {
    return content;
  }

  public void setContent(String content) {
    this.content = content;
  }

  public List<Comment> getComments() {
    return comments;
  }

  public void setComments(List<Comment> comments) {
    this.comments = comments;
  }
}
package com.sample.seungdols.domain.vo;

public class Comment {

  Integer commentId;
  Integer boardId;
  String writer;
  String content;

  public Comment() {
  }

  public Comment(Integer commentId, Integer boardId, String writer, String content) {
    this.commentId = commentId;
    this.boardId = boardId;
    this.writer = writer;
    this.content = content;
  }

  public Integer getCommentId() {
    return commentId;
  }

  public void setCommentId(Integer commentId) {
    this.commentId = commentId;
  }

  public Integer getBoardId() {
    return boardId;
  }

  public void setBoardId(Integer boardId) {
    this.boardId = boardId;
  }

  public String getWriter() {
    return writer;
  }

  public void setWriter(String writer) {
    this.writer = writer;
  }

  public String getContent() {
    return content;
  }

  public void setContent(String content) {
    this.content = content;
  }
}

resources - xml mapper와 data.sql, schema.sql 파일이 있는데, H2DB에 넣을 데이터와 스키마 정보인데, 파일 이름을 위처럼 하면, 자동으로 시작 될때, 스키마와 데이터 입력이 자동으로 된다. 

config - 디렉토리의 경우, 설정 패키지로 생각 했는데, 하나의 설정만 들어가 있다. H2를 외부 클라이언트로 연결 하려면, Server 모드로 설정 해야 해서 넣은 H2Server 설정이 들어가 있다. 

package com.sample.seungdols.config;

import com.zaxxer.hikari.HikariDataSource;
import java.sql.SQLException;
import javax.sql.DataSource;
import org.h2.tools.Server;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
@Profile("local")
class H2ServerConfiguration {
  @Bean
  @ConfigurationProperties("spring.datasource.hikari")
  public DataSource dataSource() throws SQLException {
    Server.createTcpServer("-tcp", "-tcpAllowOthers", "-tcpPort", "9092").start();
    return new com.zaxxer.hikari.HikariDataSource();
  }
}

그리고 Mapper interface가 있다. 디렉토리 구조를 잡을때 늘 어려운게, 어디에 맵핑 시키는게 좋을지 모호하다. 

package com.sample.seungdols.domain;

import com.sample.seungdols.domain.vo.Board;
import com.sample.seungdols.domain.vo.Comment;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface BoardMapper {
  Board getBoardById(Integer boardId);

  List<Comment> getCommentListById(Integer boardId);

  Board selectBoardWithComment(Integer boardId);
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
  PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sample.seungdols.domain.BoardMapper">

  <resultMap id="boardResult" type="com.sample.seungdols.domain.vo.Board">
    <id property="boardId" column="board_id"/>
    <result property="title" column="board_title"/>
    <result property="content" column="board_content"/>
    <collection property="comments" column="{boardId = board_id}"
      select="getCommentListById" javaType="List" ofType="com.sample.seungdols.domain.vo.Comment" />
  </resultMap>

  <resultMap id="commentResult" type="com.sample.seungdols.domain.vo.Comment">
    <id property="commentId" column="comment_id"/>
    <result property="boardId" column="comment_board_id"/>
    <result property="writer" column="comment_writer"/>
    <result property="content" column="comment_content"/>
  </resultMap>

  <resultMap id="boardWithCommentResult" type="com.sample.seungdols.domain.vo.Board">
    <id property="boardId" column="board_id"/>
    <result property="title" column="board_title"/>
    <result property="content" column="board_content"/>
    <collection property="comments" column="boardId"
      resultMap="commentResult" />
  </resultMap>

  <select id="selectBoardWithComment" resultMap="boardWithCommentResult">
    SELECT b.board_id as board_id,
           b.board_title as board_title,
           b.board_content as board_content,
           c.comment_id as comment_id,
           c.writer as writer,
           c.content as content
    FROM board b
           LEFT JOIN comment c ON b.board_id = c.board_id
    WHERE b.board_id = #{boardId}
  </select>

  <select id="getBoardById" resultMap="boardResult">
    SELECT board_id, board_title, board_content
    FROM board
    WHERE board_id = #{boardId}
  </select>

  <select id="getCommentListById" resultType="com.sample.seungdols.domain.vo.Comment">
    SELECT comment_id as commentId,
           board_id as boardId,
           writer as writer,
           content as comment
    FROM comment
    WHERE board_id = #{boardId}
  </select>
</mapper>

Mybatis nested select 

 <resultMap id="boardResult" type="com.sample.seungdols.domain.vo.Board">
    <id property="boardId" column="board_id"/>
    <result property="title" column="board_title"/>
    <result property="content" column="board_content"/>
    <collection property="comments" column="{boardId = board_id}"
      select="getCommentListById" javaType="List" ofType="com.sample.seungdols.domain.vo.Comment" />
  </resultMap>
  
  <select id="getBoardById" resultMap="boardResult">
    SELECT board_id, board_title, board_content
    FROM board
    WHERE board_id = #{boardId}
  </select>
  
  <select id="getCommentListById" resultType="com.sample.seungdols.domain.vo.Comment">
    SELECT comment_id as commentId,
           board_id as boardId,
           writer as writer,
           content as comment
    FROM comment
    WHERE board_id = #{boardId}
  </select>

https://mybatis.org/mybatis-3/ko/sqlmap-xml.html#collection-%EC%9D%84-%EC%9C%84%ED%95%9C-%EB%82%B4%ED%8F%AC%EB%90%9C-nested-select

 

MyBatis – 마이바티스 3 | 매퍼 XML 파일

Mapper XML 파일 마이바티스의 가장 큰 장점은 매핑구문이다. 이건 간혹 마법을 부리는 것처럼 보일 수 있다. SQL Map XML 파일은 상대적으로 간단하다. 더군다나 동일한 기능의 JDBC 코드와 비교하면

mybatis.org

위 링크를 보면, 정말 설명이 잘 나와 있다. 

결과적으로 어떤 데이터를 VO 객체에 담을 때, 특정 List 정보를 Sub Query형태로 넣고 싶을때가 있다. 그런 경우, collection 타입에 select 구문에 서브 쿼리를 넣어주면 된다. 

참고로, 아래 구문을 주의해야 한다. 

<collection property="comments" column="{boardId = board_id}"
      select="getCommentListById" javaType="List" ofType="com.sample.seungdols.domain.vo.Comment" />

위의 부분에서 column은 실제 select 쿼리에 조건절에 담길 파라미터를 해당 `{}`를 통해서 넣는데, 다수의 값이라면, 아래처럼 넣어주면 된다. 

<collection property="comments" parameterType="java.util.Map" column="{boardId = board_id, comment_id = comment_id}"
      select="getCommentListById" javaType="List" ofType="com.sample.seungdols.domain.vo.Comment" />

다만 다수의 값을 넘기는 경우, 파라미터 타입을 Map 형태로 설정해주어야 한다. 이 부분때문에, 삽질을 좀 오래 했다. 

이렇게 구성하고, 실행 시키면 실제로 데이터가 반환 되는 것을 알 수 있다. 실제 쿼리상으로는 Row가 2개가 나올 수 있지만, 코드상으로는 comments 필드에 리스트 형태로 데이터가 들어가게 된다. 

다만, 문제는 nested select의 경우 N+1 문제를 일으킬 수 있다. 그렇기 때문에, nested result 방식으로 풀어내는 것이 성능상으로 이점이 된다. 

Mybatis nested result

  <resultMap id="boardWithCommentResult" type="com.sample.seungdols.domain.vo.Board">
    <id property="boardId" column="board_id"/>
    <result property="title" column="board_title"/>
    <result property="content" column="board_content"/>
    <collection property="comments" column="boardId"
      resultMap="commentResult" />
  </resultMap>

  <select id="selectBoardWithComment" resultMap="boardWithCommentResult">
    SELECT b.board_id as board_id,
           b.board_title as board_title,
           b.board_content as board_content,
           c.comment_id as comment_id,
           c.writer as writer,
           c.content as content
    FROM board b
           LEFT JOIN comment c ON b.board_id = c.board_id
    WHERE b.board_id = #{boardId}
  </select>

위와 같이 쿼리를 Join으로 엮으면 하나의 쿼리로 동일한 효과를 낼 수 있다. 해당 방식이 nested result 방식이다. 

물론, 상황에 따라 데이터가 얼마 되지 않고, 잦은 read가 부담이 없는 경우라면 nested select를 사용해도 무방하다고 생각 한다. 

ref. https://stackoverflow.com/questions/54140228/mybatis-nested-select-vs-nested-results 참고하면 두 방식의 차이를 이해하는데 도움이 된다. 

참고로, 해당 프로젝트는 github에 올라가 있어서 참고 할 수 있다. 

https://github.com/seungdols/mybatis-nested-java

 

GitHub - seungdols/mybatis-nested-java

Contribute to seungdols/mybatis-nested-java development by creating an account on GitHub.

github.com

 

Kotlin 버전 프로젝트 

https://github.com/seungdols/mybatis-nested-kotlin

 

GitHub - seungdols/mybatis-nested-kotlin

Contribute to seungdols/mybatis-nested-kotlin development by creating an account on GitHub.

github.com

사실 위의 Java와 구조가 동일해서, 따로 더 설명할 부분은 없지만, 코틀린의 경우가 본래 목적이기에 부연 설명을 하자면,

위의 샘플 프로젝트에서 VO 클래스들은 모두 data class이고, 해당 케이스에서 문제가 생긴다. 

궁금 했었는데, 기본적으로 Mybatis는 기본 생성자를 통해 인스턴스를 생성 하고, 해당 데이터를 채워 넣는다고 알려져 있다.

그렇다면, kotlin data class는 기본 생성자를 만들려면, 플러그인을 통해 설정을 해주어야 한다.

plugins {
    id("org.springframework.boot") version "2.6.0"
    id("io.spring.dependency-management") version "1.0.11.RELEASE"
    kotlin("jvm") version "1.6.10"
    kotlin("plugin.noarg") version "1.6.10"
    kotlin("plugin.spring") version "1.6.10"
}

group "org.example"
version "1.0-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11

noArg {
    annotation("com.sample.seungdols.annotation.NoArg")
    invokeInitializers = true
}

위와 같이 noarg 플러그인 설정이 필요로 하고, noArg 블록을 통한 어노테이션 설정이 필요로 하다. 

package com.sample.seungdols.annotation

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class NoArg
package com.sample.seungdols.domain.vo

import com.sample.seungdols.annotation.NoArg

@NoArg
data class Board(
  val boardId: Int,
  val title: String,
  val content: String,
  val comments: List<Comment>
)
package com.sample.seungdols.domain.vo

import com.sample.seungdols.annotation.NoArg

@NoArg
data class Comment(
  val commentId: Int,
  val writer: String,
  val content: String
)

위처럼 각 데이터 클래스에 어노테이션을 붙여주면 된다. 그럼 데이터들을 정상적으로 오류 없이 가져올 수 있다. 

글이 두서가 없었지만, 그래도 일말의 작은 도움이 되었으면 좋겠다. 

반응형