안녕하세요 회사와 함께 성장하고 싶은 KOSE입니다.
이번 프로젝트는 MSA 아키텍처를 적용하여 서비스를 구축해 보는 활동을 진행하고 있습니다.
Gateway 역할을 수행하는 서버를 기준으로 각 서비스가 개별적으로 운영되는 아키텍처를 구상하고 있습니다.
그중, wait-service는 대기실 관련 서비스를 Redis를 통해 구현하고자 하였습니다.
이번 포스팅은, Redis를 활용하여 redis-server에 값을 저장, 수정, 삭제하는 테스트 코드를 작성하고자 합니다.
1. Redis-Server Ubuntu20.04에 추가로 적용하기
저는 기존에 Token을 저장하는 Redis-Server를 6379 port에서 활용하고 있었습니다.
token 관련 운영은 게이트웨이에서 진행하도록 프로젝트를 구성하여, wait-server에는 다른 redis-server를 적용하고자 redis-server를 하나 더 추가하였습니다.
cd /etc/redis
// 권한 설정 에러가 발생하면 아래 명령어를 입력합니다.
chmod 777 redis
chmod 777 redis.conf
// redis 복사하기
mv redis.conf redis_6380.conf
// 포트 수정
sudo vim redis_6380.conf
port 6379 <- 수정 대상
port 6380 <- 변경 후 저장
// redis 실행하기
redis-server --port 6380
redis-cil -h 127.0.0.1 -p 6380
2. SpringBoot Redis 적용하기
예시를 작성하는 SpringBoot 및 Java version입니다.
springboot = 3.0.2
java = 17
먼저, build.gradle에 의존성을 주입합니다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
다음은 application.yml에 redis 데이터 서버로 활용할 호스트명과 포트명을 작성합니다.
spring:
data:
redis:
host: localhost
port: 6380
Redis를 사용하기 위해 Redis전용 config 파일을 작성합니다.
import org.springframework.beans.factory.annotation.Value;
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.repository.configuration.EnableRedisRepositories;
@Configuration
@EnableRedisRepositories
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);
}
@Bean
public RedisTemplate<?, ?> redisTemplate() {
RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
application.yml에 있는 host와 port 값을 가져오기 위해, org.springframework.beans.factory.annotation.Value 패키지에서 @Value 어노테이션을 활용합니다.
RedisConnectionFactory 인터페이스로 LettuceConnectionFactory 구현체를 생성하여 반환합니다.
Lettuce는 Netty(Netty는 비동기 이벤트 기반 고성능 네트워크 프레임워크) 기반의 Redis 클라이언트입니다.
(Lettuce의 자세한 내용은 향로님 블로그와 lettuce.io 공식 문서를 하단 링크 첨부하였습니다.)
RedisTemplate는 Spring 공식문서에서 다음과 같이 설명하고 있습니다.
While RedisConnection offers low-level methods that accept and return binary values (byte arrays), the template takes care of serialization and connection management, freeing the user from dealing with such details.
RedisTemplate uses a Java-based serializer for most of its operations. This means that any object written or read by the template is serialized and deserialized through Java.
From the framework perspective, the data stored in Redis is only bytes. While Redis itself supports various types, for the most part, these refer to the way the data is stored rather than what it represents. It is up to the user to decide whether the information gets translated into strings or any other objects.
정리하면, RedisConnection은 이진 배열을 처리하여 반환하는 역할을 수행하고 template는 직렬화와 연결 관리를 처리하는 역할을 수행합니다. RedisTemplate는 대부분 작업에 Java 기반 serializer를 사용하는데, 이는 자바를 통한 직렬화 역직렬화 기능을 수행하는 것을 의미합니다. 추가로 String-focused Convenience Classes를 제공하기도 하는데 문자열 작업을 위한 편리한 기능을 제공하기도 합니다.
이번 포스팅에서는 해당 RedisTemplate 주석처리하여도 redis-cli에 이상 없이 처리되는 것을 확인하였습니다.
3. 코드 작성하기 (Domain, Repository)
@Getter
@RedisHash("waitRoom")
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class WaitRoom implements Serializable {
@Id
private String id;
private String roomName;
private String hostId;
private int limitMembers;
private List<String> members = new LinkedList<>();
private LocalDateTime createdAt;
private LocalDateTime modifiedAt;
protected WaitRoom (CreateWaitRoomDto roomDto) {
id = UUID.randomUUID().toString();
roomName = roomDto.getRoomName();
hostId = roomDto.getUserId();
limitMembers = roomDto.getLimitMembers();
createdAt = now();
modifiedAt = now();
members.add(hostId);
}
public static WaitRoom of(CreateWaitRoomDto createWaitRoomDto) {
return new WaitRoom(createWaitRoomDto);
}
/**
* 대기방 인원이 여유가 있고, 요청이 호스트가 아니라면 회원 추가
*/
public boolean joinMembers(String userId) {
if (isNotFullMembers() && !isHost(userId)) {
members.add(userId);
modifiedAt = now();
return true;
}
return false;
}
/**
* 대기방에 있는 유저 나가기
*/
public void leaveMembers(String userId) {
if (!isHost(userId)) {
members.remove(userId);
modifiedAt = now();
}
}
/**
* 요청 유저 아이디가 방의 호스트와 같은지 파악
*/
public boolean isHost(String userId) {
return userId.equals(this.hostId);
}
/**
* 대기방 만석 파악
*/
private boolean isNotFullMembers() {
return members.size() < limitMembers;
}
}
public interface WaitRoomRepository extends CrudRepository<WaitRoom, String> {
}
RedisHash를 사용하기 위해서는 @RedisHash()를 작성하여 집계루트를 설정합니다.
@Id 어노테이션은 해당 객체가 redis에 저장될 때, RedisHash의 value와 @Id의 값으로 key가 형성됩니다.
(테스트 코드를 작성할 때, @Id 어노테이션을 제거한 후 실행했을 때도 테스트가 통과되었습니다. 서비스 테스트 과정에서도 문제가 없는지 추후 다시 테스트하겠습니다.)
Redis 전용 Repository는 CrudRepository 인터페이스를 상속받아 도메인과 도메인의 id 타입으로 구성할 수 있습니다.
4. 테스트 실행하기
먼저 저장 관련 테스트 하나는 다음과 같습니다.
waitRoomRepository에 값을 저장한 후, uuid로 생성된 id로 repository에서 값을 찾아오면 테스트에 성공하는 것을 확인할 수 있습니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class WaitRoomRepositoryTest {
@Autowired WaitRoomRepository waitRoomRepository;
private WaitRoom waitRoom;
@BeforeEach
void init() {
CreateWaitRoomDto roomDto = new CreateWaitRoomDto("kose", "koseTest1", 5);
waitRoom = WaitRoom.of(roomDto);
}
@AfterEach
void tearDown() {
waitRoomRepository.deleteAll();
}
@Test
@DisplayName("Redis에 createWaitRoom 요청이 오면, 저장되어야 한다.")
public void saveWaitRoom() throws Exception {
//given
waitRoomRepository.save(waitRoom);
//when
WaitRoom findWaitRoom = findById(waitRoom.getId());
//then
assertThat(findWaitRoom.getRoomName()).isEqualTo("koseTest1");
assertThat(findWaitRoom.getHostId()).isEqualTo("kose");
assertThat(findWaitRoom.getLimitMembers()).isEqualTo(5);
assertThat(findWaitRoom.getMembers().size()).isEqualTo(1);
}
}
실제 redis-cli을 활용하여 값을 확인하면 다음과 같습니다.
먼저, Redis는 저장되는 타입에 따라 다른 방법으로 Value를 확인할 수 있습니다.
keys * // 모든 키 가져오기
type waitRoom // type [key명]
smembers waitRoom // type이 set인 경우 value 가져오기
HGETALL [key명] // type이 hash인 경우 value 가져오기
같은 방법으로 수정 및 삭제하는 코드는 다음과 같습니다.
<수정>
@Test
@DisplayName("대기방에 입장 요청이 오면, 인원을 추가하여 값을 변경하여 저장 해야 한다.")
public void joinMembers() throws Exception {
//given
waitRoomRepository.save(waitRoom);
WaitRoom findRoom = findById(waitRoom.getId());
//when
findRoom.joinMembers("kose2");
findRoom.joinMembers("kose3");
findRoom.joinMembers("kose4");
waitRoomRepository.save(findRoom);
WaitRoom result = findById(findRoom.getId());
//then
assertThat(result.getRoomName()).isEqualTo("koseTest1");
assertThat(result.getHostId()).isEqualTo("kose");
assertThat(result.getLimitMembers()).isEqualTo(5);
assertThat(result.getMembers().size()).isEqualTo(4);
assertThat(result.getMembers().get(1)).isEqualTo("kose2");
assertThat(result.getMembers().stream().filter(f -> f.startsWith("kose")).collect(Collectors.toList()).size()).isEqualTo(4);
}
@Test
@DisplayName("대기방에 있던 유저가 퇴장하면, 인원 변동되어 저장해야 한다.")
public void leaveMembers() throws Exception {
//given
waitRoomRepository.save(waitRoom);
WaitRoom findRoom = findById(waitRoom.getId());
findRoom.joinMembers("kose2");
findRoom.joinMembers("kose3");
findRoom.joinMembers("kose4");
waitRoomRepository.save(findRoom);
//when
WaitRoom room = findById(waitRoom.getId());
room.leaveMembers("kose2");
room.leaveMembers("kose4");
WaitRoom result = waitRoomRepository.save(room);
//then
assertThat(result.getRoomName()).isEqualTo("koseTest1");
assertThat(result.getHostId()).isEqualTo("kose");
assertThat(result.getLimitMembers()).isEqualTo(5);
assertThat(result.getMembers().size()).isEqualTo(2);
assertThat(result.getMembers().get(1)).isEqualTo("kose3");
}
<삭제>
삭제를 확인하는 과정은 Exception 처리가 될 수 있으므로 AssertJ의 assertThatThrownBy 메서드로,
assertThatThrownBy([에러 가능성이 있는 처리상황 람다식]).isInstanceOf([클래스명])
등으로 표현할 수 있습니다.
@Test
@DisplayName("호스트가 대기방을 나가면, 방이 종료된다.")
public void leaveHost() throws Exception {
//given
waitRoomRepository.save(waitRoom);
WaitRoom findRoom = findById(waitRoom.getId());
findRoom.joinMembers("kose2");
findRoom.joinMembers("kose3");
findRoom.joinMembers("kose4");
findRoom.joinMembers("kose5");
WaitRoom result = waitRoomRepository.save(findRoom);
//when
waitRoomRepository.delete(result);
//then
Assertions.assertThatThrownBy(() -> findById(result.getId()))
.isInstanceOf(NotExistsRoomIdException.class);
}
해당 테스트도 진행하면, 테스트가 성공하는 것을 확인할 수 있습니다.
최종적으로 작성한 모든 테스트를 전부 실행했을 때, 성공 메시지를 확인할 수 있었습니다.
이번 포스팅은 Redis로 해시 테이블을 구성하여 Repository 테스트를 작성하는 과정을 정리하였습니다.
다음 포스팅은 Service 로직과 Controller 로직에서 해당 redis를 활용하여 다른 외부 서비스 서버와 연동하는 과정을 작성하도록 하겠습니다.
감사합니다.!
참고 자료:
https://pearlluck.tistory.com/727
https://jojoldu.tistory.com/297
'SpringBoot' 카테고리의 다른 글
[SpringBoot] RedissonClient로 멀티스레드 환경의 동시성 극복하기(2) (0) | 2023.02.18 |
---|---|
[SpringBoot] RedissonClient로 멀티스레드 환경의 동시성 극복하기 (2) | 2023.02.18 |
[SpringBoot] @GeneratedValue 분석하기 (0) | 2023.01.13 |
[SpringBoot] Data JPA 벌크연산 (0) | 2023.01.12 |
[SpringBoot] 프록시와 내부 호출 프록시 미적용 문제 해결하기 (0) | 2023.01.10 |