From b92518eb5a8596a1c0b4ad9dfc75d76bc1821e18 Mon Sep 17 00:00:00 2001 From: curry772 Date: Fri, 11 Sep 2026 14:08:04 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B1=B0=EB=9E=98=20=EC=A4=91=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=EC=9D=84=20=EB=B6=84?= =?UTF-8?q?=EC=82=B0=EB=9D=BD=20=EA=B2=BD=EB=A1=9C=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 재발급 결과를 캐시에 반영하지 않아 거래마다 발급되고, synchronized 가 JVM 단위라 클러스터에서 노드 수만큼 중복 발급되던 문제 - SessionManager.reissueOutboundAccessToken 추가 : 분산락 안에서 oldToken 을 비교해 이미 갱신됐으면 그 토큰을 쓰고, 아니면 한 번만 발급 후 캐시에 반영 - 발급 구현체는 호출자가 넘긴 어댑터 속성 기준으로 선택 (기존 의미 유지) - HttpClient5AdapterServiceRest : 응답 기반 재발급도 DB 기반 매니저를 쓰도록 정리 - 아웃바운드 토큰 분산락 키를 전역 상수에서 어댑터그룹별로 변경 - 단위테스트 12건 추가 (경합 상황 포함) --- .../impl/HttpClient5AdapterServiceRest.java | 5 +- .../authoutbound/AccessTokenManagerByDB.java | 61 +++-- .../eai/common/session/SessionManager.java | 14 + .../session/SessionManagerForEhcache.java | 6 + .../session/SessionManagerForIgnite.java | 75 +++++- .../AccessTokenManagerByDBRetryTest.java | 192 ++++++++++++++ .../SessionManagerForIgniteReissueTest.java | 248 ++++++++++++++++++ 7 files changed, 572 insertions(+), 29 deletions(-) create mode 100644 src/test/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDBRetryTest.java create mode 100644 src/test/java/com/eactive/eai/common/session/SessionManagerForIgniteReissueTest.java diff --git a/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRest.java b/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRest.java index 6d3ff9f..bc322e4 100644 --- a/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRest.java +++ b/src/main/java/com/eactive/eai/adapter/http/client/impl/HttpClient5AdapterServiceRest.java @@ -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); diff --git a/src/main/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDB.java b/src/main/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDB.java index 2cd97a9..7b60418 100644 --- a/src/main/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDB.java +++ b/src/main/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDB.java @@ -602,29 +602,48 @@ public class AccessTokenManagerByDB implements Lifecycle { * 3. 주의사항 * **/ - public synchronized AccessTokenVO retryAccessTokenVO(String adapterGroupName, Properties properties, - String oldToken) throws Exception { + public 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)) { - - if (isOAuthCredentialRegistered(adapterGroupName) == false) { - throw new Exception( - "There is no OAuthCredentialRegistered information, or whether to use it is 'N'."); - } - String type = properties.getProperty("ADAPTER_TOKEN_ISSUING_CLIENT_TYPE"); - - HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type); - OutboundOAuthCredentialVo outboundOAuthCredentialVo = outboundOAuthCredentialVos.get(adapterGroupName); - if (service != null) { - accessToken = (AccessTokenVO) service.execute(adapterGroupName, properties, - outboundOAuthCredentialVo); - } + if (isOAuthCredentialRegistered(adapterGroupName) == false) { + throw new Exception("There is no OAuthCredentialRegistered information, or whether to use it is 'N'."); } - return accessToken; + String type = properties.getProperty("ADAPTER_TOKEN_ISSUING_CLIENT_TYPE"); + final HttpClientAccessTokenServiceByDB service = HttpClientAccessTokenServiceFactoryByDB.createFactory(type); + + 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() { + + @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); + + 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; + } + } + }); } } diff --git a/src/main/java/com/eactive/eai/common/session/SessionManager.java b/src/main/java/com/eactive/eai/common/session/SessionManager.java index 671ea97..ebe1fb7 100644 --- a/src/main/java/com/eactive/eai/common/session/SessionManager.java +++ b/src/main/java/com/eactive/eai/common/session/SessionManager.java @@ -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 tokenSupplier); + public abstract void removeOutboundAccessToken(String key); public abstract void clearOutboundAccessToken(); diff --git a/src/main/java/com/eactive/eai/common/session/SessionManagerForEhcache.java b/src/main/java/com/eactive/eai/common/session/SessionManagerForEhcache.java index 6f628eb..effb325 100644 --- a/src/main/java/com/eactive/eai/common/session/SessionManagerForEhcache.java +++ b/src/main/java/com/eactive/eai/common/session/SessionManagerForEhcache.java @@ -956,6 +956,12 @@ public class SessionManagerForEhcache extends SessionManager { throw new UnsupportedOperationException(); } + @Override + public AccessTokenVO reissueOutboundAccessToken(String key, String oldToken, + Function tokenSupplier) { + throw new UnsupportedOperationException(); + } + @Override public void removeOutboundAccessToken(String key) { throw new UnsupportedOperationException(); diff --git a/src/main/java/com/eactive/eai/common/session/SessionManagerForIgnite.java b/src/main/java/com/eactive/eai/common/session/SessionManagerForIgnite.java index 352f34c..21948ed 100644 --- a/src/main/java/com/eactive/eai/common/session/SessionManagerForIgnite.java +++ b/src/main/java/com/eactive/eai/common/session/SessionManagerForIgnite.java @@ -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 evictMasterCache = null; // Socket Session cache 정보 @@ -831,6 +836,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 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) { @@ -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()) { diff --git a/src/test/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDBRetryTest.java b/src/test/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDBRetryTest.java new file mode 100644 index 0000000..0d00d11 --- /dev/null +++ b/src/test/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDBRetryTest.java @@ -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 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 map = (Map) 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; + } + } +} diff --git a/src/test/java/com/eactive/eai/common/session/SessionManagerForIgniteReissueTest.java b/src/test/java/com/eactive/eai/common/session/SessionManagerForIgniteReissueTest.java new file mode 100644 index 0000000..447abe2 --- /dev/null +++ b/src/test/java/com/eactive/eai/common/session/SessionManagerForIgniteReissueTest.java @@ -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 store; + + /** 어댑터그룹별 락 (같은 키면 같은 인스턴스를 돌려줘야 경합 테스트가 성립한다) */ + private Map locks; + + @BeforeEach + void setUp() throws Exception { + sessionManager = new SessionManagerForIgnite(); + store = new ConcurrentHashMap<>(); + locks = new ConcurrentHashMap<>(); + + @SuppressWarnings("unchecked") + IgniteCache 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 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 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 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 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")); + } +}