Spring Session — HttpSession을 Redis로

의존성

// build.gradle.kts
implementation("org.springframework.session:spring-session-data-redis")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("org.springframework.boot:spring-boot-starter-web")

설정

application.yml

spring:
  session:
    store-type: redis
    redis:
      namespace: "myapp:session"   # Redis 키 prefix
      flush-mode: on-save          # on-save | immediate
      save-mode: on-set-attribute  # always | on-set-attribute | on-get-attribute
      cleanup-cron: "0 * * * * *"  # 만료 세션 정리 (매 분)
    timeout: 7200s                 # 세션 만료 시간
  data:
    redis:
      host: localhost
      port: 6379
@EnableRedisHttpSession(
    maxInactiveIntervalInSeconds = 7200,  // 2시간
    redisNamespace = "myapp:session",
)
@Configuration
class SessionConfig {
 
    // 세션 쿠키 설정
    @Bean
    fun cookieSerializer(): CookieSerializer {
        return DefaultCookieSerializer().apply {
            setCookieName("SESSION")
            setCookiePath("/")
            setUseSecureCookie(true)
            setUseHttpOnlyCookie(true)
            setSameSite("Strict")
            setCookieMaxAge(7200)
        }
    }
}

기본 사용

@RestController
class AuthController(
    private val authService: AuthService,
) {
 
    @PostMapping("/login")
    fun login(
        @RequestBody request: LoginRequest,
        session: HttpSession,
    ): LoginResponse {
        val user = authService.authenticate(request.email, request.password)
 
        // 세션에 저장 → Redis에 자동 저장
        session.setAttribute("userId", user.id)
        session.setAttribute("role", user.role)
        session.setAttribute("email", user.email)
 
        return LoginResponse(
            userId = user.id,
            sessionId = session.id,
        )
    }
 
    @PostMapping("/logout")
    fun logout(session: HttpSession) {
        session.invalidate()  // Redis에서 세션 삭제
    }
 
    @GetMapping("/me")
    fun me(session: HttpSession): UserResponse {
        val userId = session.getAttribute("userId") as? Long
            ?: throw UnauthorizedException()
 
        return userService.get(userId).toResponse()
    }
}

세션 보안

// 로그인 성공 시 세션 ID 재생성 (Session Fixation 방지)
@PostMapping("/login")
fun login(
    request: HttpServletRequest,
    @RequestBody loginRequest: LoginRequest,
): LoginResponse {
    val user = authService.authenticate(loginRequest.email, loginRequest.password)
 
    // 기존 세션 무효화 후 새 세션 생성
    val oldSession = request.getSession(false)
    val oldAttributes = oldSession?.let {
        it.attributeNames.asSequence().associateWith { name -> it.getAttribute(name) }
    }
    oldSession?.invalidate()
 
    val newSession = request.getSession(true)
    oldAttributes?.forEach { (name, value) -> newSession.setAttribute(name, value) }
 
    newSession.setAttribute("userId", user.id)
    newSession.setAttribute("role", user.role)
 
    return LoginResponse(userId = user.id)
}

사용자별 세션 관리

// application.yml 추가
spring:
  session:
    redis:
      namespace: "myapp:session"
@Configuration
class SessionConfig {
 
    // FindByIndexNameSessionRepository 활성화
    @Bean
    fun sessionRepository(
        connectionFactory: RedisConnectionFactory,
        defaultSerializer: RedisSerializer<Any>?,
    ): FindByIndexNameSessionRepository<RedisSession> {
        return RedisIndexedSessionRepository(
            RedisTemplate<String, Any>().apply {
                setConnectionFactory(connectionFactory)
                afterPropertiesSet()
            }
        ).also {
            it.setDefaultMaxInactiveInterval(Duration.ofHours(2))
            it.setRedisKeyNamespace("myapp:session")
        }
    }
}
@Service
class SessionManagementService(
    private val sessionRepository: FindByIndexNameSessionRepository<*>,
) {
 
    // 로그인 시 세션에 사용자 인덱스 추가
    fun setUserIndex(session: HttpSession, userId: Long) {
        session.setAttribute(
            FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME,
            userId.toString()
        )
    }
 
    // 특정 사용자의 모든 세션 조회
    fun getUserSessions(userId: Long): Map<String, *> {
        return sessionRepository.findByPrincipalName(userId.toString())
    }
 
    // 특정 사용자 강제 로그아웃 (모든 세션 삭제)
    fun forceLogout(userId: Long) {
        val sessions = sessionRepository.findByPrincipalName(userId.toString())
        sessions.keys.forEach { sessionId ->
            sessionRepository.deleteById(sessionId)
        }
        logger.info("강제 로그아웃: userId=$userId, 세션 수=${sessions.size}")
    }
 
    // 세션 수 제한 (최대 N개 동시 세션)
    fun enforceMaxSessions(userId: Long, maxSessions: Int) {
        val sessions = sessionRepository.findByPrincipalName(userId.toString())
        if (sessions.size >= maxSessions) {
            // 가장 오래된 세션 삭제
            sessions.entries
                .sortedBy { it.value.creationTime }
                .take(sessions.size - maxSessions + 1)
                .forEach { (sessionId, _) ->
                    sessionRepository.deleteById(sessionId)
                }
        }
    }
}

Redis에 저장되는 세션 구조

# 세션 데이터 (Hash)
myapp:session:sessions:<session-id>
 Hash {
      sessionAttr:userId = 1001,
      sessionAttr:role = "admin",
      creationTime = 1700000000000,
      lastAccessedTime = 1700003600000,
      maxInactiveInterval = 7200,
    }
 
# 만료 트리거 (더미 키)
myapp:session:sessions:expires:<session-id>
 "" (TTL 있음)
 
# 만료 인덱스
myapp:session:expirations:<roundedExpiry>
 Set { <session-id> }
 
# 사용자 인덱스 (FindByPrincipalName용)
myapp:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:<userId>
 Set { <session-id-1>, <session-id-2> }

다중 도메인 세션 공유

// 서브도메인 간 세션 공유
@Bean
fun cookieSerializer(): CookieSerializer {
    return DefaultCookieSerializer().apply {
        setCookieName("SESSION")
        // example.com의 모든 서브도메인에서 공유
        setDomainName("example.com")
        // 또는 패턴으로
        setDomainNamePattern("^.+?\\.(.+\\.[a-z]+)$")
    }
}

헤더 기반 세션 (REST API)

// 쿠키 대신 헤더로 세션 ID 전달
@Bean
fun httpSessionStrategy(): HttpSessionIdResolver {
    return HeaderHttpSessionIdResolver.xAuthToken()
    // 요청 헤더: X-Auth-Token: <session-id>
    // 응답 헤더: X-Auth-Token: <session-id>
}

정리

  • @EnableRedisHttpSession: HttpSession → Redis 자동 저장
  • session.setAttribute(key, value): Redis Hash에 저장
  • session.invalidate(): Redis에서 세션 삭제
  • FindByIndexNameSessionRepository: 사용자별 세션 조회/삭제
  • 세션 고정 방지: 로그인 시 세션 ID 재생성
  • 동시 세션 제한: findByPrincipalName + 오래된 세션 삭제