기존 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은 세션 하나의 정보를 담고 있다:

필드타입설명
sessionIdString세션 ID (JSESSIONID 값)
principalObject세션 주인 (DiscodeitUserDetails)
lastRequestDate마지막 요청 시간
expiredboolean만료 여부

주요 메서드:

  • isExpired() : 만료됐으면 true
  • expireNow() : 세션을 즉시 만료 상태로 표시
  • getSessionId() : 세션 ID 반환
  • getPrincipal() : 사용자 정보 반환

그래서 anyMatch(session -> !session.isExpired())는 꺼낸 세션들 중 isExpired()false인 것, 즉 아직 살아있는 세션이 하나라도 있으면 온라인으로 판단하는 것이다.


UserStatus 엔티티 삭제 범위

UserStatus와 관련된 다음 코드를 모두 삭제한다:

  • UserStatus 엔티티
  • UserStatusRepository
  • UserStatusService 인터페이스
  • BasicUserStatusService
  • UserStatusController
  • UserStatusDto, 관련 request/response DTO
  • UserStatusMapper
  • User.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();
}

전체 흐름

sequenceDiagram participant Client participant Filter as Security Filter participant Registry as SessionRegistry participant Service Client->>Filter: 로그인 Filter->>Registry: 세션 등록 Filter-->>Client: JSESSIONID 발급 Client->>Filter: 로그아웃 Filter->>Registry: 세션 제거 (HttpSessionEventPublisher) Service->>Registry: getAllSessions(user) → 빈 리스트 Service-->>Service: 오프라인으로 판단