기존 UserStatus의 문제
기존에는 UserStatus 엔티티에 lastActiveAt을 저장해 온라인 여부를 판단했다. 이 방식의 문제:
- DB를 계속 조회/업데이트해야 함
- 정확한 로그인 상태가 아닌 "최근 활동 시간" 기반 추정
- 로그아웃해도 즉시 오프라인으로 반영되지 않음
SessionRegistry를 활용하면 실제 세션 존재 여부로 온라인 상태를 정확하게 판단할 수 있다.
SessionRegistry로 온라인 여부 확인
public boolean isOnline(String username) {
return sessionRegistry.getAllPrincipals().stream()
.filter(p -> p instanceof DiscodeitUserDetails)
.map(p -> (DiscodeitUserDetails) p)
.filter(p -> p.getUsername().equals(username))
.flatMap(p -> sessionRegistry.getAllSessions(p, false).stream())
.anyMatch(session -> !session.isExpired());
}
각 단계를 나눠서 보면:
sessionRegistry.getAllPrincipals().stream()
- 현재 세션을 가진 모든 사용자 목록을 Stream으로 변환
.filter(p -> p instanceof DiscodeitUserDetails)
.map(p -> (DiscodeitUserDetails) p)
DiscodeitUserDetails타입인 것만 필터링 후 캐스팅
.filter(p -> p.getUsername().equals(username))
- 찾는 username과 일치하는 사용자만 남김
.flatMap(p -> sessionRegistry.getAllSessions(p, false).stream())
- 여기서
getAllSessions()는List<SessionInformation>을 반환한다 map을 쓰면Stream<List<SessionInformation>>이 되어 리스트가 원소로 남는다flatMap을 쓰면 각 리스트를 펼쳐서Stream<SessionInformation>으로 만든다- 한 사용자가 여러 세션을 가질 수 있기 때문에 이 과정이 필요하다
택배 보관함에 비유하면 이렇다.
getAllPrincipals(): 보관함을 사용 중인 사람 목록 조회filter+map: 그 중에서 찾는 사람 "앨리스"만 고름getAllSessions(): 앨리스의 보관함 칸 목록 조회 →[A칸, B칸]
여기서 문제가 생긴다.
map을 쓰면:
앨리스 → map → [ [A칸, B칸] ]
박스 안에 박스가 들어온 상태다. "A칸이 비었나요?"라고 물어보려 해도 박스를 열어야 한다.
flatMap을 쓰면:
앨리스 → flatMap → [A칸, B칸]
박스를 풀어서 칸들을 꺼낸 상태다. 이제 "A칸 비었나요?", "B칸 비었나요?" 바로 확인 가능하다.
anyMatch(session -> !session.isExpired())는 "칸 중에 아직 물건이 있는 게 하나라도 있나요?"다. 있으면 온라인(true).
.anyMatch(session -> !session.isExpired());
- 만료되지 않은 세션이 하나라도 있으면
true(온라인)
SessionInformation 안에는 무엇이 있나?
flatMap으로 꺼낸 각 SessionInformation은 세션 하나의 정보를 담고 있다:
| 필드 | 타입 | 설명 |
|---|---|---|
sessionId | String | 세션 ID (JSESSIONID 값) |
principal | Object | 세션 주인 (DiscodeitUserDetails) |
lastRequest | Date | 마지막 요청 시간 |
expired | boolean | 만료 여부 |
주요 메서드:
isExpired(): 만료됐으면trueexpireNow(): 세션을 즉시 만료 상태로 표시getSessionId(): 세션 ID 반환getPrincipal(): 사용자 정보 반환
그래서 anyMatch(session -> !session.isExpired())는 꺼낸 세션들 중 isExpired()가 false인 것, 즉 아직 살아있는 세션이 하나라도 있으면 온라인으로 판단하는 것이다.
UserStatus 엔티티 삭제 범위
UserStatus와 관련된 다음 코드를 모두 삭제한다:
UserStatus엔티티UserStatusRepositoryUserStatusService인터페이스BasicUserStatusServiceUserStatusControllerUserStatusDto, 관련 request/response DTOUserStatusMapperUser.online필드 (@Transient)
UserStatus를 참조하는 코드가 남아 있으면 컴파일 에러가 난다. 삭제 후 전체 빌드로 누락 여부를 확인하자.
HttpSessionEventPublisher의 역할
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
HttpSession이 만료되거나 무효화될 때 Spring이 HttpSessionDestroyedEvent를 발행한다. HttpSessionEventPublisher가 이 이벤트를 받아 SessionRegistry에서도 해당 세션 정보를 자동으로 제거한다.
이 빈이 없으면:
- 세션이 타임아웃으로 만료돼도
SessionRegistry에는 계속 남는다 isOnline()결과가 부정확해진다- 동시 로그인 방지도 제대로 동작하지 않는다
최종 SecurityConfig 구조
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http,
LoginSuccessHandler loginSuccessHandler,
LoginFailureHandler loginFailureHandler,
AuthenticationEntryPointHandler authenticationEntryPointHandler,
AuthenticationDeniedHandler authenticationDeniedHandler,
SessionRegistry sessionRegistry) throws Exception {
return http
.sessionManagement(management -> management
.sessionConcurrency(concurrency -> concurrency
.maximumSessions(1)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry)
)
)
.authorizeHttpRequests(...)
.formLogin(...)
.logout(...)
.exceptionHandling(...)
.build();
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}