토큰 만료 전 선제 갱신

- getOutboundAccessToken 은 토큰이 없거나 이미 만료됐을 때만 발급 함수를 호출해서,
  그 안에 있던 "다음 스케줄 전에 만료되면 재발급" 판정에 도달하지 못했음
  (토큰이 항상 만료된 뒤에야 갱신되어 최대 intervalSec 동안 만료 토큰을 사용)
- 스케줄러가 peekOutboundAccessToken 으로 먼저 확인하고, 다음 틱 전에 만료되면
  reissueOutboundAccessToken 으로 미리 갱신하도록 판정 위치를 옮김
- 여러 노드가 동시에 들어와도 분산락 + oldToken 비교로 발급은 한 번만 일어남
- 단위테스트 5건 추가
This commit is contained in:
curry772
2026-09-11 14:08:18 +09:00
parent b92518eb5a
commit e46678ed76
2 changed files with 166 additions and 26 deletions
@@ -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<AccessTokenVO, AccessTokenVO>() {
SessionManager sessionManager = SessionManager.getInstance();
@Override
public AccessTokenVO apply(AccessTokenVO accessToken) {
long intervalTime = System.currentTimeMillis() + (credential.getIntervalSec() * 1000);
// // 토큰이 없거나, 다음 스케줄 시간 전에 만료될 경우 재발급
if (accessToken == null) {
// 발급을 유발하지 않는 조회로 먼저 상태를 본다.
// 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);
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);
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, accessToken.getExpiration());
return accessToken;
logger.debug(
"Token still valid until next schedule for adapter group: {}, expiration date: {}",
adapterGroupName, cached.getExpiration());
}
} else {
//토큰이 있지만 만료 시간이 없는 경우
logger.debug("Token exists but expiration time is null for adapter group: {}", adapterGroupName);
return accessToken;
}
}
});
logger.debug("Token issuance completed for adapter group: {}", adapterGroupName);
} catch (Exception e) {
@@ -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());
}
}