안녕하세요.! 회사와 함께 성장하고 싶은 KOSE입니다.

오늘은 크리스마스 동안 붙들었던 redis cache를 QueryDsl의  Page<T> 타입에 적용한 후기를 적어보고자 합니다.

(다들 연말 잘 보내세요!. 늦은 시간이지만, 너무나도 즐겁게 글을 작성할 수 있어서 행복합니다..ㅎㅎ!)

 

저는, Redis를  로그인에 사용되는 액세스 토큰 및 리프레쉬 토큰을 저장하는 저장소로, 두 번째는, 캐시화 처리를 위한 용도로 사용하였습니다. 토큰을 저장하는 용도의 사용은 다른 분들께서 자세히 적어주셔서 해당 과정은 에러 없이 해결했지만, 후자는 많은 에러가 발생했습니다. 다시 한번, 백엔드의 어려움과 경의로움을 경험할 수 있었습니다.

 

이 글은, Redis에 대한 정의, Local cache Vs global cache 비교, 에러 상황, 에러 해결 순으로 글을 작성하도록 하겠습니다.

글을 읽으시는 분들 중에 저와 비슷한 에러가 발생하신 분들이 있다면, 도움이 되셨으면 좋겠습니다.

1. Redis?

참조:&nbsp;https://devlog-wjdrbs96.tistory.com/374

 

Redis는 캐시 시스템으로 동일한 기능을 제공하면서 영속성,
다양한 데이터 구조와 같은 부가적인 기능을 지원합니다.

Redis는 모든 데이터를 메모리에 저장하고 조회하는 인메모리 데이터베이스입니다.

 

a. 인메모리 데이터베이스?

인메모리 데이터베이스는 보조 기억 장치를 사용하는 기존 데이터베이스에 비해 빠릅니다. (하드디스크 등) 

컴퓨터 중앙 처리 장치(CPU)는 주 메모리에 저장된 데이터만 직접 접속할 수 있습니다. 따라서, 주메모리 방식이 보조기억 장치보다 훨씬 빠르게 데이터를 처리할 수 있습니다.

 

b. 인메모리 데이터베이스와 디스크 기반 데이터베이스 차이점

디스크 기반 데이터베이스는 IO 작업을 수행하기 때문에, 디스크에서 쓰기/읽기 작업을 필요로 합니다. 디스크 기반 데이터 베이스는 데이터를 저장하는 구조가 복잡한데, 데이터 베이스에 데이터를 저장하기 전에 디스크 액세스가 효율적임을 먼저 확인합니다.

하지만, 인메모리 데이터베이스는 주 메모리에서 데이터를 무작위로 액세스 하는 것이 효율적이기 때문에 저장 구조가 간단합니다.

 

2. Local cache Vs Global cache

저는 이전 프로젝트에서 local cache의 일종인 caffeinCached를 사용한 경험이 있습니다. 그 당시에는 local cache와 global cache 차이점을 알지 못하고 redis는 토큰 저장소의 일종이라고만 생각을 했었습니다. 프론트분과 협업을 진행하는 과정에서 redis의 무궁한 기술을 접하게 되었고 두 캐시 방법의 차이점을 느끼게 되었습니다.

 

a. Local cache

 

참조:&nbsp;https://deveric.tistory.com/71

Local cache는 서버마다 캐시를 따로 저장합니다. 즉 여러 대의 서버가 존재한다면, 다른 서버의 캐시를 참조하기 어렵습니다. 이는 곧 정합성 문제와 직결되는데, 다중 서버를 운영한다면 같은 로직의 데이터 요청이 들어올 때, 서버마다 캐시가 참조가 어려우므로 데이터 정합성 문제가 발생할 수 있습니다.

이는 제가 경험해보지 못하여서 확실하지 않지만, 제 생각은 local cache는 사용자의 요청에 따라 캐시가 생성되고 인메모리에 저장되는데, 다른 서버에 저장된 캐시는 확인하기 어려우므로 다시 캐시가 생성될 수 있습니다. 

또한, a라는 서버에 부하가 생길 시 로드밸런서가 a 서버 요청을 b 서버로 위임하게 된다면, 로그인을 다시 해야 하는 상황이 생길 수 있다고 생각합니다. 하지만, 서버 내에 캐시가 저장되므로 속도가 빠르다는 장점이 있습니다.

 

b. Global cache 

 

https://deveric.tistory.com/71

global cache는 여러 서버에서 캐시 서버를 참조하는 방식입니다. 이는 네트워크 트래픽을 사용하므로 로컬 캐시보다 느리다는 단점이 있으나, 서버 간 데이터 공유가 쉽습니다.

먼저, global cache는 외부 캐시 저장소에 접근하여 데이터를 가져오므로 네트워크 I/O 비용이 발생합니다. 하지만, 서버 인스턴스가 추가되더라도, 동일한 비용만 요구하므로 서버가 고도화될수록 더 높은 효율을 발휘됩니다.

또한, 위의 local cache와는 다르게 모든 서버의 인스턴스가 동일한 캐시 저장소에 접근하므로 데이터의 정합성이 보장됩니다.

(참조: https://souljit2.tistory.com/72)

 

 

3. Redis 캐싱 에러 발생

저는 Redis를 토큰 저장소와 자주 사용하지만 변할 가능성이 적은 데이터에 활용하고자 하였습니다. 학교 찾기와 같은 로직은 데이터가 잘 변하지 않지만, 매번 요청마다 데이터베이스에서 데이터를 가져와 클라이언트에 제공하는 과정이 서버에 많은 부하를 주리라 생각하였습니다. 따라서, 학교 찾는 서비스 로직에 Redis를 적용하고자 하였습니다.

 

먼저 Redis를 사용하기 위해선, bulild.gradle에 redis 관련 라이브러리를 의존받고, yml에 포트 및 호스트 등록, redis를 스프링 빈으로 사용하기 위해 RedisConfig 등록이 필요합니다.

// build.gradle

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

spring:
  redis:
    host: localhost
    port: 6379
@Configuration
public class RedisConfig {

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

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

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

    @SuppressWarnings("deprecation")
    @Bean
    public CacheManager cacheManager() {
        RedisCacheManagerBuilder builder = fromConnectionFactory(redisConnectionFactory());
        RedisCacheConfiguration configuration = defaultCacheConfig()
                .serializeValuesWith(fromSerializer(new GenericJackson2JsonRedisSerializer()))
                .entryTtl(Duration.ofMinutes(30));
        builder.cacheDefaults(configuration);
        return builder.build();
    }

}

serializeValuesWith(fromSerializer(new GenericJackSon2JsonRedisSerializer())) 는  객체를 Json 타입으로 직렬화/역직렬화를 수행하여 binary 데이터 형태를 String으로 혹은 그 반대로 전환하는 역할을 수행해 줍니다.

entryTtl은 해당 캐시의 지속시간을 설정할 수 있습니다.

 

SchoolSearchDto로 클라이언트에게 학교 찾는 요청을 받는 Dto입니다.

@Data
@NoArgsConstructor
public class SchoolSearchReqDto extends BaseDefaultPageable {

    private String schoolName;
    private String schoolAddress;

    @Builder
    public SchoolSearchReqDto(String schoolName, String schoolAddress, Integer page, Integer size) {
        super(page, size);
        this.schoolName = schoolName;
        this.schoolAddress = schoolAddress;
    }
    
}

해당 소스는, 학교 이름 혹은 학교 주소를 동적으로 입력받아 쿼리를 생성하는 쿼리메서드입니다.

@Override
    public Page<SchoolSearchDto> searchSchoolsPageComplex(SchoolSearchCondition condition, Pageable pageable) {

        List<SchoolSearchDto> content = query
                .select(
                        new QSchoolSearchDto(
                                school.id,
                                school.schoolName,
                                school.schoolAddress))
                .from(school)
                .where(
                        schoolNameContains(condition.getSchoolName()),
                        schoolAddressContains(condition.getSchoolAddress())
                )
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        JPAQuery<Long> countQuery = query
                .select(school.count())
                .from(school)
                .where(
                        schoolNameContains(condition.getSchoolName()),
                        schoolAddressContains(condition.getSchoolAddress())
                );

        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }

 

    /**
     *
     * 학교 검색
     * schoolSearchReqDto를 입력받아, name, address로 동적 쿼리 생성
     * schoolSearchReqDto의 page 관련 파라미터를 받아 pageable 생성
     *
     * @param reqDto
     * @return
     */
    @Cacheable(value = "Page<SchoolSearchDto>", key = "#reqDto.schoolName.hashCode()", cacheManager = "cacheManager", unless = "#reqDto.schoolName.hashCode() == ''")
    public Page<SchoolSearchDto> getSchoolSearchDto(SchoolSearchReqDto reqDto) {

        SchoolSearchCondition condition = SchoolSearchCondition.of(reqDto.getSchoolName(), reqDto.getSchoolAddress());
        Pageable pageable = PageRequest.of(reqDto.getPage(), reqDto.getSize(), Sort.by("id"));
        return schoolQueryRepository.searchSchoolsPageComplex(condition, pageable);

    }

SchoolQueryRepositoryImpl의 의존성 주입을 받은 SchoolService는 클라이언트의 SearchReqDto를 파라미터로 받아서 Page<SchoolSeachDto>를 리턴하는 메서드입니다. 

 

    @GetMapping("/search")
    public ResponseEntity getSchoolsSearchRes(@RequestBody SchoolSearchReqDto reqDto) {
        Page<SchoolSearchDto> schoolSearchDto = schoolService.getSchoolSearchDto(reqDto);
        return new ResponseEntity(new RestPage<>(schoolSearchDto), HttpStatus.OK);
    }

컨트롤러에서는 해당 데이터와 상태코드를 제공합니다.

 

이후, postman에서 해당 로직을 검증한 결과, 아래와 같은 문제가 발생하였습니다.

 

4. 에러 해결 과정

서비스 로직에 놓았던 @Cacheable 어노테이션을 컨트롤러에 위치하는 등 다양한 방법을 사용했지만, 이와 같은 상황이 발생했습니다. 구글링에 검색한 결과, 직렬화 과정에서 SchoolSearchDto와 PageIm의 기본생성자가 없어서 발생한 문제였습니다.

 

따라서, PageIm <T>를 상속받는 RestPage <T>를 생성하였습니다.

@JsonIgnoreProperties(ignoreUnknown = true, value = {"pageable"})
public class RestPage<T> extends PageImpl<T> {
    @JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
    public RestPage(@JsonProperty("content") List<T> content,
                    @JsonProperty("number") int page,
                    @JsonProperty("size") int size,
                    @JsonProperty("totalElements") long total) {
        super(content, PageRequest.of(page, size), total);
    }

    public RestPage(Page<T> page) {
        super(page.getContent(), page.getPageable(), page.getTotalElements());
    }
}

또한, SchoolSearchDto 역시 기본 생성자 어노테이션을 추가하였습니다.

@Data
@ToString
@NoArgsConstructor
public class SchoolSearchDto {

    private Long id;
    private String schoolName;
    private String schoolAddress;

    @Builder
    @QueryProjection
    public SchoolSearchDto(Long id, String schoolName, String schoolAddress) {
        this.id = id;
        this.schoolName = schoolName;
        this.schoolAddress = schoolAddress;
    }
}

그 결과, postman의 응답결과와 redis-cli의 value가 잘 전달되고 저장된 것을 확인할 수 있습니다.

 

5. Why 기본 생성자?

 

과거에 이펙티브 자바를 공부할 때, 리플렉션이라는 개념을 공부한 경험이 있습니다. 그 당시에 와닿지 않아서 넘겼는데, 리플렉션이 기본생성자와 많은 연관이 있음을 알게 되었습니다.

Reflection은 접근 제어자와 상관없이 클래스 객체를 동적으로 생성(런타임 시점)하는 자바 API입니다. 자바 리플렉션은 구체적인 클래스 타입을 알지 못해도, 클래스의 메서드, 타입, 변수들에 접근할 수 있도록 하는 기능을 수행하는데, 기본 생성자가 없다면 자바 리플랙션이 객체를 생성할 수 없습니다.

스프링 데이터 Jpa도 동적으로 객체를 생성하는 리플렉션을 사용하므로 Dto에 기본 생성자가 존재하지 않아서 발생한 에러입니다.


한 가지 의문점이 들었습니다. Dto에 기본 생성자를 추가했는데, "왜 다시 에러가 났을까?"
그리고, "RestPage <T> 클래스로 Page<SchoolSearchDto>를 감쌌을 때는 왜 에러가 나지 않았을까?"입니다. 
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)

이에 대한 해답은  어노테이션에 있었습니다.

Jackson의 @JsonCreator를 이용하면 인자가 없는 기본 생성자와 set메서드가 없이도 객체를 생성할 수 있는 경이로운 기능이 숨겨져 있었습니다.

따라서, 해당 어노테이션을 사용하면 기본 생성자 문제가 해결되므로, 이상 없이 이 문제를 해결할 수 있었습니다.

 

정말 배움에는 끝이 없는 것 같습니다. 

하지만, 배움으로 더 발전하고, 성장할 수 있어서 행복합니다.

긴 글 읽어주셔서 감사드립니다. 다들 좋은 하루 되십시오.!

+ Recent posts