Skip to content

Commit 2e5f8bd

Browse files
authored
Merge pull request #246 from prgrms-web-devcourse-final-project/#0
fix: 경매방 하나라도 퇴장 시 세션 만료되는 문제 수정
2 parents 0236ea6 + f87cca3 commit 2e5f8bd

3 files changed

Lines changed: 112 additions & 3 deletions

File tree

src/main/java/devut/buzzerbidder/domain/chat/service/ChatRoomService.java

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,6 @@ public void enterChatRoom(User user, ChatRoom chatRoom) {
161161
}
162162

163163
public void exitAuctionChatRoom(Long auctionId, User user) {
164-
165-
walletRedisService.flushBalanceAndClearSession(user.getId(), null);
166-
167164
ChatRoom chatRoom = chatRoomRepository.findByAuctionId(auctionId)
168165
.orElseThrow(() -> new BusinessException(ErrorCode.CHATROOM_NOT_FOUND));
169166

src/main/java/devut/buzzerbidder/domain/liveBid/service/HeartbeatService.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public class HeartbeatService {
1414

1515
private final StringRedisTemplate redis;
1616

17+
private static final String SESSION_EXP_ZSET = "auction:sessions:exp";
1718
private static final Duration SESSION_TTL = Duration.ofSeconds(35);
1819
private static final Duration BALANCE_TTL = Duration.ofMinutes(10);
1920

@@ -29,5 +30,7 @@ public void heartbeat(Long userId) {
2930

3031
redis.expire(balKey, BALANCE_TTL);
3132
redis.expire(verKey, BALANCE_TTL);
33+
long expireAtMs = System.currentTimeMillis() + SESSION_TTL.toMillis();
34+
redis.opsForZSet().add(SESSION_EXP_ZSET, userId.toString(), expireAtMs);
3235
}
3336
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package devut.buzzerbidder.domain.liveBid.service;
2+
3+
import devut.buzzerbidder.domain.wallet.service.WalletRedisService;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.springframework.data.redis.core.StringRedisTemplate;
7+
import org.springframework.data.redis.core.script.DefaultRedisScript;
8+
import org.springframework.scheduling.annotation.Scheduled;
9+
import org.springframework.stereotype.Component;
10+
11+
import java.util.List;
12+
import java.util.Objects;
13+
import java.util.concurrent.TimeUnit;
14+
15+
@Component
16+
@RequiredArgsConstructor
17+
@Slf4j
18+
public class SessionExpireScheduler {
19+
20+
private final StringRedisTemplate redis;
21+
private final WalletRedisService walletRedisService;
22+
23+
private static final String SESSION_PREFIX = "auction:session:";
24+
private static final String SESSION_EXP_ZSET = "auction:sessions:exp";
25+
26+
// 한 번에 처리할 최대 개수(배치 제한)
27+
private static final int BATCH_SIZE = 100;
28+
29+
/**
30+
* score <= nowMs 인 멤버를 최대 limit개 꺼내고, 같은 스크립트 안에서 제거까지 수행
31+
*/
32+
private static final String POP_DUE_MEMBERS_SCRIPT = """
33+
local zkey = KEYS[1]
34+
local nowMs = tonumber(ARGV[1])
35+
local limit = tonumber(ARGV[2])
36+
37+
if (not limit) or limit <= 0 then
38+
return {}
39+
end
40+
41+
local members = redis.call('ZRANGEBYSCORE', zkey, '-inf', nowMs, 'LIMIT', 0, limit)
42+
if (not members) or (#members == 0) then
43+
return {}
44+
end
45+
46+
redis.call('ZREM', zkey, unpack(members))
47+
return members
48+
""";
49+
50+
private final DefaultRedisScript<List<String>> popDueMembersScript = buildPopDueMembersScript();
51+
52+
@Scheduled(fixedDelay = 500)
53+
public void processExpiredSessions() {
54+
long nowMs = System.currentTimeMillis();
55+
56+
// 원자적으로 due userId들을 꺼냄(다른 서버와 경합해도 중복 감소)
57+
List<String> userIds = executeStringList(
58+
popDueMembersScript,
59+
List.of(SESSION_EXP_ZSET),
60+
String.valueOf(nowMs),
61+
String.valueOf(BATCH_SIZE)
62+
);
63+
64+
if (userIds.isEmpty()) return;
65+
66+
for (String userIdStr : userIds) {
67+
String sessionKey = SESSION_PREFIX + userIdStr;
68+
69+
try {
70+
// 1) 진짜 만료됐는지 최종 확인
71+
Boolean exists = redis.hasKey(sessionKey);
72+
if (Boolean.TRUE.equals(exists)) {
73+
// 아직 살아있으면(heartbeat가 늦게 갱신) TTL로 다시 스케줄
74+
Long ttlSec = redis.getExpire(sessionKey, TimeUnit.SECONDS);
75+
if (ttlSec != null && ttlSec > 0) {
76+
long expireAtMs = System.currentTimeMillis() + ttlSec * 1000L;
77+
redis.opsForZSet().add(SESSION_EXP_ZSET, userIdStr, expireAtMs);
78+
}
79+
continue;
80+
}
81+
82+
// 2) 만료 확정이면 flush
83+
walletRedisService.flushBalanceAndClearSession(Long.parseLong(userIdStr), null);
84+
85+
} catch (Exception e) {
86+
// 실패 시 로그
87+
log.error("SessionExpireScheduler failed. userId={}", userIdStr, e);
88+
}
89+
}
90+
}
91+
92+
/* ==================== Lua Script 빌더/실행 헬퍼 ==================== */
93+
94+
private DefaultRedisScript<List<String>> buildPopDueMembersScript() {
95+
DefaultRedisScript<List<String>> script = new DefaultRedisScript<>();
96+
97+
@SuppressWarnings({"rawtypes", "unchecked"})
98+
Class<List<String>> listClass = (Class) List.class;
99+
script.setResultType(listClass);
100+
101+
script.setScriptText(POP_DUE_MEMBERS_SCRIPT);
102+
return script;
103+
}
104+
105+
private List<String> executeStringList(DefaultRedisScript<List<String>> script, List<String> keys, String... args) {
106+
List<String> result = redis.execute(script, keys, (Object[]) args);
107+
return Objects.requireNonNullElse(result, List.of());
108+
}
109+
}

0 commit comments

Comments
 (0)