1. HttpSession
1) Session
- HTTP 요청에는 상태가 없음. 즉, 각각의 요청이 독립적으로 이뤄지며,
서버는 사용자가 보낸 몇번째 요청인지에 대한 정보 같은 게 저장되지 않는다는 뜻임.
→ 사용자 브라우저 측은 서버에 요청할 때마다 자신을 식별할 수 있는 정보를 알려줘야 함. - 응답시 쿠키에 요청을 보낸 브라우저 식별값을 보내줄 수 있음.
- 이후 사용자가 요청을 보낼때, 해당 값을 보내주면 서버에서 저장 해놓은 정보를 기반으로
브라우저에 로그인한 사용자가 누구인지를 구분할 수 있음. - 상태를 저장하지 않는 HTTP 통신을 사용하면서, 이전에 요청을 보낸 사용자를 기억하는 상태를 유지하는 것
⇒ 세션
2) HttpSession
- SpringBoot를 사용시 SpringBoot 프로젝트에 내장되어 있는 Tomcat 서버가 세션을 생성함.
- Chrome 검사 > Application > Storage > Cookie
- JSESSIONID : 서버 내부의 Tomcat이 처음 접근한 브라우저에게 발급하는 쿠키
- Tomcat은 JSESSIONID을 바탕으로 세션을 관리하며 HttpSession 객체로 만들어 주는데,
해당 HttpSession은 Controller 메서드를 통해 가져와 사용할 수 있음. - SessionController.java
@RestController
public class SessionController {
@GetMapping("/set")
public String set(@RequestParam("q") String q, HttpSession session) {
session.setAttribute("q", q);
return "Saved: " + q;
}
@GetMapping("/get")
public String get(HttpSession session) {
return String.valueOf(session.getAttribute("q"));
}
}
① http://localhost:8080/set?q=password 으로 이동하면, HttpSession에 password가 저장됨.
브라우저에서 JSESSIONID를 확인할 수 있음.
② http://localhost:8080/get 으로 이동하면, 저장해둔 password가 조회됨.
만약 이 과정에서 JSESSIONID를 제거하고 /get으로 이동하면, Tomcat이 연결된 세션이 없다고 판단하고
세션에서 데이터를 찾지 못하고 오류 발생으로 이어짐.
2. 서버를 여러 개 동작한다면 세션이 유지될 수 있을까?
- Scale-Out
여러 서버를 동작해 각 서버들에게 요청을 분산하여 부하를 줄이는(Load Balancing) 방식의 확장
- Scale-Up
서버 자원의 크기 자체를 높이는 방식의 확장
1) Sticky Session
- 특정 사람이 보낸 요청을 하나의 서버로 고정하는 방법
- 요청을 분산하는 로드밸런서를 통해 요청을 보낸 사용자를 기록하고,
해당 사용자가 다시 요청을 할 경우 최초로 요청이 전달된 서버로 요청을 전달하는 방식 - 문제점 → 특정 서버로 보내진 사용자만 활발하게 활동한다면, 요청이 균등하게 분산되지 않음.
- B 서버만 활발하게 이용된다면? → 과부화로 이어질 수 있음.
만약 B 서버가 다운이라도 된다면 세션도 사라지고 사용자는 갑자기 본인 데이터 잃게 되는 것.
- 이에 떠오른 대안 → Session Clustering
2) Session Clustering
- 여러 서버들이 하나의 저장소를 공유하고, 해당 저장소에 세션에 대한 정보를 저장함으로서, 요청이 어느 서버로 전달이 되든 세션 정보가 유지될 수 있도록 하는 방법
1️⃣ 사용자가 서버에 요청을 보냈을 때, 서버는 세션을 생성하되
이 세션에 연결된 정보를 내부가 아니라 외부 저장소에 저장을 하는 것
2️⃣ 해당 사용자가 다시 요청을 할 경우, 다른 서버로 요청이 보내진다고 하더라도,
세션 정보 자체는 외부 저장소에 있기 때문에 확인할 수 있음.
3️⃣ 외부 저장소를 사용하므로 사용자 요청을 처리하는 서버의 자유로운 추가 제거도 가능함.
중간에 서버를 제거해도 세션이 사라지지 않고, 사용자가 증가하면 서버를 증가시키는 등 유연한 대처 가능함.
4️⃣ 단점
서버 외부에 세션을 저장하는 것이므로 관리 포인트가 늘어나게 되며, 통신 과정에서 어쩔 수 없는 지연 발생
→ 지연이 적은 Redis 같은 인메모리 데이터베이스 많이 사용하게 됨.
Sticky SessionSession Clustering장점단점
Sticky Session | Session Clustering | |
장점 | 애플리케이션 입장에선 구현이 쉬움. 외부와 통신할 필요가 없음. |
Load Balancer에서 균등하게 요청 분산 가능 서버의 추가 제거가 비교적 자유로움 |
단점 | 요청이 분산되지 않아 과부하 가능성 존재 한 서버가 다운되면 그 서버가 관리하는 세션도 삭제 |
외부 저장소라는 관리 포인트 추가 외부와 통신하는 지연이 발생 |
3) Spring Session
SpringBoot와 Redis를 함께 사용하고 있다면, EZ하게 Session Clustering 적용 가능함.
- Spring Session은 사용자의 세션 정보를 다루는데 유용한 API를 제공함.
- Spring Session Data Redis를 추가하면, 내장 Tomcat의 세션 기능을 사용하지 않고 Redis에 별도로 세션을 저장하게 됨.
implementation 'org.springframework.session:spring-session-data-redis'
- 더이상 Tomcat을 사용하지 않기 때문에 JSESSIONID 대신 SESSION이라는 새로운 쿠키를 사용
- 기본적으로 Java 기반 직렬화되는데, RedisTemplate을 사용할때 처럼 JSON을 비롯한 방식으로 직렬화를 하고 싶다면, springSessionDefaultRedisSerializer Bean을 등록해주면 됨.
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return RedisSerializer.json();
}
- 읽을 수 있는 형태로 Redis에 저장됨.
- 단, Spring Security의 일부인 SecurityContext와 같은 객체가 Redis에 저장될 경우, SecurityContext의 기본 생성자가 없기 때문에 오류가 발생할 수도 있음.
3. 리더보드
실시간 랭킹을 보여주는 기능
예) 게임 - 점수 순위, 검색 엔진이면 실시간 검색 순위 등
이커머스 - 인기상품 등
1) Sorted Set
이커머스라고 가정했을 때, 인기의 기준 → 가장 많이 구매한 물품
- 관계형 데이터베이스로 구현한다면? → 인기상품 반환 기능을 만들기 위해선 JOIN 및 집계합수 사용이 필요할 것임.
- 이를 피하기 위해 item에 구매횟수 컬럼을 추가한다 해도,
해당 컬럼에 대한 빈번한 수정과 데이터 조회 과정에서 정렬이 필요하므로 성능 저하 가능성 up
- Redis의 Sorted Set의 시간복잡도는 아래와 같음. SQL 고민도 안 해도 됨.
- ZADD(데이터 추가) : O(log(N))
- ZRANGE(M개 데이터 조회) : O(log(N) + M)
2) 인기상품 리더보드 만들기
- application.yaml - Redis & h2 설정
spring:
data:
redis:
host: localhost
port: 6379
username: default
password: systempass
datasource:
url: jdbc:h2:mem:test;
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create
defer-datasource-initialization: true
show-sql: true
sql:
init:
mode: always
- Item
package com.example.redis.domain;
import jakarta.persistence.*;
import lombok.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Item {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Setter
private String name;
@Setter
private String description;
@Setter
private Integer price;
@OneToMany(mappedBy = "item", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private final List<ItemOrder> orders = new ArrayList<>();
}
- ItemOrder
package com.example.redis;
import com.example.redis.domain.Item;
import com.example.redis.domain.ItemOrder;
import com.example.redis.repository.ItemRepository;
import com.example.redis.repository.OrderRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
@Slf4j
@Service
@RequiredArgsConstructor
public class ItemService {
private final ItemRepository itemRepository;
private final OrderRepository orderRepository;
public void purchase(Long id) {
Item item = itemRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
orderRepository.save(ItemOrder.builder()
.item(item)
.count(1)
.build());
}
}
- RedisConfig
package com.example.redis;
import com.example.redis.domain.ItemDto;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
public RedisTemplate<String, ItemDto> rankTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, ItemDto> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setValueSerializer(RedisSerializer.json());
return redisTemplate;
}
}
- ItemService
package com.example.redis;
import com.example.redis.domain.Item;
import com.example.redis.domain.ItemDto;
import com.example.redis.domain.ItemOrder;
import com.example.redis.repository.ItemRepository;
import com.example.redis.repository.OrderRepository;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
@Slf4j
@Service
public class ItemService {
private final ItemRepository itemRepository;
private final OrderRepository orderRepository;
private final ZSetOperations<String, ItemDto> rankOps;
public ItemService(ItemRepository itemRepository, OrderRepository orderRepository, RedisTemplate<String, ItemDto> rankTemplate) {
this.itemRepository = itemRepository;
this.orderRepository = orderRepository;
this.rankOps = rankTemplate.opsForZSet();
}
public void purchase(Long id) {
Item item = itemRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
orderRepository.save(ItemOrder.builder()
.item(item)
.count(1)
.build());
ItemDto itemDto = ItemDto.fromEntity(item);
// incrementScore = Sorted Set의 ZINCRBY
// 전달된 데이터가 Sorted Set에 없는 경우 알아서 추가하고 score++해줌.
rankOps.incrementScore("soldRanks", itemDto, 1);
}
public List<ItemDto> getMostSold() {
// reverseRange = Sorted Set의 ZRANGE 역순위
Set<ItemDto> ranks = rankOps.reverseRange("soldRanks", 0, 9);
if (ranks == null) {
return Collections.emptyList();
}
return ranks.stream().toList();
}
}
- ItemController
package com.example.redis;
import com.example.redis.domain.ItemDto;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("items")
@RequiredArgsConstructor
public class ItemController {
private final ItemService itemService;
@PostMapping("{id}/purchase")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void purchase(@PathVariable("id") Long id) {
itemService.purchase(id);
}
@GetMapping("/ranks")
public List<ItemDto> getRanks() {
return itemService.getMostSold();
}
}
'TIL' 카테고리의 다른 글
TIL19. RabbitMQ (0) | 2025.03.10 |
---|---|
TIL18-2. 캐싱 (0) | 2025.03.07 |
TIL17. Redis (2) SpringBoot로 Redis 사용해보기 + 실습 (0) | 2025.03.06 |
TIL16. Redis (1) 특징, 설치, 타입별 명령어 (0) | 2025.03.05 |
TIL15. GitLab CI/CD + Github Actions (0) | 2025.03.04 |