
안녕하세요.
저희 수강신청 프로젝트내에서, 수강신청 가능 기간은 저장할 내용이 한정적이기에 spring data redis 를 사용하여 메모리에 저장하였습니다.
잘 알지못하고 적용한 redis 였기에 로직상으로 불필요하고 지저분한 부분이 많았었는데 이를 개선해나가는 과정과, 개선해나가는 과정에서 배운 내용들을 블로깅하려고 합니다.
우선 리팩토링전의 코드들을 설명하겠습니다.
@RedisHash("enrollmentRegistrationPeriod")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class EnrollmentRegistrationPeriod {
@Id
private String targetGrade;
private LocalDateTime startTime;
private LocalDateTime endTime;
@Builder
private EnrollmentRegistrationPeriod(Grade targetGrade, LocalDateTime startTime, LocalDateTime endTime) {
this.targetGrade = targetGrade.name();
this.startTime = startTime;
this.endTime = endTime;
}
public boolean isWithinTimeRange(LocalDateTime now) {
return startTime.compareTo(now) <= 0 && now.compareTo(endTime) <= 0;
}
}
redis 에 저장될 해쉬, 수강신청 기간을 나타낸다.
public interface EnrollmentRegistrationPeriodStorage extends CrudRepository<EnrollmentRegistrationPeriod, String> {
}
redis CRUD를 편하게 사용하기 위한 추상화된 리포지토리 인터페이스
이처럼 spring data 진영에서 제공하는 CrudRepository 와 @RedisHash 를 사용함으로써, 쉽게 객체를 redis 에 해쉬로 저장할 수 있었습니다.
그러나 CrudRepository 를 쓰면서 생긴 단점도 있었습니다.
public RegistrationDate validateEnrollmentRegistrationPeriod(LocalDateTime now, Grade grade) {
EnrollmentRegistrationPeriod registrationPeriodInGrade = enrollmentRegistrationPeriodStorage.findById(grade.name())
.orElseThrow(EnrollmentRegistrationPeriodNotFoundException::new);
CurrentYearAndSemester currentYearAndSemester = clockService.fetchCurrentClock();
if (registrationPeriodInGrade.isWithinTimeRange(now)) {
return new RegistrationDate(currentYearAndSemester);
}
EnrollmentRegistrationPeriod registrationPeriodInCommon = enrollmentRegistrationPeriodStorage.findById(Grade.COMMON.name())
.orElseThrow(CommonEnrollmentRegistrationPeriodNotFoundException::new);
if (registrationPeriodInCommon.isWithinTimeRange(now)) {
return new RegistrationDate(currentYearAndSemester);
}
throw new InvalidEnrollmentTimeException();
}
학생이 현재 수강신청을 하려는 시간이 (
now), 적절한 수강신청 기간내의 시간인지를 검증하는 메서드
CrudRepository 를 사용함으로써 생긴 단점을 설명하기 전에 저희 비즈니스 로직을 먼저 설명하겠습니다.
수강신청을 하려는 학생의 학년과, 현재 시간을 바탕으로 수강신청 가능 기간인지 검증을 시작합니다.
우선, 학생의 학년을 key 로 하는 hash-value (학년 전용 수강 신청 기간) 을 가져옵니다.
2-1. 만약 학생의 학년을 key 로 하는 hash-value 가 없다면 예외를 던집니다.
현재 시간이 앞서 구한 학년 전용 수강 신청 기간내의 시간인지 검증합니다.
3-1. 검증을 성공하면, 검증 로직을 정상 종료합니다.
현재 시간이 학년 전용 수강 신청 기간이 아니므로, COMMON 을 key 로 하는 hash-value (공통 수강 신청 기간) 을 가져옵니다.
4-1. 만약 COMMON 을 key 로 하는 hash-value 가 없다면 예외를 던집니다.
현재 시간이 공통 수강 신청 기간내의 시간인지 검증합니다.
5-1. 검증을 성공하면, 검증 로직을 정상 종료합니다.
만약 검증이 정상 종료되지 않았다면, 현재 시간이 어떠한 수강 신청 기간에도 걸리지 않는 시간이므로 예외를 던집니다.
말로 정리하니 더욱 복잡합니다.
사실, redis 에 저장된 수강 신청 기간을 가져올 때, 학년 전용 수강 신청 기간과 공통 수강 신청 기간을 한번에 가져온 뒤, 한번에 검증을 진행한다면 이해하기 쉬울 것입니다.
그러나 CrudRepository 를 사용하기 때문에 세세한 쿼리를 할 수 없고 findById 로 수강 신청 기간을 하나씩 가져오다보니 로직이 이해하기 어려워졌습니다.
문제는 이뿐만이 아니었습니다.
이러한 비즈니스 로직으로 인해, 예외 발생이 일관적이지 않은 문제가 있었습니다.
공통 수강 신청이 존재하지 않는다는 예외가 발생합니다.
그러나
공통 수강 신청이 존재하지 않는다는 예외가 발생합니다.
이처럼 예외 발생의 일관성이 없고, 비즈니스 로직이 헷갈려 이를 좀 더 직관적으로 개선해보고자 리팩토링을 마음먹게 되었습니다.
redis 에 저장된 수강 신청 기간 중, 학년 전용 수강 신청 기간과 공통 수강 신청 기간을 한번에 가져오는 것이 리팩토링의 목표였습니다.
특정 수강 신청 2개만 가져오는 로직을 CrudRepository 로는 하기 힘들다 판단하여 RedisTemplate 을 사용하였습니다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class EnrollmentRegistrationPeriodService {
// ...
private final RedisTemplate<String, String> redisTemplate;
// ...
}
서비스 클래스에
RedisTemplate추가
redis 에 파이프라이닝을 통해 특정 수강 신청들만 가져오는 로직을 구현할 수 있다고 판단하여 다음과 같이 비즈니스 로직을 수정하였습니다.
public RegistrationDate validateEnrollmentRegistrationPeriod(LocalDateTime now, Grade grade) {
ObjectMapper objectMapper = new ObjectMapper();
redisTemplate.executePipelined(
(RedisCallback<Object>)connection -> {
StringRedisConnection stringRedisConnection = (StringRedisConnection)connection;
stringRedisConnection.hGetAll(ENROLLMENT_REGISTRATION_PERIOD_PREFIX + grade.name());
stringRedisConnection.hGetAll(ENROLLMENT_REGISTRATION_PERIOD_PREFIX + Grade.COMMON.name());
return null;
}
).stream()
.map(lhm -> objectMapper.convertValue(lhm, EnrollmentRegistrationPeriod.class))
.filter(period -> period.isWithinTimeRange(now))
.findAny()
.orElseThrow(InvalidEnrollmentTimeException::new);
CurrentYearAndSemester currentYearAndSemester = clockService.fetchCurrentClock();
return new RegistrationDate(currentYearAndSemester);
}
바뀐 비즈니스 로직
redisTemplate.executePipelined(...) 내부에 한번에 보내고자 하는 redis 명령어 파이프라인을 만듦으로써 한번에 실행할 수 있습니다.
이 메서드의 return 값들은 List<LinkedHashMap<String, String>> 으로 redis 에 저장된 각 hash-value 의 필드명-필드값들이 하나의 LinkedHashMap 이 되어 리스트를 이루게 됩니다.
LinkedHashMap 으로는 현재 시간이 수강 신청 기간에 부합하는지 검증할 수 없으므로 LinkedHashMap 을 EnrollmentRegistrationPeriod 객체로 변환하기 위해 ObjectMapper 를 생성 후 사용했습니다.
filter 를 이용하여 검증에 부합하는 수강 신청 기간이 단 하나라도 남는다면 현재 시간은 수강 신청이 가능한 기간이므로 검증을 통과합니다.
반면, 남는 수강 신청 기간이 하나도 없다면 현재 시간은 수강 신청이 가능하지 않은 기간이므로 예외를 던집니다.
훨씬 로직이 깔끔하고 직관적으로 변했습니다.
그러나 위의 비즈니스 코드는 정상적으로 동작하지 않습니다.
위의 비즈니스 코드는 런타임 에러를 발생시킵니다.
런타임 에러가 발생하는 이유는 2가지입니다.
@RedisHash("enrollmentRegistrationPeriod")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class EnrollmentRegistrationPeriod {
@Id
private String targetGrade;
private LocalDateTime startTime;
private LocalDateTime endTime;
@Builder
private EnrollmentRegistrationPeriod(Grade targetGrade, LocalDateTime startTime, LocalDateTime endTime) {
this.targetGrade = targetGrade.name();
this.startTime = startTime;
this.endTime = endTime;
}
public boolean isWithinTimeRange(LocalDateTime now) {
return startTime.compareTo(now) <= 0 && now.compareTo(endTime) <= 0;
}
}
redis 에 저장될 해쉬, 수강 신청 기간을 나타낸다.
redis 에 저장될 수강 신청 기간 클래스를 다시한번 보겠습니다.
수강 신청 기간을 저장할 때 CrudRepository 를 사용해 @RedisHash 를 통째로 저장하면 redis 에 어떻게 저장될까요?
redis 에 저장되는 하나의 hash-value 가 갖는 필드들은 다음과 같습니다.
_class 필드는 @RedisHash 가 붙은 클래스의 패키지 정보와 클래스 명이 저장됩니다.
이처럼 _class 필드 때문에 deserialize 하기 위한 입력값인 LinkedHashMap 에는 _class 필드가 추가됩니다.
반면 deserialize 의 결과값인 EnrollmentRegistrationPeriod 클래스에는 _class 라는 필드는 없으니 런타임 예외가 발생합니다.
이를 해결하기 위해서는 redis 에 hash-value 를 저장할 때, _class 필드를 저장하지 않거나, _class 필드를 deserialize 할 때 무시하는 방법이 있습니다.
EnrollmentRegistrationPeriod 클래스는 수강 신청 기간 정보를 가져야하기 때문에 LocalDateTime 필드를 갖고 있습니다.
그러나 기본설정의 ObjectMapper 는 LocalDateTime 필드를 serialize/deserialize 할 수 없으니 런타임 예외가 발생합니다.
이를 해결하기 위해서는 ObjectMapper 에 시간 변환을 위한 모듈을 추가해주어야 합니다.
앞서 런타임 예외가 발생한 문제점과 해결방안들을 살펴보았습니다.
각각의 문제점 해결을 위한 해결방안들을 적용해주어도 되지만, 스프링 부트 환경에서는 이 문제들을 더 쉽고 간편하게 한번에 해결할 수 있습니다.
바로 스프링 컨테이너에서 주입해주는 ObjectMapper 를 사용하는 것입니다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class EnrollmentRegistrationPeriodService {
// ...
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
// ...
public RegistrationDate validateEnrollmentRegistrationPeriod(LocalDateTime now, Grade grade) {
redisTemplate.executePipelined(
(RedisCallback<Object>)connection -> {
StringRedisConnection stringRedisConnection = (StringRedisConnection)connection;
stringRedisConnection.hGetAll(ENROLLMENT_REGISTRATION_PERIOD_PREFIX + grade.name());
stringRedisConnection.hGetAll(ENROLLMENT_REGISTRATION_PERIOD_PREFIX + Grade.COMMON.name());
return null;
}
).stream()
.map(lhm -> objectMapper.convertValue(lhm, EnrollmentRegistrationPeriod.class))
.filter(period -> period.isWithinTimeRange(now))
.findAny()
.orElseThrow(InvalidEnrollmentTimeException::new);
CurrentYearAndSemester currentYearAndSemester = clockService.fetchCurrentClock();
return new RegistrationDate(currentYearAndSemester);
}
- 최종적으로 완성된 비즈니스 로직
- 다음과 같이 스프링 컨테이너에서 주입해주는 ObjectMapper 를 사용함으로써 앞서 런타임 에러들을 해결할 수 있다.
스프링 컨테이너에 있는 ObjectMapper 는 기본설정의 ObjectMapper 와 무엇이 다를까요?
ObjectMapper 는 여러가지 옵션을 주어 커스터마이징을 할 수 있습니다.
스프링 부트 내부에서는 ObjectMapper 를 생성할 때, 앞선 1번, 2번 문제들을 해결하는 세팅을 포함한 여러가지 세팅을 합니다.
configureFeature(objectMapper, DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
입력값을 Object 로 변환하는 deserialize 시에 Object 에 없는 필드가 있어도 에러를 발생시키지 않는다. 이러한 세팅이 1번 문제를 해결한다.
Class<? extends Module> javaTimeModuleClass = (Class<? extends Module>)
ClassUtils.forName("com.fasterxml.jackson.datatype.jsr310.JavaTimeModule", this.moduleClassLoader);
Module javaTimeModule = BeanUtils.instantiateClass(javaTimeModuleClass);
modulesToRegister.set(javaTimeModule.getTypeId(), javaTimeModule);
LocalDateTime 과 같은 시간 변환을 가능하게 해주는 모듈을 추가한다. 이러한 세팅이 2번 문제를 해결한다.
지금까지 spring data redis 를 사용한 수강 신청 기간을 검증하는 로직을 개선하는 과정과, 그 과정에서 공부한 ObjectMapper 에 대해 설명하였습니다.
잘 알지 못하는 상태에서 도입하는 기술들은 지저분한 코드를 만듭니다.
이렇게 쌓인 지저분한 코드들은 부채가 되어 마음한켠에 찝찝한 마음을 남기곤 했습니다.
이번에 리팩토링 기간을 길게 둔 덕분에, 잘 몰랐던 기술들을 다시금 차분히 공부해나가면서 리팩토링을 할 시간이 생겼는데 처음엔 막막했지만 조금씩 개선해나가면서 뿌듯함과 재미를 느낄 수 있어 뜻깊은 시간이었습니다.
작성자: Hyun
출처
https://docs.spring.io/spring-data/redis/reference/redis/pipelining.html