문제의 시작

설정 옵션이 존재한다고 해서 항상 원하는 지점에 적용되는 것은 아니다. 특히 database driver 내부에서 parameter binding이 결정되는 경우, 문서에 적힌 옵션과 실제 호출 경로 사이에 간극이 생길 수 있다. 이 글은 setSendStringParametersAsUnicode=false가 기대대로 작동하지 않던 문제를 JDBC driver 내부까지 따라간 기록이다.

앞선 SQL Server VARCHAR index 문제의 해결책으로, 파라미터들을 기존의 nvarchar값으로 두되 index를 타는 파라미터들을 varchar/char로 쿼리할 수 있는 방안을 마련해야 한다.

전체 parameter를 varchar로 전송하는 방법도 있다. JDBC URL에 setSendStringParametersAsUnicode=false를 붙이는 방식이다.

이렇게 되면 모든 nvarchar로 전송되던 파라미터들이 varchar로 전송되는데, 몇몇 db에 예외적으로 nvarchar로 선언된 컬럼들이 있다. 이를 다루기 위하여 @Nationalized annotation을 사용하였다. 이렇게 되면 jdbc url에 파라미터를 varchar로 보내겠다는 선언을 하여도 attribute의 isNationalized값이 true로 설정되어 nvarchar 타입의 쿼리가 발생한다.

문제는 저장 경로와 조회 경로가 다르게 동작한다는 점이었다. 저장 시에는 쿼리가 nvarchar로 전송되어 Unicode 특수문자가 정상 저장되지만, 일부 DB에서 해당 column을 읽는 과정에서는 Could not extract column x from JDBC ResultSet - The conversion from varchar to NCHAR is unsupported. 오류가 발생했다.

구현하면서 확인한 흐름

public void contributeTypes(TypeContributions typeContributions, ServiceRegistry serviceRegistry) {
    // by default, not much to do...
    registerColumnTypes( typeContributions, serviceRegistry );
    final NationalizationSupport nationalizationSupport = getNationalizationSupport();
    final JdbcTypeRegistry jdbcTypeRegistry = typeContributions.getTypeConfiguration().getJdbcTypeRegistry();
    if ( nationalizationSupport == NationalizationSupport.EXPLICIT ) {
       jdbcTypeRegistry.addDescriptor( NCharJdbcType.INSTANCE );
       jdbcTypeRegistry.addDescriptor( NVarcharJdbcType.INSTANCE );
       jdbcTypeRegistry.addDescriptor( LongNVarcharJdbcType.INSTANCE );
       jdbcTypeRegistry.addDescriptor( NClobJdbcType.DEFAULT );
    }

@Nationalized 를 선언할 시, SQLServerDialect.java 의 contributeTypes method에서 NVarcharJdbcType 이 jdbcTypeRegistry에 생기며, db값 → Java object로 Hibernate에서 mapping을 시도 할 때 사용하는 함수 BasicExtractor.java의 extract function 에서

@Override
public J extract(ResultSet rs, int paramIndex, WrapperOptions options) throws SQLException {
    final J value = doExtract( rs, paramIndex, options );
    if ( value == null || rs.wasNull() ) {
       if ( JdbcExtractingLogging.LOGGER.isTraceEnabled() ) {
          JdbcExtractingLogging.logNullExtracted(
                paramIndex,
                getJdbcType().getDefaultSqlTypeCode()
          );
       }
       return null;
    }

abstract function인 doExtract를 NVarcharJdbcType에서 가져온다.

@Override
public <X> ValueExtractor<X> getExtractor(final JavaType<X> javaType) {
    return new BasicExtractor<>( javaType, this ) {
       @Override
       protected X doExtract(ResultSet rs, int paramIndex, WrapperOptions options) throws SQLException {
          return javaType.wrap( rs.getNString( paramIndex ), options );
       }
       @Override
       protected X doExtract(CallableStatement statement, int index, WrapperOptions options) throws SQLException {
          return javaType.wrap( statement.getNString( index ), options );
       }
       @Override
       protected X doExtract(CallableStatement statement, String name, WrapperOptions options) throws SQLException {
          return javaType.wrap( statement.getNString( name ), options );
       }
    };
}

getNString function을 살펴보면,

@Override
public String getNString(int columnIndex) throws SQLException {
    loggerExternal.entering(getClassNameLogging(), "getNString", columnIndex);
    checkClosed();
    String value = (String) getValue(columnIndex, JDBCType.NCHAR);
    loggerExternal.exiting(getClassNameLogging(), "getNString", value);
    return value;
}

데이터베이스에 들어가있는 값을 NCHAR로 가져오려고 함을 알 수 있다.

일부 거래처의 컬럼들이 요청으로 NVARCHAR로 선언되어 있는 것을 핸들링하기 위해 @Nationalized annotation을 추가한 것이, VARCHAR로 들어가있는 대부분의 데이터베이스 컬럼들을 변환시도시 The conversion from varchar to NCHAR is unsupported. 에러가 발생한 것이다.

최종적으로 어디서 에러가 throw되는지 파보면,

dtv.java

Object getValue(DTV dtv, JDBCType jdbcType, int scale, InputStreamGetterArgs streamGetterArgs, Calendar cal,
        TypeInfo typeInfo, CryptoMetadata cryptoMetadata, TDSReader tdsReader, SQLServerStatement statement) throws SQLServerException {
    SQLServerConnection con = tdsReader.getConnection();
    Object convertedValue = null;
    byte[] decryptedValue;
    boolean encrypted = false;
    SSType baseSSType = typeInfo.getSSType();
    // If column encryption is not enabled on connection or on statement, cryptoMeta will be null.
    if (null != cryptoMetadata) {
        assert (SSType.VARBINARY == typeInfo.getSSType()) || (SSType.VARBINARYMAX == typeInfo.getSSType());
        baseSSType = cryptoMetadata.baseTypeInfo.getSSType();
        encrypted = true;
        if (aeLogger.isLoggable(java.util.logging.Level.FINE)) {
            aeLogger.fine("Data is encrypted, SQL Server Data Type: " + baseSSType + ", Encryption Type: "
                    + cryptoMetadata.getEncryptionType());
        }
    }
    // Note that the value should be prepped
    // only for columns whose values can be read of the wire.
    // If valueMark == null and isNull, it implies that
    // the column is null according to NBCROW and that
    // there is nothing to be read from the wire.
    if (null == valueMark && (!isNull))
        getValuePrep(typeInfo, tdsReader);
    // either there should be a valueMark
    // or valueMark should be null and isNull should be set to true(NBCROW case)
    assert ((valueMark != null) || (valueMark == null && isNull));
    if (null != streamGetterArgs) {
        if (!streamGetterArgs.streamType.convertsFrom(typeInfo))
            DataTypes.throwConversionError(typeInfo.getSSType().toString(), streamGetterArgs.streamType.toString());
    } else {
        if (!baseSSType.convertsTo(jdbcType) && !isNull) {
            // if the baseSSType is Character or NCharacter and jdbcType is Longvarbinary,
            // does not throw type conversion error, which allows getObject() on Long Character types.
            if (encrypted) {
                if (!Util.isBinaryType(jdbcType.getIntValue())) {
                    DataTypes.throwConversionError(baseSSType.toString(), jdbcType.toString());
                }
            } else {
                DataTypes.throwConversionError(baseSSType.toString(), jdbcType.toString());
            }
        }
boolean convertsTo(JDBCType jdbcType) {
    return GetterConversion.converts(this, jdbcType);
}

static final boolean converts(SSType fromSSType, JDBCType toJDBCType) {
    return conversionMap.get(fromSSType.category).contains(toJDBCType.category);
}

fromSSType(DB 선언 타입) 이 varchar이며, toJDBCType(Application 선언 타입)이 NCHAR인게 현재 상황인데, 이 경우 SSTYPE이 CHARACTER가 되며 NCHAR가 맵에 없기 때문에 에러가 발생한다.

결과를 어떻게 검증했는가

86fca1b8-8937-474c-8435-e2047f1d2c0a.png

검토했던 해결 방법들

  1. 특수문자 저장과 조회를 포기하고 @Nationalized annotation을 제거해 전체를 varchar로 취급하기

  2. runtime에 select query 실행시 컬럼 타입 검사 및 jdbcTypeRegistry의 descriptor 강제변경

  3. 별도의 테이블에 nvarchar로 선언된 컬럼들을 모아놓고 2번과정

  4. @Nationalized annotation을 제거하고 CUD query에서 native query를 사용해 nvarchar로 강제 type casting하기 (물론 read는 varchar로 수행되므로 nvarchar column의 일부 특수문자에서 문제가 생긴다)

  5. mssql-jdbc 라이브러리 클론받아서 v2 탄생시키기 이걸 한다면 cohttp://m.microsoft.sqlserver.jdbc.SSType.GetterConversion 의 CHARACTER에 SSTYPE.Category.NCHAR가 추가되면 될 것 같다. http://m.microsoft.sqlserver.jdbc.JDBCType.UpdaterConversion 에는 CHARACTER에 SSType.Category.NCHAR가 들어가있는걸 보니 write는 안터지게 해둔것 같은데, 아쉬운 자리가 아닐수 없다.

아래는 GetterConversion 의 CHARACATER enum map에 강제로 NCHARACTER를 추가하는 소스코드이다. 라이브러리 파일의 강제 수정이 들어간 만큼 사용이 필요할 시 필히 엄밀한 검토 후 사용되어야 한다.

Reflection과 Unsafe 클래스를 이용한 접근 허용 및 라이브러리 enum의 재선언 방지를 위한 immutability 설정, 에러 suppresion 등 서운한 구석이 한둘이 아니다. 더 좋은 강구책이 있다면 댓글에,,

import jakarta.annotation.PostConstruct
import org.springframework.context.annotation.Configuration
import sun.misc.Unsafe
import java.lang.reflect.Field
import java.lang.reflect.Modifier
import java.util.Collections
import java.util.EnumSet

@Configuration
class SSTypeConfiguration {

    @PostConstruct
    fun modifyCharacterEnumSet() {
        try {
            val getterConversionClass = Class.forName("com.microsoft.sqlserver.jdbc.SSType\$GetterConversion")
            val characterField: Field = getterConversionClass.getDeclaredField("CHARACTER")
            characterField.isAccessible = true

            val characterEnum = characterField.get(null)

            val categoriesField: Field = characterEnum.javaClass.getDeclaredField("to")
            categoriesField.isAccessible = true

            val unsafeField = Unsafe::class.java.getDeclaredField("theUnsafe")
            unsafeField.isAccessible = true
            val unsafe = unsafeField.get(null) as Unsafe

            val fieldOffset = unsafe.objectFieldOffset(categoriesField)

            @Suppress("UNCHECKED_CAST")
            val enumSet = categoriesField.get(characterEnum) as EnumSet<*>

            val nCharacterCategory = Class.forName("com.microsoft.sqlserver.jdbc.JDBCType\$Category")
                .enumConstants.first { (it as Enum<*>).name == "NCHARACTER" }

            @Suppress("UNCHECKED_CAST")
            (enumSet as MutableSet<Any>).add(nCharacterCategory)

            val immutableEnumSet = Collections.unmodifiableSet(enumSet)

            unsafe.putObject(characterEnum, fieldOffset, immutableEnumSet)

            val conversionMapField = getterConversionClass.getDeclaredField("conversionMap")
            conversionMapField.isAccessible = true
            @Suppress("UNCHECKED_CAST")
            val conversionMap = conversionMapField.get(null) as MutableMap<Any, EnumSet<*>>

            val sstypeCategoryClass = Class.forName("com.microsoft.sqlserver.jdbc.SSType\$Category")
            val characterCategory = sstypeCategoryClass.enumConstants.first { (it as Enum<*>).name == "CHARACTER" }

            @Suppress("UNCHECKED_CAST")
            (conversionMap[characterCategory] as MutableSet<Any>?)?.add(nCharacterCategory)
           } catch (e: Exception) {
            // runtime에 터뜨리고 롤백과정을 밟는 것이 이상적일듯함
        }
    }
}

Driver 경계에서 남은 것

이런 문제는 application 설정만 바꿔서는 끝나지 않는다. Driver가 어떤 조건에서 어떤 type으로 parameter를 보내는지 확인해야 하고, 때로는 library source까지 읽어야 한다. 운영 성능 문제는 framework, driver, database engine의 경계에서 가장 자주 복잡해진다.