Skip to content

Commit 89f0fa5

Browse files
authored
[refactor] Auth Redis 장애 발생 시, DB로 fallback. CirCuitBreaker추가하여 Auth Redis 장애 대처
1 parent 4a174c3 commit 89f0fa5

19 files changed

Lines changed: 485 additions & 50 deletions

backend/build.gradle.kts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ plugins {
55
checkstyle
66
jacoco
77
}
8+
val springCloudVersion by extra("2025.0.1")
89

910
group = "com"
1011
version = "0.0.1-SNAPSHOT"
@@ -89,6 +90,9 @@ dependencies {
8990
// flyway
9091
implementation("org.flywaydb:flyway-core")
9192
implementation("org.flywaydb:flyway-database-postgresql")
93+
94+
// resilience4j
95+
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")
9296
}
9397

9498
tasks.withType<Test> {
@@ -326,4 +330,9 @@ tasks.named<org.gradle.api.plugins.quality.Checkstyle>("checkstyleMain") {
326330
dependsOn(tasks.named("compileTestJava"))
327331
// QueryDSL generated 폴더 제외 (src/main/generated 기준 상대 경로)
328332
exclude("com/back/**/entity/Q*.java")
329-
}
333+
}
334+
dependencyManagement {
335+
imports {
336+
mavenBom("org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion")
337+
}
338+
}

backend/src/main/java/com/back/api/auth/service/AuthService.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@
1414
import com.back.api.auth.dto.response.AuthResponse;
1515
import com.back.api.auth.dto.response.TokenResponse;
1616
import com.back.api.auth.dto.response.UserResponse;
17+
import com.back.api.auth.store.AuthStore;
1718
import com.back.domain.auth.entity.ActiveSession;
1819
import com.back.domain.auth.entity.RefreshToken;
1920
import com.back.domain.auth.repository.ActiveSessionRepository;
20-
import com.back.domain.auth.repository.RefreshTokenRedisRepository;
2121
import com.back.domain.auth.repository.RefreshTokenRepository;
2222
import com.back.domain.user.entity.User;
2323
import com.back.domain.user.entity.UserActiveStatus;
@@ -29,9 +29,11 @@
2929
import com.back.global.utils.TokenHash;
3030

3131
import lombok.RequiredArgsConstructor;
32+
import lombok.extern.slf4j.Slf4j;
3233

3334
@Service
3435
@RequiredArgsConstructor
36+
@Slf4j
3537
public class AuthService {
3638

3739
private final UserRepository userRepository;
@@ -41,7 +43,7 @@ public class AuthService {
4143
private final RefreshTokenRepository refreshTokenRepository;
4244
private final ActiveSessionCache activeSessionCache;
4345
private final ActiveSessionRepository activeSessionRepository;
44-
private final RefreshTokenRedisRepository refreshTokenRedisRepository;
46+
private final AuthStore authStore;
4547

4648
@Transactional
4749
public AuthResponse signup(SignupRequest request) {
@@ -103,8 +105,7 @@ public void logout() {
103105
.orElseThrow(() -> new ErrorException(AuthErrorCode.ACCESS_OTHER_DEVICE));
104106

105107
refreshToken.revoke();
106-
107-
refreshTokenRedisRepository.delete(userId);
108+
authStore.deleteRefreshCache(userId);
108109

109110
// ActiveSession 캐시 무효화
110111
activeSessionCache.evict(userId);
@@ -129,7 +130,7 @@ private JwtDto loginAsSingleDevice(User user) {
129130
ActiveSession saved = activeSessionRepository.saveAndFlush(session);
130131

131132
refreshTokenRepository.revokeAllActiveByUserId(user.getId());
132-
refreshTokenRedisRepository.delete(user.getId());
133+
authStore.deleteRefreshCache(user.getId());
133134

134135
// 로그인 직후 Redis에 캐싱 불필요 DB접근 최소화
135136
ActiveSessionDto dto = ActiveSessionDto.from(saved);

backend/src/main/java/com/back/api/auth/service/AuthTokenService.java

Lines changed: 83 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,45 +4,48 @@
44
import java.time.LocalDateTime;
55

66
import org.apache.commons.lang3.StringUtils;
7+
import org.springframework.dao.DataIntegrityViolationException;
78
import org.springframework.stereotype.Service;
89
import org.springframework.transaction.annotation.Transactional;
910

1011
import com.back.api.auth.dto.JwtDto;
1112
import com.back.api.auth.dto.cache.RefreshTokenCache;
13+
import com.back.api.auth.store.AuthStore;
1214
import com.back.domain.auth.entity.ActiveSession;
1315
import com.back.domain.auth.entity.RefreshToken;
14-
import com.back.domain.auth.repository.RefreshTokenRedisRepository;
1516
import com.back.domain.auth.repository.RefreshTokenRepository;
1617
import com.back.domain.user.entity.User;
1718
import com.back.domain.user.repository.UserRepository;
1819
import com.back.global.error.code.AuthErrorCode;
1920
import com.back.global.error.exception.ErrorException;
20-
import com.back.global.http.HttpRequestContext;
21+
import com.back.global.http.RequestMetaProvider;
2122
import com.back.global.security.JwtClaims;
2223
import com.back.global.security.JwtProvider;
2324
import com.back.global.utils.TokenHash;
2425

2526
import lombok.RequiredArgsConstructor;
27+
import lombok.extern.slf4j.Slf4j;
2628

2729
@Service
2830
@RequiredArgsConstructor
31+
@Slf4j
2932
public class AuthTokenService {
3033

3134
private final JwtProvider jwtProvider;
3235
private final RefreshTokenRepository tokenRepository;
3336
private final UserRepository userRepository;
34-
private final HttpRequestContext requestContext;
35-
private final RefreshTokenRedisRepository refreshTokenRedisRepository;
37+
private final RequestMetaProvider requestMetaProvider;
3638
private final SessionGuard sessionGuard;
39+
private final AuthStore authStore;
3740

3841
@Transactional
3942
public JwtDto issueTokens(User user, String sessionId, long tokenVersion) {
4043
JwtDto dto = createJwtDto(user, sessionId, tokenVersion);
4144

42-
saveRefreshToRedis(user.getId(), dto.refreshToken(), sessionId, tokenVersion);
43-
4445
saveRefreshMetaToDB(user, dto.refreshToken(), sessionId, tokenVersion);
4546

47+
saveRefreshToStoreBestEffort(user.getId(), dto.refreshToken(), sessionId, tokenVersion);
48+
4649
return dto;
4750
}
4851

@@ -65,32 +68,91 @@ public JwtDto rotateTokenByRefreshToken(String refreshTokenStr) {
6568
long userId = claims.userId();
6669
String sid = claims.sessionId();
6770
long tokenVersion = claims.tokenVersion();
68-
69-
// 1. 세션을 읽고 현재 세션과 비교
70-
ActiveSession active = sessionGuard.requireActiveSessionForUpdate(userId);
71-
72-
sessionGuard.assertMatches(active, sid, tokenVersion);
73-
74-
// 2. Redis 검증: 현재 유효 refresh 인지 확인 (해시 비교)
75-
RefreshTokenCache cached = refreshTokenRedisRepository.find(userId)
76-
.orElseThrow(() -> new ErrorException(AuthErrorCode.ACCESS_OTHER_DEVICE));
71+
String jti = claims.jti();
7772

7873
String hash = TokenHash.sha256(refreshTokenStr);
79-
if (!hash.equals(cached.getRefreshTokenHash())
80-
|| !sid.equals(cached.getSessionId())
81-
|| tokenVersion != cached.getTokenVersion()) {
74+
75+
// 캐시에 "현재 유효 refresh"가 들어있다면, 불일치 요청은 DB까지 가지 않고 차단
76+
if (tryEarlyRejectByCache(userId, sid, tokenVersion, hash)) {
77+
log.warn("ROTATE_EARLY_DENY(cache): userId={} sid={} tokenVersion={} jti={}",
78+
userId, sid, tokenVersion, jti);
8279
throw new ErrorException(AuthErrorCode.ACCESS_OTHER_DEVICE);
8380
}
8481

82+
// 1. 세션을 읽고 현재 세션과 비교
83+
ActiveSession active = sessionGuard.requireActiveSessionForUpdate(userId);
84+
sessionGuard.assertMatches(active, sid, tokenVersion);
85+
8586
int updated = tokenRepository.revokeIfActive(hash);
8687
if (updated != 1) {
88+
log.warn("Rotate denied by concurrent/invalid refresh userId={} sid={} tokenVersion={}",
89+
userId, sid, tokenVersion);
8790
throw new ErrorException(AuthErrorCode.ACCESS_OTHER_DEVICE);
8891
}
8992

9093
User user = userRepository.findById(userId)
9194
.orElseThrow(() -> new ErrorException(AuthErrorCode.UNAUTHORIZED));
9295

93-
return issueTokens(user, active.getSessionId(), active.getTokenVersion());
96+
try {
97+
JwtDto dto = issueTokens(user, active.getSessionId(), active.getTokenVersion());
98+
log.info("ROTATE_OK: userId={} sid={} tokenVersion={} oldJti={}", userId, sid, tokenVersion, jti);
99+
return dto;
100+
} catch (DataIntegrityViolationException e) {
101+
// partial unique index(유저당 active 1개) 위반 시그널
102+
log.error("ROTATE_CONFLICT: unique active refresh violated userId={} sid={} tokenVersion={} jti={}",
103+
userId, sid, tokenVersion, jti, e);
104+
throw new ErrorException(AuthErrorCode.ACCESS_OTHER_DEVICE);
105+
}
106+
}
107+
108+
private boolean tryEarlyRejectByCache(long userId, String sid, long tokenVersion, String refreshHash) {
109+
try {
110+
// 캐시가 아예 unavailable 이면(OPEN/timeout) -> DB로
111+
if (!authStore.isAvailable()) {
112+
return false;
113+
}
114+
115+
return authStore.findRefreshCache(userId)
116+
.map(cache ->
117+
!refreshHash.equals(cache.getRefreshTokenHash())
118+
|| !sid.equals(cache.getSessionId())
119+
|| tokenVersion != cache.getTokenVersion())
120+
.orElse(false); // cache miss 면 DB로
121+
} catch (Exception e) {
122+
// Redis 장애는 rotate 자체를 막지 않고 DB로 진행 (가용성)
123+
log.warn("Redis cache check failed -> DB fallback. userId={}, cause={}", userId, e.toString());
124+
return false;
125+
}
126+
}
127+
128+
private void saveRefreshToStoreBestEffort(long userId, String refreshToken, String sid, long tokenVersion) {
129+
if (!authStore.isAvailable()) {
130+
log.warn("RedisAuthStore unavailable: skip cache write userId={}", userId);
131+
return;
132+
}
133+
134+
JwtClaims claims = jwtProvider.payloadOrNull(refreshToken);
135+
if (claims == null) {
136+
// 캐시에 잘못된 값 저장 방지
137+
log.warn("Refresh token claims null: skip cache write userId={}", userId);
138+
return;
139+
}
140+
141+
long refreshValiditySeconds = jwtProvider.getRefreshTokenValiditySeconds();
142+
143+
RefreshTokenCache cache = RefreshTokenCache.builder()
144+
.refreshTokenHash(TokenHash.sha256(refreshToken))
145+
.sessionId(sid)
146+
.tokenVersion(tokenVersion)
147+
.jti(claims.jti())
148+
.issuedAtEpochMs(System.currentTimeMillis())
149+
.build();
150+
151+
try {
152+
authStore.saveRefreshCache(userId, cache, Duration.ofSeconds(refreshValiditySeconds));
153+
} catch (Exception e) {
154+
log.warn("Cache write failed (ignored). userId={}", userId, e);
155+
}
94156
}
95157

96158
private JwtDto createJwtDto(User user, String sessionId, long tokenVersion) {
@@ -119,25 +181,6 @@ private JwtDto createJwtDto(User user, String sessionId, long tokenVersion) {
119181
);
120182
}
121183

122-
private void saveRefreshToRedis(long userId, String refreshToken, String sid, long tokenVersion) {
123-
JwtClaims claims = jwtProvider.payloadOrNull(refreshToken);
124-
if (claims == null) {
125-
throw new ErrorException(AuthErrorCode.INVALID_TOKEN);
126-
}
127-
128-
long refreshValiditySeconds = jwtProvider.getRefreshTokenValiditySeconds();
129-
130-
RefreshTokenCache cache = RefreshTokenCache.builder()
131-
.refreshTokenHash(TokenHash.sha256(refreshToken))
132-
.sessionId(sid)
133-
.tokenVersion(tokenVersion)
134-
.jti(claims.jti())
135-
.issuedAtEpochMs(System.currentTimeMillis())
136-
.build();
137-
138-
refreshTokenRedisRepository.save(userId, cache, Duration.ofSeconds(refreshValiditySeconds));
139-
}
140-
141184
private void saveRefreshMetaToDB(User user, String refreshToken, String sid, long tokenVersion) {
142185
JwtClaims claims = jwtProvider.payloadOrNull(refreshToken);
143186
String jti = (claims == null) ? null : claims.jti();
@@ -152,8 +195,8 @@ private void saveRefreshMetaToDB(User user, String refreshToken, String sid, lon
152195
.issuedAt(issuedAt)
153196
.expiresAt(expiresAt)
154197
.revoked(false)
155-
.userAgent(requestContext.getUserAgent())
156-
.ipAddress(requestContext.getClientIp())
198+
.userAgent(requestMetaProvider.userAgent())
199+
.ipAddress(requestMetaProvider.clientIp())
157200
.sessionId(sid)
158201
.tokenVersion(tokenVersion)
159202
.build();
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.back.api.auth.store;
2+
3+
import java.time.Duration;
4+
import java.util.Optional;
5+
6+
import com.back.api.auth.dto.cache.RefreshTokenCache;
7+
8+
public interface AuthStore {
9+
Optional<RefreshTokenCache> findRefreshCache(long userId);
10+
11+
void saveRefreshCache(long userId, RefreshTokenCache value, Duration ttl);
12+
13+
void deleteRefreshCache(long userId);
14+
15+
boolean isAvailable(); // redis 가 건강한지 확인
16+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package com.back.api.auth.store;
2+
3+
import java.time.Duration;
4+
import java.util.Optional;
5+
import java.util.concurrent.atomic.AtomicLong;
6+
7+
import org.springframework.cloud.client.circuitbreaker.CircuitBreaker;
8+
import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory;
9+
import org.springframework.stereotype.Component;
10+
11+
import com.back.api.auth.dto.cache.RefreshTokenCache;
12+
import com.back.domain.auth.repository.RefreshTokenRedisRepository;
13+
14+
import lombok.RequiredArgsConstructor;
15+
import lombok.extern.slf4j.Slf4j;
16+
17+
@Component
18+
@RequiredArgsConstructor
19+
@Slf4j
20+
public class SafeRedisAuthStore implements AuthStore {
21+
22+
private static final String CB_NAME = "redisAuth";
23+
private static final long COOL_DOWN_MS = 3000;
24+
25+
private final RefreshTokenRedisRepository redisRepository;
26+
private final CircuitBreakerFactory<?, ?> circuitBreakerFactory;
27+
28+
// isAvailable()을 매번 ping 하면 비용이 있으니, 최근 실패시간 기반으로 빠르게 false 처리
29+
private final AtomicLong lastFailureEpochMs = new AtomicLong(0);
30+
31+
private CircuitBreaker circuitBreaker() {
32+
return circuitBreakerFactory.create(CB_NAME);
33+
}
34+
35+
@Override
36+
public Optional<RefreshTokenCache> findRefreshCache(long userId) {
37+
return circuitBreaker().run(
38+
() -> redisRepository.find(userId),
39+
throwable -> {
40+
markFailure(throwable, "find", userId);
41+
return Optional.empty();
42+
}
43+
);
44+
}
45+
46+
@Override
47+
public void saveRefreshCache(long userId, RefreshTokenCache value, Duration ttl) {
48+
circuitBreaker().run(
49+
() -> {
50+
redisRepository.save(userId, value, ttl);
51+
return null;
52+
},
53+
throwable -> {
54+
markFailure(throwable, "save", userId);
55+
return null;
56+
}
57+
);
58+
}
59+
60+
@Override
61+
public void deleteRefreshCache(long userId) {
62+
circuitBreaker().run(
63+
() -> {
64+
redisRepository.delete(userId);
65+
return null;
66+
},
67+
throwable -> {
68+
markFailure(throwable, "delete", userId);
69+
return null;
70+
}
71+
);
72+
}
73+
74+
@Override
75+
public boolean isAvailable() {
76+
// 최근에 장애가 났으면 잠깐 false로 보고 빠르게 반환 (ex: 3초)
77+
long lastFail = lastFailureEpochMs.get();
78+
long now = System.currentTimeMillis();
79+
if (lastFail > 0 && (now - lastFail) < COOL_DOWN_MS) {
80+
return false;
81+
}
82+
// circuit breaker 가 run 에 성공하면 true 반환
83+
return circuitBreaker().run(
84+
() -> true,
85+
throwable -> {
86+
markFailure(throwable, "available-check", -1L);
87+
return false;
88+
}
89+
);
90+
}
91+
92+
private void markFailure(Throwable throwable, String op, long userId) {
93+
lastFailureEpochMs.set(System.currentTimeMillis());
94+
if (userId >= 0) {
95+
log.warn("Redis op failed. op={}, userId={}, cause={}", op, userId, throwable.toString());
96+
} else {
97+
log.warn("Redis op failed. op={}, cause={}", op, throwable.toString());
98+
}
99+
}
100+
}

0 commit comments

Comments
 (0)