배경
새로운 결제 시스템 연동 작업 중 발생한 문제를 공유하고자 한다.
해당 결제 시스템은 서버 간 통신을 통해 이루어지며, API 호출 시 토큰이 필요하다.
즉, 토큰을 얻어오는 API 호출 후, 해당 토큰을 포함하여 외부 API 호출을 해야한다.
나는 타입 안정성을 위해 얻아온 토큰을 Value Class로 래핑했고, 네트워크 오류에 대응하기 위해 resilience4j의 retry 기능을 사용하였다.
문제
예시 코드는 아래와 같다.
해당 코드에서 fallback 메서드를 찾지 못하는 문제가 발생하였다.
@Service
class RetryServiceTest {
@Retry(name = "retryServiceTest", fallbackMethod = "fallback")
fun test(token: Token) {
println("외부 API 호출")
throw RuntimeException("ERROR 발생")
}
private fun fallback(token: Token, e: Exception) {
println("Fallback!!")
println(e.message)
}
}
@JvmInline
value class Token(val value: String)
i.g.r.spring6.fallback.FallbackExecutor : No fallback method match found ... fallback(class java.lang.String,class java.lang.Throwable)
retry 후 최종 실패한다면 이후 핸들링을 어떻게 할지 fallback 메서드를 정의할 수 있다.
fallback 메서드의 조건은 메서드 시그니처가 동일하면서, 마지막 인자로 Throwable 타입을 받고 있어야 한다. 이를 만족시키지 않으면 fallback 메서드로 동작하지 않는다.
원인
아래 메서드는 resilience4j의 실제 폴백 메서드 정보를 생성하는 메서드이다.
public static FallbackMethod create(String fallbackMethodName, Method originalMethod,
Object[] args, Object original, Object proxy) throws NoSuchMethodException {
MethodMeta methodMeta = new MethodMeta(
fallbackMethodName,
originalMethod.getParameterTypes(),
originalMethod.getReturnType(),
original.getClass());
Map<Class<?>, Method> methods = FALLBACK_METHODS_CACHE
.computeIfAbsent(methodMeta, FallbackMethod::extractMethods);
if (!methods.isEmpty()) {
return new FallbackMethod(methods, originalMethod.getReturnType(), args, original, proxy);
} else {
throw new NoSuchMethodException(String.format("%s %s.%s(%s,%s)",
methodMeta.returnType, methodMeta.targetClass, methodMeta.fallbackMethodName,
StringUtils.arrayToDelimitedString(methodMeta.params, ","), Throwable.class));
}
}
여기서 폴백 메서드를 찾는 곳은 FallbackMethod::extractMethods 부분이다.
private static Map<Class<?>, Method> extractMethods(MethodMeta methodMeta) {
Map<Class<?>, Method> methods = new HashMap<>();
ReflectionUtils.doWithMethods(methodMeta.targetClass,
method -> merge(method, methods),
method -> filter(method, methodMeta)
);
return methods;
}
private static boolean filter(Method method, MethodMeta methodMeta) {
if (!method.getName().equals(methodMeta.fallbackMethodName)) {
return false;
}
if (!methodMeta.returnType.isAssignableFrom(method.getReturnType())) {
return false;
}
if (method.getParameterCount() == 1) {
return Throwable.class.isAssignableFrom(method.getParameterTypes()[0]);
}
if (method.getParameterCount() != methodMeta.params.length + 1) {
return false;
}
Class[] targetParams = method.getParameterTypes();
for (int i = 0; i < methodMeta.params.length; i++) {
if (methodMeta.params[i] != targetParams[i]) {
return false;
}
}
return Throwable.class.isAssignableFrom(targetParams[methodMeta.params.length]);
}
문제가 되는 필터 메서드를 보자.
첫 번째 파라미터 method는 실제 클래스에 정의된 메서드이다.
두 번째 파라미터 methodMeta는 직접 정의한 fallback 메서드 정보 등이 담긴 클래스이다.
첫 번째 if문을 봐보면 클래스의 이름을 비교한다. -> 코드에서는 두 이름을 제대로 썻는데 왜 못찾을까?
문제의 핵심은 koltin value class가 사용하는 mangling 이라는 기술에 있다.
value class는 런타임에 기본 타입으로 변환되는데, 컴파일 단계 때 메서드 시그니처가 겹치는 것을 방지하기 위해 메서드 이름에 해시코드를 붙인다.
즉, 런타임 때 파라미터에 value class가 존재하면, 바이트 코드에 정의된 메서드 이름이 fallback-<hashCode> 형태로 바뀐다.
다시 돌아오면 클래스 이름을 비교한다. 내가 찾고 싶은 메서드 이름은 fallback인데, 실제 메서드 이름은 fallback-<hashCode>이기 때문에 이름 매칭이 되지 않아 최종적으로 폴백 메서드를 찾아 오지 못한 것이다.
해결 방법
근본적인 문제는 메서드 이름이 달라서 발생하는 문제이다. 이를 해결하기 위해서는 메서드 이름을 맞춰줘야 한다.
@JvmName을 활용하면 지정한 이름으로 설정이 된다.
즉, @JvmName("fallback")으로 지정 시 value class가 포함되어 있어도 해시코드가 붙지 않고, 지정한 name으로 메서드 이름이 지정된다.
이렇게 지정하면 정상적으로 fallback 메서드를 찾을 수 있다.
후기
사실 처음 문제 접근을 잘못하였다. 실제로 Token 클래스로 가져와서 타입이 달라서 매칭이 되지 않는 줄 알았다. 이 문제를 발견하고 resilience4j 포크를 따서, 코드를 직접 상세히 보며 분석하였다. 디버깅과 함께 보니 타입은 제대로 가져오는 것을 학인하였고, 메서드 이름 비교 쪽에서 실패한 것을 알게되었다.
그 후, 이를 수정 후 PR을 날려보려 했으나, mangling 규칙을 자바에서 컨트롤 하기가 어렵다고 판단하여 그만두었다. 코틀린 컴파일러 소스를 본 결과 아래 코드로 해시 코드를 만든다. 정말 복잡하다..
fun hashSuffix(
useOldMangleRules: Boolean,
valueParameters: List<IrType>,
returnType: IrType?,
addContinuation: Boolean = false
): String? =
collectFunctionSignatureForManglingSuffix(
useOldMangleRules,
valueParameters.any { it.getRequiresMangling() },
// The JVM backend computes mangled names after creating suspend function views, but before default argument
// stub insertion. It would be nice if this part of the continuation lowering happened earlier in the pipeline.
// TODO: Move suspend function view creation before JvmInlineClassLowering.
if (addContinuation)
valueParameters.map { it.asInfoForMangling() } +
InfoForMangling(FqNameUnsafe("kotlin.coroutines.Continuation"), isValue = false, isNullable = false)
else
valueParameters.map { it.asInfoForMangling() },
returnType?.asInfoForMangling()
)?.let(::md5base64)
오랜만에 오픈소스를 보며 코드 분석을 해보니 정말 재미있었다. 항상 이렇게 문제를 해결하며 코드를 분석할 때가 시간 가는줄 모르고, 즐기는 것 같다.
오픈소스에 PR을 날려서 반영시키는 것이 꿈이다.
+ 일단 Issue는 올려봤다. https://github.com/resilience4j/resilience4j/issues/2253
추가 자료
- Kotlin Value class란: https://kotlinlang.org/docs/inline-classes.html
정리하면 -> 클래스에 값을 래핑함으로써, 안전하게 사용할 수 있지만 + 런타임 단계에서는 기본 타입으로 변경되어 성능 저하가 되지 않는다. 실제 성능 비교 글은 아래 페이지에 있다.
Data classes took 7.268809 ms
Primitive types took 2.799518 ms
Type aliases took 2.627111 ms
Value classes took 2.883411 ms
- https://quickbirdstudios.com/blog/kotlin-value-classes/
- resilience4j란?: https://resilience4j.readme.io/docs/getting-started
'개발 이야기' 카테고리의 다른 글
스케일 아웃 환경에서의 서킷브레이커 동기화 (0) | 2025.01.01 |
---|---|
MultiPart와 @Async 사용 시 주의점 (0) | 2024.11.16 |
슬랙 알림 최적화 - 배치처리, 버퍼링 (0) | 2024.08.25 |
데이터 대량 등록/수정 성능 개선 with JDBC Batch Update (0) | 2024.08.17 |
효율적인 개발과 유지보수를 위한 문서화의 힘 (0) | 2024.08.11 |