Spring Boot
Redis Integration
Spring Boot auto-configures Redis through spring-boot-starter-data-redis, providing RedisTemplate for low-level operations, Spring Cache abstraction for annotation-driven caching, Spring Session for distributed session storage, and Spring Data repositories for structured data access. This entry covers setup, RedisTemplate operations, caching with @Cacheable, pub/sub messaging, distributed locks, rate limiting, and session management.
Setup and Configuration
Add spring-boot-starter-data-redis and optionally the Lettuce connection pool dependency. Spring Boot auto-configures a LettuceConnectionFactory and a RedisTemplate. Configure the connection in application.yml. For production, enable connection pooling and configure timeouts explicitly.
XML
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Connection pooling (Lettuce requires commons-pool2) -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
# ── application.yml ────────────────────────────────────────────────────
spring:
data:
redis:
host: localhost
port: 6379
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 16 # max connections in pool
max-idle: 8
min-idle: 2
max-wait: 1000ms # max time to wait for connection
# ── Redis Sentinel (high availability) ───────────────────────────────
spring:
data:
redis:
sentinel:
master: mymaster
nodes:
- sentinel-1:26379
- sentinel-2:26379
- sentinel-3:26379
password: ${REDIS_PASSWORD}
# ── Redis Cluster ────────────────────────────────────────────────────
spring:
data:
redis:
cluster:
nodes:
- redis-1:6379
- redis-2:6379
- redis-3:6379
max-redirects: 3
// ── Custom RedisTemplate configuration ────────────────────────────────
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// Key serializer — human-readable keys
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// Value serializer — JSON for portability
GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer();
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
@Bean
public StringRedisTemplate stringRedisTemplate(
RedisConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
}RedisTemplate Operations
RedisTemplate exposes operation objects for each Redis data structure: opsForValue() for strings, opsForHash() for hashes, opsForList() for lists, opsForSet() for sets, and opsForZSet() for sorted sets. Each operation object provides the full command set for that structure.
Java
@Service
@RequiredArgsConstructor
@Slf4j
public class RedisOperationsService {
private final RedisTemplate<String, Object> redisTemplate;
private final StringRedisTemplate stringTemplate;
// ── String operations ─────────────────────────────────────────────
public void stringOps() {
ValueOperations<String, Object> ops = redisTemplate.opsForValue();
// SET with TTL
ops.set("user:1:profile", new UserProfile("Alice", "alice@example.com"),
Duration.ofHours(1));
// GET
UserProfile profile = (UserProfile) ops.get("user:1:profile");
// SET if absent (SETNX)
Boolean set = ops.setIfAbsent("lock:order:42", "locked",
Duration.ofSeconds(30));
// Increment counter
Long views = ops.increment("article:1:views");
// GET and SET atomically
Object previous = ops.getAndSet("user:1:token", "newToken");
// Bulk GET
List<Object> values = ops.multiGet(
List.of("user:1:profile", "user:2:profile"));
}
// ── Hash operations ───────────────────────────────────────────────
public void hashOps() {
HashOperations<String, String, Object> ops =
redisTemplate.opsForHash();
// HSET multiple fields
Map<String, Object> fields = Map.of(
"name", "Alice",
"email", "alice@example.com",
"role", "ADMIN"
);
ops.putAll("user:1", fields);
// HGET single field
String name = (String) ops.get("user:1", "name");
// HMGET multiple fields
List<Object> values = ops.multiGet("user:1",
List.of("name", "email"));
// HGETALL
Map<String, Object> all = ops.entries("user:1");
// HDEL
ops.delete("user:1", "role");
// HINCRBY
ops.increment("user:1", "loginCount", 1);
}
// ── List operations ───────────────────────────────────────────────
public void listOps() {
ListOperations<String, Object> ops = redisTemplate.opsForList();
// RPUSH — append to right
ops.rightPush("queue:emails", new EmailTask("Welcome", "alice@example.com"));
ops.rightPushAll("queue:emails",
new EmailTask("Promo", "bob@example.com"),
new EmailTask("Alert", "carol@example.com"));
// LPOP — consume from left (FIFO queue)
EmailTask task = (EmailTask) ops.leftPop("queue:emails");
// BLPOP — blocking pop with timeout
EmailTask blocking = (EmailTask) ops.leftPop(
"queue:emails", Duration.ofSeconds(5));
// LRANGE — read without consuming
List<Object> pending = ops.range("queue:emails", 0, -1);
// LLEN
Long size = ops.size("queue:emails");
}
// ── Set operations ────────────────────────────────────────────────
public void setOps() {
SetOperations<String, Object> ops = redisTemplate.opsForSet();
// SADD
ops.add("product:1:tags", "electronics", "sale", "featured");
// SISMEMBER
Boolean isMember = ops.isMember("product:1:tags", "sale");
// SMEMBERS
Set<Object> tags = ops.members("product:1:tags");
// SINTER — intersection
Set<Object> common = ops.intersect(
"product:1:tags", "product:2:tags");
// SUNION — union
Set<Object> all = ops.union("product:1:tags", "product:2:tags");
// SREM
ops.remove("product:1:tags", "sale");
}
// ── Sorted set operations ─────────────────────────────────────────
public void sortedSetOps() {
ZSetOperations<String, Object> ops = redisTemplate.opsForZSet();
// ZADD
ops.add("leaderboard", "alice", 1500.0);
ops.add("leaderboard", "bob", 1200.0);
ops.add("leaderboard", "carol", 1800.0);
// ZINCRBY
ops.incrementScore("leaderboard", "alice", 100.0);
// ZRANGE — ascending by score
Set<Object> bottom = ops.range("leaderboard", 0, 9);
// ZREVRANGE — descending (top 10)
Set<Object> top10 = ops.reverseRange("leaderboard", 0, 9);
// ZRANK — position (0-based)
Long rank = ops.reverseRank("leaderboard", "alice");
// ZSCORE
Double score = ops.score("leaderboard", "alice");
}
}Caching with @Cacheable
Spring Cache abstraction decouples caching logic from business logic. Enable it with @EnableCaching, configure a RedisCacheManager with TTL per cache, and annotate service methods with @Cacheable, @CachePut, and @CacheEvict. Spring serialises return values to Redis and returns the cached value on subsequent calls without invoking the method.
Java
// ── Enable caching ────────────────────────────────────────────────────
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(
RedisConnectionFactory factory) {
// Default configuration
RedisCacheConfiguration defaults = RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.disableCachingNullValues()
.serializeKeysWith(RedisSerializationContext
.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext
.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
// Per-cache TTL overrides
Map<String, RedisCacheConfiguration> configs = Map.of(
"products", defaults.entryTtl(Duration.ofHours(1)),
"users", defaults.entryTtl(Duration.ofMinutes(15)),
"categories", defaults.entryTtl(Duration.ofHours(24)),
"sessions", defaults.entryTtl(Duration.ofMinutes(30))
);
return RedisCacheManager.builder(factory)
.cacheDefaults(defaults)
.withInitialCacheConfigurations(configs)
.build();
}
}
// ── Service with cache annotations ────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductService {
private final ProductRepository productRepo;
// ── @Cacheable — return cached value or invoke method and cache ────
@Cacheable(value = "products", key = "#id")
public ProductResponse findById(Long id) {
log.debug("Cache miss — loading product {} from DB", id);
return productRepo.findById(id)
.map(ProductResponse::from)
.orElseThrow(() -> new ProductNotFoundException(id));
}
// ── Conditional caching ───────────────────────────────────────────
@Cacheable(value = "products",
key = "#id",
condition = "#id > 0",
unless = "#result.price > 1000")
public ProductResponse findByIdConditional(Long id) {
return productRepo.findById(id)
.map(ProductResponse::from)
.orElseThrow(() -> new ProductNotFoundException(id));
}
// ── @CachePut — always invoke method, update cache ────────────────
@CachePut(value = "products", key = "#result.id")
@Transactional
public ProductResponse update(Long id, UpdateProductRequest request) {
Product product = productRepo.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
product.setName(request.name());
product.setPrice(request.price());
return ProductResponse.from(productRepo.save(product));
}
// ── @CacheEvict — remove entry on delete ─────────────────────────
@CacheEvict(value = "products", key = "#id")
@Transactional
public void delete(Long id) {
productRepo.deleteById(id);
}
// ── Evict all entries in a cache ──────────────────────────────────
@CacheEvict(value = "products", allEntries = true)
public void clearProductCache() {
log.info("Cleared all product cache entries");
}
// ── @Caching — multiple cache operations on one method ───────────
@Caching(evict = {
@CacheEvict(value = "products", key = "#id"),
@CacheEvict(value = "categories", allEntries = true)
})
@Transactional
public void deleteWithCategoryRefresh(Long id) {
productRepo.deleteById(id);
}
}Pub/Sub Messaging
Redis pub/sub allows services to broadcast messages on channels and receive them asynchronously. Spring provides RedisMessageListenerContainer to manage listener threads and MessageListener / @RedisListener interfaces to receive messages. Use pub/sub for cache invalidation signals, real-time notifications, and lightweight event broadcasting.
Java
// ── Message model ─────────────────────────────────────────────────────
public record CacheInvalidationMessage(
String entityType,
Long entityId,
String action // CREATED, UPDATED, DELETED
) {}
// ── Publisher ────────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class RedisPublisher {
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
public void publish(String channel, Object message) {
try {
String json = objectMapper.writeValueAsString(message);
redisTemplate.convertAndSend(channel, json);
log.debug("Published to {}: {}", channel, json);
} catch (JsonProcessingException e) {
log.error("Failed to publish message to {}", channel, e);
}
}
public void invalidateCache(String entityType,
Long entityId, String action) {
publish("cache:invalidation",
new CacheInvalidationMessage(entityType, entityId, action));
}
}
// ── Subscriber ────────────────────────────────────────────────────────
@Component
@RequiredArgsConstructor
@Slf4j
public class CacheInvalidationSubscriber implements MessageListener {
private final ObjectMapper objectMapper;
private final CacheManager cacheManager;
@Override
public void onMessage(Message message, byte[] pattern) {
try {
String json = new String(message.getBody(),
StandardCharsets.UTF_8);
CacheInvalidationMessage event = objectMapper
.readValue(json, CacheInvalidationMessage.class);
Cache cache = cacheManager.getCache(
event.entityType().toLowerCase() + "s");
if (cache != null) {
cache.evict(event.entityId());
log.info("Evicted {} {} from cache",
event.entityType(), event.entityId());
}
} catch (Exception e) {
log.error("Failed to process cache invalidation", e);
}
}
}
// ── Configuration — register listener container ───────────────────────
@Configuration
@RequiredArgsConstructor
public class RedisMessagingConfig {
private final RedisConnectionFactory factory;
private final CacheInvalidationSubscriber subscriber;
@Bean
public RedisMessageListenerContainer listenerContainer() {
RedisMessageListenerContainer container =
new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
// Subscribe to channel
container.addMessageListener(subscriber,
new ChannelTopic("cache:invalidation"));
// Pattern subscription — matches cache:* channels
container.addMessageListener(subscriber,
new PatternTopic("cache:*"));
return container;
}
@Bean
public RedisSerializer<String> redisSerializer() {
return new StringRedisSerializer();
}
}Distributed Locks
Redis distributed locks coordinate access to shared resources across multiple application instances. Implement the lock using SET NX PX (set if not exists with expiry) through RedisTemplate, or use Redisson's RLock which handles lock renewal, reentrance, and unlock safety automatically.
Java
// ── Simple lock with RedisTemplate ───────────────────────────────────
@Component
@RequiredArgsConstructor
@Slf4j
public class RedisDistributedLock {
private final StringRedisTemplate stringTemplate;
private static final String LOCK_PREFIX = "lock:";
/**
* Try to acquire a lock. Returns true if acquired.
* @param key resource identifier
* @param value unique caller ID (e.g. UUID) — used to release safely
* @param ttl maximum lock hold time
*/
public boolean tryAcquire(String key, String value, Duration ttl) {
Boolean acquired = stringTemplate.opsForValue()
.setIfAbsent(LOCK_PREFIX + key, value, ttl);
return Boolean.TRUE.equals(acquired);
}
/**
* Release the lock — only if we own it (compare-and-delete).
*/
public boolean release(String key, String value) {
String luaScript = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""";
Long result = stringTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
List.of(LOCK_PREFIX + key),
value);
return Long.valueOf(1L).equals(result);
}
/**
* Execute work under a distributed lock.
*/
public <T> T executeWithLock(String key, Duration ttl,
Supplier<T> work) {
String lockValue = UUID.randomUUID().toString();
if (!tryAcquire(key, lockValue, ttl)) {
throw new LockNotAvailableException(
"Could not acquire lock for: " + key);
}
try {
return work.get();
} finally {
release(key, lockValue);
}
}
}
// ── Service using the distributed lock ───────────────────────────────
@Service
@RequiredArgsConstructor
public class InventoryService {
private final RedisDistributedLock lock;
private final InventoryRepository inventoryRepo;
public void reserveStock(Long productId, int quantity) {
lock.executeWithLock(
"inventory:" + productId,
Duration.ofSeconds(10),
() -> {
InventoryItem item = inventoryRepo
.findByProductId(productId)
.orElseThrow();
if (item.getStockLevel() < quantity) {
throw new InsufficientStockException(
productId, quantity, item.getStockLevel());
}
item.setStockLevel(item.getStockLevel() - quantity);
inventoryRepo.save(item);
return null;
}
);
}
}
// ── Redisson distributed lock (production-grade) ──────────────────────
// <dependency>
// <groupId>org.redisson</groupId>
// <artifactId>redisson-spring-boot-starter</artifactId>
// <version>3.25.2</version>
// </dependency>
@Service
@RequiredArgsConstructor
public class RedissonLockService {
private final RedissonClient redisson;
public void executeWithLock(String resource, Runnable work) {
RLock lock = redisson.getLock("lock:" + resource);
try {
// Try for 5s, hold for max 30s, auto-renew via watchdog
if (!lock.tryLock(5, 30, TimeUnit.SECONDS)) {
throw new LockNotAvailableException(
"Could not acquire lock for: " + resource);
}
work.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockNotAvailableException("Lock interrupted");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}Rate Limiting
Redis is ideal for rate limiting — atomic Lua scripts ensure the check-and-increment operation is race-condition-free across multiple application instances. The sliding window and token bucket are the two most common algorithms. Use rate limiting to protect REST endpoints from abuse and to enforce per-user quotas.
Java
// ── Fixed window rate limiter ─────────────────────────────────────────
@Component
@RequiredArgsConstructor
@Slf4j
public class RedisRateLimiter {
private final StringRedisTemplate stringTemplate;
/**
* Fixed window: allow maxRequests per window duration.
* Returns true if the request is allowed.
*/
public boolean isAllowed(String key, int maxRequests,
Duration window) {
String redisKey = "rate:" + key;
String luaScript = """
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
return 0
end
return 1
""";
Long result = stringTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
List.of(redisKey),
String.valueOf(maxRequests),
String.valueOf(window.toSeconds()));
return Long.valueOf(1L).equals(result);
}
/**
* Sliding window: count requests in the last windowSeconds.
*/
public boolean isAllowedSlidingWindow(String key,
int maxRequests, int windowSeconds) {
String redisKey = "rate:sliding:" + key;
long now = System.currentTimeMillis();
long windowStart = now - (windowSeconds * 1000L);
String luaScript = """
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
local count = redis.call('ZCARD', KEYS[1])
if count < tonumber(ARGV[3]) then
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return 1
end
return 0
""";
Long result = stringTemplate.execute(
new DefaultRedisScript<>(luaScript, Long.class),
List.of(redisKey),
String.valueOf(windowStart),
String.valueOf(now),
String.valueOf(maxRequests),
String.valueOf(windowSeconds + 1));
return Long.valueOf(1L).equals(result);
}
}
// ── Rate limit filter — applied to every request ──────────────────────
@Component
@RequiredArgsConstructor
@Order(1)
@Slf4j
public class RateLimitFilter extends OncePerRequestFilter {
private final RedisRateLimiter rateLimiter;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String clientIp = getClientIp(request);
String key = "ip:" + clientIp;
if (!rateLimiter.isAllowed(key, 100, Duration.ofMinutes(1))) {
log.warn("Rate limit exceeded for IP: {}", clientIp);
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(
"{"error":"Too Many Requests"," +
""message":"Rate limit exceeded. " +
"Try again in 1 minute."}");
return;
}
chain.doFilter(request, response);
}
private String getClientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
return forwarded != null
? forwarded.split(",")[0].trim()
: request.getRemoteAddr();
}
}
// ── Per-user rate limit in a controller ───────────────────────────────
@GetMapping("/api/v1/reports/generate")
public ResponseEntity<ReportResponse> generate(
@AuthenticationPrincipal UserDetails user) {
if (!rateLimiter.isAllowedSlidingWindow(
"user:" + user.getUsername(), 10, 60)) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header("Retry-After", "60")
.build();
}
return ResponseEntity.ok(reportService.generate());
}Spring Session with Redis
Spring Session replaces the HTTP session with a Redis-backed store, enabling stateless application instances to share session state. Add spring-session-data-redis, annotate the configuration with @EnableRedisHttpSession, and Spring handles session serialisation, expiry, and retrieval transparently.
XML
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
# ── application.yml ────────────────────────────────────────────────────
spring:
session:
store-type: redis
redis:
namespace: myapp:session # Redis key prefix
flush-mode: on-save # or IMMEDIATE
timeout: 30m # session expiry
// ── Enable Redis HTTP session ─────────────────────────────────────────
@Configuration
@EnableRedisHttpSession(
maxInactiveIntervalInSeconds = 1800, // 30 minutes
redisNamespace = "myapp:session",
flushMode = FlushMode.ON_SAVE
)
public class SessionConfig {
// Spring Session creates its own serializer — configure if needed:
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
// ── Session usage in a controller ────────────────────────────────────
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
public ResponseEntity<UserResponse> login(
@RequestBody @Valid LoginRequest request,
HttpSession session) {
UserResponse user = authService.authenticate(request);
// Store in Redis-backed session
session.setAttribute("currentUser", user);
session.setAttribute("loginTime",
LocalDateTime.now().toString());
session.setMaxInactiveInterval(1800); // 30 min
return ResponseEntity.ok(user);
}
@GetMapping("/me")
public ResponseEntity<UserResponse> me(HttpSession session) {
UserResponse user =
(UserResponse) session.getAttribute("currentUser");
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
return ResponseEntity.ok(user);
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpSession session) {
session.invalidate(); // removes from Redis immediately
return ResponseEntity.noContent().build();
}
}
// ── Session event listener ────────────────────────────────────────────
@Component
@Slf4j
public class SessionEventListener {
@EventListener
public void onSessionCreated(SessionCreatedEvent event) {
log.info("Session created: {}", event.getSessionId());
}
@EventListener
public void onSessionExpired(SessionExpiredEvent event) {
log.info("Session expired: {}", event.getSessionId());
}
@EventListener
public void onSessionDeleted(SessionDeletedEvent event) {
log.info("Session deleted: {}", event.getSessionId());
}
}Redis Repository
Spring Data Redis repositories map Java objects to Redis hashes with @RedisHash. They support CRUD operations, TTL via @TimeToLive, and secondary indexes via @Indexed. Use them for lightweight entities that do not need a relational database — tokens, sessions, rate limit counters, and ephemeral domain objects.
Java
// ── Redis entity ──────────────────────────────────────────────────────
@RedisHash(value = "refresh_tokens", timeToLive = 604800) // 7 days
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class RefreshToken {
@Id
private String token; // Redis key: refresh_tokens:<token>
@Indexed
private String userId; // secondary index for lookup by userId
@Indexed
private String deviceId;
private String userAgent;
private String ipAddress;
private LocalDateTime createdAt;
private LocalDateTime lastUsedAt;
@TimeToLive
private Long ttl; // per-instance TTL override (seconds)
}
// ── Repository ────────────────────────────────────────────────────────
public interface RefreshTokenRepository
extends CrudRepository<RefreshToken, String> {
List<RefreshToken> findByUserId(String userId);
List<RefreshToken> findByUserIdAndDeviceId(
String userId, String deviceId);
void deleteByUserId(String userId);
}
// ── Service ───────────────────────────────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class RefreshTokenService {
private final RefreshTokenRepository tokenRepo;
public RefreshToken create(String userId, String deviceId,
HttpServletRequest request) {
RefreshToken token = new RefreshToken(
UUID.randomUUID().toString(),
userId,
deviceId,
request.getHeader("User-Agent"),
getClientIp(request),
LocalDateTime.now(),
LocalDateTime.now(),
null // use default TTL from @RedisHash
);
return tokenRepo.save(token);
}
public RefreshToken validate(String tokenValue) {
return tokenRepo.findById(tokenValue)
.orElseThrow(() -> new InvalidTokenException(
"Refresh token not found or expired"));
}
public void revokeAll(String userId) {
tokenRepo.deleteByUserId(userId);
log.info("Revoked all refresh tokens for user {}", userId);
}
public List<RefreshToken> findActiveSessions(String userId) {
return tokenRepo.findByUserId(userId);
}
private String getClientIp(HttpServletRequest request) {
String forwarded = request.getHeader("X-Forwarded-For");
return forwarded != null
? forwarded.split(",")[0].trim()
: request.getRemoteAddr();
}
}