들어가며
현재 이미지를 받아서 결과를 받아내는 로직은 다음과 같다.
- 클라이언트에서 이미지 데이터 post
- 서버에서 해당 이미지를 s3 에 업로드
- 업로드된 이미지 url을 모델 서버로 전송
이 3단계의 모든 과정에서 HTTP 통신이 발생하기 때문에, 네트워크 통신에 대한 오버헤드가 존재한다. 이러한 부분을 해결하기 위해 프로젝트의 구조에 캐시 구조를 적용하기로 하였다.
프로젝트 구조 설계
프로젝트에는 Look aside cache로, 요청으로 받은 이미지를 모델 서버로 보내기 전에 해당 이미지가 이미 존재하는지 확인해보고, 있다면 그 값을 리턴하는 방식으로 적용할 것이다. key 값으로는 이미지 해시를 이용한다.
또한 추후 서버 구조에 대한 리팩터링이 있을 예정이고, 다른 서버가 그 캐시를 참고할 가능성이 있기 때문에, 한 서버에 캐시를 내장하는 방식보다는 Global Cache 를 사용하여 프로젝트의 구성 요소들이 이 캐시를 참조할 수 있도록 할 것이다.
+-----------+ +-----------------------+ +----------------------+
| | | | | |
| Client +-------->+ Spring Boot Application | Redis Cache |
| | | |<-------->+ |
+-----------+ +-----------------------+ +----------------------+
| | |
| | |
v | v
+-----------+ +-----------------+ +----------------+
| | | File Upload | | Model Server |
| S3 +<---->+ Service +---->+ |
| | +-----------------+ +----------------+
+-----------+ | ^
| |
v |
+-----------+ |
| Cache +<-------------+
| Service |
+-----------+
|
v
+-----------+
| Model |
| Service |
+-----------+
캐싱 라이브러리 선택하기
Spring Boot 애플리케이션에서 많이 사용되는 캐싱 라이브러리는 Redis, Ehcache, Memcached 가 있다.
| 특성 | Redis | Ehcache | Memcached |
|---|---|---|---|
| 데이터 구조 | Strings, hashes, lists, sets, sorted sets 등 다양한 구조 | 주로 객체 캐시 (on-heap, off-heap, disk) | Strings |
| 데이터 지속성 | Snapshotting, AOF (Append-Only File) | Off-heap 및 disk 저장소 옵션 제공 | 없음 |
| 성능 | 높은 성능, 다양한 기능으로 인해 메모리 사용량 많음 | Java 애플리케이션과 높은 호환성, 성능 중간 | 매우 빠른 속도, 낮은 메모리 오버헤드 |
| 관리 인터페이스 | Redis CLI, 다양한 서드파티 툴 | Ehcache Management Console | 없음 |
| 통합 | 다양한 언어와 프레임워크 지원 | Java, Hibernate 통합 쉬움 | 다양한 언어와 프레임워크 지원 |
현재 프로젝트는 두 개의 서버로 이루어져있으며 이들은 각기 다른 서버 프레임워크 (Spring Boot, FastAPI) 를 사용하고 있다. 따라서 라이브러리 중 Java 에 특화되어 있는 Encache 는 제외하였다.
그리고 프로젝트에 캐시를 이용하여 DTO 클래스를 반환해야 한다. 따라서 cache 에 저장될 데이터의 형태가 다소 복잡해질 수 있기 때문에 컬렉션을 유연하게 저장할 수 있는 Redis 가 프로젝트에 적합하다고 판단하였다.
Expiration 정책 설정
캐시의 데이터는 계속해서 저장하고 있을 필요가 없기 때문에, 캐시 항목에 대해 만료 정책을 설정하여 Redis 메모리를 더욱 효율적으로 사용하고자 하였다.
Spring Boot 에서 Redis 에 적용할 수 있는 만료 정책은 다음과 같은 두 가지 방식이 있다.
Cache Expiration Policy
- TTL (Time To Live)
- 캐시 항목이 생성된 후 부터 만료될 때 까지의 전체 시간
- Spring 의 경우, 쓰기 연산(
create,update) 을 기준으로 한다.- 항목이 캐시에 추가된 시점으로부터 정해진 시간이 지나면 그 항목이 만료된다.
- 항목에 접근하더라도 TTL 타이머는 재설정되지 않는다.
- TTI (Time To Idle)
- 캐시 항목에 마지막으로 접근한 시점부터 만료될 때까지의 시간
- 즉, 읽기 연산과 쓰기 연산 모두를 기준으로 한다.
- 항목에 접근할 때마다 타이머가 리셋되며, 설정된 시간 동안 접근이 없을 경우 항목이 만료된다.
이 중 TTI (Time To Idle) 방식의 경우, 실제로 Redis에서 지원하지는 않지만 Spring Data Redis의 캐시 구현을 통해 유사한 동작을 구현할 수 있다.
프로젝트에서는 조금 더 간단한 구현을 위하여 TTL 방식으로 만료 정책을 결정하였고, 이를 RedisAnalyzeDto에 적용해보자. @RedisHash 애노테이션을 통해 적용할 수 있다.
@RedisHash(value = "AnalyzeDto", timeToLive = 3600)
// 이하 생략
public class RedisAnalyzeDto {
}
Eviction 정책 설정
데이터가 빠르게 쌓일 수 있는 상황에서 Redis의 기본 설정인 noeviction을 사용하는 경우, 캐시로 인한 이점을 얻기 힘들기 때문에 eviction 정책을 설정하였다.
Redis 가 제공하는 주요 eviction 정책은 다음과 같다.

- 출처 : Redis Documentation
이 중, 컴퓨터 구조에서도 가장 대중적으로 사용하는 LRU(Least Recently Used) 방식 중, 모든 키를 대상으로 수행하는 allkeys-lru 정책을 적용해보자.
redis.conf 파일에서 maxmemory-policy 를 수정하거나 redis-cli 를 이용해서 명령어를 통해 설정할 수 있다.
# redis-cli 를 사용하여 설정
CONFIG SET maxmemory-policy allkeys-lru
# redis.conf
maxmemory-policy allkeys-lru
이와 같이 설정하면, Redis는 모든 키를 대상으로 LRU 알고리즘을 적용하여 가장 오랫동안 사용되지 않은 키를 제거하게 된다.
결과
캐싱을 적용하지 않은 경우

캐싱을 적용한 경우

캐싱을 적용하기 전과 적용한 후, 약 96.67%의 성능 개선이 있다! 가장 시간이 오래걸리는 네트워크 통신이 많은 api 라서 더욱 유의미한 차이가 존재했다. 이 후에 성능 개선을 위해서는 네트워크 통신의 오버헤드를 줄일 수 있는 방법을 찾아봐야겠다.
아쉬운 점
키 값으로 이미지 해시를 이용하는데, 현실 세계를 생각한다면 이러한 해시 값들이 이미지 크기에 따라 달라질 수 있다. 또한 웹 사이트에서는 같은 이미지라도 아주 다양한 크기의 이미지가 존재하기 때문에, 현실성이 뒤떨어지는 것 같다. 따라서 이를 해결하기 위해, 이미지에 대해 식별자를 만드는 또 다른 방법이 필요하다.
Redis를 이용하기 때문에 중간에 Redis에 대한 네트워크 지연 역시 추가되었다. 따라서 key 값이 Redis에 있다면, 더욱 빠르게 응답할 수 있지만 없다면 기존보다 응답이 더욱 느릴 수도 있다. 따라서 이를 해결하기 위해 네트워크 지연을 줄일 수 있는 방법이 필요하다.
현재 프로젝트 구조에서는 DB, Redis, 모델 서버 (FastAPI), 애플리케이션 서버 (Spring Boot)가 각자 따로 실행되고 있기 때문에 관리와 배포의 복잡도가 늘어난 상태이다. 이러한 인스턴스들을 통합해서 관리할 수 있는 방법이 필요하다.
