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 7b60418..e433226 100644 --- a/src/main/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDB.java +++ b/src/main/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDB.java @@ -247,33 +247,37 @@ public class AccessTokenManagerByDB implements Lifecycle { try { logger.debug("Executing token issuance for adapter group: {}", adapterGroupName); - SessionManager.getInstance().getOutboundAccessToken(adapterGroupName, new Function() { + SessionManager sessionManager = SessionManager.getInstance(); - @Override - public AccessTokenVO apply(AccessTokenVO accessToken) { - long intervalTime = System.currentTimeMillis() + (credential.getIntervalSec() * 1000); - // // 토큰이 없거나, 다음 스케줄 시간 전에 만료될 경우 재발급 - if (accessToken == null) { - logger.debug("Token not exists for adapter group: {}", adapterGroupName); - return issueToken(credential); - } else if(accessToken.getExpiration() != null) { - // 토큰이 있고 만료시간이 설정 된 경우 - if(accessToken.getExpiration().before(new Date(intervalTime))){ - // 다음 스케줄 전에 토큰이 만료되는 경우 재발급 - logger.debug("Token expired : {}, expiration date: {}", adapterGroupName, accessToken.getExpiration()); - return issueToken(credential); - } else { - // 토큰이 아직 유효한 경우 - logger.debug("Token still valid until next schedule for adapter group: {}, expiration date: {}", adapterGroupName, accessToken.getExpiration()); - return accessToken; - } - } else { - //토큰이 있지만 만료 시간이 없는 경우 - logger.debug("Token exists but expiration time is null for adapter group: {}", adapterGroupName); - return accessToken; - } - } - }); + // 발급을 유발하지 않는 조회로 먼저 상태를 본다. + // getOutboundAccessToken 은 "없거나 이미 만료" 일 때만 발급 함수를 부르기 때문에, + // 만료 임박 판정을 그 안에 두면 도달하지 못한다. + AccessTokenVO cached = sessionManager.peekOutboundAccessToken(adapterGroupName); + long intervalTime = System.currentTimeMillis() + (credential.getIntervalSec() * 1000L); + + if (cached == null) { + logger.debug("Token not exists for adapter group: {}", adapterGroupName); + sessionManager.getOutboundAccessToken(adapterGroupName, + token -> issueToken(credential)); + + } else if (cached.getExpiration() == null) { + // 만료시각이 없는 토큰은 갱신 시점을 판단할 수 없다. + logger.debug("Token exists but expiration time is null for adapter group: {}", + adapterGroupName); + + } else if (cached.getExpiration().before(new Date(intervalTime))) { + // 다음 스케줄 전에 만료되므로 미리 갱신한다. + // 여러 노드가 동시에 들어와도 분산락 안에서 oldToken 을 비교해 한 번만 발급된다. + logger.debug("Token expires before next schedule : {}, expiration date: {}", + adapterGroupName, cached.getExpiration()); + sessionManager.reissueOutboundAccessToken(adapterGroupName, cached.getAccessToken(), + token -> issueToken(credential)); + + } else { + logger.debug( + "Token still valid until next schedule for adapter group: {}, expiration date: {}", + adapterGroupName, cached.getExpiration()); + } logger.debug("Token issuance completed for adapter group: {}", adapterGroupName); } catch (Exception e) { diff --git a/src/test/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDBScheduleTest.java b/src/test/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDBScheduleTest.java new file mode 100644 index 0000000..2a43cfe --- /dev/null +++ b/src/test/java/com/eactive/eai/common/authoutbound/AccessTokenManagerByDBScheduleTest.java @@ -0,0 +1,136 @@ +package com.eactive.eai.common.authoutbound; + +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.lang.reflect.Method; +import java.util.Date; +import java.util.concurrent.ExecutorService; + +import org.junit.jupiter.api.AfterEach; +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.common.session.SessionManager; +import com.openbanking.eai.common.token.OAuth2AccessTokenVO; + +/** + * AccessTokenManagerByDB 스케줄러 태스크의 갱신 판정 단위테스트 + * + * 캐시 조회(peek) 결과에 따라 어느 경로로 가는지를 본다. + * - 토큰 없음 : getOutboundAccessToken (분산락 안에서 발급) + * - 다음 틱 전 만료 : reissueOutboundAccessToken (선제 갱신) + * - 아직 유효 : 아무것도 하지 않음 + */ +class AccessTokenManagerByDBScheduleTest { + + private static final String GROUP = "TESTGRP"; + + /** 토큰 재발급 주기(초) */ + private static final int INTERVAL_SEC = 30; + + private AccessTokenManagerByDB manager; + private SessionManager mockSessionManager; + private Method executeTokenTask; + + @BeforeEach + void setUp() throws Exception { + manager = new AccessTokenManagerByDB(); + + mockSessionManager = Mockito.mock(SessionManager.class); + Field field = SessionManager.class.getDeclaredField("instance"); + field.setAccessible(true); + field.set(null, mockSessionManager); + + executeTokenTask = AccessTokenManagerByDB.class.getDeclaredMethod("executeTokenTask", String.class, + OutboundOAuthCredentialVo.class); + executeTokenTask.setAccessible(true); + } + + @AfterEach + void tearDown() throws Exception { + Field field = AccessTokenManagerByDB.class.getDeclaredField("scheduler"); + field.setAccessible(true); + ((ExecutorService) field.get(manager)).shutdownNow(); + } + + /** 스케줄러 태스크를 한 번 실행한다. */ + private void runTask() throws Exception { + OutboundOAuthCredentialVo credential = new OutboundOAuthCredentialVo(); + credential.setAdapterGroupName(GROUP); + credential.setUseYn("Y"); + credential.setIntervalSec(INTERVAL_SEC); + + executeTokenTask.invoke(manager, GROUP, credential); + } + + /** 만료까지 expiresInSec 남은 캐시 토큰을 준비한다. */ + private void cached(String accessToken, Long expiresInSec) { + OAuth2AccessTokenVO vo = new OAuth2AccessTokenVO(); + vo.setAccessToken(accessToken); + if (expiresInSec != null) { + vo.setExpiration(new Date(System.currentTimeMillis() + expiresInSec * 1000L)); + } + Mockito.when(mockSessionManager.peekOutboundAccessToken(GROUP)).thenReturn(vo); + } + + @Test + @DisplayName("1. 캐시에 토큰이 없으면 분산락 경로로 발급한다") + void 토큰없음_발급() throws Exception { + Mockito.when(mockSessionManager.peekOutboundAccessToken(GROUP)).thenReturn(null); + + runTask(); + + Mockito.verify(mockSessionManager).getOutboundAccessToken(eq(GROUP), any()); + Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), any()); + } + + @Test + @DisplayName("2. 다음 틱 전에 만료되면 선제 갱신한다 (만료 전이어도)") + void 만료임박_선제갱신() throws Exception { + // 아직 20초 남았지만 다음 틱(30초 뒤) 전에 만료된다. + cached("OLD-TOKEN", 20L); + + runTask(); + + Mockito.verify(mockSessionManager).reissueOutboundAccessToken(eq(GROUP), eq("OLD-TOKEN"), any()); + Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any()); + } + + @Test + @DisplayName("3. 이미 만료된 토큰도 선제 갱신 경로로 간다") + void 이미만료_갱신() throws Exception { + cached("OLD-TOKEN", -60L); + + runTask(); + + Mockito.verify(mockSessionManager).reissueOutboundAccessToken(eq(GROUP), eq("OLD-TOKEN"), any()); + } + + @Test + @DisplayName("4. 다음 틱까지 유효하면 아무것도 하지 않는다") + void 유효하면_그대로() throws Exception { + cached("VALID-TOKEN", 600L); + + runTask(); + + Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), any()); + Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any()); + } + + @Test + @DisplayName("5. 만료시각이 없는 토큰은 갱신 판단을 하지 않는다") + void 만료시각없음_보류() throws Exception { + cached("NO-EXPIRATION", null); + + runTask(); + + Mockito.verify(mockSessionManager, Mockito.never()).reissueOutboundAccessToken(anyString(), any(), any()); + Mockito.verify(mockSessionManager, Mockito.never()).getOutboundAccessToken(anyString(), any()); + } +}