오늘은 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를 이용하는 방법도 있다.
맡고있는 팀이 다루는 데이터가 나날히 증가하면서, sync를 담당하는 메시지 큐 프로세서가 Full GC를 계속해서 수행하는 이슈가 있었다. 꽤나 오랫동안 스케일 업이나 스케일 아웃 없이 유지한 서버라서 처음에는 서버 추가로 대응할 까 했지만, 튜닝할 수 있는 부분은 튜닝하는게 맞다고 생각하고 조사를 시작했다.
Thread dump를 많이 떠봤지만? 글쎄,,,
메시지 큐 프로세서 서버의 특성상 수행시간이 오래 걸리는 task들을 수행하는 경우가 많다. Full GC 문제상황에서 계속해서 thread dump를 떠봤지만, 교착상태나 프로세스를 느리게 만드는 원인을 찾지 못했다. 가끔씩 hang 걸린 스레드들이 있었으나, 문제 발생시마다 발견되었던 것은 아니기 때문에 원인으로 보지 않았다. 분석 툴은 tda 를 이용했다.
조사중에 문제는 더 심해졌으며 약 2주동안 서버를 아침 peak 시간마다 서버를 반복 배포하는 상황이 발생했다..
Heap dump를 떠보자!
GC를 수행해도 메모리를 점유하고 있는 (leak) 이 있는것인가? 약 3일동안 10초 주기로 힙덤프를 3~5씩 수행하고 비교를 시작했다. 분석툴은 mat를 이용했다. mat 툴 내에서 leak 취약점은 발견되지 않았다. 또한 각 힙덤프 마다 (GC가 여러번 수행됐을 때) 계속해서 메모리를 점유하고 있는 특정 객체가 없었다.
1차 힙덤프2차 힙덤프3차 힙덤프
( 이 외에도 덤프 파일은 더 많았지만, 특별한 상황으로 판단할 근거가 없었다. )
GC가 어떻게 수행되고 있는건가?
좀 더 자세한 정보가 필요했다. Full GC 전 후 상황을 알 수 있는 로그가 없을 까? 찾아보니 gc log를 생성하는 jvm 옵션을 걸어놨었고 해당 파일을 참조했다. 또한 실시간으로 문제가 발생할 때는 jstat으로 관찰을 시작했다.
서버 스펙 : -Xms8g -Xmx8g -XX:NewSize=5g -XX:MaxNewSize=5g -XX:SurvivorRatio=2 -XX:+UseParallelGC -XX:+UseParallelOldGC
1. full gc가 0.5초마다 발생함 2. full gc가 발생함에도 oldGeneration 영역의 메모리가 확보되지 않음. 3. minor gc또한 매우 빈번하게 발생하여, eden 영역의 객체들이 매우 빠르게 survivor 영역으로 넘어감.
추론
위의 상황 및 여러 글들을 참조해서 추론을 시작했다.
요청량 자체가 최근 급증하여 gc를 수행하는 순간에도 계속해서 힙영역이 full로 차게 되는 것 같다. young 영역에서 eden:survivor 비율 , new : old 영역의 비율을 조정해보거나, GC 알고리즘을 변경해보자. 건드려 보려는 옵션은 다음과 같았다. 참조 : https://d2.naver.com/helloworld/37111
peak 시간에도 Full GC는 발생하지 않았으며, 더불어 MQ 메시지가 밀리는 상황도 어느정도 개선되었다. (Full GC 로 인한 STW가 줄었기 때문인 듯) 이정도 단계에서 문제가 해결되어서 더 조사는 해보지 않았다. 결론은 GC 튜닝은 각각의 특수한 상황에 따라 케이스가 매우 많기 때문에, 여러 자료를 참고 정도밖에 할 수 없었고, 맨 땅에 헤딩해야 한다는 것이다.
직렬화(直列化) 또는 시리얼라이제이션(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를 포함시켜 줍니다.