[Basic-4] 세션 관리

2025. 7. 3. 21:08·Database/Redis

1. Redis를 통한 세션 관리

앞서 우리는 Lock(Mysql Lock, Redis Lock)을 활용해 분산 환경에서의 자원 보호 및 동시성 문제 해결 방법을 살펴보았다. 이번에는 그 연장선으로, 로그인 상태 유지(세션 관리)라는 주제를 Redis를 통해 어떻게 해결할 수 있을지 살펴보겠다.

1.1. 기존 세션 처리 방식의 한계

 웹 애플리케이션에서 세션(Session)은 사용자의 로그인 상태나 인증 정보를 유지하기 위한 메커니즘이다. Spring Boot 기반 웹 앱은 기본적으로 WAS(Tomcat)의 메모리 내에 세션 정보를 저장한다. 그러나 이 방식은 분산 환경, 특히 멀티 AZ 구조의 클라우드 인프라에서는 다음과 같은 문제가 발생할 수 있다. 

 

 아래 이미지를 보면서 설명을 이어가겠다. 아래 그림은 AWS EKS 환경에서의 전형적인 구성이다. 각기 다른 Availability Zone(AZ)에 퍼져 있는 EKS Worker Node들이 있고, 이들은 각각 Private Subnet에 위치하며 외부에서 접근 가능한 로드밸런서(ELB)와 연결되어 있다.

📌 문제 시나리오
1. 사용자A가 웹에 접속해 로그인을 시도한다. 
2. 로드밸런서는 사용자 A의 요청을 AZ-a에 위치한 특정 EKS Worker Node로 전달한다.
3. 이때 사용자 A의 세션 정보는 해당 노드의 메모리에 저장된다.

 

그런데 사용자가 로그아웃 후 다시 접속하면, 이번에는 로드밸런서가 요청을 AZ-c의 다른 노드로 보낼 수도 있다. 이 경우, 처음 저장된 세션 정보는 다른 노드에 있기 때문에 로그인 상태가 유지되지 않고 다시 로그인을 요구받게된다.

1.2. 외부 세션 저장소로 Redis를 선택하는 이유

이러한 문제를 해결하기 위해, 세션 정보를 개별 WAS 인스턴스가 아닌 외부 저장소에 저장하는 방식이 필요하다. 외부 세션 저장소로는 여러 가지가 있지만, 가장 널리 사용되는 것이 바로 Redis이다.

✅ Redis를 세션 저장소로 사용하는 이유

항목 이유
비휘발성 데이터 아님 로그인 세션은 휘발성 정보로, 디스크 저장이 불필요
빠른 응답 속도 메모리 기반 저장소로 I/O 성능 우수
TTL 지원 세션 만료시간(Time To Live)을 자연스럽게 설정 가능
분산 처리 적합 여러 노드가 동일한 Redis를 바라볼 수 있어 세션 공유 용이
스프링 연동 Spring Session + Redis 구성으로 손쉽게 적용 가능
📌 참고로 MySQL 같은 RDB를 세션 저장소로 사용할 수도 있지만, TTL 기능이 없어 직접 만료 처리를 해야 하고, I/O 병목이 발생할 수 있어 실무에서는 거의 사용하지 않다.

1.3. Redis를 활용한 세션 관리 방식

Spring Boot에서는 spring-session-data-redis 라이브러리를 통해 세션을 Redis에 저장하도록 손쉽게 구성할 수 있다. 이렇게 하면 어느 AZ에 위치한 노드로 요청이 가든, 하나의 Redis 저장소에서 동일한 세션 정보를 가져올 수 있게 되어 앞서 언급한 문제를 해결할 수 있다.

1.4. Redis Session 환경 구축

1.4.1. Configuration 작성

io/redis/performance/config/SessionConfig.java

더보기
더보기
@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600) // 1시간
@Slf4j
public class SessionConfig {

    @Value("${spring.data.redis.host}")
    private String redisHost;

    @Value("${spring.data.redis.port}")
    private int redisPort;

    @Value("${spring.data.redis.password}")
    private String redisPassword;

    @Bean
    public HttpSessionIdResolver httpSessionIdResolver() {
        return HeaderHttpSessionIdResolver.xAuthToken(); // 헤더 기반 세션
    }

    // 세션 이벤트 리스너
    @Component
    public static class SessionEventListener {

        @EventListener
        public void handleSessionCreated(SessionCreatedEvent event) {
            log.info("Session created: {}", event.getSessionId());
        }

        @EventListener
        public void handleSessionDeleted(SessionDeletedEvent event) {
            log.info("Session deleted: {}", event.getSessionId());
        }

        @EventListener
        public void handleSessionExpired(SessionExpiredEvent event) {
            log.info("Session expired: {}", event.getSessionId());
        }
    }

    // Redisson을 사용한 더 안전한 분산 락
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        String redisUrl = String.format("redis://%s:%d", redisHost, redisPort);
        config.useSingleServer()
                .setAddress(redisUrl)
                .setPassword(redisPassword.isEmpty() ? null : redisPassword);
        return Redisson.create(config);
    }
    
    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        return new GenericJackson2JsonRedisSerializer(mapper);
    }

}

1.4.2. Service 작성

io/redis/performance/service/UserSessionService.java

더보기
더보기
@Service
@RequiredArgsConstructor
@Slf4j
public class UserSessionService {

    private final RedisTemplate<String, Object> redisTemplate;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class UserSession {
        private String userId;
        private String username;
        private List<String> roles;
        private LocalDateTime loginTime;
        private String ipAddress;
        private String userAgent;
    }

    // 로그인 처리
    public void login(HttpSession session, UserSession userSession) {
        session.setAttribute("userSession", userSession);
        session.setAttribute("userId", userSession.getUserId());

        // 추가 정보를 Redis에 직접 저장
        String sessionKey = "spring:session:sessions:" + session.getId();
        redisTemplate.opsForHash().put(sessionKey, "loginTime", userSession.getLoginTime());
        redisTemplate.opsForHash().put(sessionKey, "ipAddress", userSession.getIpAddress());

        String customKey = "user:session:meta:" + session.getId();
        Map<String, Object> meta = Map.of(
                "loginTime", userSession.getLoginTime().toString(),
                "ipAddress", userSession.getIpAddress()
        );
        redisTemplate.opsForHash().putAll(customKey, meta);

        // TTL 세션과 동일 (SessionConfig)
        // @EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)과 일치 시켜야 함.
        redisTemplate.expire(customKey, Duration.ofSeconds(3600));

        // 동시 로그인 제한
        limitConcurrentSessions(userSession.getUserId(), session.getId());
    }

    // 동시 로그인 제한 (최대 2개 세션)
    private void limitConcurrentSessions(String userId, String currentSessionId) {
        String userSessionsKey = "user:sessions:" + userId;

        // 현재 세션 추가
        redisTemplate.opsForZSet().add(
                userSessionsKey,
                currentSessionId,
                System.currentTimeMillis()
        );

        // 오래된 세션 제거
        Set<Object> sessions = redisTemplate.opsForZSet().range(userSessionsKey, 0, -1);
        if (sessions.size() > 2) {
            Set<Object> toRemove = redisTemplate.opsForZSet().range(userSessionsKey, 0, sessions.size() - 3);
            for (Object sessionId : toRemove) {
                // 세션 무효화
                redisTemplate.delete("spring:session:sessions:" + sessionId);
                redisTemplate.opsForZSet().remove(userSessionsKey, sessionId);
                log.info("Removed old session for user {}: {}", userId, sessionId);
            }
        }
    }

    // 활성 사용자 수 조회
    public long getActiveUserCount() {
        Set<String> sessionKeys = redisTemplate.keys("spring:session:sessions:*");
        return sessionKeys != null ? sessionKeys.size() : 0;
    }

    // 사용자별 세션 조회
    public List<Map<String, Object>> getUserSessions(String userId) {
        String userSessionsKey = "user:sessions:" + userId;
        Set<ZSetOperations.TypedTuple<Object>> sessions =
                redisTemplate.opsForZSet().rangeWithScores(userSessionsKey, 0, -1);

        List<Map<String, Object>> result = new ArrayList<>();
        for (ZSetOperations.TypedTuple<Object> session : sessions) {
            String sessionId = session.getValue().toString();
            Map<String, Object> sessionInfo = new HashMap<>();
            sessionInfo.put("sessionId", sessionId);
            sessionInfo.put("loginTime", new Date(session.getScore().longValue()));

            // 세션 상세 정보 조회
            String sessionKey = "spring:session:sessions:" + sessionId;
            Map<Object, Object> sessionData = redisTemplate.opsForHash().entries(sessionKey);
            sessionInfo.put("lastAccess", sessionData.get("lastAccessedTime"));
            sessionInfo.put("ipAddress", sessionData.get("ipAddress"));

            result.add(sessionInfo);
        }

        return result;
    }
}

1.4.3. Controller 작성

io/redis/performance/controller/SessionController.java

더보기
더보기
@RestController
@RequestMapping("/api/session-test")
@RequiredArgsConstructor
public class SessionController {

    private final UserSessionService userSessionService;
    private final ObjectMapper mapper;


    @PostMapping("/login")
    public ResponseEntity<Map<String, Object>> login(
            HttpSession session,
            @RequestBody LoginRequest request,
            HttpServletRequest servletRequest) {

        // 세션 생성 시간 측정
        long startTime = System.nanoTime();

        UserSession userSession = UserSession.builder()
                .userId(request.getUserId())
                .username(request.getUsername())
                .roles(Arrays.asList("USER"))
                .loginTime(LocalDateTime.now())
                .ipAddress(servletRequest.getRemoteAddr())
                .userAgent(servletRequest.getHeader("User-Agent"))
                .build();

        userSessionService.login(session, userSession);

        long duration = System.nanoTime() - startTime;

        return ResponseEntity.ok(Map.of(
                "sessionId", session.getId(),
                "creationTime", duration / 1_000, // μs
                "message", "Login successful"
        ));
    }

    @GetMapping("/validate")
    public ResponseEntity<Map<String, Object>> validateSession(HttpSession session) {
        long startTime = System.nanoTime();

        Object obj = session.getAttribute("userSession");
        UserSession userSession = mapper.convertValue(obj, UserSession.class);
        boolean valid = userSession != null;

        long duration = System.nanoTime() - startTime;

        return ResponseEntity.ok(Map.of(
                "valid", valid,
                "validationTime", duration / 1_000, // μs
                "sessionId", session.getId()
        ));
    }

    @GetMapping("/active-users")
    public ResponseEntity<Map<String, Object>> getActiveUsers() {
        return ResponseEntity.ok(Map.of(
                "activeUsers", userSessionService.getActiveUserCount(),
                "timestamp", LocalDateTime.now()
        ));
    }
}

1.4.4.  DTO 작성

io/redis/performance/dto/LoginRequest.java

더보기
더보기
@Getter
public class LoginRequest {
    private String userId;
    private String username;
}

1.5. 실습: Redis Session

1.5.1. 로그인 테스트

POST | http://localhost:8083/api/session-test/login

 

1.5.2. 로그인 유효성 테스트

GET | http://localhost:8083/api/session-test/validate

 

valid 결과를 보면 false로 나와있다. 로그인을 수행할 때 로그인 정보(sessionId) 없이 요청을 보냈기 때문이다. 즉, 정상적인 로그인을 하기 위해서는 로그인 정보(sessionId)를 포함해서 요청을 보내야 한다. 그런데 validate API 요청 코드를 보면 GET 요청이기 때문에 사용자 세션 정보를 넣을 수 없어보인다. 어떻게 해야할까?

 

SessionConfig를 살펴보자. @Bean으로 헤더 기반의 세션 정보를 담아서 보낼 수 있는 Resolver가 정의되어 있다. 즉, 세션 정보를 헤더에 담아 전달하면 Redis가 이를 인식할 수 있다.

 

따라서 validate를 수행할 때 Header에 X-Auth-Token이라는 헤더 이름으로 이전 로그인 했던 세션 아이디를 넣어서 전송하면 정상적으로 로그인 상태 확인이 가능하다.

 

1.5.3. 로그인 사용자 수 조회

🚨 1) user1에서 두 번 로그인 (= 다른 기기에서 각각 로그인 했다는 의미)하고 user2에서도 두 번 로그인했을 때, 활성화 된 로그인 사용자 수가 4인지 확인할 것 !

'Database > Redis' 카테고리의 다른 글

[Optimization-2] 캐싱 개념 (2)  (0) 2025.12.28
[Optimization-1] 캐싱 개념(1)  (0) 2025.12.28
[Basic-3] DB 병렬 작업과 Lock 전략  (0) 2025.07.03
[Basic-2] 캐싱 전략  (0) 2025.07.02
[Basic-1] 기초 이론 및 구축 실습  (0) 2025.07.01
'Database/Redis' 카테고리의 다른 글
  • [Optimization-2] 캐싱 개념 (2)
  • [Optimization-1] 캐싱 개념(1)
  • [Basic-3] DB 병렬 작업과 Lock 전략
  • [Basic-2] 캐싱 전략
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (250) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (25) N
        • MSA 기본 (11)
        • MSA 아키텍처 (14) N
      • Kafka (30)
        • Core (18)
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[Basic-4] 세션 관리
상단으로

티스토리툴바