[프로젝트] Redis 캐싱으로 응답 시간 단축 및 외부 API 호출 절감하기

chaen-ing·2026년 2월 1일

프로젝트

목록 보기
4/5

🧩 문제 배경


가맹점 상세 조회 시 성능 및 비용 문제

  • 가맹점의 조회수를 저장해서 조회수 별 정렬, 인기 가맹점 등의 기능에 사용해야함
    • 가맹점 상세보기를 누를때마다 DB에서 조회수를 업데이트 할 시, 트래픽 증가 상황에서 응답 지연, DB 부하 발생
  • 가맹점 상세보기 api에서 외부 API 기반 정보(영업시간, 길찾기, 메뉴링크 등)를 제공해야함
    • 상세보기 할때마다 외부 API 호출시 한도 초과 및 성능 저하

🔧 상세 트러블슈팅 기록


현재 코드

public StoreInfoResponse getStoreInfoWithoutCache(Long storeId, Long userId) {
    Store store = getStoreOrThrow(storeId); // DB 직접 조회
    store.increaseViewCount();

    String representativeTag =
        storeTagCountRepository
            .findRepresentativeTagByStoreId(storeId)
            .map(Tag::getLabel)
            .orElse(null);

    if (userId != null) {
      User user = getUserOrThrow(userId);
      store.setIsScrapped(storeScrapRepository.existsByStoreIdAndUserId(storeId, user.getId()));
    } else {
      store.setIsScrapped(null);
    }

    return StoreInfoResponse.from(
        store, representativeTag, storeGoogleApiClient.getStoreOpeningHours(store));
  }
  1. DB에서 Store 조회
  2. 조회수 증가
  3. 대표 태그 조회
  4. 로그인 시: 스크랩 여부 확인
  5. Google API에서 영업시간 조회
  6. DTO 변환

🚨 문제 1:

조회수 증가 로직으로 인한 DB 락 충돌

  • 현상: 동시 요청 시 RDB의 visit_count가 충돌
  • 해결:
    • Redis에서 HGETALL store-views로 우선 처리
    • 별도 스케줄러가 1시간 주기로 Redis 값을 RDB에 반영
    • → DB 부하 감소 + 동시성 문제 해결

🚨 문제 2:

Google Maps API 호출 속도 및 한도 문제

  • 현상: 상세보기 화면에 있는 영업시간을 보기 위해 외부 API 호출이 필요하나 구글은 월 한도 제한이 있음
  • 해결:
    • 검색 결과에서 받은 place_id와 영업시간 정보를 Redis에 캐싱
    • TTL을 15일 설정으로 일정 주기마다 갱신 가능
    • → 응답 속도 단축, 외부 API 호출 횟수 대폭 감소

✅ 해결 과정


Redis를 활용해서 문제를 해결해보기로 !!

Redis 사용이유

  • 인메모리 저장소로서, 읽기/쓰기 속도가 매우 빠름 (마이크로초 단위)
  • 싱글스레드 기반으로 명령이 순차 처리되어,
    조회수 증가와 같은 연산에서 동시성 이슈를 단순화할 수 있음
  • 트래픽 급증 상황에서도 안정적인 처리 가능
  • TTL(Time-To-Live) 설정을 통해 외부 API 응답 캐싱에 최적화

Redis 사용방법

  1. Redis 설치 (Mac) → 로컬을 위해

    brew install redis
    brew services start redis

    또는 도커 내부에 설치

  2. 프로젝트 내 설정

    • gradle에 redis, cache 관련 dependency 추가
      // Redis
      	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
      
      	// Spring Boot Cache
      	implementation 'org.springframework.boot:spring-boot-starter-cache'
    • application.yml에 redis 정보 추가
        data:
          redis:
            host: localhost
            port: 6379
    • 메인 Application에 @EnableCaching 추가
    • RedisConfig와 RedisCacheConfig
      @Configuration
      @EnableRedisRepositories
      public class RedisConfig {
      
      	@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 RedisConnectionFactory redisConnectionFactory() {
      		RedisStandaloneConfiguration redisStandaloneConfiguration =
      			new RedisStandaloneConfiguration(redisHost, redisPort);
      		redisStandaloneConfiguration.setPassword(redisPassword);
      		return new LettuceConnectionFactory(redisStandaloneConfiguration);
      	}
      
      	@Bean
      	public RedisTemplate<String, Object> redisTemplate() {
      		RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
      
      		StringRedisSerializer stringSerializer = new StringRedisSerializer();
      		redisTemplate.setKeySerializer(stringSerializer);
      		redisTemplate.setValueSerializer(stringSerializer);
      		redisTemplate.setHashKeySerializer(stringSerializer);
      		redisTemplate.setHashValueSerializer(stringSerializer);
      		redisTemplate.setConnectionFactory(redisConnectionFactory());
      		return redisTemplate;
      	}
      }
      
      @EnableCaching
      @Configuration
      public class RedisCacheConfig {
      
        @Bean
        public CacheManager contentCacheManager(RedisConnectionFactory cf) {
          RedisCacheConfiguration redisCacheConfiguration =
              RedisCacheConfiguration.defaultCacheConfig()
                  .serializeKeysWith(
                      RedisSerializationContext.SerializationPair.fromSerializer(
                          new StringRedisSerializer()))
                  .serializeValuesWith(
                      RedisSerializationContext.SerializationPair.fromSerializer(
                          new GenericJackson2JsonRedisSerializer())) // Value Serializer 변경
                  .entryTtl(Duration.ofMinutes(3L)); // 캐시 수명 30분
      
          return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf)
              .cacheDefaults(redisCacheConfiguration)
              .build();
        }
      }
      
  1. 서버에서 레디스 설치, 반영

    # 설치
    sudo apt update
    sudo apt install redis-server
    
    # 서비스 재시작
    sudo systemctl restart redis
    sudo systemctl enable redis-server
    
    # 접속 확인
    redis-cli ping   # → PONG

    ec2에 레디스 설치 > 도커에 같이 띄우기

    version: '3.8'
    
    services:
      redis:
        image: redis:7
        container_name: redis
        restart: always
        ports:
          - "***"
        command: ["redis-server", "--requirepass", "****"]
        volumes:
          - redis-data:/data
    
    volumes:
      redis-data:

    Redis 보안 설정은 환경 변수 기반으로 관리하며, 상세 구성은 생략합니다.
    서버 환경에서는 Redis를 컨테이너 기반으로 분리 배포하여 운영했습니다.

  2. 사용하기

    서비스로직에다가 애노테이션을 붙여주면 되고 이 때 서비스내에서 호출하는 경우에는 사용해도 먹히지 않습니다

    • @Cacheable(value = "view-id", key = "#viewId") (예시)
      • 위의애노테이션이 함수위에 붙어있으면 함수 실행전에 redis먼저 확인
    • 또는 RedisTemplate으로 직접 구현

  • value는 크게 상관없음
  • key기준으로 먼저 redis에서 찾고 있으면 바로 리턴, 없으면 메소드를 실행한다
  • key는 파라미터에 있는애
  • 저장되는 값은 return값
  • @CacheEvict(value = "store-id", allEntries = true)
    - key = “#storeId” 이런식으로 하면 특정 키 삭제
    - allEntries = true는 모든 키 삭제
  • @CachePut(value = "store", key = "#store.id")
  • 업데이트, 캐시 덮어쓰기 용도

✨ 결과 (K6)


import http from 'k6/http';
import { sleep, check } from 'k6';

export const options = {
  vus: 60, // 60명의 유저가 동시에 요청
  duration: '1s', // 1초간 유지 → 약 60TPS
};

export default function () {
        const BASE_URL = '***;
        const LOCAL_BASE_URL = '***';
        const res = http.get(`***`);

        check(res, {
                'status is 200': (r) => r.status === 200,
                'response time < 500ms': (r) => r.timings.duration < 500,
        });
}

K6을 활용해서 60TPS의 환경에서 테스트를 진행해보았다.

  1. 기존 로직 (No caching)
  • 전체 응답 중 약 90%가 성공, 약 10%는 실패
  • 19%는 응답시간이 500ms 초과
    avg (평균 응답 시간)272.91ms
    min (최소값)18.33ms
    med (중앙값)218.63ms
    max (최대값)627.13ms
  1. 캐싱을 적용한 조회 로직

  • 전체 응답 100% 성공
  • 전체 응답시간이 500ms 이내
    avg (평균 응답 시간)147.53ms
    min (최소값)18.09ms
    med (중앙값)146.54ms
    max (최대값)381.83ms
  1. 실제 배포 서버에서의 테스트

  • 전체 응답 100% 성공
  • 전체 응답시간이 500ms 이내
    avg (평균 응답 시간)181.68ms
    min (최소값)38.58ms
    med (중앙값)186.98ms
    max (최대값)494.89ms
💡

캐싱 도입 전에는 평균 응답 시간이 273ms에 달했고, 최대 627ms까지 지연되는 경우도 발생했습니다. 하지만 Redis 기반 조회수·외부 API 결과 캐싱 구조를 설계한 이후, 평균 응답 시간은 147ms로 46% 단축, 응답 실패율은 0%로 개선되었습니다.

또한 외부 API 호출 캐싱을 통해 비용과 한도 문제를 해결할 수 있었습니다.

profile
💻 개발 공부 기록장

0개의 댓글