Skip to content
6 changes: 6 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,15 @@ dependencies {
//swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'

// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

// test-containers
testImplementation "org.testcontainers:testcontainers:1.20.4"
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package endolphin.backend.global.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisConfig {

@Value("${spring.data.redis.host}")
private String host;

@Value("${spring.data.redis.port}")
private int port;

@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}

/**
* RedisTemplate for binary data (byte[]) storage. 키는 String, 값은 byte[]로 다룹니다.
*/
@Bean
@Primary
public RedisTemplate<String, byte[]> redisByteTemplate(
RedisConnectionFactory connectionFactory) {
RedisTemplate<String, byte[]> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setValueSerializer(RedisSerializer.byteArray());
template.setHashValueSerializer(RedisSerializer.byteArray());
template.afterPropertiesSet();
return template;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package endolphin.backend.global.redis;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.RedisSystemException;
import org.springframework.data.redis.connection.RedisCommandsProvider;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisKeyCommands;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class DiscussionBitmapService {

private final RedisTemplate<String, byte[]> redisTemplate;

/**
* Redis 키 생성: "{discussionId}:{minuteKey}"
*/
private String buildRedisKey(Long discussionId, long minuteKey) {
return discussionId + ":" + minuteKey;
}

/**
* LocalDateTime을 분 단위의 long 값(에포크 기준 분 값)으로 변환.
*/
private long convertToMinuteKey(LocalDateTime dateTime) {
return dateTime.toEpochSecond(ZoneOffset.ofHours(9)) / 60;
}

/**
* 해당 논의 및 시각(분)에 대해 16비트(2바이트) 크기의 비트맵을 초기화하여 Redis에 저장합니다.
*
* @param discussionId 논의 식별자
* @param dateTime 초기화할 기준 시각 (분 단위로 변환됨)
*/
public void initializeBitmap(Long discussionId, LocalDateTime dateTime) {
try {
int byteSize = 2;
long minuteKey = convertToMinuteKey(dateTime);
String redisKey = buildRedisKey(discussionId, minuteKey);
byte[] initialData = new byte[byteSize];
//TODO: 만료 설정?
redisTemplate.opsForValue().set(redisKey, initialData);
} catch (RedisSystemException e) {
//TODO: 예외 처리
throw e;
}
Comment on lines +51 to +54
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Implement proper exception handling

Currently, the catch block rethrows the exception without additional handling. Consider adding logging or custom exception handling to provide more context and facilitate debugging when an error occurs.

}

/**
* 해당 논의의 특정 시각(분) 비트맵 데이터에서, 지정 오프셋의 비트를 수정합니다.
*
* @param discussionId 논의 식별자
* @param dateTime 수정할 시각 (분 단위로 변환됨)
* @param bitOffset 수정할 비트의 오프셋 (0부터 시작, 최대 15까지 사용 가능)
* @param value 설정할 비트 값 (true/false)
* @return 이전 비트 값 (true/false)
*/
public Boolean setBitValue(Long discussionId, LocalDateTime dateTime, long bitOffset,
boolean value) {
long minuteKey = convertToMinuteKey(dateTime);
String redisKey = buildRedisKey(discussionId, minuteKey);
return redisTemplate.opsForValue().setBit(redisKey, bitOffset, value);
}
Comment on lines +66 to +71
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add validation for bitOffset range.

Since this is a 16-bit bitmap, bitOffset should be between 0 and 15.

     public Boolean setBitValue(Long discussionId, LocalDateTime dateTime, long bitOffset,
         boolean value) {
+        if (bitOffset < 0 || bitOffset >= BITMAP_SIZE_BYTES * 8) {
+            throw new IllegalArgumentException("bitOffset must be between 0 and " + 
+                (BITMAP_SIZE_BYTES * 8 - 1));
+        }
         long minuteKey = convertToMinuteKey(dateTime);
         String redisKey = buildRedisKey(discussionId, minuteKey);
         return redisTemplate.opsForValue().setBit(redisKey, bitOffset, value);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public Boolean setBitValue(Long discussionId, LocalDateTime dateTime, long bitOffset,
boolean value) {
long minuteKey = convertToMinuteKey(dateTime);
String redisKey = buildRedisKey(discussionId, minuteKey);
return redisTemplate.opsForValue().setBit(redisKey, bitOffset, value);
}
public Boolean setBitValue(Long discussionId, LocalDateTime dateTime, long bitOffset,
boolean value) {
if (bitOffset < 0 || bitOffset >= BITMAP_SIZE_BYTES * 8) {
throw new IllegalArgumentException("bitOffset must be between 0 and " +
(BITMAP_SIZE_BYTES * 8 - 1));
}
long minuteKey = convertToMinuteKey(dateTime);
String redisKey = buildRedisKey(discussionId, minuteKey);
return redisTemplate.opsForValue().setBit(redisKey, bitOffset, value);
}


/**
* 해당 논의의 특정 시각(분) 비트맵 데이터에서, 지정 오프셋의 비트 값을 조회합니다.
*
* @param discussionId 논의 식별자
* @param dateTime 조회할 시각 (분 단위로 변환됨)
* @param bitOffset 조회할 비트 오프셋 (0부터 시작)
* @return 조회된 비트 값 (true/false)
*/
public Boolean getBitValue(Long discussionId, LocalDateTime dateTime, long bitOffset) {
long minuteKey = convertToMinuteKey(dateTime);
String redisKey = buildRedisKey(discussionId, minuteKey);
return redisTemplate.opsForValue().getBit(redisKey, bitOffset);
}
Comment on lines +81 to +85
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add validation for bitOffset range.

Similar to setBitValue, validate that bitOffset is within the valid range.

     public Boolean getBitValue(Long discussionId, LocalDateTime dateTime, long bitOffset) {
+        if (bitOffset < 0 || bitOffset >= BITMAP_SIZE_BYTES * 8) {
+            throw new IllegalArgumentException("bitOffset must be between 0 and " + 
+                (BITMAP_SIZE_BYTES * 8 - 1));
+        }
         long minuteKey = convertToMinuteKey(dateTime);
         String redisKey = buildRedisKey(discussionId, minuteKey);
         return redisTemplate.opsForValue().getBit(redisKey, bitOffset);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public Boolean getBitValue(Long discussionId, LocalDateTime dateTime, long bitOffset) {
long minuteKey = convertToMinuteKey(dateTime);
String redisKey = buildRedisKey(discussionId, minuteKey);
return redisTemplate.opsForValue().getBit(redisKey, bitOffset);
}
public Boolean getBitValue(Long discussionId, LocalDateTime dateTime, long bitOffset) {
if (bitOffset < 0 || bitOffset >= BITMAP_SIZE_BYTES * 8) {
throw new IllegalArgumentException("bitOffset must be between 0 and " +
(BITMAP_SIZE_BYTES * 8 - 1));
}
long minuteKey = convertToMinuteKey(dateTime);
String redisKey = buildRedisKey(discussionId, minuteKey);
return redisTemplate.opsForValue().getBit(redisKey, bitOffset);
}


/**
* 해당 논의의 특정 시각(분) 비트맵 데이터 전체를 조회합니다.
*
* @param discussionId 논의 식별자
* @param dateTime 조회할 시각 (분 단위로 변환됨)
* @return byte[] 형태의 전체 비트맵 데이터 (없으면 null)
*/
public byte[] getBitmapData(Long discussionId, LocalDateTime dateTime) {
long minuteKey = convertToMinuteKey(dateTime);
String redisKey = buildRedisKey(discussionId, minuteKey);
return redisTemplate.opsForValue().get(redisKey);
}

/**
* SCAN을 이용해 해당 논의의 모든 비트맵 키를 찾고 삭제합니다.
*
* @param discussionId 논의 식별자
*/
public void deleteDiscussionBitmapsUsingScan(Long discussionId) {
String pattern = discussionId + ":*";

// SCAN 옵션
ScanOptions scanOptions = ScanOptions.scanOptions()
.match(pattern)
.count(1000)
.build();

redisTemplate.execute((RedisConnection connection) -> {

RedisKeyCommands keyCommands = ((RedisCommandsProvider) connection).keyCommands();

try (Cursor<byte[]> cursor = keyCommands.scan(scanOptions)) {
while (cursor.hasNext()) {
byte[] rawKey = cursor.next();
keyCommands.del(rawKey);
}
}

return null;
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package endolphin.backend.global.redis;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.LocalDateTime;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;


@SpringBootTest
public class DiscussionBitmapServiceTest {

@Autowired
private DiscussionBitmapService bitmapService;


@DisplayName("비트맵 초기화 및 비트 연산 테스트")
@Test
public void testInitializeAndBitOperations() {
Long discussionId = 100L;
LocalDateTime dateTime = LocalDateTime.now();

// 1. 초기화: 해당 논의 및 시각에 대해 16비트 크기의 비트맵을 생성하여 Redis에 저장
bitmapService.initializeBitmap(discussionId, dateTime);

// 2. 오프셋 5의 비트를 true로 설정
Boolean previousValue = bitmapService.setBitValue(discussionId, dateTime, 5, true);
// 초기화된 비트맵은 모두 0이므로 이전 값은 false여야 함
assertThat(previousValue).isFalse();

// 3. 수정된 비트 조회: 오프셋 5의 비트 값이 true인지 확인
Boolean bitValue = bitmapService.getBitValue(discussionId, dateTime, 5);
assertThat(bitValue).isTrue();

// 4. 전체 비트맵 데이터 조회 및 검증
byte[] bitmapData = bitmapService.getBitmapData(discussionId, dateTime);
int byteIndex = 5 / 8; // 0
int bitPosition = 5 % 8; // 5
// big-endian 순서로 확인: (7 - bitPosition)
boolean extractedBit = ((bitmapData[byteIndex] >> (7 - bitPosition)) & 1) == 1;
assertThat(extractedBit).isTrue();

// 5. SCAN을 이용하여 해당 discussionId의 모든 비트맵 키 삭제
bitmapService.deleteDiscussionBitmapsUsingScan(discussionId);

// 6. 삭제 후 데이터 검증: 비트맵 데이터가 없어야 함
byte[] afterDelete = bitmapService.getBitmapData(discussionId, dateTime);
assertThat(afterDelete).as("deleteDiscussionBitmapsUsingScan should remove the bitmap")
.isNull();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package endolphin.backend.global.redis;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;

@SpringBootTest
public class RedisTemplateTest {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3: 삭제, 업데이트 테스트 코드도 있으면 좋을 거 같습니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추가했습니다


@Autowired
private RedisTemplate<String, byte[]> redisTemplate;

@DisplayName("작성한 Redis template 테스트")
@Test
public void testSetAndGetByteArray() {
String key = "test:bytearray";
byte[] expected = new byte[] {10, 20, 30, 40, 50};

// 값 저장
redisTemplate.opsForValue().set(key, expected);

// 값 조회
byte[] actual = redisTemplate.opsForValue().get(key);

assertThat(actual).isEqualTo(expected);
}
Comment on lines +19 to +30
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add more test cases and cleanup.

The test should include more edge cases and cleanup Redis after test execution.

+    @AfterEach
+    void cleanup() {
+        redisTemplate.delete("test:bytearray");
+    }

     @Test
     @DisplayName("작성한 Redis template 테스트")
     public void testSetAndGetByteArray() {
         // ... existing test ...
     }

+    @Test
+    @DisplayName("대용량 바이트 배열 테스트")
+    public void testLargeByteArray() {
+        String key = "test:large:bytearray";
+        byte[] expected = new byte[1024 * 1024]; // 1MB
+        new Random().nextBytes(expected);
+
+        redisTemplate.opsForValue().set(key, expected);
+        byte[] actual = redisTemplate.opsForValue().get(key);
+
+        assertThat(actual).isEqualTo(expected);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void testSetAndGetByteArray() {
String key = "test:bytearray";
byte[] expected = new byte[] {10, 20, 30, 40, 50};
// 값 저장
redisTemplate.opsForValue().set(key, expected);
// 값 조회
byte[] actual = redisTemplate.opsForValue().get(key);
assertThat(actual).isEqualTo(expected);
}
@AfterEach
void cleanup() {
redisTemplate.delete("test:bytearray");
}
@Test
@DisplayName("작성한 Redis template 테스트")
public void testSetAndGetByteArray() {
String key = "test:bytearray";
byte[] expected = new byte[] {10, 20, 30, 40, 50};
// 값 저장
redisTemplate.opsForValue().set(key, expected);
// 값 조회
byte[] actual = redisTemplate.opsForValue().get(key);
assertThat(actual).isEqualTo(expected);
}
@Test
@DisplayName("대용량 바이트 배열 테스트")
public void testLargeByteArray() {
String key = "test:large:bytearray";
byte[] expected = new byte[1024 * 1024]; // 1MB
new Random().nextBytes(expected);
redisTemplate.opsForValue().set(key, expected);
byte[] actual = redisTemplate.opsForValue().get(key);
assertThat(actual).isEqualTo(expected);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package endolphin.backend.global.redis;

import org.junit.jupiter.api.DisplayName;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;

@DisplayName("Redis Test Containers")
@Profile("dev")
@Configuration
public class RedisTestContainer {

private static final String REDIS_DOCKER_IMAGE = "redis:6-alpine";

static {
GenericContainer<?> REDIS_CONTAINER =
new GenericContainer<>(DockerImageName.parse(REDIS_DOCKER_IMAGE))
.withExposedPorts(6379)
.withReuse(true);

REDIS_CONTAINER.start();

System.setProperty("spring.data.redis.host", REDIS_CONTAINER.getHost());
System.setProperty("spring.data.redis.port",
REDIS_CONTAINER.getMappedPort(6379).toString());
}
Comment on lines +16 to +27
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Consider using @DynamicPropertySource instead of static block.

Using a static block for container initialization can cause issues with Spring context lifecycle. Consider using @DynamicPropertySource for better integration with Spring Boot test context.

-    static {
-        GenericContainer<?> REDIS_CONTAINER =
-            new GenericContainer<>(DockerImageName.parse(REDIS_DOCKER_IMAGE))
-                .withExposedPorts(6379)
-                .withReuse(true);
-
-        REDIS_CONTAINER.start();
-
-        System.setProperty("spring.data.redis.host", REDIS_CONTAINER.getHost());
-        System.setProperty("spring.data.redis.port",
-            REDIS_CONTAINER.getMappedPort(6379).toString());
-    }
+    static final GenericContainer<?> REDIS_CONTAINER;
+    
+    static {
+        REDIS_CONTAINER = new GenericContainer<>(DockerImageName.parse(REDIS_DOCKER_IMAGE))
+            .withExposedPorts(6379)
+            .withReuse(true);
+        REDIS_CONTAINER.start();
+    }
+    
+    @DynamicPropertySource
+    static void redisProperties(DynamicPropertyRegistry registry) {
+        registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost);
+        registry.add("spring.data.redis.port", () -> 
+            REDIS_CONTAINER.getMappedPort(6379).toString());
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
static {
GenericContainer<?> REDIS_CONTAINER =
new GenericContainer<>(DockerImageName.parse(REDIS_DOCKER_IMAGE))
.withExposedPorts(6379)
.withReuse(true);
REDIS_CONTAINER.start();
System.setProperty("spring.data.redis.host", REDIS_CONTAINER.getHost());
System.setProperty("spring.data.redis.port",
REDIS_CONTAINER.getMappedPort(6379).toString());
}
static final GenericContainer<?> REDIS_CONTAINER;
static {
REDIS_CONTAINER = new GenericContainer<>(DockerImageName.parse(REDIS_DOCKER_IMAGE))
.withExposedPorts(6379)
.withReuse(true);
REDIS_CONTAINER.start();
}
@DynamicPropertySource
static void redisProperties(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost);
registry.add("spring.data.redis.port", () ->
REDIS_CONTAINER.getMappedPort(6379).toString());
}

}