오늘 쿠팡파트너스 API의 최종 승인이 떨어졌고 이를 기반으로 상품 검색 API를 직접 연동해 테스트를 진행했다. 기본적인 동작은 영어 키워드 기준으로 잘 작동했지만 한글 키워드를 사용할 경우 인증 오류가 발생하는 현상을 겪었다.
keywords = "연인"
으로 한글 키워드로 요청을 보냈는데 아래와 같은 에러가 떴다.
org.springframework.web.client.HttpClientErrorException$Unauthorized:
401 Unauthorized:
"{
<EOL> "code" : "ERROR",
<EOL> "message" : "Invalid signature.",
<EOL> "transactionId" : "36b96dc7-8f79-483e-9517-ce7e31011179"<EOL>
}"
쿠팡파트너스 API를 정상적으로 발급을 했고 문서를 따라 HMAC signature
생성 로직, URL 인코딩
를 구현했는데 왜 안되지? 라는 생각에 영어 키워드(keywords="friend"
)로 입력해서 요청을 보내니 제대로 동작했다.
쿠팡파트너스 API는 RFC2104 기반의 HMAC 서명 방식을 이용한다. HMAC signature
를 아래 정보를 기준으로 생성된다.
signature = SHA256(timestamp + method + path + query)
signature 계산 시 사용한 query
와 실제 요청에 포함되는 query가 1글자라도 다르면 무조건 실패한다는 뜻이다. 즉, query
에 포함된 파라미터들까지도 완벽히 일치해야 한다.
String encodedKeyword = URLEncoder.encode(keyword, StandardCharsets.UTF_8);
String encodedQuery = "keyword=" + encodedKeyword; // 요청에 들어가는 실제 쿼리
String rawQuery = "keyword=" + keyword.replace(" ", "+"); // 서명 생성용 쿼리
ResponseEntity<String> response = restTemplate.exchange(
fullUrl, HttpMethod.GET, new HttpEntity<>(headers), String.class
);
이때 rawQuery
는 인코딩되지 않은 keyword=연인
인 반면 실제 요청에 포함되는 encodedQuery
는 keyword=%EC%97%B0%EC%9D%B8
이다. 이 두 값이 다르기 때문에 서명 결과가 완전히 달라지고 결국 쿠팡 API 서버는 Invalid signature
로 응답하게 된다.
String encodedKeyword = URLEncoder.encode(keyword, StandardCharsets.UTF_8);
String rawQuery = "keyword=" + encodedKeyword;
ResponseEntity<String> response = restTemplate.exchange(
fullUrl, HttpMethod.GET, new HttpEntity<>(headers), String.class
);
이렇게 바꿔주도 정상적으로 안되고 Invalid signature 오류가 났다.
Spring의 RestTemplate.exchange(String url, ...)
메서드는 String
타입의 URL을 전달 받으면 내부적으로 한 번 더 URL 인코딩을 수행해버린다.즉 우리가 미리 인코딩한 한글 키워드가 다시 인코딩되어 이중 인코딩이 되어버려서 실패하게 된다. 그렇게 되면 서명에 사용한 rawQuery
와 다르므로 쿠팡파트너스 API는 무조건 서명 오류로 판단하게 된다.
rawQuery : keyword=%EC%97%B0%EC%9D%B8+%EC%9A%B0%EC%95%84%ED%95%9C
uri.getQuery() : keyword=연인+우아한
rawQuery : keyword=연인 우아한
URI query : keyword=연인 우아한
rawQuery bytes :
[107, 101, 121, 119, 111, 114, 100, 61, 37, 69, 67, 37, 57, 55, 37, 66, 48,
37, 69, 67, 37, 57, 68, 37, 66, 56, 43, 37, 69, 67, 37, 57, 65, 37, 66, 48,
37, 69, 67, 37, 57, 53, 37, 56, 52, 37, 69, 68, 37, 57, 53, 37, 57, 67]
uri.getQuery bytes :
[107, 101, 121, 119, 111, 114, 100, 61, -20, -105, -80, -20,
-99, -72, 43, -20, -102, -80, -20, -107, -124, -19, -107, -100]
rawQuery equals uri.getQuery: false
decoded equals : true
다음 두 개가 완벽히 일치해야 한다는 점을 생각해야 한다.
HMAC signature
를 생성할 때 사용한 query
문자열query
문자열위와 문제를 해결하기 위해 String
URL이 아닌 java.net.URI
객체를 직접 생성하여 RestTemplate
에 전달하는 방식으로 변경했다.
String encodedKeyword = URLEncoder.encode(keyword, StandardCharsets.UTF_8);
String rawQuery = "keyword=" + encodedKeyword;
URI uri = new URI(productUrl);
ResponseEntity<String> response = restTemplate.exchange(
uri, HttpMethod.GET, new HttpEntity<>(headers), String.class
);
이 형태로 전달하게 되면 RestTemplate
이 내부적으로 인코딩을 수행하지 않기 때문에 서명 계산에 사용한 rawQuery
와 실제 요청 URI가 완전히 일치하게 되어 정상 인증 통과하게 된다.
이번 경험을 통해 HMAC 기반 인증 구조에서 "1바이트의 오차도 허용되지 않는다"는 점을 뼈저리게 느꼈다. 서명은 단순히 파라미터 이름과 값이 같다는 수준이 아니라 그 인코딩 방식, 공백 문자(+ vs %20), 대소문자 등까지 포함해 바이트 단위로 완벽히 일치해야 한다. 특히 한글 키워드처럼 멀티바이트 문자가 포함된 경우 URLEncoder.encode()
를 한 후, RestTemplate.exchange(String url, ...)
을 사용하면 내부적으로 이중 인코딩이 발생하여 서명과 URI 간 불일치로 이어질 수 있다.
이 문제를 해결하려면 URI 객체를 직접 생성해서 RestTemplate에 넘겨야 하고 이를 통해 불필요한 인코딩을 방지하고 서명과 URI의 정합성을 유지할 수 있다. 서명을 다루는 로직에선 RestTemplate
의 동작 특성을 반드시 이해하고 조심스럽게 접근해야 한다.