Resilience4jFeign 을 사용해 Content-Type 이 x-www-form-urlencoded 인 API 를 호출할 때 요청 객체를 Record 로 선언하면 발생하는 문제입니다.
결제 시스템을 구현하던 중에 발생한 이슈입니다. 새 결제 시스템 프로젝트 구성은 아래와 같습니다.
- Java 17
- Spring boot 2.7.9
- resilience4j-feign 2.0.2
- feign-(core,okhttp,jackson,slf4j) 12.2
- feign-form 3.8.0
위와 같은 환경을 구성해 카카오페이 결제준비 API 를 호출할 때 발생한 이슈에 대해서 기록해 놓으려고 합니다. 회사 프로젝트를 올릴 순 없어 해당 이슈 기록을 위한 작은 프로젝트를 하나 만들었습니다. 아래 코드는 이슈를 설명하기 위해 필요한 코드만 적어놨습니다.
public record KakaopayReadyApiRequest(
String cid,
String partner_order_id,
String partner_user_id,
String item_name,
Integer quantity,
Integer total_amount,
String approval_url,
String cancel_url,
String fail_url,
String payment_method_type,
Integer install_month
) {
@Builder
public KakaopayReadyApiRequest {}
}
@Headers(value = {"Authorization: KakaoAK {Authorization}", "Accept: application/json", "Content-Type: application/x-www-form-urlencoded"})
public interface KakaopayApiClient {
@RequestLine("POST /v1/payment/ready")
KakaopayReadyApiResponse ready(@Param("Authorization") String adminKey, KakaopayReadyApiRequest request);
}
@Bean
public KakaopayApiClient kakaopayApiClient() {
CircuitBreaker cb = CircuitBreaker.of("kakaopayApiClientCB",
CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.slidingWindow(20, 20, CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
.permittedNumberOfCallsInHalfOpenState(10)
.waitDurationInOpenState(Duration.ofSeconds(10L))
.build()
);
FeignDecorators decorators = FeignDecorators.builder()
.withCircuitBreaker(cb)
.build();
ObjectMapper objectMapper = (new ObjectMapper())
.configure(SerializationFeature.INDENT_OUTPUT, true)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.registerModules(new JavaTimeModule());
return Resilience4jFeign.builder(decorators)
.client(new OkHttpClient())
.encoder(new FormEncoder())
.decoder(new JacksonDecoder(objectMapper))
.options(new Request.Options(3000, TimeUnit.MILLISECONDS, 10000, TimeUnit.MILLISECONDS, true))
// 0.1초 간격으로 시작해 최대 3초의 간격으로 3번 재시도
.retryer(new Retryer.Default(100, SECONDS.toMillis(3), 3))
.logger(new Slf4jLogger(KakaopayApiClient.class))
.target(KakaopayApiClient.class, url);
}
위와 같이 Resilience4jFeign 을 사용해서 api 클라이언트를 만들었습니다. 다른 옵션들에 대한 설명은 제외하고 이번 이슈에 필요한 부분만 설명 하겠습니다. 이번 이슈는 encoder 옵션과 관련이 있습니다. 카카오페이 결제준비 API는 문서에서 확인할 수 있듯이 API 요청시 Content-Type 을 application/x-www-form-urlencoded 형태로 보내야 합니다. 그래서 encoder 에 feign-form의 FormEncoder 를 사용했습니다. 위와 같이 구성하고 API 를 호출하면 아래와 같은 에러가 발생합니다.
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is feign.FeignException$BadRequest: [400 Bad Request] during [POST] to [
https://kapi.kakao.com/v1/payment/ready
] [KakaopayApiClient#ready(String,KakaopayReadyApiRequest)]: [{"msg":"cid can't be null.","code":-2}]] with root cause
요청 객체에 cid 를 넣었음에도 카카오페이 결제준비 API 호출 응답에서는 cid가 null이 되면 안된다는 응답이 왔습니다. 그래서 혹시 인코딩이 잘 안됐나 싶어서 api 요청시 인코딩을 담당하는 FormEncoder 를 추적 해보기로 했습니다.
FormEncoder 에 encode 메서드를 따라가다 보면 PojoUtil.toMap(object) 코드가 있습니다. toMap 메서드는 API 요청 객체인 KakaopayReadyApiRequest 를 Map 으로 변환하는 코드 입니다. 그런데 변환할때 문제가 되는건 아래 코드 입니다.
for(int var6 = 0; var6 < var5; ++var6) {
Field field = var4[var6];
int modifiers = field.getModifiers();
if (!Modifier.isFinal(modifiers) && !Modifier.isStatic(modifiers)) {
setAccessibleAction.setField(field);
AccessController.doPrivileged(setAccessibleAction);
Object fieldValue = field.get(object);
if (fieldValue != null) {
String propertyKey = field.isAnnotationPresent(FormProperty.class) ? ((FormProperty)field.getAnnotation(FormProperty.class)).value() : field.getName();
result.put(propertyKey, fieldValue);
}
}
}
KakaopayReadyApiRequest 의 필드를 하나씩 읽어 Map 에 넣는 코드인데,
if !(Modifiers.isFinal(modifiers) 와 !Modifier.isStatic(modifiers)) 을 보면 필드에 final 이 있을때 해당 로직을 실행하지 않게 되어 있습니다. KakaopayReadyApiRequest 객체는 record 로 선언이 되어 있기에 이 객체의 필드는 final 로 선언이 되어 있습니다.(Record 타입 필드엔 final 이 붙습니다) final 로 선언이 되어 있기 때문에 if 문 안에 있는 로직을 실행하지 않고 map 에는 아무 데이터도 들어가지 않게 됩니다. 그래서 해당 API 를 실행할 때 요청 객체 인코딩이 제대로 안되는 것이고 에러가 발생하게 되는 것 입니다.
이 문제를 해결하기 위해 요청 객체를 아래와 같이 record → class 로 변경했습니다.
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KakaopayReadyApiRequest {
private String cid;
private String partner_order_id;
private String partner_user_id;
private String item_name;
private Integer quantity;
private Integer total_amount;
private String approval_url;
private String cancel_url;
private String fail_url;
private String payment_method_type;
private Integer install_month;
}
class 로 변경하니 PojoUtil.toMap(object) 의 Map에 데이터가 잘 들어가고 요청 또한 잘 전달되는 것을 확인할 수 있었습니다. 이 이슈는 전에 Content-Type 을 json 타입으로 전송할때는 Record 로 전송이 잘 됐어서 이번에 URLENCODED 로 전송할 때도 잘 되겠거니 생각했던게 문제였습니다.
Reference Keyword
- Resilience4j
- Feign
- Java Record