Spring boot Redis timeout 설정 - Spring boot Redis timeout seoljeong

Spring Boot 2에서 정식 채택된 Redis 라이브러리인 Lettuce를 이용한 프로젝트 개발 예제입니다.

개요

레디스는 웹 개발에서 빼놓을 수 없는 대표적인 인메모리 저장소 중 하나로, 빠른 응답속도로 시스템의 읽기 부하를 많이 해소해줄 수 있습니다.

오늘은 Lettuce를 사용해 서비스 로직을 만들어보고, 그 서비스 로직을 이용한 RESTful API를 구현하여 직접 호출해보면서 결과를 확인할 수 있도록 하려고 합니다.

이번 예제에서는 아래의 API 스펙을 커버하는 기능을 함께 작성해보겠습니다.

API Endpoint

HTTP Method

Request Body

Description

/redis-sample/v1/users

POST

{

"username": "testUser"

}

사용자 등록

/redis-sample/v1/users/{username}

DELETE

사용자 삭제

/redis-sample/v1/users/{username}

GET

사용자 조회

/redis-sample/v1/users

GET

전체 사용자 조회

/redis-sample/v1/blocked-users/{username}

POST

사용자 차단

/redis-sample/v1/blocked-users/{username}

DELETE

사용자 차단해제

/redis-sample/v1/blocked-users/{username}

GET

사용자 차단여부 조회

스프링 부트 프로젝트 생성

먼저 Spring Boot Initializr를 이용해 스프링 부트 프로젝트를 생성합니다.

저는 아래와 같은 설정으로 프로젝트를 생성하였습니다.

Project

Gradle Project

Language

Java

Spring Boot

2.2.1

Project Metadata

Group

com.joonsang.sample

Artifact

spring-boot-redis

∨ Options

Packaging

Jar

Java

11

Dependencies

-

Dependencies는 미리 선택해서 생성해도 되지만 저는 나중에 설정하기 위해 따로 선택하지 않았습니다.

프로젝트 디펜던시 작성

우리가 작성할 프로젝트의 라이브러리 디펜던시는 아래와 같습니다.

build.gradle

dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-json' testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' } implementation group: 'org.projectlombok', name: 'lombok', version: '1.18.10' implementation group: 'com.google.guava', name: 'guava', version: '28.1-jre' implementation group: 'org.msgpack', name: 'jackson-dataformat-msgpack', version: '0.8.18' implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.9' implementation group: 'org.apache.commons', name: 'commons-text', version: '1.8' annotationProcessor("org.projectlombok:lombok:1.18.10") testAnnotationProcessor("org.projectlombok:lombok:1.18.10") }

우리는 웹 어플리케이션을 개발하려고 하므로 spring-boot-starter-web을 디펜던시에 추가합니다.

다음으로는 레디스 라이브러리를 사용하기 위해 spring-boot-starter-data-redis를 추가합니다.

JSON 핸들링을 위해 spring-boot-starter-json 라이브러리도 추가해줍니다.

스프링 부트 라이브러리의 준비는 끝났으니 이제는 써드파티 라이브러리를 디펜던시에 추가해봅시다.

레디스에 객체를 저장하기 위해 자바빈 클래스를 작성할텐데, 반복되는 코드를 줄이고 코드를 우아하게 작성할 수 있도록 도와주는 lombok 라이브러리를 디펜던시에 추가합니다.

ImmutableMap을 사용하기 위해 guava 라이브러리를 추가합니다.

객체를 레디스에 저장할 때 사용할 jackson-data-format-msgpack 라이브러리를 추가합니다.

StringUtils를 사용하기 위해 commons-lang3 라이브러리를 추가합니다.

StringSubstitutor를 사용하기 위해 commons-text 라이브러리를 추가합니다.

마지막으로 annotation processor를 lombok으로 지정해줍니다.

이번 예제에서 테스트 케이스는 작성하지 않을 것이므로 test annotation processor는 지정하지 않아도 무방합니다.

IDE에서의 Lombok 라이브러리 지원

IntelliJ IDEA를 사용하고 있다면 IDE에서 Lombok 라이브러리를 지원할 수 있으며 이를 위해 몇가지 설정이 필요합니다.

먼저 아래 메뉴로 접근하여 Enable annotation processing 체크박스를 체크합니다.

File > Settings > Build, Execution, Deployment > Compiler > Annotation Processors > Enable annotation processing

다음으로 아래 메뉴에서 Lombok 플러그인을 검색해서 인스톨 후 IDE를 재시작합니다.

File > Settings > Plugins

스프링 부트 설정과 레디스 접속정보 작성

application.properties

# Log settings logging.level.root=debug # Redis Settings redis.hostname={your_redis_hostname} redis.port={your_redis_port} redis.database={your_redis_database} redis.password={your_redis_password} redis.timeout={your_redis_timeout}

디버깅을 편하게 하기 위해 루트 로그 레벨을 debug로 설정합니다.

아래에는 레디스 접속정보를 입력합니다.

Config 작성

이제 프로젝트 설정은 모두 끝났습니다!

본격적인 개발을 위해 스프링 부트 configuration을 작성하도록 하겠습니다.

RedisConfig.java

@Configuration public class RedisConfig { private final String HOSTNAME; private final int PORT; private final int DATABASE; private final String PASSWORD; private final long TIMEOUT; public RedisConfig( @Value("${redis.hostname}") String hostname, @Value("${redis.port}") int port, @Value("${redis.database}") int database, @Value("${redis.password}") String password, @Value("${redis.timeout}") long timeout ) { this.HOSTNAME = hostname; this.PORT = port; this.DATABASE = database; this.PASSWORD = password; this.TIMEOUT = timeout; } @Bean public RedisConnectionFactory redisConnectionFactory() { RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); config.setHostName(HOSTNAME); config.setPort(PORT); config.setDatabase(DATABASE); config.setPassword(PASSWORD); LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() .commandTimeout(Duration.ofMillis(TIMEOUT)) .build(); return new LettuceConnectionFactory(config, clientConfig); } @Bean public StringRedisTemplate stringRedisTemplate( @Qualifier("redisConnectionFactory") RedisConnectionFactory redisConnectionFactory ) { StringRedisTemplate template = new StringRedisTemplate(); template.setConnectionFactory(redisConnectionFactory); return template; } @Bean public RedisTemplate<String, byte[]> messagePackRedisTemplate( @Qualifier("redisConnectionFactory") RedisConnectionFactory redisConnectionFactory ) { RedisTemplate<String, byte[]> template = new RedisTemplate<>(); template.setConnectionFactory(redisConnectionFactory); template.setKeySerializer(new StringRedisSerializer()); template.setEnableDefaultSerializer(false); return template; } @Bean public ObjectMapper messagePackObjectMapper() { return new ObjectMapper(new MessagePackFactory()) .registerModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); } }

먼저 Redis 접속을 위한 RedisConnectionFactory 빈을 작성합니다.

다음으로는 텍스트를 저장하기 위한 StringRedisTemplate 빈을 작성합니다.

객체를 저장하기 위해 message pack redis template 빈을 작성합니다.

이제 객체의 저장이나 조회 시 serialize를 위해 ObjectMapper의 빈을 작성합니다.

추가로 날짜를 저장하기 위해 JavaTimeModule을 등록하고, timestamp 형식으로 저장하는 기능을 비활성화하는 옵션이 적용되어 있습니다.

위 설정 없이 기본 설정으로 사용하면 날짜형식의 데이터를 핸들링할 때 timestamp 형식으로 사용하게 됩니다.

로직에서 레디스를 사용하기 위한 코드 작성을 완료했으니 이제 서비스 로직을 개발하면 될 것 같지만, 아직 configuration을 하나 더 작성해야 합니다.

우리는 레디스 서비스 로직을 호출하는 컨트롤러도 작성하기로 했기 때문에, JSON 설정도 해주어야 API 호출 시 스프링 부트 웹 어플리케이션에서 정상적으로 JSON 형식의 응답을 해줄 수 있습니다.

JsonConfig.java

@Configuration public class JsonConfig { @Bean public ObjectMapper objectMapper() { return Jackson2ObjectMapperBuilder.json() .featuresToDisable(SerializationFeature.FAIL_ON_EMPTY_BEANS) .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .modules(new JavaTimeModule()) .build(); } }

객체를 serialize할 때 객체가 비어있으면 실패하는 기능과 timestamp로 작성하는 기능을 비활성화, 그리고 JavaTimeModule을 활성화한 ObjectMapper를 스프링 부트 어플리케이션에서 사용할 수 있도록 설정합니다.

도메인 작성

사용자 정보를 저장하기 위한 자바빈을 작성합니다.

위에서 설정한 Lombok 라이브러리를 이용하여 작성한 모습입니다.

객체를 serialize할 예정이므로 반드시 잊지않고 Serializable 인터페이스를 구현해주도록 합니다.

User.java

@NoArgsConstructor @Setter public class User implements Serializable { @Getter private String username; @Getter @JsonProperty("created_at") @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS") private LocalDateTime createdAt; }

Redis DAO 작성

드디어 레디스 저장소를 핸들링하는 data access object를 작성할 수 있습니다!

RedisDAO.java

@Repository public class RedisDAO { private static final String BLOCKED_USER_KEY = "CACHES:BLOCKED_USERS:${USERNAME}"; private static final String USER_KEY = "USERS:${USERNAME}"; private final RedisConnectionFactory redisConnectionFactory; private final StringRedisTemplate stringRedisTemplate; private final RedisTemplate<String, byte[]> messagePackRedisTemplate; private final ObjectMapper messagePackObjectMapper; public RedisDAO( @Qualifier("redisConnectionFactory") RedisConnectionFactory redisConnectionFactory, @Qualifier("stringRedisTemplate") StringRedisTemplate stringRedisTemplate, @Qualifier("messagePackRedisTemplate") RedisTemplate<String, byte[]> messagePackRedisTemplate, @Qualifier("messagePackObjectMapper") ObjectMapper messagePackObjectMapper ) { this.redisConnectionFactory = redisConnectionFactory; this.stringRedisTemplate = stringRedisTemplate; this.messagePackRedisTemplate = messagePackRedisTemplate; this.messagePackObjectMapper = messagePackObjectMapper; } public boolean isUserBlocked(String username) { String key = StringSubstitutor.replace( BLOCKED_USER_KEY, ImmutableMap.of("USERNAME", username) ); Boolean hasKey = stringRedisTemplate.hasKey(key); return Objects.requireNonNullElse(hasKey, false); } public long getUserBlockedSecondsLeft(String username) { String key = StringSubstitutor.replace( BLOCKED_USER_KEY, ImmutableMap.of("USERNAME", username) ); Long secondsLeft = stringRedisTemplate.getExpire(key, TimeUnit.SECONDS); return Objects.requireNonNullElse(secondsLeft, 0L); } public void setUserBlocked(String username) { String key = StringSubstitutor.replace( BLOCKED_USER_KEY, ImmutableMap.of("USERNAME", username) ); stringRedisTemplate.opsForValue().set(key, StringUtils.EMPTY, 5, TimeUnit.MINUTES); } public void deleteUserBlocked(String username) { String key = StringSubstitutor.replace( BLOCKED_USER_KEY, ImmutableMap.of("USERNAME", username) ); stringRedisTemplate.delete(key); } public User getUser(String username) throws IOException { String key = StringSubstitutor.replace( USER_KEY, ImmutableMap.of("USERNAME", username) ); byte[] message = messagePackRedisTemplate.opsForValue().get(key); if (message == null) { return null; } return messagePackObjectMapper.readValue(message, User.class); } public void setUser(User user) throws JsonProcessingException { String key = StringSubstitutor.replace( USER_KEY, ImmutableMap.of("USERNAME", user.getUsername()) ); byte[] message = messagePackObjectMapper.writeValueAsBytes(user); messagePackRedisTemplate.opsForValue().set(key, message, 1, TimeUnit.HOURS); } public void deleteUser(String username) { String key = StringSubstitutor.replace( USER_KEY, ImmutableMap.of("USERNAME", username) ); messagePackRedisTemplate.delete(key); } public List<String> getAllUsers() { String key = StringSubstitutor.replace(USER_KEY, ImmutableMap.of( "USERNAME", "*" )); RedisConnection redisConnection = redisConnectionFactory.getConnection(); ScanOptions options = ScanOptions.scanOptions().count(50).match(key).build(); List<String> users = new ArrayList<>(); Cursor<byte[]> cursor = redisConnection.scan(options); while (cursor.hasNext()) { String user = StringUtils.replace(new String(cursor.next()), "USERS:", ""); users.add(user); } return users; } }

위에서 설정한 string redis template 빈을 이용해서 텍스트를 저장하거나 꺼내오고, message pack redis template 빈을 이용해서 객체를 저장하거나 꺼내오는 로직을 구현한 모습입니다.

객체를 저장할 때는 레디스 컨피그에서 설정한 ObjectMapper를 이용, serialize해서 byte로 저장 및 가져오는 코드가 작성되어 있는 것을 확인할 수 있습니다.

맨 아래에는 레디스의 전체 키를 가져오는 메소드가 작성되어 있는데요,

레디스 스캔 커맨드를 이용해서 전체 키를 가져오는 로직이 구현되어 있습니다.

참고로, 간단하게 레디스 전체 키를 가져오는 방법으로 keys 명령어가 있지만 이는 프로덕션 환경에서 절대 사용하면 안되는 명령어입니다.

레디스가 키를 많이 가지고 있는 상황에서 해당 명령어를 실행하면 결과를 응답할 때까지 레디스 퍼포먼스가 심각하게 떨어지거나, 아예 전체 서비스가 멈춰버리는 대참사가 일어날 수도 있습니다.

그래서 레디스 서버에 부하를 주지 않고 모든 키를 가져오는 방법으로 scan 명령어를 사용해야 하는데요,

스캔은 커서 기반의 이터레이터로, 맨 처음에 커서를 0으로 두고 요청하면 데이터 리스트와 함께 다음 커서를 반환하는 식으로 순차적으로 호출하여 결국 모든 키를 가져올 수 있는 구조입니다.

커서 기반으로 순차호출하므로 중복된 키를 반환하거나 순서가 맞지 않을 수 있지만 안전하게 모든 키를 가져올 수 있습니다.

서비스 로직 작성

위에서 구현한 DAO를 이용해 목표한 API 스펙을 커버하기 위한 사용자 서비스 로직을 구현해봅시다.

UserService.java

@Service public class UserService { private final RedisDAO redisDAO; public UserService(RedisDAO redisDAO) { this.redisDAO = redisDAO; } public User registerUser(String username) throws IOException { User user = new User(); user.setUsername(username); user.setCreatedAt(LocalDateTime.now()); redisDAO.setUser(user); return redisDAO.getUser(username); } public void deleteUser(String username) { redisDAO.deleteUser(username); } public User getUser(String username) throws IOException { return redisDAO.getUser(username); } public List<String> getUsernameList() { return redisDAO.getAllUsers(); } public boolean isUserBlocked(String username) { return redisDAO.isUserBlocked(username); } public long getUserBlockedSecondsLeft(String username) { return redisDAO.getUserBlockedSecondsLeft(username); } public void blockUser(String username) { redisDAO.setUserBlocked(username); } public void unblockUser(String username) { redisDAO.deleteUserBlocked(username); } }

Redis DAO를 이용해 사용자 등록, 사용자 수정, 사용자 삭제, 사용자 조회, 사용자 전체 리스트 조회, 사용자 차단여부 조회, 차단된 사용자의 남은 차단시간 조회, 사용자 차단, 사용자 차단해제까지 총 8개 서비스 로직을 구현한 모습입니다.

왜 7개가 아니라 8개냐구요?

차단된 사용자의 남은 차단시간 조회는 사용자 차단여부 조회 API에서 함께 제공할 계획입니다.

컨트롤러 작성

이제 API 스펙에 맞게 컨트롤러를 작성해봅시다.

RedisSampleController.java

@RestController @RequestMapping({"/redis-sample/v1"}) public class RedisSampleController { private final UserService userService; public RedisSampleController(UserService userService) { this.userService = userService; } @GetMapping({"/users"}) public ResponseEntity<?> getAllUsers() { List<String> users = userService.getUsernameList(); return new ResponseEntity<>(ImmutableMap.of("users", users), HttpStatus.OK); } @GetMapping({"/users/{username}"}) public ResponseEntity<?> getUser( @PathVariable("username") String username ) throws IOException { User user = userService.getUser(username); if (user == null) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } return new ResponseEntity<>(user, HttpStatus.OK); } @PostMapping({"/users"}) public ResponseEntity<?> registerUser( @RequestBody RegisterUserRequest request ) throws IOException { User user = userService.registerUser(request.getUsername()); return new ResponseEntity<>(user, HttpStatus.OK); } @DeleteMapping({"/users/{username}"}) public ResponseEntity<?> deleteUser( @PathVariable("username") String username ) { userService.deleteUser(username); return new ResponseEntity<>(HttpStatus.NO_CONTENT); } @GetMapping({"/blocked-users/{username}"}) public ResponseEntity<?> isUserBlocked( @PathVariable("username") String username ) { boolean blocked = userService.isUserBlocked(username); if (!blocked) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } long secondsLeft = userService.getUserBlockedSecondsLeft(username); return new ResponseEntity<>(ImmutableMap.of("unblock_after_seconds", secondsLeft), HttpStatus.OK); } @PostMapping({"/blocked-users/{username}"}) public ResponseEntity<?> blockUser( @PathVariable("username") String username ) { userService.blockUser(username); return new ResponseEntity<>(HttpStatus.NO_CONTENT); } @DeleteMapping({"/blocked-users/{username}"}) public ResponseEntity<?> unblockUser( @PathVariable("username") String username ) { userService.unblockUser(username); return new ResponseEntity<>(HttpStatus.NO_CONTENT); } }

최초 목표했던 7개의 API를 RESTful하게 구현한 모습입니다.

위에서 정의했던 스펙에 따르면 사용자 등록 API는 request body가 필요하므로 request 객체까지 추가로 작성하며 마무리를 해봅시다.

RegisterUserRequest.java

@NoArgsConstructor @Data public class RegisterUserRequest { private String username; }

마무리

이제 스프링 어플리케이션을 실행한 후 API를 요청해봅시다.

저는 API 요청 테스트 시 개인적으로 포스트맨을 애용하고 있습니다만 각자 편한 방법으로 요청하시면 되겠습니다.

전체 소스코드는 아래 링크에서 확인하실 수 있습니다.