https://velog.io/@kshired/Spring-Kafka-ErrorHandlingDeserializer

 

Spring Kafka ErrorHandlingDeserializer

Kafka consumer를 사용하다보면, JsonDeserializer를 많이 사용하게 됩니다.하지만 가끔 정의한 schema와 다른 value가 의도치않게 들어오는 경우가 생기는데 ( 이벤트 수기 발행이라던지, 프로듀서 쪽에서

velog.io

 

ErrorHandlingDeserializer 의 동작방식은 위 페이지에서 자세하게 잘 정리해주셨습니다. 
요번에 해당 옵션을 적용하면서, 
갑자기 warn log가 엄청나게 쌓이는 현상이 발생했는데요, 
적용했던 내용, 문제 원인과 조치 방법을 공유하려 합니다. 


적용 내용

	@Bean
	public ConcurrentKafkaListenerContainerFactory<String, Object> consumerKafkaFactory() {
		ConcurrentKafkaListenerContainerFactory<String, Object> factory = new ConcurrentKafkaListenerContainerFactory<>();
		factory.setConsumerFactory(new DefaultKafkaConsumerFactory<>(consumerConfigs()));
		factory.setRecordMessageConverter(new StringJsonMessageConverter());
		return factory;
	}

	@Bean
	public Map<String, Object> consumerConfigs() {
		Map<String, Object> props = new HashMap<>();
		props.put(ConsumerConfig.GROUP_ID_CONFIG, "group-id");
		props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "bootstrap-server:9092"));
		props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "auto-offset-reset");
		props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class);
		props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, ErrorHandlingDeserializer.class);
		props.put(ErrorHandlingDeserializer.KEY_DESERIALIZER_CLASS, StringDeserializer.class);
		props.put(ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS, JsonDeserializer.class);
		return props;
	}


위 처럼, Deserializer 설정을 추가 한 뒤, Bean 등록을 해주었습니다. 

해당 옵션이 켜지게 되면, consumer 는 checkNullKeyForExceptions 옵션이 true로 설정됩니다.

이 옵션은 ErrorHandlingDeserializer 를 사용하는 한, 변경 false 로 변경 할 수 없습니다. 

			this.checkNullKeyForExceptions = this.containerProperties.isCheckDeserExWhenKeyNull()
					|| ErrorHandlingUtils.checkDeserializer(KafkaMessageListenerContainer.this.consumerFactory,
							consumerProperties, false,
							applicationContext == null
									? getClass().getClassLoader()
									: applicationContext.getClassLoader());

 

문제 상황

운영 부서 특성상, kafka record 발행 시, key 값을 optional 로 발행해주고 있었는데요, 
순서가 중요한 토픽 -> key 값에 id 

순서가 중요하지 않은 토픽 -> key 값에 null
ErrorHandlingDeserializer 의 코드 상, null 값 record 가 들어왔을 때 warn 로그를 찍어주고있습니다. 

private void invokeOnMessage(final ConsumerRecord<K, V> cRecord) {

			if (cRecord.value() instanceof DeserializationException ex) {
				throw ex;
			}
			if (cRecord.key() instanceof DeserializationException ex) {
				throw ex;
			}
			if (cRecord.value() == null && this.checkNullValueForExceptions) {
				checkDeser(cRecord, SerializationUtils.VALUE_DESERIALIZER_EXCEPTION_HEADER);
			}
			if (cRecord.key() == null && this.checkNullKeyForExceptions) {
				checkDeser(cRecord, SerializationUtils.KEY_DESERIALIZER_EXCEPTION_HEADER);
			}
			doInvokeOnMessage(cRecord);
			if (this.nackSleepDurationMillis < 0 && !this.isManualImmediateAck) {
				ackCurrent(cRecord);
			}
		}

 

SereializationUtils 코드 상, header 가 DeserializationExceptionHeader 아닐 시, 로깅합니다. 

@Nullable
	public static DeserializationException getExceptionFromHeader(final ConsumerRecord<?, ?> record,
			String headerName, LogAccessor logger) {

		Header header = record.headers().lastHeader(headerName);
		if (!(header instanceof DeserializationExceptionHeader)) {
			// 여기 부분이 찍힘
			logger.warn(
					() -> String.format("Foreign deserialization exception header in (%s) ignored; possible attack?",
							KafkaUtils.format(record)));
			return null;
		}
		if (header != null) {
			byte[] value = header.value();
			DeserializationException exception = byteArrayToDeserializationException(logger, header);
			if (exception != null) {
				Headers headers = new RecordHeaders(record.headers().toArray());
				headers.remove(headerName);
				exception.setHeaders(headers);
			}
			return exception;
		}
		return null;
	}

 

null 키로 역직렬화 된 레코드 정보가 있지만, commit 과정을 넘어가는 것에 대한 로깅으로 보시면 되겠습니다.

저희 부서에서는 warning 로그 도 저장소에 쌓고있는데요. 
이 코드가 배포되면서 급격하게 사용량이 증가하기 시작하였습니다.  😭

 

해결

따라서  log4j상에서 해당 패키지를 exclude 시킬지, key deserializer 에는 ErrorHandlingDeserializer 를 적용하지 않을지 고민하다가, 후자로 결정하여 적용하였습니다. 

 

오늘은 24년 12월 둘째주에 업무를 진행하면서 발생했던 문제 상황과 해결한 방법을 공유하려 한다.
특정 유저가 닉네임 ( DB ) 를 업데이트 했는데, 닉네임 API 를 호출하면 이전 닉네임이 응답값으로 오는 현상이 발생했다.
히스토리 상 DB 업데이트도 성공적으로 완료되었기 때문에 문제될만한 지점이 없어보였다.

 


 

문제상황 요약

간단한 닉네임 업데이트

 

유저는 자신의 닉네임을 자유롭게 변경할수 있다!
하지만, 우리 서비스에 닉네임을 조회하는 API 가 있었고, 서비스의 급 성장으로 인해
닉네임 API 에 캐싱을 적용하게 되었었다.

 

 

 

닉네임 API 호출량 증가로 캐싱 12시간 적용!

 

문제가 되는 코드는 아래와 같았다.

  1. 트랜잭션 시작
  2. 유저를 찾는다.
  3. 닉네임을 수정하고 DB 업데이트 한다.
  4. 닉네임 캐싱 API 를 evict 처리 한다.
  5. 트랜잭션이 종료 되고 update 쿼리가 수행된다.
위 상황에서 4번이 완료되면 캐시 서버에서 해당 프로파일은 삭제된다.
이 때 5번 트랜잭션이 종료되기 전에 다시 닉네임 캐싱 API 가 호출되면, 업데이트 전 프로파일 정보로 새로 캐싱하게 된다.
최종적으로 DB와 캐시 값이 일치하지 않는 상황이 발생한다.

 


 

해결 방법

 

@Transactional 과 외부 시스템 통신 코드를 하나로 묶어서 자주 발생하는 문제중에 하나인데, 이럴 경우

Transactional 동작에 맞춰서 외부 시스템 통신코드를 실행해주는게 편리하다.

 

  1. @Transactional 종료 시, 성공적으로 commit 이 발생했다면 캐시를 evict
  2. exception 이 발생했다면 캐시 유지.

 

afterCommit 이벤트 등록

Spring 에서는 현재 상태의 Transaction 에 따라 이벤트를 등록하는 방법을 제공한다.

현재 트랜잭션 상태에 있다면, afterCommit 이벤트 리스너를 등록해서, 커밋이 완료 된 후에만 캐시를 evict 하게 할 수 있다.

annotation based 로 해결하는 방법으로는 TransactionEventListener를 이용하는 방법도 있다.

 

 

깨달은 점 : @Transactional 안에서 외부 시스템을 호출 시 주의하자.

 

 

개요


자바에는 직렬화 개념이 있습니다. 

직렬화(直列化) 또는 시리얼라이제이션(serialization)은 컴퓨터 과학의 데이터 스토리지 문맥에서 데이터 구조나 오브젝트 상태를 동일하거나 다른 컴퓨터 환경에 저장(이를테면 파일이나 메모리 버퍼에서, 또는 네트워크 연결 링크 간 전송)하고 나중에 재구성할 수 있는 포맷으로 변환하는 과정이다.

출처 : https://ko.wikipedia.org/wiki/%EC%A7%81%EB%A0%AC%ED%99%94

 

직렬화 - 위키백과, 우리 모두의 백과사전

 

ko.wikipedia.org

자바에서는 Serializable을 구현한 클래스를 직렬화 역직렬화할 때, 패키지 정보 가 포함되게 됩니다. 

 

문제 상황


Redis와 연동중인 프로젝트가 있다고 합시다.
이 프로젝트는 Class A를 캐시 연동하여 저장하도록 하고 있습니다.
그러던 중 리팩터링이 필요하여 Class A의 패키지를 변경하게 되었습니다. 
이렇게 되면, Cache Get 과정 중에 ClassNotFouncException이 발생할 것 입니다. 
직렬화 할 때랑, 역직렬화 할 때 패키지 정보가 달라졌으니깐요. 

리팩터링을 진행하다보면 생각보다 패키지 변경을 자주 할 수 있는데요. 
그렇다면 캐시에 한번 올라간 Object의 pacakage path는 불변해야 할까요? 


해결


spring-core 에는 Deserializer라는 인터페이스가 있습니다. 

@FunctionalInterface
public interface Deserializer<T> {

	/**
	 * Read (assemble) an object of type T from the given InputStream.
	 * <p>Note: Implementations should not close the given InputStream
	 * (or any decorators of that InputStream) but rather leave this up
	 * to the caller.
	 * @param inputStream the input stream
	 * @return the deserialized object
	 * @throws IOException in case of errors reading from the stream
	 */
	T deserialize(InputStream inputStream) throws IOException;

	/**
	 * Read (assemble) an object of type T from the given byte array.
	 * @param serialized the byte array
	 * @return the deserialized object
	 * @throws IOException in case of deserialization failure
	 * @since 5.2.7
	 */
	default T deserializeFromByteArray(byte[] serialized) throws IOException {
		return deserialize(new ByteArrayInputStream(serialized));
	}

}

inputStream을 받아 객체화 하는 인터페이스 입니다.
이 인터페이스를 구현한 클래스 DefaultDeserializer를 확인 해 봅시다.

/**
 * A default {@link Deserializer} implementation that reads an input stream
 * using Java serialization.
 *
 * @author Gary Russell
 * @author Mark Fisher
 * @author Juergen Hoeller
 * @since 3.0.5
 * @see ObjectInputStream
 */
public class DefaultDeserializer implements Deserializer<Object> {

	@Nullable
	private final ClassLoader classLoader;


	/**
	 * Create a {@code DefaultDeserializer} with default {@link ObjectInputStream}
	 * configuration, using the "latest user-defined ClassLoader".
	 */
	public DefaultDeserializer() {
		this.classLoader = null;
	}

	/**
	 * Create a {@code DefaultDeserializer} for using an {@link ObjectInputStream}
	 * with the given {@code ClassLoader}.
	 * @since 4.2.1
	 * @see ConfigurableObjectInputStream#ConfigurableObjectInputStream(InputStream, ClassLoader)
	 */
	public DefaultDeserializer(@Nullable ClassLoader classLoader) {
		this.classLoader = classLoader;
	}


	/**
	 * Read from the supplied {@code InputStream} and deserialize the contents
	 * into an object.
	 * @see ObjectInputStream#readObject()
	 */
	@Override
	@SuppressWarnings("resource")
	public Object deserialize(InputStream inputStream) throws IOException {
		ObjectInputStream objectInputStream = new ConfigurableObjectInputStream(inputStream, this.classLoader);
		try {
			return objectInputStream.readObject();
		}
		catch (ClassNotFoundException ex) {
			throw new NestedIOException("Failed to deserialize object type", ex);
		}
	}

}

생성할 떄 classLoader를 같이 받고, deserialize 과정에서 해당 classLoader로 ConfigurableObjectInputStream을 이용하는 것을 볼 수 있습니다. 
ConfigurableObjectInputStream을 확인 해 봅시다. 

public class ConfigurableObjectInputStream extends ObjectInputStream {

	@Nullable
	private final ClassLoader classLoader;

	private final boolean acceptProxyClasses;


	/**
	 * Create a new ConfigurableObjectInputStream for the given InputStream and ClassLoader.
	 * @param in the InputStream to read from
	 * @param classLoader the ClassLoader to use for loading local classes
	 * @see java.io.ObjectInputStream#ObjectInputStream(java.io.InputStream)
	 */
	public ConfigurableObjectInputStream(InputStream in, @Nullable ClassLoader classLoader) throws IOException {
		this(in, classLoader, true);
	}

	/**
	 * Create a new ConfigurableObjectInputStream for the given InputStream and ClassLoader.
	 * @param in the InputStream to read from
	 * @param classLoader the ClassLoader to use for loading local classes
	 * @param acceptProxyClasses whether to accept deserialization of proxy classes
	 * (may be deactivated as a security measure)
	 * @see java.io.ObjectInputStream#ObjectInputStream(java.io.InputStream)
	 */
	public ConfigurableObjectInputStream(
			InputStream in, @Nullable ClassLoader classLoader, boolean acceptProxyClasses) throws IOException {

		super(in);
		this.classLoader = classLoader;
		this.acceptProxyClasses = acceptProxyClasses;
	}


	@Override
	protected Class<?> resolveClass(ObjectStreamClass classDesc) throws IOException, ClassNotFoundException {
		try {
			if (this.classLoader != null) {
				// Use the specified ClassLoader to resolve local classes.
				return ClassUtils.forName(classDesc.getName(), this.classLoader);
			}
			else {
				// Use the default ClassLoader...
				return super.resolveClass(classDesc);
			}
		}
		catch (ClassNotFoundException ex) {
			return resolveFallbackIfPossible(classDesc.getName(), ex);
		}
	}

	@Override
	protected Class<?> resolveProxyClass(String[] interfaces) throws IOException, ClassNotFoundException {
		if (!this.acceptProxyClasses) {
			throw new NotSerializableException("Not allowed to accept serialized proxy classes");
		}
		if (this.classLoader != null) {
			// Use the specified ClassLoader to resolve local proxy classes.
			Class<?>[] resolvedInterfaces = new Class<?>[interfaces.length];
			for (int i = 0; i < interfaces.length; i++) {
				try {
					resolvedInterfaces[i] = ClassUtils.forName(interfaces[i], this.classLoader);
				}
				catch (ClassNotFoundException ex) {
					resolvedInterfaces[i] = resolveFallbackIfPossible(interfaces[i], ex);
				}
			}
			try {
				return ClassUtils.createCompositeInterface(resolvedInterfaces, this.classLoader);
			}
			catch (IllegalArgumentException ex) {
				throw new ClassNotFoundException(null, ex);
			}
		}
		else {
			// Use ObjectInputStream's default ClassLoader...
			try {
				return super.resolveProxyClass(interfaces);
			}
			catch (ClassNotFoundException ex) {
				Class<?>[] resolvedInterfaces = new Class<?>[interfaces.length];
				for (int i = 0; i < interfaces.length; i++) {
					resolvedInterfaces[i] = resolveFallbackIfPossible(interfaces[i], ex);
				}
				return ClassUtils.createCompositeInterface(resolvedInterfaces, getFallbackClassLoader());
			}
		}
	}


	/**
	 * Resolve the given class name against a fallback class loader.
	 * <p>The default implementation simply rethrows the original exception,
	 * since there is no fallback available.
	 * @param className the class name to resolve
	 * @param ex the original exception thrown when attempting to load the class
	 * @return the newly resolved class (never {@code null})
	 */
	protected Class<?> resolveFallbackIfPossible(String className, ClassNotFoundException ex)
			throws IOException, ClassNotFoundException{

		throw ex;
	}

	/**
	 * Return the fallback ClassLoader to use when no ClassLoader was specified
	 * and ObjectInputStream's own default class loader failed.
	 * <p>The default implementation simply returns {@code null}, indicating
	 * that no specific fallback is available.
	 */
	@Nullable
	protected ClassLoader getFallbackClassLoader() throws IOException {
		return null;
	}

}

resolveClass가 있는데, 해당 메서드는 readObject호출 할 때 호출 되며, 스트림에 저장된 클래스 패스를 이용하여 
현재 구동된 자바 프로그램에서 class를 찾습니다. 
찾지 못했을 경우, ClassNotFoundException이 발생하며, resolveFallbackIfPossible 메서드를 호출합니다. 
resolveFallbackIfPossible 메서드를 직접 구현해봅시다. 

 

public class PackageSafeValueSerializer implements Deserializer {

    private static final String ORIGIN_CLASS_PATH = "com.joohyeok.jeong.spring.cache.a";
    private static final String TARGET_CLASS_PATH = "com.joohyeok.jeong.spring.cache.b";

    private final ClassLoader classLoader;

    public PackageSafeValueSerializer(ClassLoader classLoader) {
        this.classLoader = classLoader;
    }

    @Override
    public Object deserialize(InputStream inputStream) throws IOException {
        ObjectInputStream objectInputStream = new PackageSafeObjectStream(inputStream, this.classLoader);
        try {
            return objectInputStream.readObject();
        } catch (ClassNotFoundException ex) {
            throw new NestedIOException("Failed to deserialize object type", ex);
        }
    }

    public class PackageSafeObjectStream extends ConfigurableObjectInputStream {

        private final ClassLoader classLoader;

        public PackageSafeObjectStream(InputStream in, ClassLoader classLoader) throws IOException {
            super(in, classLoader);

            this.classLoader = classLoader;
        }

        @Override
        protected Class<?> resolveFallbackIfPossible(String className, ClassNotFoundException ex)
            throws IOException, ClassNotFoundException {

            if (className.startsWith(ORIGIN_CLASS_PATH)) {
                String targetClassName = className.replace(ORIGIN_CLASS_PATH, TARGET_CLASS_PATH);

                Class<?> aClass = ClassUtils.forName(targetClassName, this.classLoader);
                if (Objects.nonNull(aClass)) {
                    return aClass;
                }
            }

            throw ex;
        }
    }
}

ORIGIN_CLASS_PATH, TARGET_CLASS_PATH 을 정의해 두었으며, ORIGIN_CLASS_PATH로 시작하면, TARGET_CLASS_PATH을 변경한 뒤 클래스를 탐색합니다. 발견하면 해당클래스를 이용하여 역직렬화를 하게 됩니다. 

 

테스트

redis 설치

테스트용 redis를 설치합니다. 
https://redis.io/docs/getting-started/

프로젝트 생성

spring boot 프로젝트를 이용, 
maven/gradle 에 spring-boot-starter-data-redis 의존성을 추가합니다. 

implementation 'org.springframework.boot:spring-boot-starter-data-redis:2.7.0'

캐시 Config 클래스 생성

@Configuration
public class CacheConfig {

    @Bean
    LettuceConnectionFactory lettuceConnectionFactory() {
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
        redisStandaloneConfiguration.setHostName("127.0.0.1");
        redisStandaloneConfiguration.setPort(6379);


         LettuceConnectionFactory lettuceConnectionFactory = new LettuceConnectionFactory(redisStandaloneConfiguration);
         return lettuceConnectionFactory;
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(lettuceConnectionFactory());

        JdkSerializationRedisSerializer jdkSerializationRedisSerializer = new JdkSerializationRedisSerializer(
            new SerializingConverter(),
            new DeserializingConverter(new PackageSafeValueSerializer(template.getClass().getClassLoader()))
        );

        template.setValueSerializer(jdkSerializationRedisSerializer);
        template.setKeySerializer(jdkSerializationRedisSerializer);
        return template;
    }

}

redis는 기본 주소 127.0.0.1:6379 로 세팅하였습니다.
RedisTemplate 내에 JdkSerializationRedisSerializer 를 생성하고, DeserializingConverter에 미리 정의한 PackageSafeValueSerializer를 포함시켜 줍니다.

 

테스트 코드 작성

junit을 활용합니다. 
1. com.joohyeok.jeong.spring.cache.a 객체를 생성 후 캐시에 저장합니다.

2. com.joohyeok.jeong.spring.cache.a  클래스를 삭제합니다.
3. com.joohyeok.jeong.spring.cache.a 클래스로 역직렬화 합니다. 
2에서 삭제하는 이유는 classLoader에 포함되게 하지 않기 위해서 입니다. 

@SpringBootTest
class ApplicationTests {

    private static final String key = "1";
    @Autowired
    private RedisTemplate redisTemplate;



    /**
     * TC1. com.joohyeok.jeong.spring.cache.a.TestObject 캐시 set
     */
    @Test
    void cacheTestA() {
       TestObject testObject = new TestObject();
       testObject.setName("테스트A");
        testObject.setPrice(BigDecimal.valueOf(25_000L));
        testObject.setStockQuantity(50L);
        // 1. A 패키지 cache set
        redisTemplate.opsForValue().set(key, testObject);

        // 2. A 패키지 cache get
       TestObject getObjectA = (TestObject)redisTemplate.opsForValue().get(key);

        System.out.println(String.format("getObjectA : %s", getObjectA));
    }

    /**
     * TC2. com.joohyeok.jeong.spring.cache.a.TestObject 클래스 삭제후 get
     */
    @Test
    public void cacheGetB(){
        com.joohyeok.jeong.spring.cache.b.TestObject getObjectB = (com.joohyeok.jeong.spring.cache.b.TestObject)redisTemplate.opsForValue().get(key);

        System.out.println(String.format("getObject : %s,", getObjectB));
    }

}

직렬화한 class path와 역직렬화 하는 class path가 달라졌지만, 데이터를 정상적으로 가져올 수 있습니다. 

'백엔드 > spring' 카테고리의 다른 글

ErrorHandlingDeserializer  (0) 2025.02.11
@Transactional 과 외부 시스템 연동  (0) 2024.12.23
@Transactional(readOnly=true)  (0) 2022.06.13
@InitBinder autoGrowCollectionLimit  (0) 2022.06.04

개요

Spring AOP 방식의 트랜잭션을 지원하는 파라미터 중 readOnly에 대해서 정리합니다. 

 

 

발단

JPA에서 영속성 관리로 인하여 readOnly 트랜잭션을 사용했을 때 성능 향상이 있다고 말하고, 많은 개발자들이 인지하고 있는 부분입니다. 
정확하게 Spring, JPA hibernate 스택에서 어떤 부분에 영향을 미쳐서 프로그램 상 이득이 있는 것일까요?

 

조사

우선 readOnly를 설정하게 되면 다음과 같은 과정이 일어납니다.

  1.  Jdbc의 Connection.setReadOnly를 호출 하며, 이 때 각각 트랜잭션이 생성되는 것은 각 jdbc 드라이버에 따라 다름.
  2.  hibernate 환경에서 적용 시, FlushMode를 변경함.


2번을 확인하기 위해 Junit 테스트 코드를 작성하였습니다. 

> @Transactional 일 때 Session 의 상태 

    @Test
    @Transactional
    @Rollback(value = false)
    public void testTransactional1() {
        Product product = productRepository.findOne(2001611253L);
        product.setSalePrice(BigDecimal.valueOf(3000));
    }




> @Transactional(readOnly=true) 일 때 Session의 상태

    @Test
    @Transactional(readOnly=true)
    @Rollback(value = false)
    public void testTransactional2() {
        Product product = productRepository.findOne(2001611253L);

        product.setSalePrice(BigDecimal.valueOf(4000));
    }


FlushMode가 readOnly Attribute에 따라 다르게 적용되는 것을 코드로 확인하였습니다. 


여기서 FlushMode란, EntityManager가 관리하고 있는 객체를 데이터베이스와 sync하는 과정입니다.


참고 >

entity life cycle

flushMode의 타입에 따라 JPA의 변경 감지 반영 동작이 변경되게 됩니다. 
그 부분은 hibernate의 다음과 같은 코드에서 찾을 수 있는데요. 

	private void dirty(PersistentCollection collection) throws HibernateException {

		boolean forceDirty = collection.wasInitialized() &&
				!collection.isDirty() && //optimization
				getLoadedPersister() != null &&
				getLoadedPersister().isMutable() && //optimization
				( collection.isDirectlyAccessible() || getLoadedPersister().getElementType().isMutable() ) && //optimization
				!collection.equalsSnapshot( getLoadedPersister() );

		if ( forceDirty ) {
			collection.dirty();
		}

	}

코드의 네이밍에서 유추 할 수 있듯이, 객체가 변경되었는지 체크하는 모습입니다. (이 코드는 hibernate-core:5.0.11.FINAL 기준입니다.) 
위 코드가 호출되는 스택은 이렇습니다. 

  1. SessionImpl.flush()
  2. AbstractFlushingEventListener.flushEverythingToExecutions
  3. AbstractFlushingEventListener.prepareCollectionFlushes
  4. CollectionEntry.preFlush
  5. CollectionEntry.dirty

flush 과정에서, dirty 체크가 들어가는 것을 콜 스택으로 알 수 있습니다.


그렇다면, FlushMode가 위 과정에 어떤 영향을 미치는 것일까요? 
@Transactional 호출이 끝난 후 호출되는 메서드를 디버깅 해보았습니다. 

트랜잭션 완료 후 SessionImpl.beforeTransactionCompletion 을 호출합니다.

	@Override
	public void beforeTransactionCompletion() {
		LOG.tracef( "SessionImpl#beforeTransactionCompletion()" );
		flushBeforeTransactionCompletion();
		actionQueue.beforeTransactionCompletion();
		try {
			interceptor.beforeTransactionCompletion( currentHibernateTransaction );
		}
		catch (Throwable t) {
			LOG.exceptionInBeforeTransactionCompletionInterceptor( t );
		}
	}
이 코드에서 flushBeforeTransactionCompletion을 호출하게 되는데요,
여기서 flushMode가 판단 기준으로 사용됩니다. (managedFlushChecker.shouldDoManagedFlush)

	@Override
	public void flushBeforeTransactionCompletion() {
		boolean flush = isTransactionFlushable() && managedFlushChecker.shouldDoManagedFlush( this );
		try {
			if ( flush ) {
				managedFlush();
			}
		}
		catch (HibernateException he) {
			throw exceptionMapper.mapManagedFlushFailure( "error during managed flush", he );
		}
		catch (RuntimeException re) {
			throw exceptionMapper.mapManagedFlushFailure( "error during managed flush", re );
		}
	}

따라서 FlushMode가 MANUEL 일 경우, isTransactionFlushable() = false 가 되고, flush가 수행되지 않으며 
이는 스냅샷 & 더티 체킹을 줄여주는 효과로 성능 향상이 있다고 확인 할 수 있었습니다. 

명시적 방식의 트랜잭션


AOP 방식으로 트랜잭션을 적용할 수 있지만, 명시적으로 적용할 필요가 있을 떄가 있는데요. 
이 때 TransactionalTemplate를 사용 할 수 있습니다.  이 객체를 사용했을 때 @Transactional에 readOnly를 설정했던 것처럼 하려면 
setReadOnly() 메서드를 호출해주면 됩니다. 

        TransactionTemplate transactionTemplate = new TransactionTemplate(serviceTm());
        transactionTemplate.setReadOnly(true);


다만, 이 속성은 TransactionTemplate에 영구히 적용되므로, Spring Singleton 타입에 적용하기에 Risk가 큽니다. 
따라서 별도의 readOnlyTemplate를 만들어 각각의 경우에 맞게 사용하면 됩니다. 

    @Bean
    @Qualifier("transactionReadOnlyTemplate")
    public TransactionTemplate transactionReadOnlyTemplate() {
        TransactionTemplate transactionTemplate = new TransactionTemplate(serviceTm());
        transactionTemplate.setReadOnly(true);
        return transactionTemplate;
    }



번외

아울러 readOnly 타입에 따라 RoutingDataBase로 사용하여, read DB, write DB로 커넥션을 맺는 구조를 사용하기도 합니다. 

https://vladmihalcea.com/read-write-read-only-transaction-routing-spring/

@InitBinder는 spring web에서  폼값, 입력값등을 바인딩 하는 WebDataBinder 관련 설정을 가능하게 한다.

 

WebDataBinder 간단 설명

  • 스프링 컨트롤러에 매핑되어 있는 메서드 내에서 object argument로 바인딩하는 역할을 담당한다. 
  • @RequestMapping 되어 있는 모든 메서드에 적용된다. 
  • Initbinder 메서드는 반드시 리턴 값이 없어야 한다.

 

코드 예시

@RestController
public class ModelAttributeController {

    @PostMapping("/model")
    public boolean getModel(@ModelAttribute RandomCollection randomCollection) {
        return true;
    }

    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    @Validated
    public static class RandomCollection {
        private List<Long> randomCollections;
    }

    @InitBinder
    public void initBinder(WebDataBinder webDataBinder) {
        webDataBinder.setAutoGrowCollectionLimit(255);
    }
}

 

테스트

그렇다면 설정이 잘 동작하는지 확인하기 위해서, setAutoGrowCollectionLimit을 255로 설정 한 뒤, curl을 이용해서 
테스트를 진행해보자. 

curl -X 'POST' \
  'http://127.0.0.1:8080/model' \
  -H 'accept: application/json;charset=UTF-8' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d "randomCollections[0]=1&randomCollections[256]=2"

application/x-www-form-urlencoded 타입으로 256 번째 인덱스 값을 실어서 호출하였다. 


에러 메시지

{"timestamp":"2022-06-04T11:50:07.454+00:00","status":500,"error":"Internal Server Error","trace":"org.springframework.beans.InvalidPropertyException: Invalid property 'randomCollections[256]' of bean class [com.joohyeok.jeong.spring.controller.ModelAttributeController$RandomCollection]: Invalid list index in property path 'randomCollections[256]'; 
nested exception is java.lang.IndexOutOfBoundsException: Index 256 out of bounds for length 1
\tat org.springframework.beans.AbstractNestablePropertyAccessor.processKeyedProperty(AbstractNestablePropertyAccessor.java:351)
\tat org.springframework.beans.AbstractNestablePropertyAccessor.setPropertyValue(AbstractNestablePropertyAccessor.java:275)
\tat org.springframework.beans.AbstractNestablePropertyAccessor.setPropertyValue(AbstractNestablePropertyAccessor.java:266)
\tat 
org.springframework.beans.AbstractPropertyAccessor.setPropertyValues(AbstractPropertyAccessor.java:104)
\tat org.springframework.validation.DataBinder.applyPropertyValues(DataBinder.java:889)
\tat org.springframework.validation.DataBinder.doBind(DataBinder.java:780)
\tat org.springframework.web.bind.WebDataBinder.doBind(WebDataBinder.java:207)
\tat org.springframework.web.bind.ServletRequestDataBinder.bind(ServletRequestDataBinder.java:129)
\tat org.springframework.web.servlet.mvc.method.annotation.ServletModelAttributeMethodProcessor.bindRequestParameters(ServletModelAttributeMethodProcessor.java:158)
\tat org.springframework.web.method.annotation.ModelAttributeMethodProcessor.resolveArgument(ModelAttributeMethodProcessor.java:171)
\tat org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:122)
\tat org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:179)
\tat org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:146)
\tat org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117)
\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895)
\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808)
\tat org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1067)
\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963)
\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
\tat org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909)
\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:681)
\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
\tat javax.servlet.http.HttpServlet.service(HttpServlet.java:764)
\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:227)
\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
\tat org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)
\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
\tat org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
\tat org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
\tat org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
\tat org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:197)
\tat org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97)
\tat org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:541)
\tat org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:135)
\tat org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)
\tat org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78)
\tat org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:360)
\tat org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:399)
\tat org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)
\tat org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:890)
\tat org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1743)
\tat org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)
\tat org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191)
\tat org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
\tat org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
\tat java.base/java.lang.Thread.run(Thread.java:833)
Caused by: java.lang.IndexOutOfBoundsException: Index 256 out of bounds for length 1
\tat java.base/jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:64)
\tat java.base/jdk.internal.util.Preconditions.outOfBoundsCheckIndex(Preconditions.java:70)
\tat java.base/jdk.internal.util.Preconditions.checkIndex(Preconditions.java:266)
\tat java.base/java.util.Objects.checkIndex(Objects.java:359)
\tat java.base/java.util.ArrayList.set(ArrayList.java:441)
\tat org.springframework.beans.AbstractNestablePropertyAccessor.processKeyedProperty(AbstractNestablePropertyAccessor.java:348)
\t... 56 more
","message":"Invalid property 'randomCollections[256]' of bean class [com.joohyeok.jeong.spring.controller.ModelAttributeController$RandomCollection]: Invalid list index in property path 'randomCollections[256]'; nested exception is java.lang.IndexOutOfBoundsException: Index 256 out of bounds for length 1","path":"/model"}

복잡한데, 코드를 따라내려가면 다음과 같다.

RequestMappingHandler 가 호출되면서 @ModelAttribute 파라미터를 설정했을 때 위 순서대로 호출이 진행된다.

 

그렇다면, 우리가 설정한 속성은 언제 적용이 되면서 Exception이 발생하는 걸까? 

AbstractNestablePropertyAccessor 내에서, List의 파라미터를 바인딩하는 과정에서  신규로 read한 프로퍼티가 list의 크기보다 크면서, 설정한 autoGrowCollectionLimit보다 낮을 경우에 한 해서  List add 하고, 아닐 경우 이미 생성된 ArrayList에 set하게 된다. 이 때 생성된 리스트의 크기는 설정하려는 index보다 작기 때문에 java.lang.IndexOutOfBoundsException이 발생하게 된다.



발견하기 어려운 속성이며 장애를 맞기 쉬운 설정이다. 
이를 막기위해서는 mockMvc를 통한 Junit Controller 테스트가 작성되고, 배포 전 수행되는 CI/CD가 구성되어야 할 것이다.

+ Recent posts