[MyBatis] 04. 동적 쿼리
- -
이번 포스트에서는 MyBatis가 가진 멋진 기능중 하나인 동적 쿼리 작성에 대해 살펴보자. 동적 쿼리는 상황에 따라 분기 처리를 통해 SQL을 동적으로 만드는 것이다.
문자열 대체를 통한 쿼리의 재구성
${}를 이용한 쿼리 작성
이제까지 SQL을 작성하면서 사용했던 플레이스 홀더로 #을 사용했었는데 $도 사용할 수 있다. 이 둘의 차이를 살펴보자.
구분 | #{ } : 바인딩 플레이스 홀더 | $ { }: 문자열 치환 플레이스 홀더 |
용도 | 값 바인딩 | 문자열 치환 |
변경 가능성 | 값만 대체 가능 | 테이블명, 컬럼명 등 구조까지 변경 가능 |
처리 방식 | PreparedStatement 사용 | Statement 사용 |
SQL 인젝션 공격 | 안전 | 취약 |
${}는 SQL 삽입 공격이 발생할 수 있기 때문에 사용을 지양하는 것이 좋지만 쿼리의 구조를 변경할 수 있다는 점에서 가끔 사용되기도 한다.
다음의 쿼리는 map에 전달된 column 속성을 조회 대상 컬럼으로 삼고 value 속성을 대상 값으로 한다.
<select id="search" resultType="Country" parameterType="map">
select * from country
where ${column} = #{value} <!-- Statement로 SQL이 삽입될 수 있다.-->
</select>
List<Country> search(Map<String, Object> condition);
@Test
public void searchTest() {
Map<String, Object> condition = new HashMap<>();
condition.put("column", "region");
condition.put("value", "Southern Europe");
List<Country> selected = cRepo.search(condition);
assertEquals(selected.size(), 15);
}
하지만 누군가가 다음과 같은 코드를 작성해버린다면 역시 문제가 아닐 수 없다.
@Test
public void searchTest() {
Map<String, Object> condition = new HashMap<>();
condition.put("column", "1=1 or region");
condition.put("value", "Southern Europe");
List<Country> selected = cRepo.search(condition);
assertEquals(selected.size(), 15);
}
// Preparing: select * from country where 1=1 or region = ?
역시나 SQL 삽입 공격이 발생할 수 있는 위험이 도사리고 있다. 따라서 위 방법은 지양하는 것이 좋다. 대신 필요에 따라 쿼리를 동적으로 구성하려면 아래 처럼 동적 쿼리를 위한 특별한 태그들을 사용한다.
동적 쿼리
특정 Continent에 속한 Country들의 정보를 조회하는 것과 특정 Region에 속한 국가들을 조회하는 것은 검색 조건만 다를 뿐 쿼리의 구조는 동일하다. 하지만 어쨌든 다른 쿼리이기 때문에 이제까지는 별도의 쿼리로 작성해야 했다.
문제는 단지 쿼리를 따로 만들면 끝나는 것이 아니라 이에 따라 mapper interface, service 등 호출 체계 전반적으로 작업이 늘어나게 된다. 이런 과정을 손쉽게 해결할 수 있는것이 동적 쿼리(또는 동적 SQL)이다.
Java 같은 프로그래밍 언어라면 손쉽게 프로그래밍 할 수 있지만 MyBatis는 XML에서 쿼리를 작성하기 때문에 별도의 표기법이 필요하다. 그 표기법에 대해 하나씩 알아보자.
<if>
<if>는 test 속성을 만족하면 해당 쿼리를 추가한다. test 속성은 true/false를 판단할 수 있는 boolean 타입의 조건식이 필요하다.
조건식을 작성할 때 파라미터를 기준으로 하는 경우가 많은데 파라미터를 참조할 때는 SQL에서 처럼 #{파라미터명}의 형태가 아닌 그냥 파라미터명만 사용함을 주의하자.
다음은 파라미터로 continent 또는 region이라는 속성이 있는지에 따라 SQL을 동적으로 처리하는 예이다.
<select id="selectIf" resultMap="countryBase" parameterType="map">
select * from country
<if test="continent !=null">
where continent=#{continent}
</if>
<if test="region !=null">
where region = #{region}
</if>
</select>
<if>의 test 속성에 continent 또는 region 값이 null인지 여부에 따라 조건이 추가되거나 생략된다. 사용되는 위치에 따라 continent 또는 #{continent}로 다르게 사용되고 있음을 주의하자.
쿼리의 구조 상 continent, region 두 녀석이 같이 들어오면 안되겠다.
mapper interface에 호출 코드 메서드를 작성하고
List<Country> selectIf(Map<String, Object> condition);
단위테스트를 실행해보자.
@Test
public void selectIfTest() {
Map<String, Object> condition = new HashMap<>();
List<Country> selected = cRepo.selectIf(condition);
assertEquals(selected.size(), 239);
condition.put("continent", "Asia");
selected = cRepo.selectIf(condition);
assertEquals(selected.size(), 51);
condition.remove("continent");
condition.put("region", "Eastern Asia");
selected = cRepo.selectIf(condition);
assertEquals(selected.size(), 8);
}
위 테스트에는 3번의 SQL 실행이 있는데 로그를 살펴보면 각각 다음과 같이 그때 그때 where 절이 달라지는 것을 볼 수 있다.
select * from country
select * from country where continent=?
select * from country where region = ?
<if>는 간단한 동적 쿼리에는 아주 유용하지만 복잡한 쿼리를 처리하기에는 중복코드가 발생하는 등 어려움이 많다.
<choose> - <when> - <otherwise>
<if> 구문은 하나의 조건에 대한 판단만 가능하고 else 또는 else if에 대한 구문은 존재하지 않는다. 여러 배타적인 조건에 대해서 처리하기 위해서는 <choose> - <when/> -<when/> - <otherwise/> </choose> 구문을 사용한다. if ~ else if ~ else if ~ else와 같은 내용이라고 보면 된다.
<select id="selectChoose" resultMap="countryBase" parameterType="map">
select * from country
<choose>
<when test="continent !=null">
where continent=#{continent}
</when>
<when test="region !=null">
where region = #{region}
</when>
<otherwise></otherwise>
</choose>
</select>
동작은 <if>에서와 동일하므로 mapper interface와 단위테스트는 생략한다.
<where>
<where>는 엘리먼트 이름에서 풍기듯 where 조건절을 만드는데 특화된 엘리먼트이다. <if>나 <choose>계열에서는 where 절이 각 조건마다 반복해서 등장하는데 <where>를 사용하면 하위 엘리먼트에서 생성한 내용일 있을 경우에만 where를 붙여주고 없으면 무시한다. <where>내부에는 조건을 표현할 수 있는 <if>나 <choose>가 사용될 수 있다.
<select id="selectWhere" resultMap="countryBase" parameterType="map">
select * from country
<where>
<choose>
<when test="continent !=null">
continent=#{continent}
</when>
<when test="region !=null">
region = #{region}
</when>
</choose>
</where>
</select>
일단 코드가 조금 줄어들었고 where 조건 전문가 같지만 검색 조건이 한 문장이 아니라 조건별로 and, or가 추가된다면 상당한 번거로움이 예상된다.
<trim>
<trim>은 속성이 많아서 복잡해보이지만 <where>의 단점을 잘 극복할 수 있는 멋진 엘리먼트이다.
- prefixOverrides: 하위 엘리먼트 처리 후 내용의 맨 앞에 해당 문자열이 있다면 지워버림
- suffixOverrides: 하위 엘리먼트 처리 후 내용의 맨 뒤에 해당 문자열이 있다면 지워버림
- prefix: 하위 엘리먼트 처리 후 내용이 있다면 가장 앞에 붙일 내용
- suffix: 하위 엘리먼트 처리 후 내용이 있다면 가장 뒤에 붙일 내용
<select id="selectTrim" resultMap="countryBase" parameterType="map">
select * from country
<trim prefix="where" prefixOverrides="and|or">
<if test="continent !=null">
and continent=#{continent}
</if>
<if test="region !=null">
and region = #{region}
</if>
</trim>
</select>
이제 전달되는 파라미터에 따라 전체 조회, continent, retion, continent and region의 4가지 형태로 조회가 가능해졌다.
이번에는 단위테스트의 내용이 바뀔꺼 같다.
@Test
public void selectTrimTest() {
Map<String, Object> condition = new HashMap<>();
List<Country> selected = cRepo.selectTrim(condition);
assertEquals(selected.size(), 239);
condition.put("continent", "Asia");
selected = cRepo.selectTrim(condition);
assertEquals(selected.size(), 51);
// condition.remove("continent");
condition.put("region", "Eastern Asia");
selected = cRepo.selectTrim(condition);
assertEquals(selected.size(), 8);
}
3번째 테스트 과정에서 continent를 지우는 코드를 주석 처리한 것을 주의하자!! 실제 생성된 쿼리는 다음과 같다.
select * from country
select * from country where continent=?
select * from country where continent=? and region = ?
<foreach>
<foreach>는 리스트 형태로 전달된 데이터를 풀어서 in 절에 추가할 경우에 주로 사용되는 엘리먼트이다.
- collection: 값 목록을 가진 객체로 배열 또는 List로 arg0, collection, list 중 하나를 사용
- item: collection 내의 개별 값을 나타내는 변수 이름
- open: 해당 블럭을 시작할 때 사용할 기호로 주로 '('
- close: 해당 블럭을 종료할 때 사용할 기호로 주로 ')'
- separator: 각 item을 구분할 분리자 기호로 주로 ','
<select id="selectForEach" resultMap="countryBase" parameterType="list">
select * from country
<where>
<if test="continents!=null">
continent in
<foreach collection="list" item="continent" open="(" close=")" separator=",">
#{continent}
</foreach>
</if>
</where>
</select>
여기서는 전달되는 파라미터가 list로 단일 값이기 때문에 <if>의 test 속성에서 continents라는 아무 이름이나 사용하고 있다.
위 코드에 대해 단위테스트를 작성해보자.
@Test
public void selectForEachTest() {
List<String> continents = Arrays.asList("Asia", "Europe");
List<Country> selected = cRepo.selectForEach(continents);
assertEquals(selected.size(), 97);
}
실제 생성된 쿼리는 아래와 같다.
select * from country WHERE continent in ( ? , ? )
<set>
마지막으로 살펴볼 녀석은 <set>이다.
<set>은 update 문장에서 null 여부에 따라서 동적으로 할당 할 수 있다. 불필요한 , 는 자동으로 제거된다.
<update id="update" parameterType="City">
update city
<set>
<if test="name!=null">
Name = #{name},
</if>
<if test="countryCode!=null">
CountryCode = #{countryCode},
</if>
<if test="district!=null">
District = #{district},
</if>
<if test="population!=null">
Population = #{population}
</if>
</set>
where id=#{id}
</update>
'MyBatis' 카테고리의 다른 글
[MyBatis] 06. Enum 타입의 활용 (0) | 2023.06.18 |
---|---|
[MyBatis] 05. 기타 (0) | 2023.06.18 |
[MyBatis] 03. 조회 결과의 매핑 (0) | 2023.06.18 |
[MyBatis] 02. CRUD (0) | 2023.06.18 |
[MyBatis] 01. 소개 및 환경 설정 (2) | 2023.06.18 |
소중한 공감 감사합니다