오늘은 24년 12월 둘째주에 업무를 진행하면서 발생했던 문제 상황과 해결한 방법을 공유하려 한다. 특정 유저가 닉네임 ( DB ) 를 업데이트 했는데, 닉네임 API 를 호출하면 이전 닉네임이 응답값으로 오는 현상이 발생했다. 히스토리 상 DB 업데이트도 성공적으로 완료되었기 때문에 문제될만한 지점이 없어보였다.
문제상황 요약
간단한 닉네임 업데이트
유저는 자신의 닉네임을 자유롭게 변경할수 있다! 하지만, 우리 서비스에 닉네임을 조회하는 API 가 있었고, 서비스의 급 성장으로 인해 닉네임 API 에 캐싱을 적용하게 되었었다.
닉네임 API 호출량 증가로 캐싱 12시간 적용!
문제가 되는 코드는 아래와 같았다.
트랜잭션 시작
유저를 찾는다.
닉네임을 수정하고 DB 업데이트 한다.
닉네임 캐싱 API 를 evict 처리 한다.
트랜잭션이 종료 되고 update 쿼리가 수행된다.
위 상황에서 4번이 완료되면 캐시 서버에서 해당 프로파일은 삭제된다. 이 때 5번 트랜잭션이 종료되기 전에 다시 닉네임 캐싱 API 가 호출되면, 업데이트 전 프로파일 정보로 새로 캐싱하게 된다. 최종적으로 DB와 캐시 값이 일치하지 않는 상황이 발생한다.
해결 방법
@Transactional 과 외부 시스템 통신 코드를 하나로 묶어서 자주 발생하는 문제중에 하나인데, 이럴 경우
Transactional 동작에 맞춰서 외부 시스템 통신코드를 실행해주는게 편리하다.
@Transactional 종료 시, 성공적으로 commit 이 발생했다면 캐시를 evict
exception 이 발생했다면 캐시 유지.
afterCommit 이벤트 등록
Spring 에서는 현재 상태의 Transaction 에 따라 이벤트를 등록하는 방법을 제공한다.
현재 트랜잭션 상태에 있다면, afterCommit 이벤트 리스너를 등록해서, 커밋이 완료 된 후에만 캐시를 evict 하게 할 수 있다.
annotation based 로 해결하는 방법으로는 TransactionEventListener를 이용하는 방법도 있다.
직렬화(直列化) 또는 시리얼라이제이션(serialization)은 컴퓨터 과학의 데이터 스토리지 문맥에서 데이터 구조나 오브젝트 상태를 동일하거나 다른 컴퓨터 환경에 저장(이를테면 파일이나 메모리 버퍼에서, 또는 네트워크 연결 링크 간 전송)하고 나중에 재구성할 수 있는 포맷으로 변환하는 과정이다.
자바에서는 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을 변경한 뒤 클래스를 탐색합니다. 발견하면 해당클래스를 이용하여 역직렬화를 하게 됩니다.
@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를 포함시켜 줍니다.
이 코드에서 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로 커넥션을 맺는 구조를 사용하기도 합니다.
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가 구성되어야 할 것이다.