diff --git a/README.md b/README.md index ae5cbaf..6d00837 100644 --- a/README.md +++ b/README.md @@ -115,3 +115,8 @@ alter table user_roles - https://daddyprogrammer.org/post/2695/springboot2-simple-jpa-board/ - Git - https://github.com/codej99/SpringRestApi/tree/feature/board + - SpringBoot2로 Rest api 만들기(15) – Redis로 api 결과 캐싱(Caching)처리 + - Document + - https://daddyprogrammer.org/post/3870/spring-rest-api-redis-caching/ + - Git + - https://github.com/codej99/SpringRestApi/tree/cache-data-redis diff --git a/build.gradle b/build.gradle index f032f0b..abab6ee 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + //embedded-redis + implementation 'it.ozimov:embedded-redis:0.7.2' implementation 'io.jsonwebtoken:jjwt:0.9.1' implementation 'io.springfox:springfox-swagger2:2.6.1' implementation 'io.springfox:springfox-swagger-ui:2.6.1' diff --git a/src/main/java/com/rest/api/common/CacheKey.java b/src/main/java/com/rest/api/common/CacheKey.java new file mode 100644 index 0000000..492dc14 --- /dev/null +++ b/src/main/java/com/rest/api/common/CacheKey.java @@ -0,0 +1,14 @@ +package com.rest.api.common; + +public class CacheKey { + + public static final int DEFAULT_EXPIRE_SEC = 60; // 1 minutes + public static final String USER = "user"; + public static final int USER_EXPIRE_SEC = 60 * 5; // 5 minutes + public static final String BOARD = "board"; + public static final int BOARD_EXPIRE_SEC = 60 * 10; // 10 minutes + public static final String POST = "post"; + public static final String POSTS = "posts"; + public static final int POST_EXPIRE_SEC = 60 * 5; // 5 minutes + +} diff --git a/src/main/java/com/rest/api/config/EmbeddedRedisConfig.java b/src/main/java/com/rest/api/config/EmbeddedRedisConfig.java new file mode 100644 index 0000000..665031e --- /dev/null +++ b/src/main/java/com/rest/api/config/EmbeddedRedisConfig.java @@ -0,0 +1,35 @@ +package com.rest.api.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import redis.embedded.RedisServer; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; + +/** + * 로컬 환경일경우 내장 레디스가 실행된다. + */ +@Profile("local") +@Configuration +public class EmbeddedRedisConfig { + + @Value("${spring.redis.port}") + private int redisPort; + + private RedisServer redisServer; + + @PostConstruct + public void redisServer() { + redisServer = new RedisServer(redisPort); + redisServer.start(); + } + + @PreDestroy + public void stopRedis() { + if (redisServer != null) { + redisServer.stop(); + } + } +} diff --git a/src/main/java/com/rest/api/config/RedisConfig.java b/src/main/java/com/rest/api/config/RedisConfig.java new file mode 100644 index 0000000..2c32bbb --- /dev/null +++ b/src/main/java/com/rest/api/config/RedisConfig.java @@ -0,0 +1,49 @@ +package com.rest.api.config; + +import com.rest.api.common.CacheKey; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.CacheKeyPrefix; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +@RequiredArgsConstructor +@EnableCaching +@Configuration +public class RedisConfig { + + @Bean(name = "cacheManager") + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + + RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig() + .disableCachingNullValues() + .entryTtl(Duration.ofSeconds(CacheKey.DEFAULT_EXPIRE_SEC)) + .computePrefixWith(CacheKeyPrefix.simple()) + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())); + + Map cacheConfigurations = new HashMap<>(); + // 캐시 default 유효시간 설정 + cacheConfigurations.put(CacheKey.USER, RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofSeconds(CacheKey.USER_EXPIRE_SEC))); + cacheConfigurations.put(CacheKey.BOARD, RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofSeconds(CacheKey.BOARD_EXPIRE_SEC))); + cacheConfigurations.put(CacheKey.POST, RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofSeconds(CacheKey.POST_EXPIRE_SEC))); + cacheConfigurations.put(CacheKey.POSTS, RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofSeconds(CacheKey.POST_EXPIRE_SEC))); + + return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(connectionFactory).cacheDefaults(configuration) + .withInitialCacheConfigurations(cacheConfigurations).build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/rest/api/entity/User.java b/src/main/java/com/rest/api/entity/User.java index 307cd35..8e41742 100644 --- a/src/main/java/com/rest/api/entity/User.java +++ b/src/main/java/com/rest/api/entity/User.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.rest.api.entity.common.CommonDateEntity; import lombok.*; +import org.hibernate.annotations.Proxy; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; @@ -22,6 +23,7 @@ import java.util.stream.Collectors; @AllArgsConstructor // 인자를 모두 갖춘 생성자를 자동으로 생성합니다. @Table(name = "user") // 'user' 테이블과 매핑됨을 명시 @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) // Post Entity에서 User와의 관계를 Json으로 변환시 오류 방지를 위한 코드 +@Proxy(lazy = false) public class User extends CommonDateEntity implements UserDetails { @Id // pk @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/rest/api/entity/board/Board.java b/src/main/java/com/rest/api/entity/board/Board.java index f090b81..8e6cb9b 100644 --- a/src/main/java/com/rest/api/entity/board/Board.java +++ b/src/main/java/com/rest/api/entity/board/Board.java @@ -5,11 +5,12 @@ import lombok.Getter; import lombok.NoArgsConstructor; import javax.persistence.*; +import java.io.Serializable; @Entity @Getter @NoArgsConstructor -public class Board extends CommonDateEntity { +public class Board extends CommonDateEntity implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long boardId; diff --git a/src/main/java/com/rest/api/entity/board/Post.java b/src/main/java/com/rest/api/entity/board/Post.java index 538cc6f..aeaa802 100644 --- a/src/main/java/com/rest/api/entity/board/Post.java +++ b/src/main/java/com/rest/api/entity/board/Post.java @@ -1,16 +1,21 @@ package com.rest.api.entity.board; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.rest.api.entity.User; import com.rest.api.entity.common.CommonDateEntity; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.Proxy; import javax.persistence.*; +import java.io.Serializable; @Entity @Getter +@Setter @NoArgsConstructor -public class Post extends CommonDateEntity { +public class Post extends CommonDateEntity implements Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long postId; @@ -25,13 +30,13 @@ public class Post extends CommonDateEntity { @JoinColumn(name = "board_id") private Board board; // 게시글 - 게시판의 관계 - N:1 - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "msrl") private User user; // 게시글 - 회원의 관계 - N:1 // Join 테이블이 Json결과에 표시되지 않도록 처리. - protected Board getBoard() { + @JsonIgnore + public Board getBoard() { return board; } diff --git a/src/main/java/com/rest/api/service/board/BoardService.java b/src/main/java/com/rest/api/service/board/BoardService.java index 1a39806..545cfbe 100644 --- a/src/main/java/com/rest/api/service/board/BoardService.java +++ b/src/main/java/com/rest/api/service/board/BoardService.java @@ -3,6 +3,7 @@ package com.rest.api.service.board; import com.rest.api.advice.exception.CNotOwnerException; import com.rest.api.advice.exception.CResourceNotExistException; import com.rest.api.advice.exception.CUserNotFoundException; +import com.rest.api.common.CacheKey; import com.rest.api.entity.User; import com.rest.api.entity.board.Board; import com.rest.api.entity.board.Post; @@ -10,13 +11,19 @@ import com.rest.api.model.board.ParamsPost; import com.rest.api.repo.UserJpaRepo; import com.rest.api.repo.board.BoardJpaRepo; import com.rest.api.repo.board.PostJpaRepo; +import com.rest.api.service.cache.CacheSevice; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import javax.transaction.Transactional; import java.util.List; import java.util.Optional; +@Slf4j @Service @Transactional @RequiredArgsConstructor @@ -25,30 +32,36 @@ public class BoardService { private final BoardJpaRepo boardJpaRepo; private final PostJpaRepo postJpaRepo; private final UserJpaRepo userJpaRepo; + private final CacheSevice cacheSevice; // 게시판 이름으로 게시판을 조회. 없을경우 CResourceNotExistException 처리 + @Cacheable(value = CacheKey.BOARD, key = "#boardName", unless = "#result == null") public Board findBoard(String boardName) { return Optional.ofNullable(boardJpaRepo.findByName(boardName)).orElseThrow(CResourceNotExistException::new); } - // 게시판 이름으로 게시물 리스트 조회. + // 게시판 이름으로 게시글 리스트 조회. + @Cacheable(value = CacheKey.POSTS, key = "#boardName", unless = "#result == null") public List findPosts(String boardName) { return postJpaRepo.findByBoard(findBoard(boardName)); } - // 게시물ID로 게시물 단건 조회. 없을경우 CResourceNotExistException 처리 + // 게시글ID로 게시글 단건 조회. 없을경우 CResourceNotExistException 처리 + @Cacheable(value = CacheKey.POST, key = "#postId", unless = "#result == null") public Post getPost(long postId) { return postJpaRepo.findById(postId).orElseThrow(CResourceNotExistException::new); } - // 게시물을 등록합니다. 게시물의 회원UID가 조회되지 않으면 CUserNotFoundException 처리합니다. + // 게시글을 등록합니다. 게시글의 회원UID가 조회되지 않으면 CUserNotFoundException 처리합니다. + @CacheEvict(value = CacheKey.POSTS, key = "#boardName") public Post writePost(String uid, String boardName, ParamsPost paramsPost) { Board board = findBoard(boardName); Post post = new Post(userJpaRepo.findByUid(uid).orElseThrow(CUserNotFoundException::new), board, paramsPost.getAuthor(), paramsPost.getTitle(), paramsPost.getContent()); return postJpaRepo.save(post); } - // 게시물을 수정합니다. 게시물 등록자와 로그인 회원정보가 틀리면 CNotOwnerException 처리합니다. + // 게시글을 수정합니다. 게시글 등록자와 로그인 회원정보가 틀리면 CNotOwnerException 처리합니다. + //@CachePut(value = CacheKey.POST, key = "#postId") 갱신된 정보만 캐시할경우에만 사용! public Post updatePost(long postId, String uid, ParamsPost paramsPost) { Post post = getPost(postId); User user = post.getUser(); @@ -57,16 +70,18 @@ public class BoardService { // 영속성 컨텍스트의 변경감지(dirty checking) 기능에 의해 조회한 Post내용을 변경만 해도 Update쿼리가 실행됩니다. post.setUpdate(paramsPost.getAuthor(), paramsPost.getTitle(), paramsPost.getContent()); + cacheSevice.deleteBoardCache(post.getPostId(), post.getBoard().getName()); return post; } - // 게시물을 삭제합니다. 게시물 등록자와 로그인 회원정보가 틀리면 CNotOwnerException 처리합니다. + // 게시글을 삭제합니다. 게시글 등록자와 로그인 회원정보가 틀리면 CNotOwnerException 처리합니다. public boolean deletePost(long postId, String uid) { Post post = getPost(postId); User user = post.getUser(); if (!uid.equals(user.getUid())) throw new CNotOwnerException(); postJpaRepo.delete(post); + cacheSevice.deleteBoardCache(post.getPostId(), post.getBoard().getName()); return true; } } diff --git a/src/main/java/com/rest/api/service/cache/CacheSevice.java b/src/main/java/com/rest/api/service/cache/CacheSevice.java new file mode 100644 index 0000000..a571d70 --- /dev/null +++ b/src/main/java/com/rest/api/service/cache/CacheSevice.java @@ -0,0 +1,21 @@ +package com.rest.api.service.cache; + +import com.rest.api.common.CacheKey; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Caching; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class CacheSevice { + + @Caching(evict = { + @CacheEvict(value = CacheKey.POST, key = "#postId"), + @CacheEvict(value = CacheKey.POSTS, key = "#boardName") + }) + public boolean deleteBoardCache(long postId, String boardName) { + log.debug("deleteBoardCache - postId {}, boardName {}", postId, boardName); + return true; + } +} diff --git a/src/main/java/com/rest/api/service/security/CustomUserDetailService.java b/src/main/java/com/rest/api/service/security/CustomUserDetailService.java index 47a69c5..f1af42d 100644 --- a/src/main/java/com/rest/api/service/security/CustomUserDetailService.java +++ b/src/main/java/com/rest/api/service/security/CustomUserDetailService.java @@ -1,8 +1,10 @@ package com.rest.api.service.security; import com.rest.api.advice.exception.CUserNotFoundException; +import com.rest.api.common.CacheKey; import com.rest.api.repo.UserJpaRepo; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Service; @@ -13,6 +15,7 @@ public class CustomUserDetailService implements UserDetailsService { private final UserJpaRepo userJpaRepo; + @Cacheable(value = CacheKey.USER, key = "#userPk", unless = "#result == null") public UserDetails loadUserByUsername(String userPk) { return userJpaRepo.findById(Long.valueOf(userPk)).orElseThrow(CUserNotFoundException::new); } diff --git a/src/main/resources/application-alpha.yml b/src/main/resources/application-alpha.yml index a2d5702..43f9c6c 100644 --- a/src/main/resources/application-alpha.yml +++ b/src/main/resources/application-alpha.yml @@ -21,4 +21,7 @@ spring: showSql: true generate-ddl: false url: - base: http://dev-api.daddyprogrammer.org \ No newline at end of file + base: http://dev-api.daddyprogrammer.org + redis: + host: Standalone Redis 호스트 + port: Standalone Redis 포트 \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 00008ac..4f397af 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -18,3 +18,6 @@ spring: generate-ddl: true url: base: http://localhost:8080 + redis: + host: localhost + port: 6379 \ No newline at end of file diff --git a/src/test/java/com/rest/api/cache/CacheRepo.java b/src/test/java/com/rest/api/cache/CacheRepo.java new file mode 100644 index 0000000..694924a --- /dev/null +++ b/src/test/java/com/rest/api/cache/CacheRepo.java @@ -0,0 +1,67 @@ +package com.rest.api.cache; + +import com.rest.api.entity.board.Post; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +@Service +public class CacheRepo { + + private static final String CACHE_KEY = "CACHE_TEST"; + + @Cacheable(value = CACHE_KEY, key = "#postId") + public Post getPost(long postId) { + Post post = new Post(); + post.setPostId(postId); + post.setTitle("title_" + postId); + post.setAuthor("author_" + postId); + post.setContent("content_" + postId); + return post; + } + + @CachePut(value = CACHE_KEY, key = "#post.postId") + public Post updatePost(Post post) { + return post; + } + + @Cacheable(value = CACHE_KEY, key = "{#postId, #title}") + public Post getPostMultiKey(long postId, String title) { + Post post = new Post(); + post.setPostId(postId); + post.setTitle("title_" + postId); + post.setAuthor("author_" + postId); + post.setContent("content_" + postId); + return post; + } + + @CachePut(value = CACHE_KEY, key = "{#post.postId, #post.title}") +// @CachePut(value = CACHE_KEY, key = "{#post.postId, #post.getTitle()}") + public Post updatePostMultiKey(Post post) { + return post; + } + + @CacheEvict(cacheNames = {CACHE_KEY}, allEntries = true) + public void clearCache(){} + + @Cacheable(value = CACHE_KEY, key = "{#postId}", condition="#postId > 10") + public Post getPostCondition(long postId) { + Post post = new Post(); + post.setPostId(postId); + post.setTitle("title_" + postId); + post.setAuthor("author_" + postId); + post.setContent("content_" + postId); + return post; + } + + @Cacheable(value = CACHE_KEY, key = "T(com.rest.api.cache.CustomKeyGenerator).create(#postId, #title)") + public Post getPostKeyGenerator(long postId, String title) { + Post post = new Post(); + post.setPostId(postId); + post.setTitle("title_" + postId); + post.setAuthor("author_" + postId); + post.setContent("content_" + postId); + return post; + } +} diff --git a/src/test/java/com/rest/api/cache/CacheTest.java b/src/test/java/com/rest/api/cache/CacheTest.java new file mode 100644 index 0000000..cf52871 --- /dev/null +++ b/src/test/java/com/rest/api/cache/CacheTest.java @@ -0,0 +1,67 @@ +package com.rest.api.cache; + +import com.rest.api.entity.board.Post; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.junit.Assert.*; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class CacheTest { + + @Autowired + private CacheRepo cacheRepo; + + @Test + public void cacheTest() throws Exception { + // get cache + Post post = cacheRepo.getPost(1L); + assertSame(1L, post.getPostId()); + assertEquals("title_1", post.getTitle()); + // update cache + post.setTitle("title_modified"); + post.setContent("content_modified"); + cacheRepo.updatePost(post); + // get cache + Post postModified = cacheRepo.getPost(1L); + assertEquals("title_modified", postModified.getTitle()); + assertEquals("content_modified", postModified.getContent()); + } + + @Test + public void cacheTestMultiKey() throws Exception { + // get cache + Post post = cacheRepo.getPostMultiKey(1L, "title_1"); + assertSame(1L, post.getPostId()); + assertEquals("title_1", post.getTitle()); + // update cache + post.setTitle("title_modified"); + post.setContent("content_modified"); + cacheRepo.updatePostMultiKey(post); + // get cache + Post postModified = cacheRepo.getPostMultiKey(1L, "title_modified"); + assertEquals("title_modified", postModified.getTitle()); + assertEquals("content_modified", postModified.getContent()); + } + + @Test + public void cacheTestCustomKeyGenerator() throws Exception { + // get cache + Post post = cacheRepo.getPostKeyGenerator(1L, "title_1"); + assertSame(1L, post.getPostId()); + assertEquals("title_1", post.getTitle()); + } + + @Test + public void deleteAllCache() { + cacheRepo.getPost(1L); + cacheRepo.getPost(2L); + cacheRepo.getPost(3L); + cacheRepo.getPost(4L); + cacheRepo.clearCache(); + } +} \ No newline at end of file diff --git a/src/test/java/com/rest/api/cache/CustomKeyGenerator.java b/src/test/java/com/rest/api/cache/CustomKeyGenerator.java new file mode 100644 index 0000000..6a5b7d2 --- /dev/null +++ b/src/test/java/com/rest/api/cache/CustomKeyGenerator.java @@ -0,0 +1,7 @@ +package com.rest.api.cache; + +public class CustomKeyGenerator { + public static Object create(Object o1, Object o2) { + return "FRONT:" + o1 + ":" + o2; + } +}