[Redis,Cache] Query 분석 및 캐싱 전략 설계

김탁형·2024년 7월 31일
post-thumbnail

정의

1. Cache

  • 데이터를 임시로 복사해두는 Storage 계층
  • 적은 부하로 API 응답을 빠르게 처리하기 위해 캐싱을 사용

2. 메모리 캐시

(1) 원리
<요청1>

  • 유저의 API 요청이 들어왔을 때 Memory 에 Cache Hit 확인
  • Cache Miss 시에 비즈니스 로직 수행 ( DB, 외부API 등 통신 ) 후 Memory 에 데이터 저장
  • 이후 응답

<요청2>

  • 유저의 API 요청이 들어왔을 때 Memory 상에 상응하는 응답이 있는지 확인
  • 있으면 해당 값을 이용해 그대로 응답

(2) 특징

  • 신속성 - 인스턴스의 메모리에 캐시 데이터를 저장하므로 속도가 가장 빠름
  • 저비용 - 인스턴스의 메모리에 캐시 데이터를 저장하므로 별도의 네트워크 비용이 발생하지 않음
  • 휘발성 - 애플리케이션이 종료될 때, 캐시 데이터는 삭제됨
  • 메모리 부족 - 활성화된 애플리케이션 인스턴스에 데이터를 올려 캐싱하는 방법이므로 메모리 부족으로 인해 비정상 종료로 이어질 수 있음
  • 분산 환경 문제 - 분산 환경에서 서로 다른 서버 인스턴스 간에 데이터 불일치 문제가 발생할 수 있음

3. 별도의 캐시 서비스 (external Level)

  • 별도의 캐시 Storage 혹은 이를 담당하는 API 서버를 통해 캐싱 환경 제공
    EX) Redis, Nginx 캐시, CDN, ..

(1) 원리
< 요청 1 >

  • 유저의 API 요청이 들어왔을 때 Cache Service 에 Cache Hit 확인
  • Cache Miss 일 경우, 비즈니스 로직을 수행 후 결과를 Cache Service 에 저장
  • 이후 응답 반환
    < 요청 2 >
  • 유저의 API 요청이 들어왔을 때 Cache Service 에 Cache Hit 확인
  • Cache Hit 일 경우, 해당 데이터를 그대로 활용해 응답 반환

(2)특징
- 일관성 - 별도의 담당 서비스를 둠으로서 분산 환경 ( Multi - Instance ) 에서도 동일한 캐시 기능을 제공할 수 있음
- 안정성 - 외부 캐시 서비스의 Disk 에 스냅샷을 저장하여 장애 발생 시 복구가 용이함
- 고가용성 - 각 인스턴스에 의존하지 않으므로 분산 환경을 위한 HA 구성이 용이함
- 고비용 - 네트워크 통신을 통해 외부의 캐시 서비스와 소통해야 하므로 네트워크 비용 또한 고려해야 함

4. Cache 의 Termination Type

  • Expiration
    • 캐시 데이터의 유통기한을 두는 방법
    • Lifetime 이 지난 캐시 데이터의 경우, 삭제시키고 새로운 데이터를 사용가능하게 함
  • Eviction
    • 캐시 메모리 확보를 위해 캐시 데이터를 삭제
    • 명시적으로 캐시를 삭제시키는 기능
    • 특정 데이터가 Stale 해진 경우 ( 상한 경우 ) 기존 캐시를 삭제할 때도 사용

5. Redis의 자료구조

  • Redis는 다양한 데이터 구조를 제공하며, 각 자료구조는 특정한 사용 사례와 적합한 장단점을 가지고 있다.

(1). String

  • 설명: Redis에서 가장 기본적인 자료구조로, 단일 키에 대해 하나의 값(문자열)을 저장합니다.
  • 장점:
    단순하고 사용하기 쉬움
    다양한 데이터 형식을 저장할 수 있음 (숫자, 문자열, JSON 등)
    다양한 명령어 지원 (예: GET, SET, INCR, DECR)
  • 단점:
    단일 값만 저장할 수 있어 복잡한 데이터 구조 표현에 적합하지 않음
  • 코드 예시
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

  • 설명: 리스트는 순서가 있는 문자열의 리스트로, 왼쪽 또는 오른쪽 끝에 요소를 삽입하거나 제거할 수 있습니다.
  • 장점:
    순서가 있는 데이터를 다루기 적합
    큐 및 스택과 같은 자료구조 구현 가능
    블로킹 명령어 지원 (BLPOP, BRPOP)
  • 단점:
    대량의 데이터가 리스트에 저장될 때 메모리 사용량이 높아질 수 있음
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

  • 설명: 중복 없는 문자열의 집합입니다.
  • 장점:
    중복 데이터 방지
    빠른 멤버십 테스트 가능 (SISMEMBER)
    집합 연산 지원 (교집합, 합집합, 차집합)
  • 단점:
    순서가 없어 특정 순서가 중요한 데이터에 적합하지 않음
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

  • 설명: 각 요소가 점수와 함께 저장되는 집합으로, 점수에 따라 정렬됩니다.
  • 장점:
    점수에 따른 정렬이 필요할 때 유용
    범위 검색 및 순위 구하기에 적합
    다양한 명령어 지원 (예: ZRANGE, ZREVRANGE, ZRANK)
  • 단점:
    점수 저장으로 인해 추가적인 메모리 사용
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

  • 설명: 필드-값 쌍으로 이루어진 데이터 구조로, 하나의 키에 여러 필드를 저장할 수 있습니다.
  • 장점:
    복잡한 데이터 구조를 저장하기 적합 (예: 사용자 정보)
    개별 필드 업데이트가 가능 (HSET, HGET)
    필드별로 메모리 효율적 사용
  • 단점:
    깊은 계층의 데이터 구조를 표현하는 데 제한적
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. 어느 서비스 에 캐시 서비스 Redis를 도입할지 고민
  2. DB 접근하는 서비스에 대해서 캐시 서비스인 Redis 로 리팩토링

성능적 이점

1. 대기열 저장

(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) 비교

  • 대기열 등록 할 때 Redis 도입 시 약 0.6초의 성능적 이점 확인

2. 대기열 통과

(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)

  • Queue의 상태관리 없이 opsForZSet.range([Key],min,max) 를 이용하여 스케줄링으로 대기열 관리 가능
  • 0.5초당 50명씩 대기열에 진입하는 스케줄러 생성 후 삭제(queueRedisRepository.removeRange(min,max)) 하여 다음 대기열 통과
  • ProcessFacade.java 에 메서드 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);
    }

}

<인입된 유저의 대기열 저장>

<인입된 유저의 대기열 통과>

3. 토큰 인증

(1) DB 연동(JPA)

  • DB에 저장된 유저 Token 정보로 Interceptor에서 유효성 검토 후 대기열 진입 로직으로 구현
  • 단, 비활성화 토큰으로 만들기 위한 상태 update와 토큰 삭제로 인하여 DB 과부화 우려
    <TokenJpaRepository.java>
    @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)

  • 토큰은 유저의 정보를 갖고 있으므로 DB에 저장하는게 적합하다. 단, API 호출을 위한 상태관리는 DB에 접근안하고 ActiveToken 키값으로 Redis에서 관리하여 토큰 비활성을 위한 delete 없이 관리가 용이해지고 유저 정보를 영구적으로 보유할 수 있다.
  • DB 접근하는 TokenService 코드가 간결해질 수 있다.
  • 또한 활성화된 토큰이 1000 이 넘을 경우 ProcessFacade에 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);
    }

}

구현

  • interceptor에서 사용자 요청을 가로채어 토큰 확인후 대기열, 처리열 진입하여 정상적으로 통과 될경우 API 활성화

업로드중..

<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;
    }
}
  • processFacade.java 에서 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");
        }
    }
  • processFacade.java 에서 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");
        }
    }

성능테스트(k6)

1. 환경 셋팅

(1) Token 저장

  • 총 4999 저장

(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);
}
  • 예약 가능 콘서트 조회를 위한 호출
  • 초당 vus명 접근
  • duration 만큼 수행
  • header에 API 수행을 위한 userId 포함

2. 초당 10명 동시에 접근

(1) 유효성 검토

(2) 대기열 진입

(3) 대기열 통과

(4) 처리열 통과

(5) 캐싱 메모리 조회 (Redis)

(6) 테스트 결과

3. 초당 100명 동시에 접근

(1) 대기열 진입 시 처리열 과부화 방지를 위한 대기 후 재진입을 위한 예외처리

(2) 처리열 진입 시 서버 과부화 방지를 위한 대기 후 재진입을 위한 예외처리

(3) 캐싱 메모리 조회 (Redis)

(5) 테스트 결과

  • 활성화 토큰 MAX 로 인하여 1000명의 요청만 처리 완료
profile
김탁형/성남/31

0개의 댓글