김태오

SQL Server jdbc 라이브러리 조작을 통한setSendStringParametersAsUnicode=false 옵션 무시 본문

Spring Boot

SQL Server jdbc 라이브러리 조작을 통한setSendStringParametersAsUnicode=false 옵션 무시

ystc1247 2025. 1. 26. 01:17

 

https://ystc1247.tistory.com/entry/ORM%EC%97%90%EC%84%9C-VARCHAR-%ED%83%80%EC%9E%85%EC%9D%98-%EC%BB%AC%EB%9F%BC%EC%9D%B4-%ED%8F%AC%ED%95%A8%EB%90%9C-%EC%9D%B8%EB%8D%B1%EC%8A%A4%EB%A5%BC-%ED%83%80%EC%A7%80-%EB%AA%BB%ED%95%98%EB%8A%94-%EB%AC%B8%EC%A0%9C-SQL-Server 의 해결책으로, 파라미터들을 기존의 nvarchar값으로 두되 index를 타는 파라미터들을 varchar/char로 쿼리할 수 있는 방안을 마련해야 한다.

그냥 모두 varchar로 쏘는 방법도 존재하는데, 이는 jdbc url에 setSendStringParametersAsUnicode=false를 붙이는 것이다.

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

그런데 여기서 문제가, 저장시에는 nvarchar로 쿼리가 날라가 모든 unicode 특수문자가 제대로 저장되나, 일부 DB에서 이 컬럼값을 불러오는 과정에서 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가 맵에 없기 때문에 에러가 발생한다.

떠오르는 해결방법들 (모두 짜침)

1. 그냥 특수문자는 저장과 읽기가 안되도록 @Nationalized annotation 지우고 싸그리 varchar 취급하기

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

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

4. @Nationalized annotation 날려버리고 CUD query에서 native query 사용하여 nvarchar로 강제 타입캐스팅시키기 (물론 read를 varchar로 하기에 nvarchar로 선언된 컬럼들의 일부 특수문자에서 하자가 생김)

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에 터뜨리고 롤백과정을 밟는 것이 이상적일듯함 
        }
    }
}