(1) 원리
<요청1>
<요청2>
(2) 특징
신속성 - 인스턴스의 메모리에 캐시 데이터를 저장하므로 속도가 가장 빠름저비용 - 인스턴스의 메모리에 캐시 데이터를 저장하므로 별도의 네트워크 비용이 발생하지 않음휘발성 - 애플리케이션이 종료될 때, 캐시 데이터는 삭제됨메모리 부족 - 활성화된 애플리케이션 인스턴스에 데이터를 올려 캐싱하는 방법이므로 메모리 부족으로 인해 비정상 종료로 이어질 수 있음분산 환경 문제 - 분산 환경에서 서로 다른 서버 인스턴스 간에 데이터 불일치 문제가 발생할 수 있음(1) 원리
< 요청 1 >
Cache Service 에 Cache Hit 확인Cache Service 에 저장Cache Service 에 Cache Hit 확인(2)특징
- 일관성 - 별도의 담당 서비스를 둠으로서 분산 환경 ( Multi - Instance ) 에서도 동일한 캐시 기능을 제공할 수 있음
- 안정성 - 외부 캐시 서비스의 Disk 에 스냅샷을 저장하여 장애 발생 시 복구가 용이함
- 고가용성 - 각 인스턴스에 의존하지 않으므로 분산 환경을 위한 HA 구성이 용이함
- 고비용 - 네트워크 통신을 통해 외부의 캐시 서비스와 소통해야 하므로 네트워크 비용 또한 고려해야 함
(1). String
public class RedisStringExample {
@Autowired
private StringRedisTemplate stringRedisTemplate;
public void setString(String key, String value) {
stringRedisTemplate.opsForValue().set(key, value);
}
public String getString(String key) {
return stringRedisTemplate.opsForValue().get(key);
}
}
(2) List
public class RedisListExample {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void addToList(String key, String value) {
redisTemplate.opsForList().rightPush(key, value);
}
public List<String> getList(String key) {
return redisTemplate.opsForList().range(key, 0, -1);
}
}
(3). Set
public class RedisSetExample {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void addToSet(String key, String value) {
redisTemplate.opsForSet().add(key, value);
}
public Set<String> getSet(String key) {
return redisTemplate.opsForSet().members(key);
}
}
(4) Sorted Set
public class RedisSortedSetExample {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void addToSortedSet(String key, String value, double score) {
redisTemplate.opsForZSet().add(key, value, score);
}
public Set<String> getSortedSet(String key) {
return redisTemplate.opsForZSet().range(key, 0, -1);
}
}
(5) Hash
public class RedisHashExample {
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void putToHash(String key, String hashKey, String value) {
redisTemplate.opsForHash().put(key, hashKey, value);
}
public Object getFromHash(String key, String hashKey) {
return redisTemplate.opsForHash().get(key, hashKey);
}
}
(1) DB 연동(JPA)
<QueueRepository.java>
@Override
@Transactional
// 저장
public void register(QueueDomain queueDomain){
queueJpaRepository.save(new QueueEntity(queueDomain));
}
<등록 쿼리>
insert into queue (create_dt, modify_dt, queue_status, user_id) values (?, ?, ?, ?)
<Redis 도입 전 대기열 등록 테스트 코드 결과>
(2) Cache 연동(Redis)
<QueueRedisRepository.java>
@Override
public void register(String userId,Long score){
redisTemplate.opsForZSet().add("Queue",userId,score);
}
<Redis 도입 후 대기열 등록 테스트 코드 결과>
(3) 비교

(1) DB 연동(JPA)
대기열 통과를 위해서 대기열 통과를 위한 Service 의 checkQueue() 메서드와 스케줄러인 updateQueue() 를 통해서 상태에 따라서 50명씩 통과 시키는 복잡한 대기열 구현
<QueueService.java>
// 대기열(큐) 확인.. 내 앞에 몇명 구현 예정
@Override
@Transactional
public List<QueueResponseDTO> checkQueue(Integer userId) {
try {
Thread.sleep(1000); //1초 대기
} catch (InterruptedException e) {
e.printStackTrace();
}
if (queueRepository.findByUserId(userId, QueueStatus.PROCESSING)
.stream().map(QueueDomain::toDTO).toList().isEmpty()) {
throw new CustomException(ErrorCode.USER_NOT_FOUND, userId.toString());
}
return queueRepository.findByUserId(userId, QueueStatus.PROCESSING)
.stream().map(QueueDomain::toDTO).toList();
}
// 대기열(큐) 상태 변경
@Override
@Transactional
public void updateQueue() {
String modifyDt = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss:SSS"));
Integer countQueue = queueRepository.countWaitingQueue(QueueStatus.WAITING);
log.info("updateQueue countQueue : " + countQueue);
List<Integer> queueIds = queueRepository.findQueueIdsByStatus(QueueStatus.WAITING);
log.info("updateQueue queueIds : " + queueIds);
queueRepository.updateQueueStatusByIds(modifyDt, QueueStatus.PROCESSING, queueIds);
}
또한 다량의 유저 인입 시 상태에 따라서 대기열을 통과 시키므로 다량의 조회와 업데이트 발생으로 DB 성능 저하 우려
<Queue 테이블>
(2) Cache 연동(Redis)
opsForZSet.range([Key],min,max) 를 이용하여 스케줄링으로 대기열 관리 가능queueRedisRepository.removeRange(min,max)) 하여 다음 대기열 통과processFirstQueue 로 아직 통과되지않은 유저는 잠시 대기 상태를 가짐<ProcessFacade.java>
public boolean processFirstQueue(String userId) throws InterruptedException {
queueRedisService.saveQueue(Integer.parseInt(userId)); // 1차 대기열 진입
while (true) {
Long rank = queueRedisService.getQueueRank(userId);
if (rank == null) {
return true; // 1차 대기열 통과
}
TimeUnit.MILLISECONDS.sleep(WAIT_INTERVAL);
log.info("queue is full processFirst loading");
}
}
<ScheduledTask.java>
@Scheduled(fixedRate = 500)
public void passQueue(){
log.info("passQueue");
queueRedisService.passQueue();
}
}
<QueueRedisService.java>
// 대기열 통과
public void passQueue(){
Set<String> userIds = queueRedisRepository.getRange(min,max);
for(String userId : userIds) {
log.info("pass userId : "+userId);
}
Long removeCount = queueRedisRepository.removeRange(min,max);
log.info("==================== removeCount ==================== : "+ removeCount);
}
<QueueRedisRepository.java>
//범위 조회
@Override
public Set<String> getRange(Long start, Long end){
return redisTemplate.opsForZSet().range("Queue",start,end); // 0 ~ 50
}
@Override
// 범위 삭제
public Long removeRange(Long min, Long max){
return redisTemplate.opsForZSet().removeRange("Queue",min,max);
}
}
<인입된 유저의 대기열 저장>
<인입된 유저의 대기열 통과>
(1) DB 연동(JPA)
@Query("select t from TokenEntity t where t.userId = :userId")
TokenEntity findByUserId(@Param("userId") Integer userId);
//
@Query("SELECT t.userId FROM TokenEntity t WHERE t.userId = :userId ORDER BY t.userId ASC")
Optional<Integer> existsByUserId(@Param("userId") Integer userId);
@Modifying
@Query("DELETE FROM TokenEntity t WHERE t.userId =:userId")
void deleteByUserId(@Param("userId")Integer userId);
(2) DB 연동(JPA) + Cache 연동(Redis)
processSecondQueue 메서드로 접근 제한<ProcessFacade.java>
public boolean processSecondQueue(String userId) throws InterruptedException {
while (true) {
if (tokenRedisService.activeTokenCount() < MAX_ACTIVE_USERS) {
tokenRedisService.activeToken(userId);
return true; // 토큰 활성화 완료
}
TimeUnit.MILLISECONDS.sleep(WAIT_INTERVAL);
log.info("activeToken is full processSecond loading");
}
}
<TokenRedisService.java>
@Service
@RequiredArgsConstructor
public class TokenRedisService {
@Autowired
private final TokenRedisRepository tokenRedisRepository;
@Autowired
private final TokenRepository tokenRepository;
@Autowired
private final ActiveTokenRedisRepository activeTokenRedisRepository;
@Transactional
// 토큰 발급
public void saveToken(String userId){
if(tokenRepository.exist(Integer.parseInt(userId)).isPresent()){ // 중복 체크
throw new CustomException(ErrorCode.USER_DUPLICATED,userId);
}else{
tokenRepository.save(Integer.parseInt(userId));
}
}
// 토큰 발급 검증
public boolean checkToken(String userId){
return tokenRepository.exist(Integer.parseInt(userId)).isPresent();
}
// 토큰 활성화
@Transactional
public void activeToken(String userId){
if(tokenRepository.exist(Integer.parseInt(userId)).isPresent()){ // 데이터 유무 확인
activeTokenRedisRepository.register(userId);
}else{
throw new CustomException(ErrorCode.USER_NOT_FOUND);
}
}
//토큰 활성화 수
@Transactional
public Long activeTokenCount(){
return activeTokenRedisRepository.count();
}
// 토큰 활성화 상태 확인
@Transactional
public boolean validateActiveToken(String userId){
return activeTokenRedisRepository.check(userId);
}
//토큰 비활성화
@Transactional
public void deactivateToken(String useId){
activeTokenRedisRepository.remove(useId);
}
}
<AuthInterceptor.java>
@Component
public class AuthInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(AuthInterceptor.class);
@Autowired
private ProcessFacade processFacade;
@Autowired
private TokenRedisService tokenRedisService;
@Autowired
private QueueRedisService queueRedisService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String userId = request.getHeader("userId"); // userId 획득
log.info("요청 userId : " + userId);
if (userId != null) {
try {
if (tokenRedisService.checkToken(userId)) {
log.info("유효한 고객입니다. 고객 번호: " + userId);
queueRedisService.saveQueue(Integer.parseInt(userId)); // 1차 대기열 진입
// 1차 대기열
if (!processFacade.processFirstQueue(userId)) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "다시 시도 해주세요.");
return false;
}
// 2차 대기열(처리열)
if (!processFacade.processSecondQueue(userId)) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "다시 시도 해주세요.");
return false;
}
log.info("토큰 활성화 완료. 고객 번호: " + userId);
return true;
}else{
log.warn("존재하지않는 고객입니다. 고객 번호: " + userId);
esponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "존재하지않는 고객입니다.");
}
} catch (NumberFormatException e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않은 고객입니다.");
}
}
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "응답받은 고객이 없습니다.");
return false;
}
}
queueRedisService.getQueueRank(userId.toString()) 메서드를 이용하여 유저의 대기열 순위를 조회하여 유저의 남은 순서 확인하고 대기.. 조회되지 않으면 통과로 인식되어 2차 대기열 진입 <processFacade.java>
public boolean processFirstQueue(String userId) throws InterruptedException {
queueRedisService.saveQueue(Integer.parseInt(userId)); // 1차 대기열 진입
while (true) {
Long rank = queueRedisService.getQueueRank(userId);
if (rank == null) {
return true; // 1차 대기열 통과
}
TimeUnit.MILLISECONDS.sleep(WAIT_INTERVAL);
log.info("queue is full processFirst loading");
}
}
tokenRedisService.activeTokenCount() 메서드이용하여 최대 활성화 토큰보다 많을 경우 대기, 적을 경우 토큰 활성화<processFacade.java>
public boolean processSecondQueue(String userId) throws InterruptedException {
while (true) {
if (tokenRedisService.activeTokenCount() < MAX_ACTIVE_USERS) {
tokenRedisService.activeToken(userId);
return true; // 토큰 활성화 완료
}
TimeUnit.MILLISECONDS.sleep(WAIT_INTERVAL);
log.info("activeToken is full processSecond loading");
}
}
(1) Token 저장
(2) 성능 테스트를 위한 스크립트 파일
<ConcertTest.js>
import http from 'k6/http';
import { check, sleep } from 'k6';
// 전역 변수로 설정할 기본값들
const BASE_URL = 'http://localhost:8080';
const TOKEN = 'your_token_here';
const MAX_USER_ID = 50;
export let options = {
vus: 10,
duration: '30s',
};
export default function () {
// 각 VU에 고유한 userId를 할당
// VU 인덱스와 MAX_USER_ID를 조합하여 고유한 userId 생성
const userId = (__VU - 1) * MAX_USER_ID + (__ITER % MAX_USER_ID) + 1;
// 요청 본문에 필요한 데이터를 포함합니다
let requestPayload = JSON.stringify({
// 요청 데이터 (예시로 token 포함)
token: TOKEN,
});
// 요청 헤더에 userId 포함
let headers = {
'Content-Type': 'application/json',
'userId': userId.toString(),
};
let availabilityRes = http.post(`${BASE_URL}/concert/availabilityConcertList`, requestPayload, {
headers: headers,
});
check(availabilityRes, {
'status is 200': (r) => r.status === 200,
'response is not empty': (r) => r.body.length > 0,
});
sleep(1);
}
(1) 유효성 검토
(2) 대기열 진입
(3) 대기열 통과
(4) 처리열 통과
(5) 캐싱 메모리 조회 (Redis)
(6) 테스트 결과
(1) 대기열 진입 시 처리열 과부화 방지를 위한 대기 후 재진입을 위한 예외처리
(2) 처리열 진입 시 서버 과부화 방지를 위한 대기 후 재진입을 위한 예외처리
(3) 캐싱 메모리 조회 (Redis)
(5) 테스트 결과