Compare commits

...

3 Commits

Author SHA1 Message Date
curry772 6d3cbe1d8b 거래 중 토큰 재발급 시 새 토큰을 헤더에 반영
- 재발급받은 newaccessToken 을 쓰지 않고 방금 실패한 토큰을 그대로 헤더에 넣어
  재시도가 같은 사유로 실패하던 문제
- newaccessToken 이 null 인 경우(구현체 미확인)에는 기존 동작 유지
- Bypass 계열과 HttpClientAdapterServiceRest 는 이미 새 토큰을 사용 중
2026-09-11 12:39:24 +09:00
curry772 822ca52ef0 OAuth 토큰 현황 조회 API 진단 정보 추가
- 토큰이 발급되지 않는 사유를 message 에 모아서 표시
  (useYn=N, 어댑터그룹 없음, 이 서버에 배정된 어댑터 없음, 캐시 없음 등)
- tokenUrlResolved 추가 : 상대 경로 토큰 URL 을 어댑터 URL 과 조합한 실제 호출 주소
- adapterTokenServiceClasses 는 어댑터그룹을 찾지 못하면 null, 어댑터가 없으면 빈 값으로 구분
- 단위테스트 4건 추가
2026-09-11 11:13:30 +09:00
curry772 c6053055a4 토큰 발급 시 커넥션 풀 고갈 방지
- 풀 대기 시간 미지정 시 기본 3분을 기다려, 스케줄러 태스크 타임아웃(30초)에 먼저 인터럽트되고
  커넥션이 반납되지 않아 캐시한 풀이 마르는 문제
- setConnectionRequestTimeout 지정으로 풀 대기도 CONNECTION_TIMEOUT 을 따르도록 함
- evictExpiredConnections / evictIdleConnections 추가로 캐시한 풀의 죽은 커넥션 정리
2026-09-11 11:13:29 +09:00
5 changed files with 161 additions and 17 deletions
@@ -610,6 +610,11 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
OAuth2AccessTokenVO newaccessToken = (OAuth2AccessTokenVO) tokenManager
.retryAccessTokenVO(vo.getAdapterGroupName(), prop, oldToken);
// 재발급받은 토큰으로 헤더를 다시 세팅한다. 기존에는 방금 실패한 토큰을 그대로 넣어
// 재시도가 같은 사유로 실패했다.
if (newaccessToken != null) {
accessToken = newaccessToken;
}
setAuthHeaders(method, accessToken, authorizationHeaderName);
if (logger.isDebug() && "N".equals(vo.getTestCallYn())) {
@@ -41,6 +41,7 @@ import org.apache.hc.core5.http.io.entity.StringEntity;
import org.apache.hc.core5.http.message.BasicNameValuePair;
import org.apache.hc.core5.net.URIBuilder;
import org.apache.hc.core5.ssl.SSLContextBuilder;
import org.apache.hc.core5.util.TimeValue;
import org.apache.hc.core5.util.Timeout;
import org.springframework.security.web.util.UrlUtils;
@@ -155,6 +156,9 @@ public class HttpClientAccessTokenServiceWithConfig implements HttpClientAccessT
private static final long DEFAULT_EXPIRES_IN = 3600L;
/** 캐시한 HttpClient 의 유휴 커넥션 정리 주기(초) */
private static final int IDLE_CONNECTION_EVICT_SECONDS = 30;
/**
* mTLS 여부/clientId 조합별로 CloseableHttpClient(및 내부 PoolingHttpClientConnectionManager)를
* 1회만 생성해 재사용한다. 이 서비스 인스턴스는 HttpClientAccessTokenServiceFactoryByDB 에
@@ -381,8 +385,11 @@ public class HttpClientAccessTokenServiceWithConfig implements HttpClientAccessT
requestConfigBuilder.setProxy(proxy);
}
// 커넥션 풀 대기 시간을 지정하지 않으면 기본 3분을 기다린다. 스케줄러의 태스크 타임아웃(30초)이
// 먼저 걸려 요청이 인터럽트되고, 그 과정에서 커넥션이 반납되지 않아 풀이 마르는 악순환이 생긴다.
RequestConfig requestConfig = requestConfigBuilder
.setConnectTimeout(Timeout.ofMilliseconds(connectionTimeout))
.setConnectionRequestTimeout(Timeout.ofMilliseconds(connectionTimeout))
.setResponseTimeout(Timeout.ofMilliseconds(timeout)).build();
request.setConfig(requestConfig);
@@ -641,7 +648,10 @@ public class HttpClientAccessTokenServiceWithConfig implements HttpClientAccessT
adapterGroupName, useMtls, clientId, contentCompression, defaultUserAgent);
}
HttpClientBuilder builder = HttpClients.custom().setConnectionManager(connectionManager);
// HttpClient 를 캐시해 재사용하므로 죽은 커넥션이 풀에 남지 않도록 정리 설정을 건다.
HttpClientBuilder builder = HttpClients.custom().setConnectionManager(connectionManager)
.evictExpiredConnections()
.evictIdleConnections(TimeValue.ofSeconds(IDLE_CONNECTION_EVICT_SECONDS));
if (!contentCompression) {
builder.disableContentCompression();
}
@@ -33,12 +33,22 @@ public class OAuthTokenStatusDTO {
*
* 어댑터마다 값이 다르면 스케줄러가 쓰는 구현체와 거래 중 재발급(retryAccessTokenVO) 때
* 쓰이는 구현체가 달라진다. 그 불일치를 확인하기 위한 항목이다.
*
* 어댑터그룹 자체를 찾지 못하면 null, 그룹은 있으나 이 서버에 배정된 어댑터가 없으면 빈 값이다.
*/
Map<String, String> adapterTokenServiceClasses;
/** 토큰 발급 URL */
/** 토큰 발급 URL. DB(OutboundOAuthCredentialVo)에 설정된 원본이라 상대 경로일 수 있다. */
String tokenUrl;
/**
* 실제로 호출되는 토큰 발급 URL.
*
* tokenUrl 이 절대 URL 이 아니면 어댑터 속성 URL 뒤에 붙여 호출하므로(구현체들의 appendPath 처리),
* 같은 방식으로 조합한 값이다. 어댑터 속성을 찾지 못하면 null.
*/
String tokenUrlResolved;
/** 토큰 재발급 주기(초) */
int intervalSec;
@@ -10,6 +10,7 @@ import java.util.Map;
import java.util.Properties;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.stereotype.Service;
import com.eactive.eai.adapter.AdapterGroupVO;
@@ -77,25 +78,50 @@ public class OAuthTokenStatusService {
dto.setUseYn(credential.getUseYn());
dto.setTokenUrl(credential.getUrl());
dto.setIntervalSec(credential.getIntervalSec());
dto.setTokenServiceClass(findTokenServiceClass(adapterGroupName));
dto.setAdapterTokenServiceClasses(findAdapterTokenServiceClasses(adapterGroupName));
// 스케줄러가 고르는 어댑터의 속성에서 발급 구현체와 기준 URL 을 함께 읽는다.
Properties schedulerAdapterProp = findSchedulerAdapterProperties(adapterGroupName);
if (schedulerAdapterProp != null) {
dto.setTokenServiceClass(schedulerAdapterProp.getProperty(PROP_TOKEN_SERVICE_CLASS));
dto.setTokenUrlResolved(resolveTokenUrl(schedulerAdapterProp.getProperty("URL"), credential.getUrl()));
}
Map<String, String> adapterClasses = findAdapterTokenServiceClasses(adapterGroupName);
dto.setAdapterTokenServiceClasses(adapterClasses);
// 확인이 필요한 상태는 모아서 message 하나로 돌려준다.
List<String> notes = new ArrayList<String>();
// init() 은 useYn 과 무관하게 인증 정보를 모두 등록하지만 startToken() 은 Y 일 때만 스케줄을 건다.
if (!"Y".equalsIgnoreCase(credential.getUseYn())) {
notes.add("OAuth 인증 정보가 사용 안 함(useYn=" + credential.getUseYn()
+ ") 상태라 스케줄러가 토큰을 발급하지 않습니다.");
}
if (adapterClasses == null) {
notes.add("어댑터그룹을 찾을 수 없습니다. 그룹명이 정확한지, 그룹 사용여부가 '사용' 인지 확인하세요.");
} else if (adapterClasses.isEmpty()) {
notes.add("어댑터그룹에 이 서버로 배정된 어댑터가 없어 토큰을 발급할 수 없습니다.");
}
AccessTokenVO accessToken;
try {
accessToken = manager.peekAccessTokenVO(adapterGroupName);
} catch (UnsupportedOperationException e) {
dto.setMessage("현재 SessionManager 백엔드는 아웃바운드 토큰 캐시를 지원하지 않습니다.");
notes.add("현재 SessionManager 백엔드는 아웃바운드 토큰 캐시를 지원하지 않습니다.");
dto.setMessage(joinNotes(notes));
return dto;
} catch (Exception e) {
if (logger.isWarn()) {
logger.warn("토큰 캐시 조회 실패. adapterGroupName : " + adapterGroupName, e);
}
dto.setMessage("토큰 캐시 조회 실패 : " + e.getMessage());
notes.add("토큰 캐시 조회 실패 : " + e.getMessage());
dto.setMessage(joinNotes(notes));
return dto;
}
if (accessToken == null) {
dto.setMessage("캐시에 토큰이 없습니다.");
notes.add("캐시에 토큰이 없습니다.");
dto.setMessage(joinNotes(notes));
return dto;
}
@@ -120,38 +146,82 @@ public class OAuthTokenStatusService {
// 만료시각이 없는 토큰은 isExpired() 가 false 라 재발급 대상이 되지 않는다.
// 빈 토큰이 캐시에 들어가면 기동 후 계속 그대로 사용되므로 조회 시 눈에 띄게 알린다.
if (StringUtils.isBlank(accessToken.getAccessToken())) {
dto.setMessage("캐시에 빈 토큰이 있습니다. 발급에 실패했는데도 구현체가 빈 토큰을 반환한 것으로 보입니다."
notes.add("캐시에 빈 토큰이 있습니다. 발급에 실패했는데도 구현체가 빈 토큰을 반환한 것으로 보입니다."
+ " 만료시각이 없어 자동 재발급되지 않습니다.");
} else if (expiration == null) {
dto.setMessage("만료시각이 없어 자동 재발급되지 않습니다.");
notes.add("만료시각이 없어 자동 재발급되지 않습니다.");
}
dto.setMessage(joinNotes(notes));
return dto;
}
/**
* 어댑터 속성에서 토큰 발급 구현체 클래스명을 찾는다.
* 확인이 필요한 상태 메시지를 하나로 합친다.
*
* @param notes 메시지 목록
* @return 합친 메시지. 없으면 null.
*/
private String joinNotes(List<String> notes) {
if (notes.isEmpty()) {
return null;
}
return StringUtils.join(notes, " ");
}
/**
* 스케줄러가 토큰 발급에 사용하는 어댑터의 속성을 찾는다.
*
* AccessTokenManagerByDB.issueToken() 과 동일하게 getAdapters().next() 로 어댑터 하나를 고른다.
*
* @param adapterGroupName 어댑터그룹명
* @return 구현체 클래스명. 찾지 못하면 null.
* @return 어댑터 속성. 찾지 못하면 null.
*/
private String findTokenServiceClass(String adapterGroupName) {
private Properties findSchedulerAdapterProperties(String adapterGroupName) {
try {
AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName);
if (gvo == null || !gvo.getAdapters().hasNext()) {
return null;
}
AdapterVO avo = gvo.getAdapters().next();
Properties properties = AdapterPropManager.getInstance().getProperties(avo.getPropGroupName());
return properties.getProperty(PROP_TOKEN_SERVICE_CLASS);
return AdapterPropManager.getInstance().getProperties(avo.getPropGroupName());
} catch (Exception e) {
if (logger.isDebug()) {
logger.debug("토큰 발급 구현체 조회 실패. adapterGroupName : " + adapterGroupName);
logger.debug("어댑터 속성 조회 실패. adapterGroupName : " + adapterGroupName);
}
return null;
}
}
/**
* 실제로 호출되는 토큰 발급 URL 을 만든다.
*
* 구현체들과 동일하게, 절대 URL 이 아니면 어댑터 속성 URL 뒤에 이어 붙인다.
*
* @param adapterUrl 어댑터 속성의 URL
* @param tokenUrl DB 에 설정된 토큰 발급 URL
* @return 조합된 URL. 만들 수 없으면 null.
*/
String resolveTokenUrl(String adapterUrl, String tokenUrl) {
if (StringUtils.isBlank(tokenUrl)) {
return null;
}
if (UrlUtils.isAbsoluteUrl(tokenUrl)) {
return tokenUrl;
}
if (StringUtils.isBlank(adapterUrl)) {
return null;
}
if (!adapterUrl.endsWith("/") && !tokenUrl.startsWith("/")) {
return adapterUrl + "/" + tokenUrl;
}
if (adapterUrl.endsWith("/") && tokenUrl.startsWith("/")) {
return adapterUrl + tokenUrl.substring(1);
}
return adapterUrl + tokenUrl;
}
/**
* 그룹에 속한 어댑터별로 토큰 발급 구현체 클래스명을 모은다.
*
@@ -160,14 +230,14 @@ public class OAuthTokenStatusService {
* 서로 다른 구현체가 동작하므로 전체를 보여준다.
*
* @param adapterGroupName 어댑터그룹명
* @return 어댑터명 → 구현체 클래스명. 조회 실패 시 빈 Map.
* @return 어댑터명 → 구현체 클래스명. 어댑터그룹 자체를 찾지 못하면 null, 어댑터가 없으면 빈 Map.
*/
private Map<String, String> findAdapterTokenServiceClasses(String adapterGroupName) {
Map<String, String> result = new LinkedHashMap<String, String>();
try {
AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName);
if (gvo == null) {
return result;
return null;
}
Iterator<AdapterVO> adapters = gvo.getAdapters();
while (adapters.hasNext()) {
@@ -179,6 +249,7 @@ public class OAuthTokenStatusService {
if (logger.isDebug()) {
logger.debug("어댑터별 토큰 발급 구현체 조회 실패. adapterGroupName : " + adapterGroupName);
}
return null;
}
return result;
}
@@ -209,6 +209,54 @@ class OAuthTokenStatusServiceTest {
assertTrue(dto.getMessage().contains("재발급"));
}
@Test
@DisplayName("8-3. useYn 이 N 이면 스케줄러가 발급하지 않음을 알린다")
void 사용안함경고() {
OutboundOAuthCredentialVo credential = registerCredential(GROUP);
credential.setUseYn("N");
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(null);
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
assertTrue(dto.getMessage().contains("사용 안 함"));
assertTrue(dto.getMessage().contains("캐시에 토큰이 없습니다"), "다른 사유도 함께 표시돼야 한다");
}
@Test
@DisplayName("8-4. 어댑터그룹을 찾지 못하면 그 사유를 알린다")
void 어댑터그룹없음경고() {
registerCredential(GROUP);
Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(null);
OAuthTokenStatusDTO dto = service.getStatus(GROUP);
// 테스트 환경에는 AdapterManager 가 없으므로 그룹을 찾지 못한 것으로 처리된다.
assertNull(dto.getAdapterTokenServiceClasses());
assertTrue(dto.getMessage().contains("어댑터그룹을 찾을 수 없습니다"));
}
@Test
@DisplayName("8-5. 상대 경로 토큰 URL 은 어댑터 URL 과 조합한다")
void 토큰URL조합() {
assertEquals("https://api.example.com/oauth2/token",
service.resolveTokenUrl("https://api.example.com", "oauth2/token"));
assertEquals("https://api.example.com/oauth2/token",
service.resolveTokenUrl("https://api.example.com/", "oauth2/token"));
assertEquals("https://api.example.com/oauth2/token",
service.resolveTokenUrl("https://api.example.com", "/oauth2/token"));
assertEquals("https://api.example.com/oauth2/token",
service.resolveTokenUrl("https://api.example.com/", "/oauth2/token"));
}
@Test
@DisplayName("8-6. 절대 URL 은 그대로 쓰고, 조합할 수 없으면 null")
void 토큰URL절대경로() {
assertEquals("https://token.example.com/oauth/token",
service.resolveTokenUrl("https://api.example.com", "https://token.example.com/oauth/token"));
assertNull(service.resolveTokenUrl("https://api.example.com", null));
assertNull(service.resolveTokenUrl(null, "oauth2/token"));
}
@Test
@DisplayName("9. 전체 목록은 등록된 어댑터그룹 수만큼 반환한다")
void 전체목록() {