거래 중 토큰 재발급을 분산락 경로로 통합
- 재발급 결과를 캐시에 반영하지 않아 거래마다 발급되고, synchronized 가 JVM 단위라 클러스터에서 노드 수만큼 중복 발급되던 문제 - SessionManager.reissueOutboundAccessToken 추가 : 분산락 안에서 oldToken 을 비교해 이미 갱신됐으면 그 토큰을 쓰고, 아니면 한 번만 발급 후 캐시에 반영 - 발급 구현체는 호출자가 넘긴 어댑터 속성 기준으로 선택 (기존 의미 유지) - HttpClient5AdapterServiceRest : 응답 기반 재발급도 DB 기반 매니저를 쓰도록 정리 - 아웃바운드 토큰 분산락 키를 전역 상수에서 어댑터그룹별로 변경 - 단위테스트 12건 추가 (경합 상황 포함)
This commit is contained in:
+2
-3
@@ -78,7 +78,6 @@ import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.jayway.jsonpath.DocumentContext;
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.openbanking.eai.common.token.AccessTokenManager;
|
||||
import com.openbanking.eai.common.token.AccessTokenVO;
|
||||
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
|
||||
|
||||
@@ -604,8 +603,8 @@ public class HttpClient5AdapterServiceRest extends HttpClient5AdapterServiceSupp
|
||||
+ "]");
|
||||
}
|
||||
|
||||
// 토큰 재발급
|
||||
AccessTokenManager tokenManager = AccessTokenManager.getInstance();
|
||||
// 토큰 재발급. 전송 전 조회(:353)와 같은 DB 기반 매니저를 써야 Ignite 캐시가 갱신된다.
|
||||
AccessTokenManagerByDB tokenManager = AccessTokenManagerByDB.getInstance();
|
||||
String oldToken = accessToken == null ? null : accessToken.getAccessToken();
|
||||
OAuth2AccessTokenVO newaccessToken = (OAuth2AccessTokenVO) tokenManager
|
||||
.retryAccessTokenVO(vo.getAdapterGroupName(), prop, oldToken);
|
||||
|
||||
@@ -602,29 +602,48 @@ public class AccessTokenManagerByDB implements Lifecycle {
|
||||
* 3. 주의사항
|
||||
*
|
||||
**/
|
||||
public synchronized AccessTokenVO retryAccessTokenVO(String adapterGroupName, Properties properties,
|
||||
String oldToken) throws Exception {
|
||||
|
||||
AccessTokenVO accessToken = getAccessTokenVO(adapterGroupName);
|
||||
|
||||
// 동시 요청이 발생할 경우 synchronized 처리를 했기때문에 토큰을 다시 한번 체크한다.
|
||||
if (accessToken == null || accessToken.isExpired()
|
||||
|| StringUtils.equals(accessToken.getAccessToken(), oldToken)) {
|
||||
public AccessTokenVO retryAccessTokenVO(String adapterGroupName, Properties properties, String oldToken)
|
||||
throws Exception {
|
||||
|
||||
if (isOAuthCredentialRegistered(adapterGroupName) == false) {
|
||||
throw new Exception(
|
||||
"There is no OAuthCredentialRegistered information, or whether to use it is 'N'.");
|
||||
throw new Exception("There is no OAuthCredentialRegistered information, or whether to use it is 'N'.");
|
||||
}
|
||||
|
||||
String type = properties.getProperty("ADAPTER_TOKEN_ISSUING_CLIENT_TYPE");
|
||||
final HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type);
|
||||
|
||||
HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type);
|
||||
OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName);
|
||||
if (service != null) {
|
||||
accessToken = (AccessTokenVO) service.execute(adapterGroupName, properties,
|
||||
if (service == null) {
|
||||
logger.warn("Token service not found for type: {} and adapter group: {}", type, adapterGroupName);
|
||||
return getAccessTokenVO(adapterGroupName);
|
||||
}
|
||||
|
||||
final OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName);
|
||||
|
||||
// 클러스터 전역에서 한 번만 발급되도록 분산락 안에서 처리한다. 발급 결과는 캐시에 반영되므로
|
||||
// 뒤따르는 거래는 재발급하지 않는다. 구현체는 호출자가 넘긴 어댑터 속성 기준으로 고른다.
|
||||
return SessionManager.getInstance().reissueOutboundAccessToken(adapterGroupName, oldToken,
|
||||
new Function<AccessTokenVO, AccessTokenVO>() {
|
||||
|
||||
@Override
|
||||
public AccessTokenVO apply(AccessTokenVO currentToken) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
try {
|
||||
logger.info("Reissuing token for adapter group: {}, type: {}", adapterGroupName, type);
|
||||
|
||||
AccessTokenVO issued = (AccessTokenVO) service.execute(adapterGroupName, properties,
|
||||
outboundOAuthCredentialVo);
|
||||
}
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_RETRY, type, startTime,
|
||||
issued, null);
|
||||
|
||||
return issued;
|
||||
} catch (Exception e) {
|
||||
recordIssueHistory(adapterGroupName, TokenIssueHistory.TRIGGER_RETRY, type, startTime,
|
||||
null, toFailReason(e));
|
||||
logger.error("Token reissue failed for adapter group: {}", adapterGroupName, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +222,20 @@ public abstract class SessionManager implements Lifecycle {
|
||||
*/
|
||||
public abstract AccessTokenVO peekOutboundAccessToken(String key);
|
||||
|
||||
/**
|
||||
* 사용 중이던 토큰이 거부된 경우 재발급한다.
|
||||
*
|
||||
* 분산락 안에서 캐시의 토큰이 oldToken 과 같은지 확인한 뒤에만 발급하므로, 여러 노드가 동시에
|
||||
* 재발급을 시도해도 실제 발급은 한 번만 일어난다. 발급에 성공하면 캐시에 반영한다.
|
||||
*
|
||||
* @param key 어댑터그룹명
|
||||
* @param oldToken 거부된 accessToken 값
|
||||
* @param tokenSupplier 실제 발급 처리
|
||||
* @return 재발급된 토큰. 다른 노드가 이미 갱신했다면 그 토큰.
|
||||
*/
|
||||
public abstract AccessTokenVO reissueOutboundAccessToken(String key, String oldToken,
|
||||
Function<AccessTokenVO, AccessTokenVO> tokenSupplier);
|
||||
|
||||
public abstract void removeOutboundAccessToken(String key);
|
||||
|
||||
public abstract void clearOutboundAccessToken();
|
||||
|
||||
@@ -956,6 +956,12 @@ public class SessionManagerForEhcache extends SessionManager {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccessTokenVO reissueOutboundAccessToken(String key, String oldToken,
|
||||
Function<AccessTokenVO, AccessTokenVO> tokenSupplier) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeOutboundAccessToken(String key) {
|
||||
throw new UnsupportedOperationException();
|
||||
|
||||
@@ -72,7 +72,12 @@ import ch.qos.logback.classic.Level;
|
||||
|
||||
public class SessionManagerForIgnite extends SessionManager {
|
||||
|
||||
private static final String DISTRIBUTED_TOKEN_LOCK = "DISTRIBUTED_TOKEN_LOCK";
|
||||
|
||||
/** 아웃바운드 토큰 분산락 대기 시간(초). 스케줄러 경로에서 사용한다. */
|
||||
private static final int TOKEN_LOCK_WAIT_SECONDS = 60;
|
||||
|
||||
/** 거래 중 재발급은 거래 스레드를 잡으므로 스케줄러 경로보다 짧게 기다린다. */
|
||||
private static final int REISSUE_LOCK_WAIT_SECONDS = 10;
|
||||
// evictMaster Instance - single socket 관련
|
||||
private static IgniteCache<String, String> evictMasterCache = null;
|
||||
// Socket Session cache 정보
|
||||
@@ -832,6 +837,65 @@ public class SessionManagerForIgnite extends SessionManager {
|
||||
return cacheOutBoundAccessToken.get(key);
|
||||
}
|
||||
|
||||
/** 토큰으로 쓸 수 있는 값인지 (빈 토큰은 발급 실패로 본다) */
|
||||
private boolean isUsableToken(AccessTokenVO token) {
|
||||
return token != null && StringUtils.isNotBlank(token.getAccessToken());
|
||||
}
|
||||
|
||||
@Override
|
||||
public AccessTokenVO reissueOutboundAccessToken(String key, String oldToken,
|
||||
Function<AccessTokenVO, AccessTokenVO> tokenSupplier) {
|
||||
|
||||
AccessTokenVO token = cacheOutBoundAccessToken.get(key);
|
||||
|
||||
// 락을 잡기 전에 먼저 확인한다. 다른 노드가 이미 갱신했으면 그 토큰을 쓴다.
|
||||
if (isUsableToken(token) && !StringUtils.equals(token.getAccessToken(), oldToken)) {
|
||||
return token;
|
||||
}
|
||||
|
||||
// 어댑터그룹별로 락을 잡는다. 거래 스레드에서 호출되므로 대기 시간을 짧게 둔다.
|
||||
Lock lock = cacheOutBoundAccessToken.lock(key);
|
||||
boolean acquired = false;
|
||||
|
||||
try {
|
||||
acquired = lock.tryLock(REISSUE_LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
if (!acquired) {
|
||||
logger.warn("reissueOutboundAccessToken lock timeout. key=" + key);
|
||||
return token;
|
||||
}
|
||||
|
||||
// 락 획득 사이에 다른 노드가 갱신했을 수 있으므로 다시 확인한다.
|
||||
token = cacheOutBoundAccessToken.get(key);
|
||||
if (isUsableToken(token) && !StringUtils.equals(token.getAccessToken(), oldToken)) {
|
||||
logger.debug("reissueOutboundAccessToken already reissued by another node. key=" + key);
|
||||
return token;
|
||||
}
|
||||
|
||||
AccessTokenVO newToken = tokenSupplier.apply(token);
|
||||
|
||||
if (isUsableToken(newToken)) {
|
||||
cacheOutBoundAccessToken.put(key, newToken);
|
||||
return newToken;
|
||||
}
|
||||
|
||||
logger.warn("reissueOutboundAccessToken got empty token. not cached. key=" + key);
|
||||
return newToken;
|
||||
|
||||
} catch (Throwable e) {
|
||||
logger.error("occuring exception in reissueOutboundAccessToken. key=" + key, e);
|
||||
return token;
|
||||
} finally {
|
||||
try {
|
||||
if (acquired) {
|
||||
lock.unlock();
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
logger.error("occuring exception in reissueOutboundAccessToken unlock fail.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void putWebSocketTimeout(String key, SessionVO value) {
|
||||
cacheWebSocketTimeout.put(key, value);
|
||||
@@ -917,13 +981,14 @@ public class SessionManagerForIgnite extends SessionManager {
|
||||
|
||||
// 2. 만료되었거나 없다면 락 획득 시도
|
||||
if (token == null || token.isExpired()) {
|
||||
Lock lock = cacheOutBoundAccessToken.lock(DISTRIBUTED_TOKEN_LOCK); // Ignite 분산 락
|
||||
// 어댑터그룹별로 락을 잡는다. 전역 락 하나를 쓰면 모든 그룹의 토큰 처리가 직렬화된다.
|
||||
Lock lock = cacheOutBoundAccessToken.lock(key); // Ignite 분산 락
|
||||
|
||||
boolean acquired = false;
|
||||
|
||||
try {
|
||||
logger.debug("calling func getOutboundAccessToken = distributed ignite trylock");
|
||||
acquired = lock.tryLock(60, TimeUnit.SECONDS);
|
||||
acquired = lock.tryLock(TOKEN_LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
if ( acquired ) {
|
||||
logger.debug("calling func getOutboundAccessToken = distributed ignite trylock success=>"+acquired);
|
||||
@@ -972,13 +1037,13 @@ public class SessionManagerForIgnite extends SessionManager {
|
||||
@Override
|
||||
public void removeOutboundAccessToken(String key) {
|
||||
|
||||
Lock lock = cacheOutBoundAccessToken.lock(DISTRIBUTED_TOKEN_LOCK); // Ignite 분산 락
|
||||
Lock lock = cacheOutBoundAccessToken.lock(key); // Ignite 분산 락 (어댑터그룹별)
|
||||
|
||||
boolean acquired = false;
|
||||
try {
|
||||
|
||||
logger.debug("calling func removeOutboundAccessToken = distributed ignite trylock");
|
||||
acquired = lock.tryLock(60, TimeUnit.SECONDS);
|
||||
acquired = lock.tryLock(TOKEN_LOCK_WAIT_SECONDS, TimeUnit.SECONDS);
|
||||
|
||||
if( acquired ) {
|
||||
if (logger.isDebug()) {
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
package com.eactive.eai.common.authoutbound;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import com.eactive.eai.authoutbound.OutboundOAuthCredentialVo;
|
||||
import com.eactive.eai.authoutbound.client.HttpClientAccessTokenServiceByDB;
|
||||
import com.eactive.eai.common.session.SessionManager;
|
||||
import com.openbanking.eai.common.token.AccessTokenVO;
|
||||
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
|
||||
|
||||
/**
|
||||
* AccessTokenManagerByDB.retryAccessTokenVO 단위테스트
|
||||
*
|
||||
* 거래 중 재발급이 SessionManager 의 분산락 경로(reissueOutboundAccessToken)를 타는지,
|
||||
* 발급 구현체를 호출자가 넘긴 어댑터 속성 기준으로 고르는지를 검증한다.
|
||||
*/
|
||||
class AccessTokenManagerByDBRetryTest {
|
||||
|
||||
private static final String GROUP = "TESTGRP";
|
||||
private static final String SERVICE_CLASS = RecordingTokenService.class.getName();
|
||||
|
||||
private AccessTokenManagerByDB manager;
|
||||
private SessionManager mockSessionManager;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
manager = new AccessTokenManagerByDB();
|
||||
RecordingTokenService.reset();
|
||||
|
||||
mockSessionManager = Mockito.mock(SessionManager.class);
|
||||
injectSessionManager(mockSessionManager);
|
||||
|
||||
// 분산락 구간을 흉내 낸다. supplier 를 그대로 실행해 결과를 돌려준다.
|
||||
Mockito.when(mockSessionManager.reissueOutboundAccessToken(anyString(), any(), any()))
|
||||
.thenAnswer(invocation -> {
|
||||
Function<AccessTokenVO, AccessTokenVO> supplier = invocation.getArgument(2);
|
||||
return supplier.apply(null);
|
||||
});
|
||||
}
|
||||
|
||||
/** SessionManager 싱글턴에 mock 을 주입한다. */
|
||||
private void injectSessionManager(SessionManager sessionManager) throws Exception {
|
||||
Field field = SessionManager.class.getDeclaredField("instance");
|
||||
field.setAccessible(true);
|
||||
field.set(null, sessionManager);
|
||||
}
|
||||
|
||||
/** 인증 정보를 등록된 것으로 만든다. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private OutboundOAuthCredentialVo registerCredential(String useYn) throws Exception {
|
||||
OutboundOAuthCredentialVo credential = new OutboundOAuthCredentialVo();
|
||||
credential.setAdapterGroupName(GROUP);
|
||||
credential.setUseYn(useYn);
|
||||
credential.setClientId("client-1");
|
||||
credential.setClientSecret("secret-1");
|
||||
|
||||
Field field = AccessTokenManagerByDB.class.getDeclaredField("outboundOAuthCredentialVos");
|
||||
field.setAccessible(true);
|
||||
Map<String, OutboundOAuthCredentialVo> map = (Map<String, OutboundOAuthCredentialVo>) field.get(manager);
|
||||
map.put(GROUP, credential);
|
||||
return credential;
|
||||
}
|
||||
|
||||
/** 발급 구현체를 지정한 어댑터 속성 */
|
||||
private Properties adapterProp(String serviceClass) {
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("ADAPTER_TOKEN_ISSUING_CLIENT_TYPE", serviceClass);
|
||||
properties.setProperty("URL", "https://api.example.com");
|
||||
return properties;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1. 재발급은 SessionManager 의 분산락 경로를 통해 일어난다")
|
||||
void 분산락경로_사용() throws Exception {
|
||||
registerCredential("Y");
|
||||
|
||||
AccessTokenVO result = manager.retryAccessTokenVO(GROUP, adapterProp(SERVICE_CLASS), "OLD");
|
||||
|
||||
assertEquals("NEW", result.getAccessToken());
|
||||
Mockito.verify(mockSessionManager).reissueOutboundAccessToken(eq(GROUP), eq("OLD"), any());
|
||||
Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("2. 발급 구현체는 호출자가 넘긴 어댑터 속성으로 고른다")
|
||||
void 호출자속성으로_구현체선택() throws Exception {
|
||||
OutboundOAuthCredentialVo credential = registerCredential("Y");
|
||||
Properties prop = adapterProp(SERVICE_CLASS);
|
||||
|
||||
manager.retryAccessTokenVO(GROUP, prop, "OLD");
|
||||
|
||||
assertEquals(1, RecordingTokenService.callCount, "구현체가 한 번 실행돼야 한다");
|
||||
assertEquals(GROUP, RecordingTokenService.lastAdapterGroupName);
|
||||
assertSame(prop, RecordingTokenService.lastProperties, "호출자가 넘긴 속성이 그대로 전달돼야 한다");
|
||||
assertSame(credential, RecordingTokenService.lastCredential, "DB 인증정보가 전달돼야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3. 인증 정보가 없거나 useYn 이 N 이면 예외")
|
||||
void 미등록이면_예외() throws Exception {
|
||||
registerCredential("N");
|
||||
|
||||
assertThrows(Exception.class, () -> manager.retryAccessTokenVO(GROUP, adapterProp(SERVICE_CLASS), "OLD"));
|
||||
assertThrows(Exception.class, () -> manager.retryAccessTokenVO("NO-SUCH-GROUP",
|
||||
adapterProp(SERVICE_CLASS), "OLD"));
|
||||
assertEquals(0, RecordingTokenService.callCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("4. 구현체를 찾지 못하면 발급하지 않고 캐시 조회로 넘어간다")
|
||||
void 구현체없음_폴백() throws Exception {
|
||||
registerCredential("Y");
|
||||
OAuth2AccessTokenVO cached = new OAuth2AccessTokenVO();
|
||||
cached.setAccessToken("CACHED");
|
||||
cached.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
|
||||
Mockito.when(mockSessionManager.getOutboundAccessToken(eq(GROUP), any())).thenReturn(cached);
|
||||
|
||||
AccessTokenVO result = manager.retryAccessTokenVO(GROUP, adapterProp("no.such.TokenService"), "OLD");
|
||||
|
||||
assertEquals("CACHED", result.getAccessToken());
|
||||
Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("5. 발급 중 예외가 나면 null 을 돌려줘 캐시에 남지 않게 한다")
|
||||
void 발급실패는_null() throws Exception {
|
||||
registerCredential("Y");
|
||||
RecordingTokenService.throwOnExecute = true;
|
||||
|
||||
AccessTokenVO result = manager.retryAccessTokenVO(GROUP, adapterProp(SERVICE_CLASS), "OLD");
|
||||
|
||||
assertNull(result, "실패를 빈 토큰이 아니라 null 로 알려야 캐시에 저장되지 않는다");
|
||||
}
|
||||
|
||||
/**
|
||||
* 호출 인자를 기록하는 테스트용 발급 구현체.
|
||||
*
|
||||
* HttpClientAccessTokenServiceFactoryByDB 가 클래스명으로 생성하므로 public 이어야 한다.
|
||||
*/
|
||||
public static class RecordingTokenService implements HttpClientAccessTokenServiceByDB {
|
||||
|
||||
static int callCount;
|
||||
static String lastAdapterGroupName;
|
||||
static Properties lastProperties;
|
||||
static OutboundOAuthCredentialVo lastCredential;
|
||||
static boolean throwOnExecute;
|
||||
|
||||
static void reset() {
|
||||
callCount = 0;
|
||||
lastAdapterGroupName = null;
|
||||
lastProperties = null;
|
||||
lastCredential = null;
|
||||
throwOnExecute = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object execute(String adapterGroupName, Properties adapterProp,
|
||||
OutboundOAuthCredentialVo oAuthCredentialVo) throws Exception {
|
||||
callCount++;
|
||||
lastAdapterGroupName = adapterGroupName;
|
||||
lastProperties = adapterProp;
|
||||
lastCredential = oAuthCredentialVo;
|
||||
|
||||
if (throwOnExecute) {
|
||||
throw new Exception("token issue failed");
|
||||
}
|
||||
|
||||
OAuth2AccessTokenVO token = new OAuth2AccessTokenVO();
|
||||
token.setAccessToken("NEW");
|
||||
token.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
|
||||
return token;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package com.eactive.eai.common.session;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.apache.ignite.IgniteCache;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import com.openbanking.eai.common.token.AccessTokenVO;
|
||||
import com.openbanking.eai.common.token.OAuth2AccessTokenVO;
|
||||
|
||||
/**
|
||||
* SessionManagerForIgnite.reissueOutboundAccessToken 단위테스트
|
||||
*
|
||||
* Ignite 캐시 자리에 ConcurrentHashMap 으로 동작하는 mock IgniteCache 를 리플렉션으로 주입하고,
|
||||
* 분산락 자리에는 어댑터그룹별 ReentrantLock 을 물려 락 안의 판정 로직과 경합 동작을 검증한다.
|
||||
*
|
||||
* 노드 간 상호배제 자체는 Ignite 의 lock(key) 가 보장하는 부분이라 여기서 검증할 수 없다.
|
||||
* 이 테스트가 보는 것은 "락을 잡은 뒤 무엇을 판단하는가" 이다.
|
||||
*/
|
||||
class SessionManagerForIgniteReissueTest {
|
||||
|
||||
private static final String GROUP = "TESTGRP";
|
||||
|
||||
private SessionManagerForIgnite sessionManager;
|
||||
|
||||
/** mock IgniteCache 의 실제 저장소 */
|
||||
private Map<String, AccessTokenVO> store;
|
||||
|
||||
/** 어댑터그룹별 락 (같은 키면 같은 인스턴스를 돌려줘야 경합 테스트가 성립한다) */
|
||||
private Map<String, Lock> locks;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
sessionManager = new SessionManagerForIgnite();
|
||||
store = new ConcurrentHashMap<>();
|
||||
locks = new ConcurrentHashMap<>();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
IgniteCache<String, AccessTokenVO> cache = Mockito.mock(IgniteCache.class);
|
||||
|
||||
Mockito.when(cache.get(anyString())).thenAnswer(invocation -> store.get(invocation.getArgument(0)));
|
||||
Mockito.doAnswer(invocation -> {
|
||||
store.put(invocation.getArgument(0), invocation.getArgument(1));
|
||||
return null;
|
||||
}).when(cache).put(anyString(), any(AccessTokenVO.class));
|
||||
Mockito.when(cache.lock(anyString()))
|
||||
.thenAnswer(invocation -> locks.computeIfAbsent(invocation.getArgument(0),
|
||||
key -> new ReentrantLock()));
|
||||
|
||||
injectCache(cache);
|
||||
}
|
||||
|
||||
/** private static cacheOutBoundAccessToken 에 mock 을 주입한다. */
|
||||
private void injectCache(IgniteCache<String, AccessTokenVO> cache) throws Exception {
|
||||
Field field = SessionManagerForIgnite.class.getDeclaredField("cacheOutBoundAccessToken");
|
||||
field.setAccessible(true);
|
||||
field.set(null, cache);
|
||||
}
|
||||
|
||||
/** 만료되지 않은 토큰 */
|
||||
private OAuth2AccessTokenVO token(String accessToken) {
|
||||
OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO();
|
||||
vo.setAccessToken(accessToken);
|
||||
vo.setExpiration(new Date(System.currentTimeMillis() + 600_000L));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 호출 횟수를 세면서 지정한 토큰을 발급하는 supplier */
|
||||
private Function<AccessTokenVO, AccessTokenVO> supplier(AtomicInteger counter, String newToken) {
|
||||
return current -> {
|
||||
counter.incrementAndGet();
|
||||
return newToken == null ? null : token(newToken);
|
||||
};
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1. 캐시가 비어 있으면 발급하고 캐시에 저장한다")
|
||||
void 캐시비었을때_발급() {
|
||||
AtomicInteger issued = new AtomicInteger();
|
||||
|
||||
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", supplier(issued, "NEW"));
|
||||
|
||||
assertEquals(1, issued.get());
|
||||
assertEquals("NEW", result.getAccessToken());
|
||||
assertEquals("NEW", store.get(GROUP).getAccessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("2. 캐시 토큰이 거부된 토큰과 같으면 재발급한다")
|
||||
void 같은토큰이면_재발급() {
|
||||
store.put(GROUP, token("OLD"));
|
||||
AtomicInteger issued = new AtomicInteger();
|
||||
|
||||
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", supplier(issued, "NEW"));
|
||||
|
||||
assertEquals(1, issued.get());
|
||||
assertEquals("NEW", result.getAccessToken());
|
||||
assertEquals("NEW", store.get(GROUP).getAccessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("3. 다른 노드가 이미 갱신했으면 발급하지 않고 그 토큰을 쓴다")
|
||||
void 이미갱신됨_발급안함() {
|
||||
store.put(GROUP, token("ALREADY-NEW"));
|
||||
AtomicInteger issued = new AtomicInteger();
|
||||
|
||||
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", supplier(issued, "NEW"));
|
||||
|
||||
assertEquals(0, issued.get(), "다른 토큰이 이미 캐시에 있으면 발급하지 않아야 한다");
|
||||
assertEquals("ALREADY-NEW", result.getAccessToken());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("4. 빈 토큰이 발급되면 캐시에 저장하지 않는다")
|
||||
void 빈토큰_미캐싱() {
|
||||
store.put(GROUP, token("OLD"));
|
||||
AtomicInteger issued = new AtomicInteger();
|
||||
|
||||
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", supplier(issued, null));
|
||||
|
||||
assertEquals(1, issued.get());
|
||||
assertNull(result);
|
||||
assertEquals("OLD", store.get(GROUP).getAccessToken(), "캐시는 그대로여야 한다");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("5. 락을 얻지 못하면 발급하지 않고 기존 토큰을 반환한다")
|
||||
void 락획득실패() throws Exception {
|
||||
store.put(GROUP, token("OLD"));
|
||||
|
||||
Lock neverAcquired = Mockito.mock(Lock.class);
|
||||
Mockito.when(neverAcquired.tryLock(Mockito.anyLong(), any(TimeUnit.class))).thenReturn(false);
|
||||
locks.put(GROUP, neverAcquired);
|
||||
|
||||
AtomicInteger issued = new AtomicInteger();
|
||||
AccessTokenVO result = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", supplier(issued, "NEW"));
|
||||
|
||||
assertEquals(0, issued.get());
|
||||
assertEquals("OLD", result.getAccessToken());
|
||||
Mockito.verify(neverAcquired, Mockito.never()).unlock();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("6. 경합 - 여러 스레드가 같은 토큰으로 동시에 재발급해도 발급은 한 번만 일어난다")
|
||||
void 동시재발급_한번만() throws Exception {
|
||||
final int threadCount = 20;
|
||||
store.put(GROUP, token("OLD"));
|
||||
|
||||
final AtomicInteger issued = new AtomicInteger();
|
||||
final Function<AccessTokenVO, AccessTokenVO> slowSupplier = current -> {
|
||||
issued.incrementAndGet();
|
||||
try {
|
||||
// 발급에 시간이 걸리는 상황을 만들어 경합을 유도한다.
|
||||
Thread.sleep(50L);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return token("NEW");
|
||||
};
|
||||
|
||||
final CountDownLatch start = new CountDownLatch(1);
|
||||
final CountDownLatch done = new CountDownLatch(threadCount);
|
||||
final AccessTokenVO[] results = new AccessTokenVO[threadCount];
|
||||
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||
|
||||
try {
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
final int index = i;
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
start.await();
|
||||
results[index] = sessionManager.reissueOutboundAccessToken(GROUP, "OLD", slowSupplier);
|
||||
} catch (Exception e) {
|
||||
// 결과가 null 로 남아 아래 검증에서 걸린다.
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
start.countDown();
|
||||
assertTrue(done.await(30, TimeUnit.SECONDS), "모든 스레드가 끝나야 한다");
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
assertEquals(1, issued.get(), "동시에 들어와도 실제 발급은 한 번이어야 한다");
|
||||
assertEquals("NEW", store.get(GROUP).getAccessToken());
|
||||
for (int i = 0; i < threadCount; i++) {
|
||||
assertEquals("NEW", results[i].getAccessToken(), "모든 스레드가 새 토큰을 받아야 한다");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("7. 경합 - 어댑터그룹이 다르면 서로 막지 않는다")
|
||||
void 다른그룹은_독립적() throws Exception {
|
||||
final AtomicInteger issued = new AtomicInteger();
|
||||
final CountDownLatch bothInside = new CountDownLatch(2);
|
||||
final Function<AccessTokenVO, AccessTokenVO> blockingSupplier = current -> {
|
||||
issued.incrementAndGet();
|
||||
bothInside.countDown();
|
||||
try {
|
||||
// 두 그룹이 같은 락을 쓰면 여기서 서로를 기다리다 타임아웃된다.
|
||||
if (!bothInside.await(5, TimeUnit.SECONDS)) {
|
||||
return null;
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return token("NEW");
|
||||
};
|
||||
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
executor.submit(() -> sessionManager.reissueOutboundAccessToken("GRP-A", "OLD", blockingSupplier));
|
||||
executor.submit(() -> sessionManager.reissueOutboundAccessToken("GRP-B", "OLD", blockingSupplier));
|
||||
|
||||
assertTrue(bothInside.await(10, TimeUnit.SECONDS), "두 그룹이 동시에 발급 구간에 들어가야 한다");
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
assertEquals(2, issued.get());
|
||||
assertTrue(locks.containsKey("GRP-A") && locks.containsKey("GRP-B"), "그룹별로 락이 분리돼야 한다");
|
||||
assertSame(locks.get("GRP-A"), locks.get("GRP-A"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user