Coding/Server

[항해99] 단기 스킬업: Redis를 활용한 대규모 트래픽 처리 2주차 회고(부하 테스트)

kangplay 2025. 1. 22. 21:55

2주차 시나리오

2주차에는 1주차에 작성한 API를 바탕으로 성능 개선 및 부하 테스트를 진행하였다. 

Redis와 인덱싱을 활용한 성능 개선과 부하 테스트는 실무에서 많이 사용하는 기술들이다. 혼자서 적용해본 경험은 있지만 알맞게 구현을 한건지 의문을 가지고 있었는데, 2주차 과제를 통해 관련된 피드백을 받을 수 있을 수 있어 기대가 되었다. 

  • API Refactoring
    • 1주차에 구현한 조회 API에 검색 기능 추가
    • 검색 기능 추가 이후 부하 테스트 진행
  • Indexing
    • 1주차와 2주차 비즈니스 요구사항을 참고하여, 적절한 Index를 생성하고, 부하 테스트 진행
  • Caching
    • 메인 페이지 성능을 위해 Redis 캐싱을 활용하고 부하 테스트 진행

새로 알게 된 내용

부하테스트란?

부하테스트란, 시스템이 어느 정도의(부하=트래픽,요청)를 견딜 수 있는지 테스트하는 것으로, 시스템이 예상되는 작업 부하를 얼마나 잘 처리할 수 있는지 평가하는 테스트이다. 

DAU를 기반으로 TPS를 예측할 수 있고, 실제 진행한 부하테스트에서 예측된 TPS를 충족하지 못하면, 실패로 간주될 가능성이 높아진다.

어떤 지표(예: 응답 시간, 처리량, 오류율 등)를 기준으로 성능을 평가할지 고민하는 과정이 필요하다.

웹 대시보드에서 부하 테스트 과정을 보기 위해 Grafana를 이용했다.

참고: https://velog.io/@chisae/K6-Grafana-사용법-익히기

로컬 캐싱과 분산 캐싱

로컬 캐시는 서버 자체 메모리에 저장되므로, 외부 네트워크를 거치지 않고 데이터를 바로 읽을 수 있다. 따라서, 단일 서버 장애가 발생하면 이로 인한 영향은 다른 서버에 영향을 미치지 않는다. 상품 정보와 같이 자주 변경되지 않는 특성을 가지면서, 자주 조회되는 데이터는 로컬 캐싱을 활용하여 성능을 최적화할 수 있다.

 

분산 캐시는 네트워크를 통해 데이터에 접근해야 하므로, 상대적으로 지연 시간이 길어질 수 있다. 동적인 데이터나 자주 변경되는 데이터, 그리고 서버 간 데이터 공유가 필요한 경우에 사용하면 좋다.

캐시 전략

Cache를 사용할 땐, 성능을 향상시킬 수 있다는 점이 매력적이지만, 데이터 불일치 문제를 항상 신경써야 한다. 

가장 일반적으로 쓰이는 전략은 Look Aside + Write Around 조합이다. 

Look Around: Cache Store 가서 없으면 DB에서 데이터 가져옴 / Write Around: 캐시 갱신 없이 DB에만 저장

하지만, 정합성이 중요한 데이터라면, 다른 전략을 사용해야한다!

 

참고: https://inpa.tistory.com/entry/REDIS-%F0%9F%93%9A-%EC%BA%90%EC%8B%9CCache-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5-%EC%A7%80%EC%B9%A8-%EC%B4%9D%EC%A0%95%EB%A6%AC#look_aside_%ED%8C%A8%ED%84%B4

2주차 과제 제출과 피드백

검색 기능 추가

검색 기능을 추가한 쿼리문으로 수정(동적 쿼리가 아닌 논리연산자 이용)
논리연산자를 사용한 쿼리에 대한 리뷰

동적 쿼리를 사용하지 않고 논리 연산자를 적용하였다. 하지만 필터 조건이 많아질 수록 동적 쿼리를 사용한 것보다 가독성 문제가 발생할 것이고, 다른 메서드에서 필터링 조건을 재사용하지 못해 중복 작성될 가능성이 생긴다. 또한 하나의 메서드에 책임이 많다보니 테스트해야할 케이스가 너무 많아진다.  

인덱싱 적용 전 부하 테스트

첫 번째 쿼리문은 다음과 같다. 내 코드에는 검색 기능 외에 상영 중인 영화를 조회하기 위한 start_at 컬럼에 대한 조건이 있다.

SELECT m.id, m.name, m.grade, m.release_date, m.thumbnail, m.running_time, m.genre, t.id, t.name, s.start_at, s.end_at 
FROM movie m 
JOIN screening s ON m.id = s.movie_id 
JOIN theater t ON s.theater_id = t.id 
WHERE s.start_at >= NOW();

k6로 부하테스트를 진행하였다.

duration은 5m, target=500, 그리고 95% 요청이 200ms 이하이거나 실패율이 1% 이하인 조건을 thresholds로 추가하였다.

실패율은 0%로 통과했지만, 95% 요청이 1.1s 였기 때문에 실패한 테스트이다.

인덱싱 적용(start_at) 후 부하테스트

start_at에 대해 인덱스를 적용해보았다.

idx_start_at에 대한 인덱스를 인덱스 레인지 전략으로 활용했다.

실패율이 0%, 95% 응답 시간이 12.67ms로 테스트가 성공되었고, 인덱스를 적용하기 전보다 성능이 약 86배 향상되었다.

인덱싱 적용 시 주의할 점

인덱싱에 관련된 코드 리뷰

  • LIKE 연산을 이용할 때(중간 일치, 후방 일치를 사용할 때)
    • where 절에 인덱스가 설정되어 있는 컬럼을 사용한다고 해도 like 연산자(중간 일치, 후방 일치)를 이용할 경우에는 인덱스의 성능을 기대하기 어렵다. 따라서, LIKE 연산 쿼리에 성능을 개선하고 싶다면 Full Text Index를 활용하는 방법이 있다.
  • 1개의 컬럼만 인덱스를 걸어야한다면
    • 카디널리티가 가장 높은 것으로 잡아야한다. 해당 인덱스로 많은 부분을 걸러내야 하기 때문이다.
  • 멀티 컬럼 인덱스 구성 시
    • 카디널리티가 높은 -> 낮은 순으로 구성하는 게 좋다. 
    • 첫 번째 인덱스 조건은 조회 조건에 포함되어야만 인덱스를 활용한다.
  • 전체 데이터의 20~25% 이상을 읽을 경우 풀 테이블 스캔이 더 효율적이라고 판단될 수 있기 때문에, 항상 실행 계획을 분석하는 습관을 들이자!

참고: https://jojoldu.tistory.com/243

https://schatz37.tistory.com/42?category=878798

로컬 캐싱 적용 후 부하 테스트

현재 상영 중인 영화 조회 쿼리는, LocalDateTime을 기준으로 필터링하기 때문에, 캐싱에 의미가 없다. 따라서, LocalDateTime의 기준을 뺀, 모든 영화를 조회하는 API를 구현하여 캐싱을 진행하였다.

영화 조회 API 에 nowShowing 쿼리 파라미터를 추가하여 TRUE이면 상영 중인 영화, FALSE이면 모든 영화가 조회되도록 

모든 영화 조회 API에 대한 부하 테스트

실패율이 0%, 95% 응답 시간이 747.93ms으로 테스트가 실패하였다.

로컬 캐싱 적용 후 부하 테스트

실패율이 0%, 95% 응답 시가이 2.48ms로 테스트가 성공하였다.

🌱 Spring Cache의 작동 방식
처음에는, 메소드 내부의 메소드에 @Cacheable 어노테이션을 적용하여 진행하였더니, 캐싱이 적용되지 않았다. 그 이유는 Spring Cache가 AOP를 통해 이용하기 때문이다. @Cacheable이 설정된 메소드를 호출할 때, Proxy 객체가 생성되어 해당 호출을 intercept 한다. 그리고 Proxy 객체가 캐싱 작업을 수행하는 것이다. 하지만 이 Proxy 객체는 외부 메소드 호출에만 관여할 수 있기 때문에, 오브젝트의 메서드의 메서드를 호출하면 프록시 객체가 관여하지 않고 실제 오브젝트에서 바로 실행된다.

참고: https://kim-solshar.tistory.com/84

분산 캐싱 적용 후 부하 테스트

95% 응답 시간이 7.33ms로 로컬 캐싱보다는 느리지만 캐싱을 적용하기 전보다는 개선되었다!

🚀  캐시 키 설계시 고려할 점
- 키가 너무 세분화되면 캐시 히트율이 낮아지고, 불필요한 키가 증가하여 메모리 사용량이 높아진다.
- 값이 자주 바뀌지 않는 변수들로 키를 구성해야한다.
- 과제에서 요청받은 쿼리 파라미터를 바탕으로 캐싱을 진행하는 것보다, 장르 단위로 데이터를 캐싱하고, 이후 조건에 따라 필터링을 하던지, 데이터 전체 결과를 캐싱했다가 캐싱한 걸 가지고 필터링을 하는 등 필터를 캐싱된 결과에서 처리하거나, 적절한 수준에서 필터링 결과를 캐싱하는 방법을 선택한다.

🍒 Spring Redis 직렬화 / 역직렬화
- Redis에 객체(DTO)를 저장할 때, Serializer를 통해 직렬화해주고, Redis에서 읽을 때 바이트 배열을 다시 객체로 역직렬화해주어야 한다.
- Spring Boot는 기본적으로 Java 8 날짜/시간 API의 직렬화 및 역직렬화를 지원하는 설정을 제공하기 때문에, Redis에 날짜/시간 타입의 데이터를 저장/조회할 때에는, 따로 ObjectMapper에 JavaTimeModule을 등록해줘야한다! 또한, JsonJacksonCodec은 타입 정보를 포함하지 않기 때문에 복잡한 타입을 역질렬화할 때, Object로 읽어들여 정확한 타입으로 반환할 수 없다.
- TpyeJacksonCodec을 이용하면 타입 정보를 자동으로 포함시켜 직렬화하기 때문에 역직렬화 시 정확한 타입으로 객체를 복원할 수 있지만, 단일 타입의 DTO에 최적화 되어있으므로, 다양한 타입을 저장하기 위해서는 GenericJackson2JsonRedisSerializer(JSON 형식으로 타입 정보를 포함하여 직렬화) 또는 StringRedisSerializer(데이터를 단순한 문자열로 직렬화)를 이용하자.

2주차 진행 후 느낀 점

2주차 과제를 진행하면서, 인덱싱 그리고 캐싱을 통한 성능 개선을 경험하고, 부하테스트를 해볼 수 있었다. 또한, 2주차 피드백을 통해 인덱싱과 캐싱을 적절히 적용하는 법을 새로 알게 되었다. 또한 실무에서는 검색 엔진의 성능 향상을 위해서 ElasticSearch를 사용한다는 것을 알게 되었다. 

결국 중요한 것은, 각 API 마다 어떤 전략,어떤 기술을 적용할지 분석하는 것이 중요한 것 같다. 또한 더 좋을 것 같아서~ 가 아니라 테스트 결과를 통해 근거를 가지는 것 또한 중요하다.다양한 캐싱 전략과 인덱싱을 더 알아보고 비교해보자!

 

다음 주차는 정말 중요한 동시성 제어 내용이다. 설날이 포함되어있긴 하지만 어차피 하는 거 없으니 3주차도 화이팅 하자!!

 

+)1주차, 2주차 연속으로 Best Practice에 선정되었다. 기분이 좋다! 😄