이 글은 Spring Boot 환경에서 MyBatis를 사용할 때, 기본적인 조회 결과의 매핑에 대해 정리한 글이다.

이 글에 나오는 예제는 이 링크에서 확인할 수 있다.

  • Spring Boot 3.1.3
  • Java 17
  • mybatis-spring-boot-starter 3.0.2
    • mybatis 3.5.13
  • H2 Database

0. 예제 소개

매핑 테스트를 위한 간단한 영화(Movie) 예제를 살펴보자. 영화에 대한 정보를 저장하고 조회하는 서비스이다. 이 서비스의 ERD는 간단히 다음과 같이 만들어보았다.

Movie에 대한 DB 스키마는 다음과 같이 표현할 수 있다. (H2 Database 기준)

CREATE TABLE movie
(
    movie_id     INT PRIMARY KEY AUTO_INCREMENT,
    title        VARCHAR NOT NULL,
    running_time INT NOT NULL,
    release_date DATE NOT NULL,
    director_id  INT NOT NULL,
    created_at   DATETIME DEFAULT CURRENT_TIMESTAMP,
    modified_at   DATETIME DEFAULT CURRENT_TIMESTAMP
);

Java에서의 객체는 다음과 같을 것이다.

public class Movie {
    private Long movieId;
    private String title;
    private Director director;
    private List<Genre> genres;
    private int runningTime;
    private LocalDate releaseDate;
    private LocalDateTime createdAt;
    private LocalDateTime modifiedAt;
}

(전체 DB 스키마는 여기 링크에서 확인할 수 있다.)

1. 기본 필드 매핑

그러면 지금부터는 Mybatis에서 영화 정보에 대해서 조회를 해보자.

그 전에 다시 DB 스키마와 자바 객체를 보자. 각 필드의 표현 형태를 보면 DB는 snake_case이고, Java는 camelCase 인 것을 볼 수 있다. 이는 일반적으로 많이 사용하는 형태이다.

만약 SQL에서 DB의 필드 형태인 snake_case를 그대로 사용하면 어떻게 될까?

<select id="selectAll" 
        resultType="me.parker.springbootmybatismapping.model.Movie">
    SELECT
        movie_id
        , title
        , running_time
        , release_date
    FROM movie m
</select>

위는 Mybatis에서 SQL을 사용하기 위한 XML 파일의 일부분이다. 단순히 전체 movie 테이블을 조회하는 모습이다. 이를 Java 객체 Movie에 매핑하면 어떻게 될까? (아래에 나오는 영화 예제 데이터는 앞서 소개한 전체 예제 프로젝트 링크에서 확인할 수 있다.)

Movie(movieId=null, title=오펜하이머, director=null, genres=null, runningTime=0, releaseDate=null, createdAt=null, modifiedAt=null)
Movie(movieId=null, title=인셉션, director=null, genres=null, runningTime=0, releaseDate=null, createdAt=null, modifiedAt=null)
Movie(movieId=null, title=소울, director=null, genres=null, runningTime=0, releaseDate=null, createdAt=null, modifiedAt=null)

조회 결과를 Java 객체로 매핑한 리스트를 출력하면 위와 같을 것이다. title은 필드 표현 방식에 상관없이 같으므로 정상적으로 매핑되었지만, 매핑을 지도한 movieId, runningTime, releaseDate` 필드는 모두 null로 매핑을 하지 못하였다. (나머지 Director, Genre 정보는 조인을 수행해야하고, 중첩? 객체를 사용해야하는데 이런 복잡한 매핑에 대해서는 아래에서 살펴볼 예정이다.)

위를 해결하는 방법은 여러가지가 있다.

1.1. DB alias 사용

첫 번째 방법은 단순히 SQL에서 제공해주는 필드명을 다른 이름으로 변경해주는 alias 기능을 사용하는 것이다.

 <select id="selectAll" 
         resultType="me.parker.springbootmybatismapping.model.Movie">
    SELECT
        movie_id AS movieId
        , title
        , running_time AS runningTime
        , release_date AS releaseDate
    FROM movie m
</select>

위 결과를 보자.

Movie(movieId=1000, title=오펜하이머, director=null, genres=null, runningTime=180, releaseDate=2023-08-15, createdAt=null, modifiedAt=null)
Movie(movieId=1001, title=인셉션, director=null, genres=null, runningTime=148, releaseDate=2010-07-21, createdAt=null, modifiedAt=null)
Movie(movieId=1002, title=소울, director=null, genres=null, runningTime=107, releaseDate=2021-01-20, createdAt=null, modifiedAt=null)

매핑을 시도한 필드에 대해서는 전체적으로 모두 잘 매핑이 되었다.

1.2. Mybatis 설정 추가

Mybatis 설정 중 필드 형식에 따라 매핑을 해주는 설정이 있다. 이를 사용한 예제를 살펴보자.

스프링 부트에서는 Mybatis 설정하는 방법은 크게 두 가지가 있다.

첫번째는 mybatis-config.xml 파일 내에 추가하는 것이다.

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <!-- 자바 객체와 데이터베이스 필드 이름을 매핑할 전략을 설정합니다. -->
    <settings>
        <setting name="mapUnderscoreToCamelCase" value="true"/>
    </settings>
</configuration>

그리고 스프링 설정을 위한 application.yml(또는 application.properties) 파일 내에 설정하는 것이다.

mybatis:
  configuration:
    map-underscore-to-camel-case: true

스프링 부트를 사용한다면 첫번째 방식보다는 두번째 방식이 훨씬 간단하고 전체 설정을 관리하기가 한 눈에 볼 수 있어 편리하다. (예제 프로젝트에서는 두번째 방식을 사용하고 있다.)

위 설정을 추가한 뒤, 다시 테스트를 해보자.

<select id="selectAll" resultType="me.parker.springbootmybatismapping.model.Movie">
    SELECT
        movie_id
        , title
        , running_time
        , release_date
    FROM movie m
</select>
Movie(movieId=1000, title=오펜하이머, director=null, genres=null, runningTime=180, releaseDate=2023-08-15, createdAt=null, modifiedAt=null)
Movie(movieId=1001, title=인셉션, director=null, genres=null, runningTime=148, releaseDate=2010-07-21, createdAt=null, modifiedAt=null)
Movie(movieId=1002, title=소울, director=null, genres=null, runningTime=107, releaseDate=2021-01-20, createdAt=null, modifiedAt=null)

결과는 매핑이 정상적으로 잘된 모습을 볼 수 있다.

2. ResultMap을 활용한 복잡한 매핑

Mybatis에서는 ResultMap을 통해 복잡한 객체를 매핑할 수 있다. nested object, collection와 같은 필드에 또다른 객체를 갖는 복잡한 매핑이 필요할 때 사용할 수 있다.

Movie 예제에서 지금까지는 Movie 테이블에 있는 필드만 매핑을 했었다. 감독과 장르 등 모든 정보를 매핑으르 하려면 ResultMap을 사용할 수 있다.

<resultMap id="movieResult" type="me.parker.springbootmybatismapping.model.Movie">
    <id property="movieId" column="movie_id"/>
    <result property="title" column="title"/>
    <result property="runningTime" column="running_time"/>
    <result property="releaseDate" column="release_date"/>
    <result property="createdAt" column="movie_created_at"/>
    <result property="modifiedAt" column="movie_modified_at"/>
    <!-- has-one -->
    <association property="director" javaType="me.parker.springbootmybatismapping.model.Director">
        <id property="directorId" column="director_id"/>
        <result property="name" column="director_name"/>
        <result property="birthDate" column="birth_date"/>
        <result property="gender" column="gender"/>
        <result property="debutYear" column="debut_year"/>
        <result property="createdAt" column="director_created_at"/>
        <result property="modifiedAt" column="director_modified_at"/>
    </association>
    <!-- has-many -->
    <collection property="genres" ofType="me.parker.springbootmybatismapping.model.Genre">
        <id property="genreId" column="genre_id"/>
        <result property="genreType" column="genre_name"/>
    </collection>
</resultMap>

<select id="selectAll2" resultMap="movieResult">
    SELECT m.movie_id,
           m.title,
           m.running_time,
           m.release_date,
           m.director_id,
           m.created_at  AS movie_created_at,
           m.modified_at AS movie_modified_at,
           d.director_id,
           d.name        AS director_name,
           d.birth_date,
           d.gender,
           d.debut_year,
           d.created_at  AS director_created_at,
           d.modified_at AS director_modified_at,
           g.genre_id,
           g.name        AS genre_name
    FROM movie m
             INNER JOIN director d ON m.director_id = d.director_id
             INNER JOIN movie_genre_relation mgr ON m.movie_id = mgr.movie_id
             INNER JOIN genre g ON mgr.genre_id = g.genre_id
</select>

ResultMap을 선언하기 위해서는 <resultMap> 엘리먼트를 선언해야 한다. 선언한 ResultMap을 사용하기 위해서는 <select> 엘리먼트의 속성 중 resultMap을 사용하여 <resultMap> 엘리먼트의 id 속성에 선언해준 값을 넣어주면 된다.

위 조회 결과는 다음과 같다.

Movie(movieId=1000, title=오펜하이머, director=Director(super=Person(name=크리스토퍼 놀란, birthDate=1970-07-30, gender=MALE), directorId=100, debutYear=1989, createdAt=2023-09-10T10:01:10.135906, modifiedAt=2023-09-10T10:01:10.135906), genres=[Genre(genreId=1, genreType=DRAMA), Genre(genreId=2, genreType=THRILLER), Genre(genreId=3, genreType=WAR)], runningTime=180, releaseDate=2023-08-15, createdAt=2023-09-10T10:01:10.134188, modifiedAt=2023-09-10T10:01:10.134188)
Movie(movieId=1001, title=인셉션, director=Director(super=Person(name=크리스토퍼 놀란, birthDate=1970-07-30, gender=MALE), directorId=100, debutYear=1989, createdAt=2023-09-10T10:01:10.135906, modifiedAt=2023-09-10T10:01:10.135906), genres=[Genre(genreId=4, genreType=CRIME), Genre(genreId=5, genreType=SF), Genre(genreId=6, genreType=ACTION)], runningTime=148, releaseDate=2010-07-21, createdAt=2023-09-10T10:01:10.134917, modifiedAt=2023-09-10T10:01:10.134917)
Movie(movieId=1002, title=소울, director=Director(super=Person(name=피트 닥터, birthDate=1968-10-09, gender=MALE), directorId=101, debutYear=1988, createdAt=2023-09-10T10:01:10.136104, modifiedAt=2023-09-10T10:01:10.136104), genres=[Genre(genreId=7, genreType=ANIMATION), Genre(genreId=8, genreType=COMEDY), Genre(genreId=9, genreType=FAMILY)], runningTime=107, releaseDate=2021-01-20, createdAt=2023-09-10T10:01:10.135113, modifiedAt=2023-09-10T10:01:10.135113)

길긴하지만, 쭉 살펴보면 모든 정보가 잘 매핑된 것을 볼 수 있다.

위 예제에서 사용한 내부 엘리먼트와 속성에 대해서 알아보자. (가장 범용적으로 사용하는 것만 선언한 예제이며, 더 자세한 내용은 공식 문서의 ResultMap 섹션을 참고하자.)

<resultMap>

ResultMap을 선언하기 위한 엘리먼트.

  • id: ResultMap의 id를 지정한다.
  • type: ResultMap이 매핑할 객체의 클래스를 지정한다.

<id>

각 엘리먼트의 id를 선언하는 내부 엘리먼트. (이를 선언하는 것이 성능 향상에 도움이 된다고 한다.)

  • property: Java 객체의 필드 중 매핑할 이름
  • column: DB 필드 중 매핑할 이름

propertycolumn 속성은 Java와 DB를 매핑하는 엘리먼트는 모두 사용할 수 있으므로, 이후 엘리먼트의 속성 설명에는 제외한다.

<result>

각 엘리먼트의 일반 필드를 선언하는 내부 엘리먼트

<association>

객체와 has-one 관계의 내부 객체를 매핑할 때 사용하는 엘리먼트.

예제를 기준으로 영화는 한 명의 감독이 있다고 정의하였다. 그러므로 Movie 객체 내부에는 하나의 Director 객체가 존재하므로 has-one 관계이다.

  • property: Java 객체의 필드 중 매핑할 이름
  • javaType: 매핑할 객체의 클래스 타입

<collection>

객체와 has-many 관계의 내부 컬렉션을 매핑할 때 사용하는 엘리먼트.

객체 내부에 컬렉션이 존재하는 경우 사용하는 엘리먼트이다. 예제를 기준으로 하나의 영화는 여러 개의 장르를 갖는다. 이는 has-many 관계이다.

  • property: Java 객체의 필드 중 매핑할 이름
  • ofType: 매핑할 컬렉션의 클래스 타입
  • javaType: 매핑할 컬렉션 타입 (ex, ArrayList, HashSet 등)

2.1. <association>, <collection> 중첩 ResultMap

<association>, <collection> 두 엘리먼트는 속성으로 resultMap을 선언하여, 또 다른 ResultMap을 사용할 수 있다.

위에서 살펴봤던 같은 조회 예제를 중첩 ResultMap을 사용하여 다시 작성해보자. (select 문은 같으므로 생략한다.)

<resultMap id="movieResultUsingNestedResult" type="me.parker.springbootmybatismapping.model.Movie">
    <id property="movieId" column="movie_id"/>
    <result property="title" column="title"/>
    <result property="runningTime" column="running_time"/>
    <result property="releaseDate" column="release_date"/>
    <result property="createdAt" column="movie_created_at"/>
    <result property="modifiedAt" column="movie_modified_at"/>
    <!-- has-one -->
    <association property="director" column="director_id"
                 javaType="me.parker.springbootmybatismapping.model.Director"
                 resultMap="directorResult"/>
    <!-- has-many -->
    <collection property="genres" ofType="me.parker.springbootmybatismapping.model.Genre"
                resultMap="genreResult"/>
</resultMap>

<resultMap id="directorResult" type="me.parker.springbootmybatismapping.model.Director">
  <id property="directorId" column="director_id"/>
  <result property="name" column="director_name"/>
  <result property="birthDate" column="birth_date"/>
  <result property="gender" column="gender"/>
  <result property="debutYear" column="debut_year"/>
  <result property="createdAt" column="director_created_at"/>
  <result property="modifiedAt" column="director_modified_at"/>
</resultMap>

<resultMap id="genreResult" type="me.parker.springbootmybatismapping.model.Genre">
  <id property="genreId" column="genre_id"/>
  <result property="genreType" column="genre_name"/>
</resultMap>

Movie 객체의 ResultMap 내부에 있던 Director, Genre 객체의 ResultMap을 따로 분리하여 선언하였다. 그리고 각각 이를 사용하도록 resultMap에 선언해주었다. 이를 동작시켜보면 위 예제와 같은 결과를 얻을 수 있다.

이렇게 하면, Director와 Genre 객체에 대한 ResultMap을 재사용할 수 있으므로 코드의 중복을 줄일 수 있다.

2.2. <association>, <collection> 중첩 select

<association>, <collection> 두 엘리먼트는 속성으로 select를 선언하여, 다른 select문으로 내부 객체 또는 컬렉션을 조회할 수 있다.

이는 위의 사용법과는 다른 점이 있어 주의깊게 살펴보자.

<select id="selectAll4" resultMap="movieResultUsingNestedSelect">
    SELECT m.movie_id,
           m.title,
           m.running_time,
           m.release_date,
           m.director_id,
           m.created_at  AS movie_created_at,
           m.modified_at AS movie_modified_at
    FROM movie m
</select>

먼저 시작하는 쿼리에서는 Movie 테이블만 모든 필드를 조회한다. Director, Genre 테이블은 따로 select 문을 작성할 것이기 때문이다.

<resultMap id="movieResultUsingNestedSelect" type="me.parker.springbootmybatismapping.model.Movie">
    <id property="movieId" column="movie_id"/>
    <result property="title" column="title"/>
    <result property="runningTime" column="running_time"/>
    <result property="releaseDate" column="release_date"/>
    <result property="createdAt" column="movie_created_at"/>
    <result property="modifiedAt" column="movie_modified_at"/>
    <!-- has-one -->
    <association property="director" column="director_id"
                 javaType="me.parker.springbootmybatismapping.model.Director"
                 select="selectDirectorResult"/>
    <!-- has-many -->
    <collection property="genres" column="movie_id" javaType="ArrayList"
                ofType="me.parker.springbootmybatismapping.model.Genre"
                select="selectGenreResult"/>
</resultMap>

그리고 ResultMap의 내부 <association>, <collection> 엘리먼트는 각각 select를 선언하여 다른 select문을 사용한다고 설정한다. 여기서 주의할 점이 column 속성이다. 이는 내부 select 문에서 사용할 id 파라미터를 지정하는 것이다. (파라미터에 대한 자세한 설명은 아래에서 다룰 예정이다.)

Director는 movie 테이블에 직접 director_id가 존재하므로 column="director_id"로 설정하였다. 하지만 Genre는 movie 테이블에는 genre_id가 존재하지 않고, movie_genre_relation 테이블에서 movie와 genre 관계를 나타낸다. 그러므로 movie에 해당하는 genre 정보는 위 테이블에서 movie_id로 조인해서 가져올 수 있다.

이를 바탕으로 중첩 select 문을 작성해보자.

<select id="selectDirectorResult" resultType="me.parker.springbootmybatismapping.model.Director">
    SELECT d.director_id,
           d.name,
           d.birth_date,
           d.gender,
           d.debut_year,
           d.created_at,
           d.modified_at
    FROM director d
    WHERE d.director_id = #{id}
</select>

<select id="selectGenreResult" resultType="me.parker.springbootmybatismapping.model.Genre">
    SELECT g.genre_id,
           g.name as genre_type
    FROM genre g
             INNER JOIN movie_genre_relation mgr ON g.genre_id = mgr.genre_id
    WHERE mgr.movie_id = #{id}
</select>

위를 모두 작성하여 실행해보면, 똑같이 모든 정보를 정상적으로 가져오는 것을 확인할 수 있다.

Movie(movieId=1000, title=오펜하이머, director=Director(super=Person(name=크리스토퍼 놀란, birthDate=1970-07-30, gender=MALE), directorId=100, debutYear=1989, createdAt=2023-09-10T10:48:56.172540, modifiedAt=2023-09-10T10:48:56.172540), genres=[Genre(genreId=1, genreType=DRAMA), Genre(genreId=2, genreType=THRILLER), Genre(genreId=3, genreType=WAR)], runningTime=180, releaseDate=2023-08-15, createdAt=2023-09-10T10:48:56.171067, modifiedAt=2023-09-10T10:48:56.171067)
Movie(movieId=1001, title=인셉션, director=Director(super=Person(name=크리스토퍼 놀란, birthDate=1970-07-30, gender=MALE), directorId=100, debutYear=1989, createdAt=2023-09-10T10:48:56.172540, modifiedAt=2023-09-10T10:48:56.172540), genres=[Genre(genreId=4, genreType=CRIME), Genre(genreId=5, genreType=SF), Genre(genreId=6, genreType=ACTION)], runningTime=148, releaseDate=2010-07-21, createdAt=2023-09-10T10:48:56.171671, modifiedAt=2023-09-10T10:48:56.171671)
Movie(movieId=1002, title=소울, director=Director(super=Person(name=피트 닥터, birthDate=1968-10-09, gender=MALE), directorId=101, debutYear=1988, createdAt=2023-09-10T10:48:56.172717, modifiedAt=2023-09-10T10:48:56.172717), genres=[Genre(genreId=7, genreType=ANIMATION), Genre(genreId=8, genreType=COMEDY), Genre(genreId=9, genreType=FAMILY)], runningTime=107, releaseDate=2021-01-20, createdAt=2023-09-10T10:48:56.171862, modifiedAt=2023-09-10T10:48:56.171862)

중첩 select의 문제는 N + 1 문제가 발생한다는 것이다. (JPA를 사용해보신 분들은 아주 익숙한 문제일 것이다.)

  • 1: movie 테이블을 조회하는 기본이되는 select 문
  • N: movie 테이블의 조회 결과 개수만큼 내부 정보를 채우기 위해 중첩 select 문이 실행된다.

즉, 하나의 쿼리(1)를 실행하면, 그 결과 개수만큼 다시 쿼리가 N번 실행되므로 성능에 문제가 발생할 수 있다. 그리고 시작 쿼리에서 어떤 정보를 가져오는지 한 눈에 볼 수 없어 유지보수하는데 더욱 어려워질 수 있다. 따라서 이 방법은 개인적으로 크게 추천하지 않는 방식이며, 이러한 방법이 있다 정보만 알고 있어도 될 듯 하다.

2.3. Auto-Mapping

Mybatis는 ResultMap을 선언하지 않아도 자동으로 매핑해주는 기능이 있다. 이를 Auto-Mapping이라고 한다. 단순한 예제를 위해 기본 매핑에서 살펴본 예제를 ResultMap으로 바꿔보자.

<select id="selectAll" resultType="me.parker.springbootmybatismapping.model.Movie">
    SELECT
        movie_id
        , title
        , running_time
        , release_date
    FROM movie m
</select>

기본 매핑 예제에서는 위처럼 설정하였는데, 반환을 ResultMap으로 바꾸어보자.

<select id="selectAll" resultType="MovieResult">
    SELECT
        movie_id       AS movieId
        , title
        , running_time AS runningTime
        , release_date AS releaseDate
    FROM movie m
</select>

<resultMap id="MovieResult" 
           type="me.parker.springbootmybatismapping.model.Movie">
  <id property="movieId" column="movie_id"/>
  <result property="title" column="title"/>
  <result property="runningTime" column="running_time"/>
  <result property="releaseDate" column="release_date"/>
</resultMap>

여기서 자동 매핑을 적용한다고 하면 ResultMap은 훨씬 간단해진다.

<resultMap id="MovieResult" 
           type="me.parker.springbootmybatismapping.model.Movie">
</resultMap>

Java 객체의 필드 이름과 모두 동일하므로, 사실 선언해줄 필요가 없다. 이는 자동 매핑의 기본값 설정이 PARTIAL 이므로 아무런 설정없이도 위처럼 사용할 수 있다. 자동 매핑은 3가지 설정값을 갖는다.

  • NONE: 자동 매핑을 사용하지 않는다.
  • PARTIAL: 조인된 테이블의 결과매핑을 제외하고 자동 매핑한다.
  • FULL: 조인을 포함하여 모든 필드를 자동매핑한다.

이는 글로벌하게 적용할 수도 있지만, ResultMap에의 속성으로 해당 ResultMap에만 따로 적용할 수도 있다.

3. 파라미터 매핑

만약 영화 예제에서 ‘오펜하이머’ 영화에 대한 정보를 조회하고 싶을 때 어떻게 할까? movie 테이블의 title에 조건을 걸어주면 될 것이다. 이와 같이, 특정 값으로 조건을 설정하고 싶을 때 Mybatis에서는 이 값을 파라미터로 변수를 넘겨줄 수 있다.

예제 코드를 보자.

@Mapper
public interface MovieMapper {
  Movie selectByTitle(String title);
}
<select id="selectByTitle" resultType="me.parker.springbootmybatismapping.model.Movie">
    SELECT
        movie_id
         , title
         , running_time
         , release_date
    FROM movie m
    WHERE m.title = #{title}
</select>

이렇게 넘겨준 파라미터를 #{}으로 감싸주면 된다.

만약 파라미터가 여러 개라면 어떨까?

2023년 이후에 개봉한 영화 중 런닝타임이 180분 이상인 영화들을 모두 조회해보자.

@Mapper
public interface MovieMapper {
    Movie selectByReleaseDateAndRunningTime(LocalDate releaseDate, int runningTime);
}
<select id="selectByReleaseDateAndRunningTime" 
        resultType="me.parker.springbootmybatismapping.model.Movie">
  SELECT movie_id
    , title
    , running_time
    , release_date
  FROM movie m
  WHERE m.release_date >= #{releaseDate}
      AND m.running_time >= #{runningTime}
</select>

위 결과로 ‘오펜하이머’ 영화에 대한 정보가 조회될 것이다.

만약 파라미터가 너무 많아진다면 어떨까? 일일이 파라미터로 선언하기보다는 객체로 감싸서 넘겨주고 싶을 것이다.

public class SelectCriteria {
  private LocalDate releaseDate;
  private int runningTime;
}

public interface MovieMapper {
  Movie selectByReleaseDateAndRunningTime2(SelectCriteria selectCriteria);
}

앞선 예제와 동일한 예제에서 넘겨주는 파라미터만 SelectCriteria라는 객체로 넘겨주었다.

이를 사용하는 방법은 크게 두가지가 있다.

@Param 어노테이션 사용

import org.apache.ibatis.annotations.Param;

public interface MovieMapper {
  Movie selectByReleaseDateAndRunningTime2(@Param("selectCriteria") SelectCriteria selectCriteria);
}
<select id="selectByReleaseDateAndRunningTime2"
        resultType="me.parker.springbootmybatismapping.model.Movie">
    SELECT movie_id
         , title
         , running_time
         , release_date
    FROM movie m
    WHERE m.release_date >= #{selectCriteria.releaseDate}
      AND m.running_time >= #{selectCriteria.runningTime}
</select>

parameterType 속성 사용

public interface MovieMapper {
  Movie selectByReleaseDateAndRunningTime2(SelectCriteria selectCriteria);
}
<select id="selectByReleaseDateAndRunningTime2"
        parameterType="me.parker.springbootmybatismapping.model.SelectCriteria"
        resultType="me.parker.springbootmybatismapping.model.Movie">
  SELECT movie_id
    , title
    , running_time
    , release_date
  FROM movie m
  WHERE m.release_date >= #{releaseDate}
    AND m.running_time >= #{runningTime}
</select>

select 엘리먼트의 parameterType을 사용하면, SelectCriteria라는 이름의 객체를 선언할 필요없이 그 필드만 선언할 수도 있다.

3.1. #{} VS ${}

사실 Mybatis에서는 매핑 방법으로 #{}${} 두 가지 표현식을 사용할 수 있다. 위에서는 #{}을 사용하였는데, #{}${}의 차이점은 무엇일까?

#{}

#{}는 파라미터를 자동으로 PreparedStatement에 바인딩해준다. 따라서 SQL Injection 공격을 방어하는 등 안전하게 파라미터를 사용할 수 있다.

하지만 문자 그대로 사용하고 싶은 경우가 있다. WHERE 조건문이 아니라 테이블 이름이나 ORDER BY 절에서 어떤 필드로 할지 또는 오름차순을 할지 내림차순을 할지 등의 경우가 있다. 이 때는 #{}을 사용하면 안된다.

${}

위와 같이 문자 그대로 사용해야하는 경우에는 ${}을 사용한다. ${}는 파라미터는 바인딩과 같은 작업없이 그대로 문자열로 치환하여 쿼리를 실행하므로, 성능상 빠르다. 그리고 문자 그대로 사용하므로 어디서든 사용이 가능하다.

하지만 SQL Injection 공격 등에 취약하여 안전상 문제가 있으므로 조건절에는 사용하지 않는 것이 좋다.

4. 주의할점 및 TIP

조회 결과로 *는 사용하지 말자

테이블의 모든 결과를 조회하고 싶을 때, 간편하게 *를 사용하는 경우가 있다. 하지만 이는 조회 결과를 매핑할 때 문제가 발생할 수 있다. 매핑 문제는 런타임에 발생하므로, 만약 테스트를 하지 않으면 실행 중 언제 에러가 발생할지 모른다.

또 한가지 문제점은 만약 테이블의 컬럼명이 바뀌거나 어떤 컬럼명을 사용하고 있는 SQL문을 찾고 싶은 경우 *를 사용하면 어떤 컬럼을 사용하고 있는지 알 수 없다는 것이다. 이는 유지보수 관점에서도 매우 좋지 않은 점이다.

그래서 조회할 때는 *를 사용하지 말고, 필요한 컬럼만 명시적으로 작성하는 것이 좋다.

참고자료