우리가 MyBatis를 활용하는 이유는 무엇일까?
물론 Query에 객체 값을 넣어줘서 자동으로 내가 원하는 Query를 생성해주고 동적 SQL을 통해 상황에 맞는 Query를 생성할 수 있다는 점도 있을 것이다.
하지만, 개인적으로 생각하는 MyBatis나 ORM의 가장 큰 장점은 "결과를 객체에 담아준다"라는 것이다.
원래라면 DB에서 데이터를 뽑을 때는 단순한 데이터의 형태일 뿐이다. 예를 들어, Name이라는 Column에 "홍길동"이라는 Data가 들어있다고 하더라도 DB에서 뽑으면 단순히 "홍길동"이라는 Data가 뽑힐 뿐, 후처리 코드를 통해 내가 원하는 객체에 이 Data를 넣어줘야 한다.
하지만 MyBatis나 ORM은 Column과 객체 Property를 자동으로 매핑시켜 DB에서 데이터 뽑은 값을 정제까지 하여 반환해주기 때문에 JDBC를 활용할 때보다 엄청난 개발 효율을 보이게 되는 것이다.
(특히 Boolean같은 경우 javaType에는 존재하지만, MySQL에는 Boolean형이 존재하지 않는다. 하지만 TINYINT 또는 INT로 Column Data Type으로 정하면 MyBatis 측에서 알아서 Boolean과 TINYINT 값을 변형해준다. 1이면 True, 0이면 False로 반환해주는 것이고 역의 경우도 마찬가지이다)
이런 점에서 ResultMap은 MyBatis의 핵심이라고도 할 수 있을 것이다.
왜냐하면 ResultMap을 통해 뽑은 Data를 객체의 적절한 Property에 저장할 수 있기 때문이다.
물론, ResultMap을 활용하지 않고 typeAlias를 활용하여 resultType으로 객체를 연결시켜줄 수도 있다.
하지만 이 경우 객체의 어떤 Property와 어떤 Column을 매칭시킬지는 정할 수 없다. 따라서 MyBatis 측에서 미리 구현된 Column 이름과 Property 이름을 매칭 시키는 로직으로 매칭 되게 된다. 이는 POJO 객체를 개발할 때 Column Data에 종속되게 되며, 반대의 경우도 마찬가지이다. 즉, 미리 짜여 있는 MyBatis 로직과 DB Table에 의존하게 된다.
이 경우 Mapping 관계가 한 눈에 들어오지 않는다는 단점도 존재하며, 유지보수가 어렵고 확장성도 어렵게 만든다는 단점이 존재하게 된다.
resultMap을 활용하면, 직관적으로 어떤 Column이 어떤 Property와 매핑되는지를 알 수 있고, 연결성이 직관적이므로 유지보수성이 쉬우며 Column Name과 Property Name이 의존적이지 않으므로 개발 및 확장에도 큰 이점을 가진다.
또한, javaType Parameter를 활용해 적절한 Data가 들어왔는지 확인할 수도 있으며 동시에 typeHandler를 적용할 수도 있게 되어 개발자가 원하는 추가 로직을 쉽게 적용할 수 있다는 장점이 존재한다
<resultMap type="com.example.Survey" id="surveyPlainResultMap">
<id column="ID" property="id" javaType="Integer" />
<result column="SUR_REG_DATE" property="surRegDate" javaType="Date"/>
<result column="SUR_CONTENTS" property="contents" javaType="String"/>
</resultMap>
<resultMap>
태그 Property<select>
태그의 resultMap의 인자로 넘겨줌으로써 선언한 resultMap을 활용할 수 있음<resultMap>
태그 내에서 활용할 수 있는 태그들<id>
: 객체 Instance를 비교하는 Property<id>
태그를 활용한다.<result>
: Column과 Property를 연결하기 위한 태그. 별다른 제한 없이 Mapping을 위해서만 활용할 수 있으므로 대다수의 Mapping은 <result>
태그를 활용한다<id>
, <result>
Tag에 활용할 수 있는 속성들MyBatis는 값을 어떻게 저장할까?
MyBatis는"Setter"를 통해 Column Data를 저장한고, "Getter"를 통해 Property 값을 DB에 전달한다.
그런데, "Getter"는 많이 활용하니까 그럴 수 있는데, "Setter"를 병적으로 싫어하는 사람이 있을 수 있다.
"어 Setter 뭔가 보안상 안좋다던데? 생성자 주입이 좋다고 하던데요?"
일단 먼저 짚고 넘어가야 할게, Setter가 불안한 점이 있는 것은 맞다. Setter 주입은 실수로 Setter 함수를 실행시켜 값을 추가시키지 않은다면 해당 객체에 Null 값이 저장된 형태로 실행될 수 있기 때문이다.
하지만 이것은 Service나 Repository Class에서 핵심적인 에러를 발생시키는 것이지, 객체에 대해서는 NULL 값이 들어간다고 해서 큰 문제가 되지는 않는다.
하지만, 그럼에도 불구하고 생성자 주입을 활용하고 싶은 사람이 있을 것이다. 이 경우 MyBatis는 "생성자"를 식별해야 하며, 이를 위해 활용하는 것이 Constructor 태그이다.
개인적으로는 MyBatis에서 참 필요 없는 태그라고 생각하지만 일단 있으니 공부는 해보았다.
먼저 <constructor>
태그 코드를 보자
public class Member{
private Integer id;
private String username;
private Integer age;
public Member(Integer id, String username, String age){
this.id = id;
this.username = username;
this.age = age;
}
}
<constructor>
<idArg column="id" javaType="int"/>
<arg column="username" javaType="String"/>
<arg column="age" javaType="int"/>
</constructor>
위 코드가 Member에 대한 객체이고, 아래가 <constructor>
로 만든 MyBatis코드이다.
이제 우리는 생성자를 통해 Column Data를 객체에 주입할 수 있게 되었다!
<constructor>
태그에서 활용할 수 있는 property보다는 <constructor>
안의 <idArg>
, <arg>
태그에서 활용하는 Property에 대해서만 설명할 것이다.
(<idArg>
는 <id>
, <arg>
는 <result>
와 유사한 역할을 한다고 생각하면 된다)
<idArg>
, <arg>
태그 Property이 name Property가 상당히 재미있고, 중요하다.
Java에서 생성자를 써봤으면 알 것이다. 가장 불편한 점이 무엇인가? 바로 생성자에서 받는 파라미터 순서를 지켜야 한다는 것이다.
위에서 선언한 Member 클래스를 생성할 때 실수로 new Member(1, 17, "홍길동")으로 입력하면 자료형이 맞지 않아 에러가 발생할 것이다. Java에서는 생성자를 Builder 형태로 만들어 활용하기도 하지만 MyBatis 측에서는 이렇게 문제를 해결할 수는 없다.
이때 활용하는 것이 "name" Property이다.
MyBaits 3.4.4부터 활용할 수 있는 Property로써 생성자의 매개변수 이름을 name Parameter에 지정해줌으로써 생성자의 순서에 맞게 <idArg>
, <arg>
를 입력하지 않아도 알아서 MyBatis 측에서 순서를 맞춰준 이후 생성자를 통해 데이터를 주입하게 되며, 생성자 형성 시 주입하는 데이터의 순서가 달라서 에러가 발생하는 문제를 없애는 매우 좋은 Property이다.
<constructor>
태그를 활용하고 싶은 사람은 꼭 활용하자!
<constructor>
<idArg column="id" javaType="int" name="id" />
<arg column="age" javaType="int" name="age" />
<arg column="username" javaType="String" name="username" />
</constructor>
<!-- age와 username의 순서가 생성자와 다르지만, name으로 지정했으므로 에러가 발생하지 않음 -->
개인적으로 생각하기에 진짜 신비한 태그들이며 많이 활용할 것 같은 태그들이다.
먼저 association과 collection을 알기 전에 "has one"과 "has many"라는 개념을 알고 넘어가야 한다.
말로만 들어보면 어려워 보이지만 사실 쉬운 개념이다.
"has one"이라는 것은 1개를 가지고 있다는 의미이다. 이는 현재 내가 보고 있는 Table 기준으로 1개의 Row Data가 1개의 다른 Table Row Data와 연결된다는 의미이다.
"has many"라는 것은 여러개를 가지고 있다는 의미이다. 즉 현재 내가 보고 있는 Table 기준으로 1개의 Row Data가 다른 Table 여러 개의 Row Data와 연결된다는 의미이다.
자 이렇게 이해해도 좀 어려울 수 있으니 예시를 들어보자.
"가수"는 여러 개의 "앨범"을 낸다. 그리고 "앨범"은 여러 개의 "노래"를 수록하고 있다. 그리고 우리는 "앨범" 입장에서 has one과 has many를 이해해보도록 하자.
"앨범" 입장에서 해당 앨범을 출간한 가수는 1명밖에 존재하지 않는다. 따라서 앨범 입장에서 "has one" 관계인 것은 "가수"이다.
그리고 "앨범" 입장에서 앨범에 수록된 노래는 매우 많다. 따라서 앨범 입장에서 "has many"관계인 것은 "앨범"이 되는 것이다.
이런 has one과 has many를 MyBatis 측에서 처리하는 방법은 2가지가 존재하는데 "Nested Select"와 "Nested Results"이다.
Nested Select는 1번의 추가 Select를 통해 여러 Table에 존재하는 결과를 가지고 오는 방식을 의미하며 Nested Results는 JOIN을 통해 한 번에 데이터를 가지고 오는 것을 의미한다.
또 예시를 들어 이해해보자.
나는 "A"라는 앨범에 수록된 곡을 알고 싶다.
Nested Select라는 것은 먼저 "A"라는 곡에 대한 정보를 Table에서 얻어온 이후 이렇게 얻어온 정보를 통해 SELECT문을 추가로 실행해서 앨범에 수록된 곡 정보를 받아오는 것이다.
Nested Results는 "A"라는 곡을 받아올 때 JOIN문을 통해 곡에 대한 Table까지 참조하여 1번의 Select문으로 전체 데이터를 한번에 가지고 오는 것이다.
내가 설명할 방법이자 앞으로 활용할 방법은 "Nested Reuslts"방법이다. 왜냐하면 Nested Select문에는 문제점이 존재하기 때문이다.
Nested Select 방식은 "N+1 Select 문제"를 발생시킬 가능성이 존재한다.
이전에도 설명했지만, 다시 한 번 N+1 Select 문제에 대해 짚고 넘어가자.
만약 가수 A가 부른 곡에 대한 정보를 얻어오고 싶다고 가정하자. 그렇다면 A는 먼저 자신이 부른 앨범을 모두 SELECT문을 통해 검색할 것이다. 그렇게 얻어온 정보가 총 N개라고 하자.
이때까지 발생한 SELCT문은 총 1번이다.
그런데 우리는 최종적으로 "노래"에 대한 정보를 얻고 싶은 것이었다. 즉, 위에서 얻어온 N개의 정보를 활용하여 1개의 정보마다 1개의 SELECT문을 실행하여 앨범에 수록된 곡을 모두 가지고 와야 할 것이다. JOIN 문을 활용하면 1번의 SELECT문을 통해 처리할 수 있는 문제를 1번의 SELECT문과 그 결과물 N개에 대해 1번씩 수행해야 하는 N번의 SELECT문, 총 N+1번의 SELECT가 발생하게 되고, 이를 "N+1 Select 문제"라고 한다.
NestedResults는 JOIN문을 활용하므로 이런 문제가 발생하지 않는다
물론, Table 관계 상 Foriegn Key가 제대로 설정되어 있어야 MyBatis도 이를 제대로 인지하고 has one과 has many 관계를 처리할 수 있게 됨을 알고 있자.
Foreign Key 설정을 하지 않아 Table 간의 관계도를 모르는데 MyBatis 측에 처리해주라고 요청하는 것은 MyBatis에 대한 너무 큰 기대감을 가지고 있는 것이다
@Data
public class Artist {
private Long id;
private String name;
}
@Data
public class Album {
private Long id;
private String title;
private Artist artist; // has one
private List<Song> songs; // has many
}
@Data
public class Song {
private Long id;
private Album album; // has one
private String name;
private int playtime;
}
<?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" >
<!--resultMap은 모두 선언되어 있다고 가정-->
<mapper namespace="com.example.AlbumMapper">
<resultMap id="albumResultMap" type="com.example.Album">
<id column="ID" property="id" jdbcType="BIGINT"/>
<result column="TITLE" property="title" jdbcType="VARCHAR"/>
<!-- association -->
<association column="ARTIST_ID" property="artist" select="selectArtistByPrimaryKey"/>
<!-- collection -->
<collection column="ID" property="songs" select="selectSongByAlbumKey"/>
</resultMap>
<!-- SELECT Query(for associatoin) -->
<select id="selectArtistByPrimaryKey" resultMap="artistResultMap" parameterType="java.lang.Long">
select
*
from
ARTISTS
where
ID = #{id}
</select>
<!-- SELECT Query(for collection) -->
<select id="[2] : selectSongByAlbumKey"
resultMap="songResultMap" parameterType="java.lang.Long">
select
*
from
SONGS
where
ALBUM_ID = #{id}
</select>
</mapper>
먼저 PK와 FK 관계는 이미 Table에 선언되어 있기 때문에 MyBatis측에서는 어떤 값이 Input으로 들어와야 할지 까지는 굳이 입력해주지 않아도 된다.
여기서 중요한 건 association과 collection의 column 값 차이이다.
association은 has one을 처리하는 것으로 FK값이 현재 검색하는 Table에 존재한다. 따라서, 현재 존재하는 Table에서 FK를 담고 있는 Column Name을 지정하면 되는 것이다.
하지만 collection은 has many를 처리하기 때문에 현재 PK값이 many쪽 table의 FK값, 위 예시에서는 "노래" Table의 FK값으로 들어가게 된다. 따라서 column에 "노래 Table에 FK를 저장하는 Column Name"을 지정해줘야 하는 것이다
상황에 따라 반환하는 Element가 다를 경우 활용하는 태그이다.
내가 구현했던 설문조사 로직을 생각하다보니 이해가 조금 쉬웠다.
설문조사에는 "주관식"과 "객관식"이 존재하는데, "주관식"일 경우 해당 설문조사 질문에 대한 답변을 저장한 Table에서 데이터를 불러와야 할 것이며 "객관식"일 경우 객관식 질문 답변들을 불러오고, 답변들을 선택한 횟수도 같이 불러와야 할 것이다.
즉, 설문조사 문항의 유형에 따라 "User가 입력한 답변"을 가지고 와야 할지 "미리 정해져 있는 답변"을 가지고 와야 할지 상황이 달라지는 것이다.
아래 코드는 discriminator를 활용한 차량에 대한 정보를 가지고 오는 코드이다.
<resultMap id="vehicleResult" type="Vehicle">
<id property="id" column="id" />
<result property="vin" column="vin"/>
<result property="year" column="year"/>
<result property="make" column="make"/>
<result property="model" column="model"/>
<result property="color" column="color"/>
<discriminator javaType="int" column="vehicle_type">
<case value="1" resultMap="carResult"/>
<case value="2" resultMap="truckResult"/>
<case value="3" resultMap="vanResult"/>
<case value="4" resultMap="suvResult"/>
</discriminator>
</resultMap>
먼저 Table에서 "vehicle_type"이라는 Column에는 Integer형의 데이터가 저장되어 있을 것이다.
만약 이 값이 1이라면 Car Table에 가서 carResult를 Select 하고, 2라면 Truck Table에 가서 truckResult를 Select 하는 등 상황에 따라 가지고 올 차량에 대한 데이터가 달라지는 것이다.
Java에서는 상위 Class나 Interface를 생성하여 넓은 범위를 포함하는 객체를 만들고 이를 상속받아 구체적인 객체를 생성하는 경우가 많다.
이런 Case를 DB와 Query문에 그대로 적용시켜 활용할 수 있다는 점에서 어느정도의 활용도를 가지고 있다고 생각한다