프로젝트를 진행하면서 조회 로직들의 개선이 필요하다는 생각이 들었습니다. 그 과정에서 겪은 시행착오와 고민들을 공유하고자 기록을 남깁니다.
개발환경
- spring boot 3.2.0
- gradle 8.5
- QueryDSL
- EC2
- Docker
- MySQL 8.0.32
- Redis
JMeter를 이용한 성능테스트 진행
먼저 현재 구현한 로직의 조회속도를 파악해야 했습니다. 저는 자바 오픈 소스 툴인 JMeter를 선택했는데요, 설치와 사용법이 쉽고, HTML 형식의 보고서를 제공해 주는 장점이 있어 선택하게 되었습니다. JMeter를 설치하고 본격적으로 성능테스트를 진행했습니다. 몇 명의 사용자가 접근할지 Thread Group을 설정하고, 테스트하고자 하는 메서드의 HTTP Request를 설정했습니다.
우선 두 가지 조회 로직을 테스트했습니다. 하나는 리팩토링 하고자 하는 멘토링 리스트 조회 API와, 또 다른 하나는 Authentication을 이용한 회원 조회 기능입니다. 리포트를 보면 멘토링 리스트 조회 API의 평균 응답시간이 5757ms로 말도 안 되는 결과가 나왔습니다. 우려했던 성능 문제가 가시화되었고, 만약 트래픽이 증가한다면 조회 관련 쿼리가 지속적으로 DB에 요청되어 디스크 I/O가 증가해 서버 전체의 성능저하가 우려됐습니다.
성능 개선에 대한 고민
그렇다면 어떤 방식으로 조회 성능을 개선하는 게 가장 효율적인 방법일까요? 우선 멘토링 리스트 조회 API의 특징을 알아보겠습니다.
- 첫 번째, 사용자들은 동일한 자원에 접근합니다. Mentoring 테이블의 정보와, 연관 테이블인 Mentor 테이블을 LeftJoin 한 결과를 반환받습니다.
- 두 번째, 데이터의 변경의 빈도가 낮습니다. 개설된 멘토링 내용은 최소 한 달 단위로 일정이 유지되어 데이터 삭제나 수정의 빈도가 낮습니다.
이런 특징을 고려했을 때, 조회속도를 개선하는 여러 방법 중 가장 적합하다고 생각한 것은 캐싱이었습니다. 캐싱은 자주 사용되는 데이터를 Redis와 같은 In-memory 저장소에 저장하여 빠르게 접근하는 방법입니다. DB I/O를 줄여 조회 속도와 성능을 향상합니다. 저는 Redis를 이용해서 캐싱을 구현했습니다.
Redis 설정과정
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
application.yml
spring:
data:
redis:
host: 호스트 이름
port: 포트 번호
password: 비밀번호
RedisConfig.java 클래스 생성
@Configuration
@EnableCaching // 캐싱 기능 활성화
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Value("${spring.data.redis.password}")
private String password;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
// Redis 서버의 설정 정보 설정
RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration();
redisConfig.setHostName(host);
redisConfig.setPort(port);
redisConfig.setPassword(RedisPassword.of(password));
// Lettuce 라이브러리를 사용하여 Redis 연결을 관리합니다.
return new LettuceConnectionFactory(redisConfig);
}
...
...
@Bean
public CacheManager redisCacheManager(){
//Redis 연결 팩토리에서 캐시 매니저 빌더를 생성
RedisCacheManager.RedisCacheManagerBuilder builder = fromConnectionFactory(redisConnectionFactory());
// 캐시 구성 설정 생성
// 값을 JSON 형식으로 직렬화하고 캐시 항목의 기본 TTL (Time-To-Live)을 30분으로 설정
RedisCacheConfiguration configuration = defaultCacheConfig()
.serializeValuesWith(fromSerializer(new GenericJackson2JsonRedisSerializer()))
.entryTtl(Duration.ofMinutes(30));
builder.cacheDefaults(configuration);
return builder.build();
}
}
위와 같이 기본적인 설정을 마무리하면 CacheManager를 통해 캐시 관련 어노테이션으로 쉽게 캐싱을 적용할 수 있습니다.
캐싱 적용하기
본격적으로 메서드에 캐싱을 적용해 보겠습니다.
@Cacheable(value = "mentoringCache", cacheManager = "redisCacheManager", key = "'mentoringInfoList:' + #offset + ':' + #size")
public Page<mentoringListPagingDto> mentoringInfoList(int offset, int size) {
Pageable pageable = PageRequest.of(offset, size);
Page<mentoringListPagingDto> resultPage = mentoringRepository.mentoringList(pageable);
return resultPage;
}
@Cacheable 어노테이션을 추가한 뒤, value = "mentoringCache" , cacheManager = "redisCacheManager", key 값을 차례대로 설정합니다.
- value는 메서드가 반환하는 데이터가 저장될 캐시의 이름
- cacheManager는 위에서 설정한 RedisConfig 클래스에서 Bean으로 등록한 캐시매니저 이름
- key는 매개변수로 넘어온 값을 기준으로 캐시 항목의 키를 지정. - SpEL(Spring Expression Language)를 사용합니다.
@Cacheable 어노테이션은 데이터를 캐시에 저장하고, 메서드를 호출할 때 캐시의 이름(value)과 키(key)를 확인해 이미 저장된 데이터가 존재하면 해당 데이터를 리턴하고, 만약 데이터가 없다면 메서드를 수행 후 결괏값을 저장합니다. 이렇게 간단하게 어노테이션을 추가하는 것만으로도 캐싱을 적용할 수 있었습니다.
트러블슈팅 - PageImpl 생성자 부재 오류
하지만 @Cacheable 어노테이션을 적용했을 때 다음과 같은 문제가 발생했습니다.
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed:
org.springframework.data.redis.serializer.SerializationException: Could not read JSON:Cannot construct instance of `org.springframework.data.domain.PageImpl` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
오류 코드를 읽어보니 PageImpl 객체에 기본 생성자가 없기 때문에 직렬화 과정에서 오류가 발생한 것이었습니다. 기본 생성자가 없는 문제를 해결하기 위해서 PageImpl<>을 상속받는 wrapper class를 만들어 Controller의 반환값으로 설정했습니다.
RestPage<T>
@JsonIgnoreProperties(ignoreUnknown = true, value = {"pageable"})
public class RestPage<T> extends PageImpl<T> {
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES) // JSON 객체 형식으로 역직렬화
public RestPage(@JsonProperty("content") List<T> content,
@JsonProperty("number") int page,
@JsonProperty("size") int size,
@JsonProperty("totalElements") long total) {
super(content, PageRequest.of(page, size), total);
}
public RestPage(Page<T> page) {
super(page.getContent(), page.getPageable(), page.getTotalElements());
}
}
Controller
@GetMapping("/list")
public ResponseEntity<RestPage<mentoringListPagingDto>> getMentoringList(Pageable pageable){
RestPage<mentoringListPagingDto> result = mentoringService.mentoringInfoList(pageable.getPageNumber(), pageable.getPageSize());
return ResponseEntity.ok()
.body(result);
}
Service
@Cacheable(value = "mentoringCache", cacheManager = "redisCacheManager", key = "'mentoringInfoList:' + #offset + ':' + #size")
public RestPage<mentoringListPagingDto> mentoringInfoList(int offset, int size) {
Pageable pageable = PageRequest.of(offset, size);
Page<mentoringListPagingDto> resultPage = mentoringRepository.mentoringList(pageable);
return new RestPage<>(resultPage);
}
Redis로 캐싱 후 성능테스트
코드 리팩토링 후 다시 동일한 조건으로 성능테스트를 진행했습니다. 평균 조회 속도는 5757ms → 1878ms, (5757 - 1878) / 1878 * 100 = 206.5% 를 개선했고, 처리량은 25.0/sec → 60.9/sec으로 개선되었습니다.
JMeter 테스트 - Thread Properties 설정
위 테스트에서는 리팩토링 전, 후로 회원 관리 메서드까지 같이 테스트했습니다. 또한 스레드 수만 설정하고 테스트를 진행했기 때문에 "설득력 있는 결과일까?"라는 의문이 들었습니다. 그래서 멘토링 리스트 조회 api만 따로 다시 테스트를 진행했습니다. 이번에는 Thread Group의 Thread Properties를 더 세부적으로 설정했습니다. JMeter에서 설정할 수 있는 Thread Properties는 다음과 같습니다.
- Number of Threads (users): 동시에 실행되는 스레드 수 (사용자 수)
- Ramp-up period (seconds): 모든 스레드가 시작되기까지의 시간 설정
- Loop Count: 해당 테스트 계획을 반복 실행하는 횟수
이번 테스트에서는 Number of Threads (users)가 300, Ramp-up period (seconds)가 20, Loop Count가 10인 경우 즉, 300/20*10 = 150으로 1초에 150명의 가상 사용자가 조회 로직에 접근하는 상황을 가정했습니다.
결과적으로 평균 응답시간은 13ms , Throughput은 148.6/sec으로 개선되었습니다.
참고)
https://creampuffy.tistory.com/209
Apache JMeter를 이용한 부하 테스트 및 리포트 생성
서버의 성능을 최적화하기 위해선 어떤 작업이 필요할까요? 어떤 지표를 기준으로 성능을 측정할 것인지, 정의된 지표에 영향을 미치는 변수에는 무엇이 있는지, 해당 변수들의 변화가 성능에
creampuffy.tistory.com
https://ksh-coding.tistory.com/129
[Docker] EC2 환경에서 Docker에 Redis 설치 후 실행하기
개인 프로젝트에서 EC2 환경에서 Docker에 Redis를 설치할 일이 생겼습니다. 다음에도 설치 과정을 구글링해서 찾아볼 것 같아서 기록용으로 기록해보고자 합니다! 설치 과정은 도커가 설치되어 있
ksh-coding.tistory.com
https://gose-kose.tistory.com/4
[SpringBoot] QueryDsl Redis 적용
안녕하세요.! 회사와 함께 성장하고 싶은 KOSE입니다. 오늘은 크리스마스 동안 붙들었던 redis cache를 QueryDsl의 Page 타입에 적용한 후기를 적어보고자 합니다. (다들 연말 잘 보내세요!. 늦은 시간이
gose-kose.tistory.com
Error during Deserialization of PageImpl : Cannot construct instance of `org.springframework.data.domain.PageImpl`
Issue is when using Spring cache with redis cache manager, not able to deserializer Spring Pageable response due to no default constructor The spring boot version used is 2.1.4.RELEASE Redis config...
stackoverflow.com
Redis를 활용한 캐시 사용시 주의점
- 기본생성자 유무 - Serializer / Deserializer 적용 - 클래스 패키지 위치 Q1. 기본생성자 사용 Page findDataAll (@AuthenticationPrincipal LoginUserDetails loginUserDetails, Pageable pageable) { if(loginUserDetails.isAdmin()){ return da
icthuman.tistory.com
'Spring' 카테고리의 다른 글
[트러블슈팅] Github Actions + submodule application.yml을 못 읽는 경우 (0) | 2024.04.13 |
---|---|
[트러블슈팅] @ColumnDefault() 사용 시 DB에 null로 저장되는 이유와 해결방법 (0) | 2024.02.08 |