Spring API Cache

Panda·2022년 7월 22일
0

Spring

목록 보기
29/42

이전에 Cache에 대해 공부하였는데 실제로 한번 스프링에 캐싱처리를 적용을 시켜보려고 합니다.
제가 삽질을 좀많이해서 이번 글은 장문의 글이 될것같네요 ㅎㅎㅎㅎ

Spring에서 Cache 사용

환경설정

@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {

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

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

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

    @Bean
    @Override
    public CacheManager cacheManager() {
        RedisCacheManager.RedisCacheManagerBuilder builder = RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(redisConnectionFactory());
        RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) // value Serializer 변경
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) // key Serializer 변경
                .prefixCacheNameWith("Saboten:") // Key Prefix로 "Saboten:"를 앞에 붙여 저장
                .entryTtl(Duration.ofMinutes(30)); // TTL 30분
        builder.cacheDefaults(configuration);
        return builder.build();
    }
}

CacheManager를 등록 시키는 환경설정 코드 입니다.
Redis를 사용한 CacheManager 환경설정을 했습니다!
편한 개발을 위해 CachingConfigurerSupport를 상속받았습니다.
(상속 안받아도 문제 없어용)

@Cacheable

@Cacheable(value = "postVotes", key = "#post.postId")
List<VoteEntity> findAllByPost(PostEntity post);
  • 다음과 같이 post 목록을 전부 조회할 때 처음 API 조회할 때만 쿼리를 조회하고 캐싱이 만료되기 전까지는 캐시에서 조회합니다.
  • 캐싱을 저장하거나 조회할 때 사용합니다.
  • value : 캐시 Name 입니다.
  • key : key 값입니다. 보통 특정Id로 설정을 해줘요. (특정 유저, 특정 게시물 지정하게끔)

@CacheEvict

@CacheEvict(value = "post", key = "#post.postId")
void deleteAllByPost(PostEntity post);
  • 캐싱된 데이터가 업데이트 되거나 제거될때는 캐시를 없애줘야 하기때문에 캐시를 제거합니다.

삽질의 시작

첫번째 시도

API Response 자체를 캐싱

@Cacheable(value = "post", key = "#id")
@GetMapping("/post/{id}")
public ApiResponse<PostResponse> getPost(@PathVariable Long id) {

	UserEntity userEntity = getUser();
    PostEntity postEntity = postService.findPost(id);
    List<VoteResponse> votes = voteService.findVotes(postEntity);
    List<CategoryResponse> categories = categoryInPostService.findCagegoriesInPost(postEntity);
    Long voteResult = voteSelectService.findVoteSelectResult(userEntity, postEntity);
    boolean isLike = postLikeService.findPostIsLike(userEntity, postEntity);

	PostResponse post = new PostResponse(postEntity.getPostId(),
    	postEntity.getPostText(),
        postEntity.getUser().toDto(),
        votes,
        categories,
        voteResult,
        isLike,
        postEntity.getRegistDate().toString(), postEntity.getModifyDate().toString());

	return ApiResponse.withMessage(post, PostResponseMessage.POST_FIND_ONE);
}

처음에는 API Response 자체를 캐싱 시도를 하였습니다.
하지만 Missing type id when trying to resolve subtype of ... 라는 에러가 발생을 하였고 엄청난 구글링을 한결과 알아낸 사실은
직렬화 자체는 성공을 하였지만 역직렬화를 할때 맵핑이 안되서 발생하는 에러였습니다.

Response Data 안에 다양한 Entity 정보들이 들어있었고 역직렬화시 이것을 맵핑을 하려고
하기 때문에 들어있는 정보들이 어떠한 타입인지 몰라 맵핑이 안되는 문제였습니다.

다른 개발자 분들은 저와 거의 유사한 코드로 캐싱 처리를 하여서 제가 잘못했나 싶어서
엄청나게 여러 시도를 해보았는데
Serializer를 변경한다던가 ObjectMapper를 직접 커스텀하여 맵핑시키던가 등을 시도를 해보았지만 동일한 에러 혹은 다른 에러가 발생을 하였습니다....

엄청난 삽질끝에 생각해낸건 하나의 캐싱안에 다양한 Entity 들이 존재하여 발생한 문제이기 때문에 그 범위를 좁혀 서비스단위로 캐싱 처리를 해주면 어떨까? 라는 생각에 두번째 시도를 하게 되었습니다.

두번째 시도

Service 별로 캐싱 시도
이번에는 Controller의 Response가 아닌 Service 단위로 캐싱 처리를 해보았습니다.

@Builder
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity @Table(name="user")
public class UserEntity extends BaseTimeEntity {
	...
	@JsonIgnore
	@OneToMany(mappedBy = "user")
	private List<PostEntity> posts = new ArrayList<>();
}
@Builder
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity @Table(name="TB_Post")
public class PostEntity extends BaseTimeEntity {
	...
	@ManyToOne(fetch = FetchType.EAGER)
    @OnDelete(action = OnDeleteAction.CASCADE)
    @JoinColumn(name="user_id", nullable = false)
    private UserEntity user;
}
// PostService
@Cacheable(value = "post", key = "#id")
@Transactional
public PostEntity findPost(Long id) {
	Optional<PostEntity> postEntity = postRepository.findById(id);
    if(postEntity.isEmpty())
    	throw new ApiException(PostResponseMessage.POST_NOT_FOUND);
    return postEntity.get();
}
// VoteService
@Cacheable(value = "postVotes", key = "#postEntity.postId")
@Transactional
public List<VoteResponse> findVotes(PostEntity postEntity) {
	List<VoteEntity> voteEntities = voteRepository.findAllByPost(postEntity);
    List<VoteResponse> votes = new ArrayList<>();
    for(VoteEntity voteEntity : voteEntities) {
        votes.add(voteEntity.toDto());
    }

	return votes;
}

일단 먼저 PostService를 처음 캐싱을 적용해 보았는데 역시나 바로 나오는에러 ㅋㅋㅋㅋ

에러내용은 JPA 양방향 관계때문에 서로가 서로를 무한참조하는 현상때문에 발생을 하였습니다.
그래서 알아낸 해결방법은

  1. 한쪽 Entity에서 @JsonIgnore 사용 : @JsonIgnore 는 json으로 변경시 해당 프로퍼티 값이 null 값으로 들어가게 됩니다.
  2. @JsonManagedReference, @JsonBackReference 사용 : 무한 순환참조 방지하는 어노테이션인데 부모 클래스에 @JsonManagedReference(직렬화에 포함됨), 자식 클래스에 @JsonBackReference(직렬화에 포함안됨) 사용을 하면 됩니다.

2번 방법을 사용하려고 했으나 PostEntity가 자식 클래스여서 PostEntity의 user값이 null값으로 들어옵니다....
그래서 결국 1번방법을 사용하여 UserEntity에 적용을 하여 무한 순환참조를 방지하였습니다.
이러한 해결방법으로 PostService의 캐싱처리가 정상적으로 성공한 것을 확인하였습니다.

근데 이 방법을 처리하면서 제가 왜 이러한 에러들을 보고있는지 슬슬 감이 잡혔습니다.
바로 DTO가 아닌 Entity 자체를 캐싱처리를 해서 이 난리가 난거였습니다....
만약 DTO 캐싱을 하였으면 순환참조 자체가 발생하지 않았겠죠?

이제 슬슬 캐싱 코드의 문제가 아닌 프로젝트 구조의 문제때문에 발생한거라고 느끼고 있었습니다.

자 이제 VoteSerivce에 대해 캐싱 처리를 진행하려는데 또 다시 발생한 에러....
생성자가 존재하지 않는다는 에러였는데.... 하지만 왜 에러가 발생을 하였는지 바로 느꼈습니다.
일단은 VoteResponse가 자바가 아닌 코틀린 data class로 작성되어있고
(data class인데 생성자가 없다고 뜬다고?!!!)

해당 모듈에는 Jackson 관련 의존성이 없어서 발생한 에러가 아닌가 라고 추측을 하였고
이떄 든 생각은 아......... 프로젝트 구조 이상하게 설계를 하였구나............

서비스 단위에서는 DTO로 반환을 하고 Controller에서 Response 형태로 Convert 시켰어야했는데............ 그러면 에러가 발생할 여지가 없었을텐데.........
더욱더 중요해지는 DTO의 중요성 그리고 클린 아키텍쳐....

하지만 이미 짜여저버린 서비스 코드들을 리팩토링 하기에는 지금 당장 리팩토링 할 시간을 내기가 애매해서(귀찮아영 ㅠㅠ)

간단한 임시조치를 취하는 세번째 시도를 하였습니다.

세번째 시도

세번째 시도는 바로 순수한 Entity를 반환하는 Repository 별로 캐싱 시도하는 거였습니다.

// PostVoteRepository
@Cacheable(value = "postVotes", key = "#post.postId")
List<VoteEntity> findAllByPost(PostEntity post);
// PostCategoriesRepository
@Cacheable(value = "postInCategories", key = "#post.postId")
List<CategoryInPostEntity> findByPost(PostEntity post);

이렇게 Repository 별로 캐싱처리를 하였고 API 작동을 해보니까
완벽하게 정상적으로 작동하는 것을 확인하였습니다.

첫번째 Request

두번째 Request

애초에 방대한 데이터가 아니라 극적인 시간 감소는 아니지만 확실히 캐시가 적용된 모습을 볼 수 있었습니다.
이를 토대로 대규모 데이터 혹은 조회가 자주되는 데이터에 적용을 하면 좋을것 같습니다.

또한 클린아키텍쳐가 적용되어있으면 서비스 단위로 캐싱처리를 하여 시간감소를 더욱 할 수 있을것 같습니다.

느낀점

스프링에서 캐시를 적용하는 방법에 대해서 공부를 하였는데
단순히 스프링에서 캐시 적용하는 법보다는 캐시가 실제 어떻게 적용이 되는지, 직렬화/역직렬화, DTO와 클린 아키텍처의 중요성에 대해서 공부했던 것 같습니다.

회사에서 하는 프로젝트는 클린아키텍처로 설계되어 있고 계층간의 데이터 전송이 DTO 모델로 잘 설계가 되어있어서 상대적으로 이 프로젝트에서 계층 간 데이터 전송이 DTO 모델로 제대로 못 이루어진점,
클린 아키텍쳐 설계가 아닌점 등이 뼈저리게 느껴지네요.

그래도 이러한 삽질을 하면서 에러자체가 문제가 아닌 프로젝트의 구조가 문제라고 느꼈다는 점이 전체적인 구조와 흐름을 파악하는 실력이 향상된 것 같아서 좋았습니다.

결국에는 스프링에서 캐시 적용하는법도 익혔고 좋은 공부였던 것 같습니다.

profile
실력있는 개발자가 되보자!

0개의 댓글