이번에 받은 요구사항이다.
- 리스트 페이지에서 특정 데이터 목록을 조회한 후 상세 조회 페이지로 이동한다.
- 상세 조회 페이지에는 "이전", "다음" 게시물 조회 기능이 존재한다.
- 리스트 페이지에서 조회된 순서에 따라 "이전", "다음" 게시물로 이동해야 한다.
위 요구사항은 조회를 할 때 사용한 파라미터를 쿼리 파라미터 등으로 넘겨 조회 조건을 유지함으로써 쉽게 구현이 가능하다.
하지만 문제가 하나 있는데, 그것은 바로 상세조회 페이지에서 "상태 변경이 가능"하고, 이 상태가 리스트 페이지에서 "조회 조건"에 포함된다는 것이었다.
예를 들어보자.
- "댓글이 0개"인 게시글만을 필터링해서 목록을 조회했다.
- 가장 첫번째에 있는 A 게시글에 들어간다.
- 댓글을 달았다.
- "다음 게시물로 이동" 버튼을 눌러 다음 게시물인 B 게시글을 조회한다.
- "이전 게시물로 이동" 버튼을 눌러 방금 댓글을 단 A 게시글을 조회한다.
단순히 검색 조건을 유지하는 것만으로는 5번의 경우를 구현할 수 없다. 댓글이 달린 이상 더 이상 검색 필터에 적합하지 않은 조건이기 때문이다. 결국 5번의 "이전 게시물로 이동" 버튼 클릭 시 "이전 게시물이 존재하지 않습니다" 등의 알람 메시지가 출력될 것이고, 위에서 언급된 "리스트 페이지에서 조회된 순서를 반드시 유지할 것"의 요구사항을 충족할 수 없었다.
이 요구사항을 해결하기 위해 고민을 했고, 결국 Redis를 이용하기로 결정했다.
첫 페이지 진입 시 Redis에 리스트에 출력된 게시글의 목록을 임시 저장한 후, 이전//다음 게시물 이동 시 해당 목록을 조회해 가져오는 것이다.
그리고 Redis는 몇 차례 사용해본 적이 있기에 쉽게 구현을 할 수 있었다.
https://123okk2.tistory.com/344
다만 문제가 하나 발생했으니, 그것은 속도가 과할 정도로 느리다는 점이었다.
Redis의 최대 장점이 속도인데, 속도가 느린게 말이 되나?
라는 생각이 들었다. 그래서 왜 느린지 파악을 하기로 했고, 그 결과 너무나도 당연하지만, 간과하고 있었던 중요한 사실을 알 수 있었다.
그것은 바로 "Redis도 네트워크 딜레이가 존재한다"는 점이었다.
옛날에 과장님 (현재는 차장님)께서 "DB는 한 번이라도 덜 접근하는 게 좋다" 라고 말씀을 해주셨는데, Redis도 결국에는 인메모리 DB인데 이 점을 간과했었던 것 같다.
오늘은 Redis를 어떻게 사용해야 네트워크 딜레이를 최소화해서 사용할 수 있는지 기재해놓겠다.
Redis에도 네트워크 딜레이가 존재한다.
DB에 십만 개의 데이터를 넣으면 얼마나 오랜 시간이 소요될까?
단순한 Insert는 그렇게 오랜 시간을 소요하지 않는다. 다만 어플리케이션이 DB에 접근하면서 소요되는 시간, 네트워크 딜레이가 분명히 소요될 것이다.
간단한 일이다. 심부름으로 우유를 사온다고 가정해보자. 우유를 사는 데에는 시간이 얼마 걸리지 않는다. 하지만 그보다 우유를 사기 위해 주변 마트나 편의점으로 가는 시간이 오래 걸리는 것은 당연한 일이다.
- 편의점에 가는데 5분이 소요되었다. (어플리케이션이 DB에 접근함)
- 우유를 사는데 1분이 소요되었다. (DB에서 Insert가 수행됨)
- 집에 돌아오는데 5분이 소요되었다. (DB가 다시 어플리케이션으로 돌아옴)
- 결과적으로 우유를 사기 위해 11분이 소요되었다. (최종 완료 시간)
이러한 점 때문에 과거 과장님께서 "DB는 한 번이라도 덜 접근하는 게 좋다" 라고 말씀하신 것이었다.
그래서 DB에 다량의 데이터를 입력/삭제할 때는 개별보다는 배치로 묶어서 다량의 데이터를 한 번에 입력/삭제하는 방법을 사용하는 것이 훨씬 효율적이다.
이 부분에 대한 설명은 이전에 포스팅한 경험이 있다.
https://123okk2.tistory.com/472
Redis도 결국은 마찬가지이다. 인메모리 DB라고는 하지만 어쨌든 어플리케이션이 네트워크를 통해 Redis에 접근하고, 이로 인해 Insert 속도가 아무리 빠를 지언정 네트워크 딜레이는 분명하게 존재한다.
예시를 위해 십만건의 데이터를 Redis에 넣어보자.
우선 프로젝트는 간단하게 Spring Boot에 Redis 디펜던시만 추구해서 생성했다.
application.properties에 아래와 같이 Redis 연결 설정을 추가한다.
spring.redis.host=127.0.0.1
spring.redis.port=6379
*이전에 설명한 적이 있는데, 최신 버전 SpringBoot는 위 설정만 추가하면 별도로 RedisTemplate을 선언해서 설정하지 않아도 된다.
이제 코드를 짜보자.
우선은 단건으로 데이터를 넣는 작업을 해보자.
List와 Map에 10만건의 데이터를 넣을 것이다.
테스트를 위해 코드를 아래와 같이 짜놓았다.
package com.example.demo;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.ListOperations;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
@Service
@Slf4j
public class RedisTestService {
@Autowired RedisTemplate redisTemplate;
// Redis에 데이터를 저장할 List와 Map
private ListOperations<String, String> listOps;
private HashOperations<String, String, Integer> hashOps;
// Redis에 저장할 키
private static final String listKey = "TEST_KEY_LIST";
private static final String hashKey = "TEST_KEY_HASH";
// 위 List와 Map에 저장될 데이터 준비
private List<String> dataToInsert;
@PostConstruct
void setDataToInsert() {
// listOps, hashOps 초기화
// 굳이 이렇게 안하고 위의 선언 위에
// @Resource(name="redisTemplate") 어노테이션만 붙여도 선언됨.
listOps = redisTemplate.opsForList();
hashOps = redisTemplate.opsForHash();
// 테스트에 사용할 데이터 초기화
dataToInsert = new ArrayList<>();
for(int i=0; i<100000; i++) {
dataToInsert.add(Integer.toString(i));
}
}
public void setInRedisIndividual() {
// 시작 시간
long startTime = System.nanoTime();
for(String data : dataToInsert) {
listOps.rightPush(listKey, data); // rightPush를 통해 일반 List의 add 사용
hashOps.put(hashKey, data, Integer.parseInt(data));
}
// 따로 지우기 귀찮으므로 1분뒤 삭제하도록 설정
redisTemplate.expire(listKey, 1, TimeUnit.MINUTES);
redisTemplate.expire(hashKey, 1, TimeUnit.MINUTES);
// 종료 시간
long endTime = System.nanoTime();
// 실행 시간 출력
log.info("Execution time: {} ms", (endTime - startTime) / 1_000_000);
}
}
간단하게 10만건의 데이터를 일일히 List와 Map에 넣는 작업이다.
이제 한 번 얼마나 오랜 시간이 걸리는 지 파악해보자.
35초라는 시간이 소요되었다. 속도를 위해 사용하는 인메모리 DB인데, 이렇게 시간이 오래 걸리면 굳이 사용을 하는 의미가 없다. 그냥 CopyOnWriteArrayList와 ConcurrentHashMap을 하나 선언해서 사용하는 것이 훨씬 이득일 것이다.
위 현상은 앞서 설명했듯, 네트워크 딜레이로 인해 발생하는 현상이다. 자바 어플리케이션이 20만 하고도 2번이나 Redis에 접근했기 때문에 그만큼 네트워크 딜레이가 발생했기 때문이다.
- ListOps Insert에 10만번
- HashOps Insert에 10만번
- expire 설정에 2번
- 총합 20만 2번
위 현상을 해결하기 위해 가능하면 최대한 Redis에 접근을 줄여야 한다. 즉, 한 번에 하나의 Insert가 아니라 한 번에 다량의 Insert를 수행해야 한다.
그러면 Redis의 배치는 어떻게 사용할까? 방법은 쉽다. 그냥 rightpush, put 뒤에 All만 붙여주면 끝나는 일이다.
public void setInRedisBatchOne() {
// 시작 시간
long startTime = System.nanoTime();
// rightPushAll을 통해 한 번에 push
listOps.rightPushAll(listKey, dataToInsert);
// dataToInsert가 map이라면 위의 list와 똑같이 putAll을 해도 됨.
// 다만 현재 dataToInsert가 list이므로, map으로 변환하는 과정이 필요.
Map<String, Integer> mapToInsert =
dataToInsert
.stream()
.collect(Collectors.toMap(
value -> value, // key에는 value를 그대로 이용
Integer::parseInt // value에는 Int로 변환해서 이용
));
// 변환된 것도 putAll
hashOps.putAll(hashKey, mapToInsert);
// 따로 지우기 귀찮으므로 1분뒤 삭제하도록 설정
redisTemplate.expire(listKey, 1, TimeUnit.MINUTES);
redisTemplate.expire(hashKey, 1, TimeUnit.MINUTES);
// 종료 시간
long endTime = System.nanoTime();
// 실행 시간 출력
log.info("Execution time: {} ms", (endTime - startTime) / 1_000_000);
}
이제 한 번 코드를 구동시켜보자.
확실이 소요시간이 확 단축되었다. 데이터를 한 번에 묶어서 Insert시켰기 때문에 네트워크 딜레이가 그만큼 줄어들은 탓이다.
하지만 여전히 느리다는 생각이 든다. 이를 더 줄일 수는 없을까?
위의 코드는 총 네 번의 Redis 네트워크 접근이 사용되었다.
- ListOps의 rightPushAll
- HashOps의 putAll
- ListOps의 expire
- HashOps의 expire
총 네 번의 네트워크 딜레이라면 미미할 지언정 그만큼 또 네트워크 지연이 발생했을 것이다.
그러면 이 명령어들을 굳이 따로따로 전송하지 않고 한 번에 묶어서 보내면 네트워크 지연이 적어질 것이다. 그리고 그 방법은 하드코딩을 하거나 할 것 없이 Redis에서 자체적으로 지원하고 있다.
바로 executePipelined이다.
executePipelined
Redis Pipeline을 지원하기 위한 메소드로, 수많은 요청을 보내기 위해 Pipeline을 설정한 후 Request를 Pipeline에 실어 한 번에 보낸다. 각각의 Response를 기다리지 않고 한 번에 여러 개의 명령어를 보낸 뒤 비동기적으로 Response를 수신한다.
아래와 같이 사용하면 된다.
public void setInRedisBatchTwo() {
// 시작 시간
long startTime = System.nanoTime();
// redis를 반복적으로 호출하지 않고, 여러 명령을 한 번의 네트워크 요청으로 묶어 처리하는 함수
// 즉 executePipelined 사용 시 여러 번 보내지 않고 한 번에 명령어를 보내버림.
// 밑의 코드의 경우에는 rightPushAll과 putAll, 두 개의 expire가 별도로 Redis에 전송되지 않고 한 번에 전송이 됨.
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
// rightPushAll을 통해 한 번에 push
listOps.rightPushAll(listKey, dataToInsert);
// dataToInsert가 map이라면 위의 list와 똑같이 putAll을 해도 됨.
// 다만 현재 dataToInsert가 list이므로, map으로 변환하는 과정이 필요.
Map<String, Integer> mapToInsert =
dataToInsert
.stream()
.collect(Collectors.toMap(
value -> value, // key에는 value를 그대로 이용
Integer::parseInt // value에는 Int로 변환해서 이용
));
// 변환된 것도 putAll
hashOps.putAll(hashKey, mapToInsert);
// 따로 지우기 귀찮으므로 1분뒤 삭제하도록 설정
redisTemplate.expire(listKey, 1, TimeUnit.MINUTES);
redisTemplate.expire(hashKey, 1, TimeUnit.MINUTES);
return null;
});
// 종료 시간
long endTime = System.nanoTime();
// 실행 시간 출력
log.info("Execution time: {} ms", (endTime - startTime) / 1_000_000);
}
그리고 실행을 해보면 직전 실행시간인 2초보다 0.3초가 더 줄어들은 1.7초에 로직이 끝남을 확인할 수 있다.
'실습 > 리눅스 서버 + 스프링 부트' 카테고리의 다른 글
JUnit Test로 컨트롤러 API 테스트 (RestTemplate, MockMVC) (5) | 2024.11.06 |
---|---|
SSE를 사용해 알람 구현 (3) | 2024.10.29 |
[Mybatis] Insert 후 자동으로 생성된 PK 가져오기 (0) | 2024.09.14 |
[Thymeleaf] 데이터를 동적으로 바꾸기 - replaceWith (1) | 2024.09.14 |
Mybatis 중복 쿼리 공통화 (<sql>과 <include>) + Interceptor (1) | 2024.09.07 |