카테고리 없음

[21] 대용량 파일 전송 시스템 개발 - Redis를 활용한 메모리 캐싱

carrotweb 2026. 9. 17. 18:02
728x90
반응형

서버 로컬 디스크나 스토리지에서 직접 파일을 읽어오면 디스크 I/O(입출력 부하)가 증가합니다. 이로 인해 시스템 응답 속도가 느려지고 병목 현상이 발생하기 쉽습니다.

 

가장 좋은 해결책은 CDN(Content Delivery Network)을 도입해 트래픽을 분산하는 것입니다. 하지만 보안상의 이유나 환경적 제약으로 인해 서버가 직접 파일을 전송해야 하는 순간이 있습니다.

 

이때 파일을 매번 디스크에서 읽지 않고 메모리에 캐싱한 뒤 전송하면 속도를 높일 수 있습니다. 하지만 모든 파일을 무작정 메모리에 올리면 서버 메모리가 바닥나는 OOM(Out of Memory) 현상을 마주할 수 있습니다.

 

이를 방지하려면 TTL(Time-To-Live, 유효 기간)을 적용해 일정 시간이 지난 데이터는 메모리에서 즉시 해제해야 합니다.

 

그래서 대용량 파일 다운로드 요청 시 Redis를 활용해 파일을 청크(Chunk, 조각) 단위로 메모리에 캐싱하고 순차적으로 전송되도록 구현하겠습니다.

 

Spring 환경에서 Redis 연동을 쉽고 강력하게 도와주는 Spring Data Redis에 대해 알아보겠습니다.

 

Spring Data Redis (https://spring.io/projects/spring-data-redis)

 

Spring Data Redis는 Redis를 다루기 위한 저수준(Low-level)과 고수준(High-level) 추상화 기능을 모두 제공하기 때문에 개발자는 복잡한 구현에 신경 쓰지 않고 비즈니스 로직에만 온전히 집중할 수 있습니다.

 

 

의존성 추가 및 설정

1. Spring Boot 환경에서 Redis 연동을 위해 transmission\pom.xml 파일에 spring-boot-starter-data-redis 의존성을 추가합니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

 

2. application.properties 파일에 Redis 서버의 연결 정보를 추가합니다.

  • Redis 서버의 host과 port를 입력합니다.
  • Redis 서버에 비밀번호가 설정되어 있으면 password를 입력해야 합니다.
# Redis
spring.data.redis.host=localhost <-- Redis 서버의 호스트 이름
spring.data.redis.port=6379 <-- Redis 서버의 포트 번호
spring.data.redis.password=kiki@2000 <-- Redis 서버의 비밀번호

 

application.yml 파일이라면 Redis 서버의 연결 정보를 spring: 다음 계층에 추가하면 됩니다.

spring
  :
  data:
    redis:
      host: localhost
      port: 6379
      password: kiki@2000

 

 

Redis에 데이터 저장 및 읽기

application.properties(또는 yml) 파일에 추가만해도 별도의 Bean 설정 없이 StringRedisTemplate을 통해 Redis 서버에 데이터를 저장하고 읽을 수 있습니다.

 

Spring Data Redis에서 제공하는 템플릿 클래스는 RedisTemplate과 StringRedisTemplate이 있습니다.

 

StringRedisTemplate

StringRedisTemplate은 RedisTemplate<String, String>을 상속받아 구현된 클래스로 Key와 Value가 모두 문자열로 저장됩니다. 직렬화 방식은 StringRedisSerializer를 사용합니다.

StringRedisTemplate<String, String>

 

StringRedisTemplate를 사용한 간단한 예제입니다.

@Autowired
private StringRedisTemplate stringRedisTemplate;
String key = "test:greetings";
String value = "Hello World!";
// 만료 시간(유효 기간)
long timeoutInMinutes = 10;

// 데이터 저장 - 분 단위
stringRedisTemplate.opsForValue().set(key, value, timeoutInMinutes, TimeUnit.MINUTES);

// 데이터 읽기
String resultText = stringRedisTemplate.opsForValue().get(key);

TTL(Time-To-Live, 유효 기간)을 10분으로 설정했습니다.

 

redis-cli에서 get으로 확인할 수 있습니다.

# redis-cli
127.0.0.1:6379> get test:greetings
(nil)
127.0.0.1:6379> get test:greetings
"Hello World!"
127.0.0.1:6379>

 

 

RedisTemplate

RedisTemplate은 직렬화 방식으로 자바 표준 직렬화(JdkSerializationRedisSerializer)를 사용하기 때문에 자바 객체를 그대로 Key나 Value로 저장할 수 있습니다.

RedisTemplate<Object, Object>

 

RedisTemplate를 사용하여 Value가 Double객체인 예제입니다.

@Autowired
private RedisTemplate<Object, Object> redisTemplate;
String key = "test:pi";
Double value = 3.14159265359;
// 만료 시간(유효 기간)
long timeoutInSeconds = 30;

// 데이터 저장 - 초 단위
redisTemplate.opsForValue().set(key, value, timeoutInSeconds, TimeUnit.SECONDS);

Object resultObject = redisTemplate.opsForValue().get(key);
// 가져온 Value는 반드시 형변환(Casting) 처리
Double resultValue= resultObject != null ? (Double) resultObject : null;

TTL(Time-To-Live, 유효 기간)을 30초로 설정했습니다.

 

Key와 Value는 직렬화(Serialization) 방식으로 인코딩되어 Redis 서버에 저장됩니다.

Key = "\xac\xed\x00\x05t\x00\atest:pi"
Value = "\xac\xed\x00\x05sr\x00\x10java.lang.Double\x80\xb3\xc2J)k\xfb\x04\x02\x00\x01D\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp@\t!\xfbTD.\xea"

 

그래서 redis-cli에서 get으로 가져올 때 Key를 "test:pi"입력하면 가져오지 못합니다. 인코딩된 Key를 입력해야 합니다.

# redis-cli
127.0.0.1:6379> get "test:pi"
(nil)
127.0.0.1:6379> get "\xac\xed\x00\x05t\x00\atest:pi"
"\xac\xed\x00\x05sr\x00\x10java.lang.Double\x80\xb3\xc2J)k\xfb\x04\x02\x00\x01D\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp@\t!\xfbTD.\xea"

 

인코딩된 Key의 구조를 설명하겠습니다.

\xac\xed\x00\x05t\x00\atest:pi

\xac\xed --> AC ED <-- STREAM_MAGIC (Magic Number, 직렬화 스트림 고유한 식별자)
\x00\x05 --> 00 05 <-- STREAM_VERSION (Version, 직렬화 스트림의 버전)

t --> 74 <-- 직렬화된 데이터가 문자열(String)

\x00\x07 --> 00 07 <-- 클래스 이름의 길이(7Byte)
test:pi <-- 전달하려는 원본 문자열

 

인코딩된 Value의 구조를 설명하겠습니다.

\xac\xed\x00\x05sr\x00\x10java.lang.Double\x80\xb3\xc2J)k\xfb\x04\x02\x00\x01D\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp@\t!\xfbTD.\xea

\xac\xed --> AC ED <-- STREAM_MAGIC (Magic Number, 직렬화 스트림 고유한 식별자)
\x00\x05 --> 00 05 <-- STREAM_VERSION (Version, 직렬화 스트림의 버전)

s <-- 새로운 객체 시작

r <-- 클래스 서술자 시작
\x00\x10 --> 00 10 <-- 클래스 이름의 길이(16Byte)
java.lang.Double <-- 데이터가 Double 객체 이름
\x80\xb3\xc2J)k\xfb\x04 --> 80 B3 C2 4A 29 6B FB 04 <-- serialVersionUID
\x02 --> 02 <-- classDescFlags(SC_SERIALIZABLE, 직렬화 가능한 객체임)
\x00\x01 --> 00 01 <-- fields 개수 (1개)
D --> 44 <-- typeCode(double)
\x00\x05 --> 00 05 <-- 클래스 이름의 길이(5Byte)
value <-- 저장하는 변수 이름
x --> 78  <-- 클래스 서술자 끝

r --> 72 <-- 클래스 서술자 시작
\x00\x10 --> 00 10 <-- 클래스 이름의 길이(16Byte)
java.lang.Number <-- Double 클래스의 부모 클래스 이름
\x86\xac\x95\x1d\x0b\x94\xe0\x8b --> 86 AC 95 1D 0B 94 E0 8B <-- serialVersionUID 
\x02 --> 02 <-- classDescFlags(SC_SERIALIZABLE, 직렬화 가능한 객체임)
\x00\x00 --> 00 00 <-- fields 개수 (0개)
x --> 78 <-- 클래스 서술자 끝

p --> 70 <-- 상위 클래 서술자가 없음

 

이처럼 RedisTemplate를 사용하면  Value가 직렬화(Serialization) 방식으로 인코딩되기 때문에 가져올 때는 반드시 역직렬화(Deserialization) 방식으로 디코딩해야 합니다.

 

RedisTemplate<Object, Object> 사용에 문제점

위에서 설명한 것처럼 Object는 자바 표준 직렬화(JdkSerializationRedisSerializer)를 사용하기 때문에 불필요한 클래스 메타데이터를 포함하여 데이터를 직렬화하므로 메모리가 낭비됩니다.

그리고 Object는 아무 객체나 넣을 수 있기 때문에 타입 캐스팅을 정확하게 하지 않으면 런타임 시점에서 ClassCastException이 발생할 확률이 높고 다른 코드에서 타입을 예측해서 개발하기 어렵습니다.

 

 

새로운 RedisTemplate 생성

이전 개발에서는 파일을 청크(Chunk) 크기로 읽어서 Base64로 변환하기 때문에 StringRedisTemplate를 사용해도 됩니다.

chunkData = Base64.getEncoder().encodeToString(buffer);

 

하지만 일부 다운로드 기능처럼 비동기 스트리밍 방식으로 바이트(Byte) 데이터를 응답해야 하는 경우 문자열 처리에 특화된 StringRedisTemplate을 그대로 활용하기 어렵습니다. RedisTemplate<Object, Object>를 쓸 수도 있지만 매번 타입 캐스팅을 해야하는 귀찮은 작업이 발생합니다.

 

이 문제를 해결하려면 RedisTemplate<String, byte[]>를 별도로 생성하여 바이트 전용으로 사용하는 것이 좋습니다.

RedisTemplate<String, byte[]>

Key나 Value의 타입을 명확하게 지정함으로써 문제점을 해결할 수 있습니다.

 

Key를 String Serializer를 적용하면 일반 문자열로 저장되기 때문에 Redis CLI나 툴에서 Key를 바로 확인할 수 있습니다.

Value를 ByteArray Serializer를 적용하면 byte[]로 저장되기 때문에 객체를 직렬화/역직렬화하는 과정에서 발생하는 오버헤드가 제거됩니다.

 

RedisTemplate<String, byte[]>를 활용하기 위해서 직렬화(Serializer) 설정이 포함된 Bean을 구성해야 합니다.

그리고 데이터 처리의 일관성을 위해 문자열 전용인 RedisTemplate<String, String>도 Bean으로 구성하겠습니다.

 

3. 커스텀 빈(@Configuration)으로 두 개의 RedisTemplate을 생성합니다.

transmission\src\main\java\com\example\transmission\config\RedisConfig.java파일을 생성합니다.

package com.example.transmission.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, byte[]> redisTemplateByte(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, byte[]> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setEnableTransactionSupport(true);

        // Key : 문자열로 저장
        redisTemplate.setKeySerializer(RedisSerializer.string());
        // Value : byte[]로 저장
        redisTemplate.setValueSerializer(RedisSerializer.byteArray());

        // Hash를 사용하는 경우
        redisTemplate.setHashKeySerializer(RedisSerializer.string());
        redisTemplate.setHashValueSerializer(RedisSerializer.byteArray());

        return redisTemplate;
    }

    @Bean
    public RedisTemplate<String, String> redisTemplateString(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setEnableTransactionSupport(true);

        // Key : 문자열로 저장
        redisTemplate.setKeySerializer(RedisSerializer.string());
        // Value : byte[]로 저장
        redisTemplate.setValueSerializer(RedisSerializer.string());

        // Hash를 사용하는 경우
        redisTemplate.setHashKeySerializer(RedisSerializer.string());
        redisTemplate.setHashValueSerializer(RedisSerializer.string());

        return redisTemplate;
    }
}

 

pom.xml에 의존성과 application.properties(또는 yml)에 설정을 바탕으로 Spring Boot가 자동으로 Redis을 구성합니다.

내부적으로 RedisAutoConfiguration 클래스가 동작되면서 직접 등록한 RedisConnectionFactory 빈(Bean)이 없을 경우 내부적으로 Lettuce(레터스)기반의 ConnectionFactory를 자동으로 생성해줍니다.

 

만약, 운영 환경에서는 Lettuce(레터스)에 대한 상세한 설정이 필요한 경우에는 RedisConnectionFactory를 직접 빈으로 등록해야 합니다.

 

Lettuce(레터스)는 Netty 기반의 Redis 클라이언트로 Spring Boot에서 기본으로 채택해 사용하는 라이브러리입니다.

 

Lettuce(레터스) 설정

  • commandTimeout : 명령어 실행 타임아웃 (밀리세컨드)
  • shutdownTimeout : 애플리케이션 종료 시 Lettuce(레터스) 커넥션 정리 대기 시간 (밀리세컨드)
  • poolConfig : Lettuce(레터스) 커넥션 풀 설정

 

Lettuce(레터스)는 기본적으로 다중 스레드가 하나의 커넥션을 공유하는 구조(비동기 논블로킹)라 싱글 커넥션으로도 동작합니다. 하지만 대량의 데이터를 수시로 저장하고 읽어야 하는 동시 요청 환경에서는 커넥션 하나만으로 병목이 생길 수 있습니다. 그래서 GenericObjectPoolConfig를 통해 커넥션을 미리 생성해서 커넥션 풀(Connection Pool)로 관리하는 것이 좋습니다.

 

Lettuce(레터스) 커넥션 풀은 Apache Commons Pool를 사용합니다.

transmission\pom.xml 파일에 commons-pool2 의존성을 추가합니다.

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

 

개발 환경을 위한 커넥션 풀 설정

// 커넥션 풀(Connection Pool) 설정
GenericObjectPoolConfig<?> poolConfig = new GenericObjectPoolConfig<>();
// 최대 생성할 수 있는 커넥션 개수
poolConfig.setMaxTotal(5);
// 유휴(Idle) 상태로 대기할 수 있는 최대 커넥션 개수
poolConfig.setMaxIdle(5);
// 항상 유지할 최소 유휴 커넥션 개수
poolConfig.setMinIdle(3);
// 풀이 가득 찼을 때 커넥션을 대기하는 최대 시간(밀리세컨드)
poolConfig.setMaxWait(Duration.ofMillis(2000));

 

자동으로 생성된 ClientConfiguration의 poolConfig을 보면 maxTotal=8, maxIdle=8, minIdle=0, maxWaitDuration=PT-0.0001S(-0.0001, -1ms, 커넥션 풀에 남는 연결이 없으면 무한 대기)로 설정됩니다.

 

시간 값 읽는 방법

PT-0.001S
----------------------------------------
P(Period): 기간을 나타내는 문자
T(Time): 시간을 나타내는 문자
-0.001S: -0.001초

 

운영 환경(Production)을 위한 커넥션 풀 설정

  • maxTotal : 최대 생성할 수 있는 커넥션 개수. 개수가 적으면 대기 병목이 생기고 많으면 Redis 서버의 메모리와 CPU를 과도하게 소모합니다. 그래서 운영 환경에서는 서버 성능에 따라 100 ~ 200개 사이로 설정합니다.
  • maxIdle : 커넥션을 반납할 때 풀에 남게 되는 개수. 갑작스러운 트래픽 폭증에 대비해 maxTotal과 동일하게 설정하는 것이 좋습니다. 개수가 적으면 커넥션을 생성하고 파괴하는 오버헤드가 많이 발생합니다.
  • minIdle : 항상 대기하는 커넥션 개수. 개수가 적으면 트래픽이 몰릴 때 커넥션을 새로 만들어지는 과정에서 지연이 발생합니다. maxTotal의 50% 수준으로 설정하는 것이 좋습니다.
  • maxWait : 풀이 가득 찼을 때 스레드가 커넥션을 기다리는 시간(밀리세컨드). 운영 환경이라도 사용자가 무한으로 대기하게 되면 서버 전체가에 문제가 되기 때문에 1 ~ 2초 이내로 짧게 잡는 것이 좋습니다.

 

개발 환경을 위한 Lettuce(레터스) 클라이언트 구성

 

commandTimeout : 명령어 실행 타임아웃. Redis 서버가 5초 동안 응답이 없으면 응답을 포기하고 에러(Timeout Exception)를 발생하게 합니다.

shutdownTimeout : 애플리케이션 종료할 때 Redis가 커넥션을 안전하게 닫을 수 있도록 정리할 시간을 0.1초을 기다려주고 종료합니다.

// Lettuce 클라이언트 구성
LettuceClientConfiguration clientConfig = LettucePoolingClientConfiguration.builder()
        .commandTimeout(Duration.ofMillis(5000)) // 명령어 실행 타임아웃(밀리세컨드)
        .shutdownTimeout(Duration.ofMillis(100)) // 애플리케이션 종료 시 커넥션 정리 대기 시간(밀리세컨드)
        .poolConfig(poolConfig) // 커넥션 풀 적용
        .build();

 

자동으로 생성된 ClientConfiguration의 clientConfiguration을 보면 timeout=PT1M(1분),  shutdownTimeout=PT0.1S(0.1초)으로 설정됩니다.

 

 

RedisConfig.java 전체 소스

package com.example.transmission.config;

import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettucePoolingClientConfiguration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;

import java.time.Duration;

@Configuration
public class RedisConfig {

    @Autowired
    private RedisProperties redisProperties;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        // Redis 설정
        RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(redisProperties.getHost(), redisProperties.getPort());

        // Redis 암호 설정
        if (redisProperties.getPassword() != null) {
            redisStandaloneConfiguration.setPassword(redisProperties.getPassword());
        }

        // 개발 환경을 위한 커넥션 풀 설정
        // 커넥션 풀(Connection Pool) 설정
        GenericObjectPoolConfig<?> poolConfig = new GenericObjectPoolConfig<>();
        // 최대 생성할 수 있는 커넥션 개수
        poolConfig.setMaxTotal(5);
        // 유휴(Idle) 상태로 대기할 수 있는 최대 커넥션 개수
        poolConfig.setMaxIdle(5);
        // 항상 유지할 최소 유휴 커넥션 개수
        poolConfig.setMinIdle(3);
        // 풀이 가득 찼을 때 커넥션을 대기하는 최대 시간(밀리세컨드)
        poolConfig.setMaxWait(Duration.ofMillis(2000));

        // Lettuce 클라이언트 구성
        LettuceClientConfiguration clientConfig = LettucePoolingClientConfiguration.builder()
                .commandTimeout(Duration.ofMillis(5000)) // 명령어 실행 타임아웃(밀리세컨드)
                .shutdownTimeout(Duration.ofMillis(100)) // 애플리케이션 종료 시 커넥션 정리 대기 시간(밀리세컨드)
                .poolConfig(poolConfig) // 커넥션 풀 적용
                .build();

        return new LettuceConnectionFactory(redisStandaloneConfiguration, clientConfig);
    }

    @Bean
    public RedisTemplate<String, byte[]> redisTemplateByte(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, byte[]> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setEnableTransactionSupport(true);

        // Key : 문자열로 저장
        redisTemplate.setKeySerializer(RedisSerializer.string());
        // Value : byte[]로 저장
        redisTemplate.setValueSerializer(RedisSerializer.byteArray());

        // Hash를 사용하는 경우
        redisTemplate.setHashKeySerializer(RedisSerializer.string());
        redisTemplate.setHashValueSerializer(RedisSerializer.byteArray());

        return redisTemplate;
    }

    @Bean
    public RedisTemplate<String, String> redisTemplateString(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setEnableTransactionSupport(true);

        // Key : 문자열로 저장
        redisTemplate.setKeySerializer(RedisSerializer.string());
        // Value : byte[]로 저장
        redisTemplate.setValueSerializer(RedisSerializer.string());

        // Hash를 사용하는 경우
        redisTemplate.setHashKeySerializer(RedisSerializer.string());
        redisTemplate.setHashValueSerializer(RedisSerializer.string());

        return redisTemplate;
    }
}

 

application.properties 파일에 Redis의 Configuration를 추가하면 RedisConnectionFactory 빈 생성없이 자동으로 생성할 수 있습니다.

# Redis
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.password=kiki@2000
spring.data.redis.ssl.enabled=false <-- SSL/TLS 보안 통신 비활성화
spring.data.redis.timeout=5000ms <-- 명령어 실행 타임아웃(밀리세컨드)
spring.data.redis.lettuce.shutdown-timeout=100ms <-- 애플리케이션 종료 시 커넥션 정리 대기 시간(밀리세컨드)
spring.data.redis.lettuce.pool.max-active=5 <-- 최대 생성할 수 있는 커넥션 개수
spring.data.redis.lettuce.pool.max-idle=5 <-- 유휴(Idle) 상태로 대기할 수 있는 최대 커넥션 개수
spring.data.redis.lettuce.pool.min-idle=3 <-- 항상 유지할 최소 유휴 커넥션 개수
spring.data.redis.lettuce.pool.max-wait=2000ms <-- 풀이 가득 찼을 때 커넥션을 대기하는 최대 시간(밀리세컨드)

 

application.yml 파일일 경우

spring
  :
  data:
    redis:
      host: localhost
      port: 6379
      password: kiki@2000
      ssl:
        enabled: false
      timeout: 5000ms
      lettuce:
        shutdown-timeout: 100ms
        pool:
          max-active: 5
          max-idle: 5
          min-idle: 3
          max-wait: 2000ms

 

 

 

 

 

 

 

728x90
반응형