Skip to content

Commit 1283fa5

Browse files
committed
Feat: 세션 리스너로 게스트 만료 세션 정리
1 parent 8cdb7a2 commit 1283fa5

File tree

6 files changed

+125
-114
lines changed

6 files changed

+125
-114
lines changed

back/src/main/java/com/back/domain/node/service/BaseLineService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
@Service
3535
@RequiredArgsConstructor
3636
@Slf4j
37-
class BaseLineService {
37+
public class BaseLineService {
3838

3939
private final BaseLineRepository baseLineRepository;
4040
private final BaseNodeRepository baseNodeRepository;

back/src/main/java/com/back/domain/user/controller/UserAuthController.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,11 @@ public ResponseEntity<UserResponse> guestLogin(HttpServletRequest request, HttpS
7373
HttpSession session = request.getSession(true);
7474

7575
session.setMaxInactiveInterval(600);
76-
session.setAttribute("guestId", savedGuest.getId());
76+
77+
session.setAttribute(
78+
org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME,
79+
savedGuest.getEmail()
80+
);
7781

7882
new HttpSessionSecurityContextRepository()
7983
.saveContext(SecurityContextHolder.getContext(), request, response);

back/src/main/java/com/back/global/config/RedisConfig.java

Lines changed: 0 additions & 27 deletions
This file was deleted.

back/src/main/java/com/back/global/scheduler/GuestCleanupScheduler.java

Lines changed: 32 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -3,117 +3,65 @@
33
import com.back.domain.user.entity.Role;
44
import com.back.domain.user.entity.User;
55
import com.back.domain.user.repository.UserRepository;
6-
import com.back.global.security.CustomUserDetails;
76
import lombok.RequiredArgsConstructor;
87
import lombok.extern.slf4j.Slf4j;
9-
import org.springframework.data.redis.core.RedisTemplate;
108
import org.springframework.scheduling.annotation.Scheduled;
11-
import org.springframework.security.core.context.SecurityContext;
9+
import org.springframework.session.FindByIndexNameSessionRepository;
1210
import org.springframework.session.Session;
13-
import org.springframework.session.SessionRepository;
1411
import org.springframework.stereotype.Component;
1512
import org.springframework.transaction.annotation.Transactional;
1613

1714
import java.util.ArrayList;
18-
import java.util.HashSet;
1915
import java.util.List;
20-
import java.util.Set;
16+
import java.util.Map;
2117

2218
/**
23-
* 만료된 게스트 유저를 주기적으로 삭제하는 스케줄러
24-
* - Redis Session을 확인하여 실제로 세션이 없는 게스트만 삭제
25-
* - 매 10분마다 실행
19+
* 게스트 유저 정리 보조 scheduler
20+
* - 세션 리스너에서 처리하지 못하고 남은 게스트가 있다면 삭제
2621
*/
2722
@Slf4j
2823
@Component
2924
@RequiredArgsConstructor
3025
public class GuestCleanupScheduler {
3126

3227
private final UserRepository userRepository;
33-
private final RedisTemplate<String, Object> redisTemplate;
34-
private final SessionRepository sessionRepository;
28+
private final FindByIndexNameSessionRepository<? extends Session> sessionIndexRepo;
3529

36-
@Scheduled(cron = "0 */30 * * * ?")
30+
@Scheduled(cron = "0 0 17 * * ?") // 매일 오후 5시
3731
@Transactional
3832
public void cleanupExpiredGuests() {
39-
log.info("=== 게스트 정리 작업 시작 ===");
33+
log.info("=== 게스트 정리 작업 시작 (index 기반) ===");
4034

41-
try {
42-
Set<String> sessionKeys = redisTemplate.keys("spring:session:sessions:*");
43-
Set<Long> activeGuestIds = new HashSet<>();
44-
45-
if (sessionKeys != null && !sessionKeys.isEmpty()) {
46-
log.debug("현재 활성 세션 수: {}", sessionKeys.size());
47-
48-
for (String key : sessionKeys) {
49-
if (key.contains("expires")) continue; // expires 키는 스킵
50-
51-
String sessionId = key.replace("spring:session:sessions:", "");
52-
53-
try {
54-
Session session = sessionRepository.findById(sessionId);
55-
if (session == null) continue;
56-
57-
SecurityContext securityContext = session.getAttribute("SPRING_SECURITY_CONTEXT");
58-
if (securityContext == null || securityContext.getAuthentication() == null) {
59-
continue;
60-
}
61-
62-
Object principal = securityContext.getAuthentication().getPrincipal();
63-
64-
if (principal instanceof CustomUserDetails) {
65-
CustomUserDetails userDetails = (CustomUserDetails) principal;
66-
User user = userDetails.getUser();
67-
68-
if (user.getRole() == Role.GUEST) {
69-
activeGuestIds.add(user.getId());
70-
log.debug("활성 게스트 세션: id={}", user.getId());
71-
}
72-
}
73-
} catch (Exception e) {
74-
log.warn("세션 읽기 실패: {} - {}", sessionId, e.getMessage());
75-
}
76-
}
77-
}
78-
79-
List<User> allGuests = userRepository.findByRole(Role.GUEST);
80-
81-
if (allGuests.isEmpty()) {
82-
log.info("데이터베이스에 게스트 유저가 없습니다.");
83-
return;
84-
}
85-
86-
log.debug("전체 게스트 유저 수: {}", allGuests.size());
87-
88-
// 3. 세션이 없는 게스트 필터링
89-
List<User> expiredGuests = new ArrayList<>();
90-
91-
for (User guest : allGuests) {
92-
if (!activeGuestIds.contains(guest.getId())) {
93-
expiredGuests.add(guest);
94-
}
95-
}
96-
97-
if (!expiredGuests.isEmpty()) {
98-
userRepository.deleteAll(expiredGuests);
35+
List<User> allGuests = userRepository.findByRole(Role.GUEST);
36+
if (allGuests.isEmpty()) {
37+
log.info("게스트 유저가 없습니다.");
38+
return;
39+
}
9940

100-
List<Long> deletedIds = expiredGuests.stream()
101-
.map(User::getId)
102-
.toList();
41+
List<User> expiredGuests = new ArrayList<>();
10342

104-
log.info("{}명의 만료된 게스트 삭제 완료 - ID: {}", expiredGuests.size(), deletedIds);
105-
} else {
106-
log.info("삭제할 만료된 게스트가 없습니다.");
107-
}
43+
for (User guest : allGuests) {
44+
String principal = guest.getEmail();
10845

109-
if (!activeGuestIds.isEmpty()) {
110-
log.info("활성 세션이 있는 게스트: {}명 - ID: {}", activeGuestIds.size(), activeGuestIds);
111-
}
46+
Map<String, ? extends Session> sessions =
47+
sessionIndexRepo.findByIndexNameAndIndexValue(
48+
FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME,
49+
principal
50+
);
11251

113-
log.info("=== 게스트 정리 작업 완료 ===");
52+
boolean hasActive = sessions != null && !sessions.isEmpty();
53+
if (!hasActive) expiredGuests.add(guest);
54+
}
11455

115-
} catch (Exception e) {
116-
log.error("게스트 정리 작업 중 오류 발생", e);
56+
if (!expiredGuests.isEmpty()) {
57+
userRepository.deleteAllInBatch(expiredGuests);
58+
log.info("만료 게스트 {}명 삭제 완료 - IDs={}",
59+
expiredGuests.size(),
60+
expiredGuests.stream().map(User::getId).toList());
61+
} else {
62+
log.info("삭제할 만료 게스트가 없습니다.");
11763
}
64+
65+
log.info("=== 게스트 정리 작업 완료 ===");
11866
}
11967
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.back.global.session;
2+
3+
import com.back.domain.node.entity.BaseLine;
4+
import com.back.domain.node.repository.BaseLineRepository;
5+
import com.back.domain.node.service.BaseLineService;
6+
import com.back.domain.user.entity.Role;
7+
import com.back.domain.user.repository.UserRepository;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.context.event.EventListener;
11+
import org.springframework.session.FindByIndexNameSessionRepository;
12+
import org.springframework.session.Session;
13+
import org.springframework.session.events.AbstractSessionEvent;
14+
import org.springframework.session.events.SessionDeletedEvent;
15+
import org.springframework.session.events.SessionExpiredEvent;
16+
import org.springframework.stereotype.Component;
17+
import org.springframework.transaction.annotation.Propagation;
18+
import org.springframework.transaction.annotation.Transactional;
19+
20+
import java.util.List;
21+
22+
/**
23+
* 게스트 세션이 만료되거나 삭제될 때 해당 게스트 계정과 관련된 모든 데이터를 정리하는 리스너
24+
* (Spring Session의 SessionExpiredEvent / SessionDeletedEvent를 수신)
25+
*/
26+
@Slf4j
27+
@Component
28+
@RequiredArgsConstructor
29+
public class GuestSessionListener {
30+
31+
private final UserRepository userRepository;
32+
private final BaseLineRepository baseLineRepository;
33+
private final BaseLineService baseLineService;
34+
35+
@EventListener({SessionExpiredEvent.class, SessionDeletedEvent.class})
36+
@Transactional(propagation = Propagation.REQUIRES_NEW)
37+
public void onSessionEvent(AbstractSessionEvent event) {
38+
final Session session = event.getSession();
39+
if (session == null) return;
40+
41+
final String principalName =
42+
session.getAttribute(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME);
43+
if (principalName == null || !principalName.startsWith("guest_")) return;
44+
45+
var userOpt = userRepository.findByEmail(principalName)
46+
.filter(u -> u.getRole() == Role.GUEST);
47+
48+
if (userOpt.isEmpty()) {
49+
log.info("[GuestSessionListener] 이미 삭제되었거나 게스트 아님: {}", principalName);
50+
return;
51+
}
52+
53+
var user = userOpt.get();
54+
Long userId = user.getId();
55+
56+
try {
57+
// 게스트 소유 baseline 조회
58+
List<Long> baseLineIds = baseLineRepository.findByUser_IdOrderByIdDesc(userId).stream().map(BaseLine::getId).toList();
59+
60+
// deleteBaseLineDeep -> baseLine부터 baseNode, decisionLine, scenario 등등 모두 다 삭제
61+
for (Long baseLineId : baseLineIds) {
62+
try {
63+
baseLineService.deleteBaseLineDeep(userId, baseLineId);
64+
} catch (Exception ex) {
65+
log.error("[GuestSessionListener] baseline({}) 삭제 실패 - 계속 진행", baseLineId, ex);
66+
}
67+
}
68+
69+
userRepository.delete(user);
70+
71+
log.info("[GuestSessionListener] {} -> 게스트 및 소유 리소스 삭제 완료: {}",
72+
(event instanceof SessionExpiredEvent) ? "SessionExpiredEvent" : "SessionDeletedEvent",
73+
principalName);
74+
75+
} catch (Exception e) {
76+
log.error("[GuestSessionListener] 게스트({}) 삭제 처리 중 오류", principalName, e);
77+
throw e;
78+
}
79+
}
80+
}

back/src/main/resources/application.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@ spring:
2828
highlight_sql: true
2929
default_batch_fetch_size: 100
3030
open-in-view: false
31-
security: # 여기에 추가
31+
32+
session:
33+
store-type: redis
34+
redis:
35+
repository-type: indexed
36+
37+
security:
3238
oauth2:
3339
client:
3440
registration:

0 commit comments

Comments
 (0)