[MyBatis] null 파라미터 , 그리고 jdbc-type-for-null 세팅

식빵·2023년 5월 6일
0

Mybatis

목록 보기
9/9
post-thumbnail

여러분들의 SQL 은 어떤가요?

MyBatis 에서는 파라미터로 전송하는 값들 중에서 null 로 세팅된 것에 대하여
명시적인 jdbcType 을 지정해야 합니다.

<select id="findWithId" resultType="NullTestDTO">
  select *
  from coding_toast.null_insert_table a
  where a.id = #{id,jdbcType=BIGINT}
</select>

이래야 하는 이유는 MyBatis 코드 깊은 곳에서 사용되는 JDBC Driver 의 PreparedStatement.setNull 메소드가 항상 타입 정보를 입력할 것을 요구해서 그럽니다. 참고로 여기서 말하는 타입정보란, JDBC 에서 제공하는 Type 값들을 의미합니다.

그런데 여러분들은 위처럼 코딩한 적이 많으신가요? 아마 많지 않을 겁니다.
오히려 아래처럼 간단히 작성해도 문제없이 잘되신 분들이 더 많았을 겁니다.

<select id="findWithId" resultType="NullTestDTO">
  select *
  from coding_toast.null_insert_table a
  where a.id = #{id}
</select>

이건 항상은 아니더라도 어느정도 jdbc-type-for-null 의 영향 때문입니다.
jdbc-type-for-null 세팅은 MyBatis 에서 제공하는 설정 프로퍼티이며, 파라미터로 들어온 null 값에 대하여 DBMS 의 어떤 타입으로 변형할지를 정할 수 있는 세팅값입니다.

이 세팅값은 기본적으로 jdbc-type-for-null=OTHER 로 세팅되어 있는데요,
덕분에 저희가 파라미터 값으로 null 을 넣어서 보내면 setNull 메소드 호출 시점에서 사용되는 타입정보가 자동으로 OTHER 이 들어가면서 실행이 잘되는 겁니다.

물론 이 OTHER 가 항상 먹히는 건 아닙니다!
OTHER 타입에 대하여 JDBC Driver 구현체의 PreparedStatement Implement 클래스가 이를 잘 처리해준다는 가정하에서만 통용되는 말입니다. 만약 OTHER 타입을 처리 못하는 JDBC Driver 구현체면 에러가 날 수도 있겠죠?


음... 뭔가 이 세팅에 대한 호기심이 발동하지 않나요?
다양한 쿼리를 작성해보고 이 세팅을 살짝 바꿔보면 어떻게 동작할지 궁금하지 않나요?!
저만 그런가요?

지금부터 jdbc-type-for-null 에 대해서 알아보겠습니다!



글의 진행방식

  • null 값 파라미터 테스트를 위한 Java 코드와 SQL 작성
  • Junit (야매) 테스트 코드 작성
  • jdbc-type-for-null 을 세팅하지 않고 테스트 돌려보기
  • jdbc-type-for-null 을 VARCHAR 로 하고 테스트 해보기
  • jdbc-type-for-null 세팅값이 사용되는 MyBatis 코드 위치 알아보고 탐구하기
  • jdbc-type-for-null 를 필수적으로 세팅해야 되는지에 대한 나의 생각

참고(1): 이 글에서는 DBMS 로 postgresql (v. 13) 을 사용합니다.
참고(2): spring boot 환경에서 실행하고 있으며, 부트 버전은 2.7.10 입니다.
참고(3): mybatis-spring-boot-starter 버전은 2.3.0 입니다.

호~옥시 왜 null 값을 주는데 Type 정보가 왜 필요한지 잘 모르시는 분들은
이 링크를 따라가서 가볍게 읽어보시기 바랍니다.
이 글은 PreparedStatement.setNull 과 관련된 글인데, MyBatis 도 내부적으로 null 값이 들어오면 해당 메소드를 호출합니다.



테스트 코드, SQL 작성

  • 테스트 TABLE 생성 + 초기 데이터 insert SQL
  • MyBatis Mapper SQL
  • DTO 코드

를 차례대로 작성해봤습니다.

CREATE SEQUENCE coding_toast.null_insert_table_sequence;
create table coding_toast.null_insert_table
(
    id   bigint not null default nextval('coding_toast.null_insert_table_sequence'),
    name varchar(50),
    age  integer,
    constraint null_insert_table_pk primary key (id)
);


insert into coding_toast.null_insert_table(id, name, age)
values (default, 'Charlie Puth', 23)
, (default, 'Brad Pitt', 30)

<!-- 숫자형 타입에 대한 테스트 -->
<select id="findWithParamType" parameterType="long" resultType="NullTestDTO">
  select *
  from coding_toast.null_insert_table a
  where a.id = #{id}
</select>

<select id="findWithOutParamType" resultType="NullTestDTO">
  select *
  from coding_toast.null_insert_table a
  where a.id = #{id}
</select>

<select id="findWithOutParamTypeButHasJdbcType" resultType="NullTestDTO">
  select *
  from coding_toast.null_insert_table a
  where a.id = #{id,jdbcType=BIGINT}
</select>


<!-- 문자열 타입에 대한 테스트 -->
<select id="findWithParamTypeString" parameterType="string" resultType="NullTestDTO">
  select *
  from coding_toast.null_insert_table a
  where a.name = #{name}
</select>



<select id="findWithOutParamTypeString" resultType="NullTestDTO">
  select *
  from coding_toast.null_insert_table a
  where a.name = #{name}
</select>


<select id="findWithOutParamTypeButHasJdbcTypeString" resultType="NullTestDTO">
  select *
  from coding_toast.null_insert_table a
  where a.name = #{name,jdbcType=VARCHAR}
</select>

쿼리가 많은 이유는 아래와 같은 경우의 수를 따지기 위해서 입니다.

  • parameter 가 long type 인 경우
    • parameterType 표기
    • parameterType 미표기
    • parameterType 미표기, 대신 jdbcType=BIGINT 명시
  • parameter 가 string type 인 경우
    • parameterType 표기
    • parameterType 미표기
    • parameterType 미표기, 대신 jdbcType=VARCHAR 명시

package coding.toast.playground.nulltest.dto;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.apache.ibatis.type.Alias;

@Getter
@Setter
@ToString
@Alias("NullTestDTO")
public class NullTestDTO {
	private Long id;
	private String name;
	private Integer age;
}

이러고 나서 테스트 코드를 그냥 쭉 작성하면...

package coding.toast.playground.nulltest;

import coding.toast.playground.nulltest.dto.NullTestDTO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest //(properties = "mybatis.configuration.jdbc-type-for-null=varchar")
class NullTestMapperTest {
	
	@Autowired
	private NullTestMapper mapper;
	
	@Test
	void findWithParamType() {
		NullTestDTO found = mapper.findWithParamType(1L);
		System.out.println("find with value = " + found);
		
		// ok, what about null?
		found = mapper.findWithParamType(null);
		System.out.println("find with null param = " + found);
	}
	
	
	@Test
	void findWithOutParamType() {
		NullTestDTO found = mapper.findWithOutParamType(1L);
		System.out.println("find with value = " + found);
		
		// ok, what about null?
		found = mapper.findWithOutParamType(null);
		System.out.println("find with null param = " + found);
	}
	
	@Test
	void findWithOutParamTypeButHasJdbcType() {
		NullTestDTO found = mapper.findWithOutParamTypeButHasJdbcType(1L);
		System.out.println("find with value = " + found);
		
		// ok, what about null?
		found = mapper.findWithOutParamTypeButHasJdbcType(null);
		System.out.println("find with null param = " + found);
	}
	
	@Test
	void findWithParamTypeString() {
		NullTestDTO found = mapper.findWithParamTypeString("Charlie Puth");
		System.out.println("find with value = " + found);
		
		// ok, what about null?
		found = mapper.findWithParamTypeString(null);
		System.out.println("find with null param = " + found);
	}
	@Test
	void findWithOutParamTypeString() {
		NullTestDTO found = mapper.findWithOutParamTypeString("Charlie Puth");
		System.out.println("find with value = " + found);
		
		// ok, what about null?
		found = mapper.findWithOutParamTypeString(null);
		System.out.println("find with null param = " + found);
	}
	
	@Test
	void findWithOutParamTypeButHasJdbcTypeString() {
		NullTestDTO found = mapper.findWithOutParamTypeButHasJdbcTypeString("Charlie Puth");
		System.out.println("find with value = " + found);
		
		// ok, what about null?
		found = mapper.findWithOutParamTypeButHasJdbcTypeString(null);
		System.out.println("find with null param = " + found);
	}
	
}

이 코드들을 다 돌리면? 에러가 발생하지 않고 문제없이 모두 성공합니다.
아무 세팅을 안했지만 잘 됩니다.




jdbc-type-for-null 설정에 따른 에러

그렇다면 이제 저희가 맨 처음에 얘기했던 mybatis.configuration.jdbc-type-for-null 설정을 바꿔보고 똑같은 테스트 코드를 재 실행해보겠습니다.

varchar 를 세팅해서 테스트해보겠습니다.
Spring Boot Test 를 사용한다면 아래처럼 간단하게 세팅할 수 있습니다.

@SpringBootTest(properties = "mybatis.configuration.jdbc-type-for-null=varchar")

//... 테스트 코드 모두 생략 ...

실행을 해보면...


결과

  • String 타입의 파라미터를 받는 SQL 은 모두 성공
  • Long 타입의 파라미터를 받는 SQL 은 #{id,jdbcType=BIGINT} 처럼 파라미터를
    설정한 것 빼고 모두 실패

에러 로그를 천천히 읽어보면 알겠지만,
파라미터로 넣은 null 에 대해서 MyBatis 가 타입을 VARCHAR 로 바꾸면서
타입 mismatch 가 발생하여 난 에러입니다.

이러는 이유는 앞서 설정한 mybatis.configuration.jdbc-type-for-null=varchar 때문입니다. 해당 설정값은 Mybatisnull 파라미터 값을 받았을 때, 이와 관련된 타입을 어떻게 변경할지를 지정하는 설정입니다.

지금의 경우는 저희가 varchar 로 했기 때문에 에러가 나는 겁니다.
id 처럼 숫자형 컬럼과 varchar 타입의 파라미터 값이 = 연산을 하니 당연한 결과겠죠?

<select id="findWithParamType" parameterType="long" resultType="NullTestDTO">
  select *
  from coding_toast.null_insert_table a
  where a.id = #{id}
</select>


<select id="findWithOutParamType" resultType="NullTestDTO">
  select *
  from coding_toast.null_insert_table a
  where a.id = #{id}
</select>




jdbc-type-for-null 전역설정 무시하기

그런데 이상하지 않나요? 저희는 id(숫자형 컬럼)로 검색하는 쿼리가 하나 더 있습니다.
그런데 해당 쿼리는 성공했습니다!

왜 이럴까요? 성공한 쿼리를 다시 보죠.

<select id="findWithOutParamTypeButHasJdbcType" resultType="NullTestDTO">
  select *
  from coding_toast.null_insert_table a
  where a.id = #{id,jdbcType=BIGINT} -- 이게 핵심
</select>

where a.id = #{id,jdbcType=BIGINT} 처럼 명시적으로 jdbcType 을 지정해줘서 성공한 겁니다. 이런 명시적인 jdbcType 작성법은 전역으로 설정한 jdbc-type-for-null 보다 우선순위가 높습니다.




jdbc-type-for-null 의 실제처리 위치

저희가 처음에 실행할 때는 mybatis.configuration.jdbc-type-for-null 에 대한 세팅을 안했을 때, 즉 mybatis.configuration.jdbc-type-for-null=other 일때는 실행이 잘됐습니다.

이건 다시 생각해보면 [파라미터 null 값 + OTHER 타입]에 대한 처리를 MyBatis(또는 Jdbc Driver) 코드 어디선가 해줬고, 그덕에 저희가 아무 세팅을 안했을 때 잘 동작했다는 의미이기도 합니다.

그렇다면 대체 어떤 코드에서 이게 처리된걸까요? 바로 아래 2가지지 메소드가 그 답입니다.


먼저 BaseTypeHandler.setParameter 메소드가 실행되면 아래와 같은 분기를 탑니다.

  • 파라미터로 바인딩된 값 자체가 null 인지를 먼저 확인합니다.
  • jdbcType 이 null 인지 아닌지를 판단하는데, 저희는 default 값인 other 가 들어갑니다.
  • 최종적으로는 위 그림의 빨간색 줄 쳐진 곳, 즉 ps.setNull 메소드가 수행됩니다.
  • 저의 경우에는 postgresql 을 사용하는데, PgPreparedStatement 라는 클래스의 인스턴스가 해당 요청을 처리하는 것을 확인했습니다.

이제 PgPreparedStatement.setNull 메소드의 내용을 계속해서 보겠습니다.

  • (길어서 중간을 생략했습니다)
  • 참고로 위의 메소드는 postgresql jdbc driver 가 구현한 코드입니다.
    다른 jdbc driver 구현체에서는 메소드 내용이 다를 수 있습니다.
  • 이 switch 문들은 JDBC 에서 지정한 Types 정보들을 실제 Postgresql 에서 사용할 수 있는 타입인 Oid 로 변경하기 위함입니다.
  • 코드를 보면 Types.OTHER 가 switch 의 조건문에 있는 것을 확인할 수 있습니다.
    음... Oid 에서는 해당 타입을 UNSPECIFIED 라는 타입으로 변경을 해줍니다.
    이 타입이 정확히 뭔지는 모르지만 "처리를 해준다"라는 것 자체에 무게를 두고 넘어가겠습니다.

작은 TIP

그런데 만약에 저 switch 문에서 처리를 안하는 Type 에 대해서는 어떻게 할까요?
예외를 던집니다! 그리고 이 예외를 처리하는 BaseTypeHandler 객체는 다시 아래와 같은 에러를 던집니다.

이 에러 문구... 어디서 많이 보지 않았나요?
가끔 몇몇 jdbc driver 에서는 OTHER 타입을 지원 안하는 경우가 있었습니다.
이때 예외가 터지면서 jdbcType 을 다른 것으로 명시해달라는 예외 메세지를 던집니다.
이를 해결하기 위해서 jdbc-type-for-null=NULL 로 처리하라는 블로그 글들이 자주 보는데, 바로 이런 OTHER 타입의 지원 및 사용 여부가 jdbc driver 마다 달라서 그런 겁니다.




jdbc-type-for-null 세팅은 필수인가?

모릅니다! 모든 jdbc driver 구현체가 각기 다르게 구현되었기 때문에 딱 잘라서
말씀 드리기 힘듭니다. (일단 제가 사용하는 postgresql 은 괜찮은 거 같습니다)

일단 제 세팅 추천 방법은 위에서 제가 작성한 테스트 코드를 한번
세팅 안하고 쭉 진행해보고, 에러가 나면 세팅을 바꿔보는 식으로 작업을 하는 겁니다.




추가: INSERT 테스트 코드

위에서는 다 Select 만 사용했는데, 혹여라도 INSERT 도 테스트하고 싶다면 아래 코드를 참조해주세요.

<insert id="insert" parameterType="NullTestDTO">
  insert into coding_toast.null_insert_table(id, name, age)
  values (default, #{name}, #{age});
</insert>
@Test
void insertTest() {
	NullTestDTO dto = new NullTestDTO();
	
	// normal insert
	dto.setName("daily Code");
	dto.setAge(14);
	mapper.insert(dto);
	
	// null field include
	dto.setAge(null);
	mapper.insert(dto);

	// null parameter
	mapper.insert(null);
}

쿼리 실행 후 콘솔로그 확인

c.t.p.n.N.insert - ==>  Preparing: insert into coding_toast.null_insert_table(id, name, age) values (default, ?, ?);
c.t.p.n.N.insert - ==> Parameters: daily Code(String), 14(Integer)
c.t.p.n.N.insert - <==    Updates: 1

c.t.p.n.N.insert - ==>  Preparing: insert into coding_toast.null_insert_table(id, name, age) values (default, ?, ?);
c.t.p.n.N.insert - ==> Parameters: daily Code(String), null
c.t.p.n.N.insert - <==    Updates: 1

c.t.p.n.N.insert - ==>  Preparing: insert into coding_toast.null_insert_table(id, name, age) values (default, ?, ?);
c.t.p.n.N.insert - ==> Parameters: null, null
c.t.p.n.N.insert - <==    Updates: 1
done3
  • java-type-for-null 을 세팅 안 해도 잘되는 걸 확인했습니다.




참고 링크

profile
백엔드를 계속 배우고 있는 개발자입니다 😊

0개의 댓글