Skip to content
Merged
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ dependencies {
testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0'

implementation 'org.springframework.retry:spring-retry'

// Cache
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'com.github.ben-manes.caffeine:caffeine'
Comment thread
hwijae33 marked this conversation as resolved.
}

tasks.named('test') {
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/kr/co/pinup/cache/AppCacheProps.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package kr.co.pinup.cache;

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.Map;

@ConfigurationProperties(prefix ="spring.cache.app")
public record AppCacheProps(
Defaults defaults,
Map<String, Spec> caches
) {
public record Defaults(Long maximumSize, Integer ttlSec) {}
public record Spec(Long maximumSize, Integer ttlSec) {}
}
6 changes: 6 additions & 0 deletions src/main/java/kr/co/pinup/cache/CacheNames.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package kr.co.pinup.cache;

public interface CacheNames {
Comment thread
hwijae33 marked this conversation as resolved.
String POST_DETAIL = "post:detail";
String POST_IMAGES = "post:images";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package kr.co.pinup.cache.listener;

import kr.co.pinup.cache.CacheNames;
import kr.co.pinup.posts.event.PostCacheEvent;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
@RequiredArgsConstructor
public class PostCacheInvalidationListener {

private final CacheManager cacheManager;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
Comment thread
hwijae33 marked this conversation as resolved.
public void on(PostCacheEvent event) {
switch (event.kind()) {
case UPDATED -> {
if (event.detailChanged()) {
evictIfPresent(CacheNames.POST_DETAIL, event.postId());
}
if (event.imagesChanged()) {
evictIfPresent(CacheNames.POST_IMAGES, event.postId());
}
}
case DISABLED -> {
evictIfPresent(CacheNames.POST_DETAIL, event.postId());
}
case DELETED -> {
evictIfPresent(CacheNames.POST_DETAIL, event.postId());
evictIfPresent(CacheNames.POST_IMAGES, event.postId());
}
}
}

private void evictIfPresent(String cacheName, Object key) {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.evict(key);
}
}
}
48 changes: 48 additions & 0 deletions src/main/java/kr/co/pinup/config/CacheConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package kr.co.pinup.config;

import com.github.benmanes.caffeine.cache.Caffeine;
import kr.co.pinup.cache.AppCacheProps;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableConfigurationProperties(AppCacheProps.class)
public class CacheConfig {

@Bean
public CacheManager cacheManager(AppCacheProps props) {

return new CaffeineCacheManager() {
{
setAllowNullValues(false);
setCacheNames(props.caches().keySet());
}

@Override
protected CaffeineCache createCaffeineCache(String name) {
Caffeine<Object, Object> builder = Caffeine.newBuilder().recordStats();

var d = props.defaults();
var s = props.caches().get(name);

Long maxSize = (s != null && s.maximumSize() != null)
? s.maximumSize()
: (d != null ? d.maximumSize() : null);

Integer ttlSec = (s != null && s.ttlSec() != null)
? s.ttlSec()
: (d != null ? d.ttlSec() : null);

if (maxSize != null) builder = builder.maximumSize(maxSize);
if (ttlSec != null) builder = builder.expireAfterWrite(java.time.Duration.ofSeconds(ttlSec));

return new CaffeineCache(name, builder.build(), false);
}

};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public interface PostImageRepository extends JpaRepository<PostImage, Long> {

List<PostImage> findByPostId(Long postId);

List<PostImage> findAllByPostIdOrderByIdAsc(Long postId);

void deleteAllByPostId(Long postId);

List<PostImage> findByPostIdAndS3UrlIn(Long id, List<String> images);
Expand Down
20 changes: 11 additions & 9 deletions src/main/java/kr/co/pinup/postImages/service/PostImageService.java
Original file line number Diff line number Diff line change
@@ -1,31 +1,28 @@
package kr.co.pinup.postImages.service;

import kr.co.pinup.custom.s3.exception.ImageDeleteFailedException;
import kr.co.pinup.postImages.model.dto.PostImageUploadRequest;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import kr.co.pinup.cache.CacheNames;
import kr.co.pinup.custom.logging.AppLogger;
import kr.co.pinup.custom.logging.model.dto.ErrorLog;
import kr.co.pinup.custom.logging.model.dto.InfoLog;
import kr.co.pinup.custom.logging.model.dto.WarnLog;
import kr.co.pinup.custom.s3.S3Service;

import kr.co.pinup.postImages.PostImage;
import kr.co.pinup.postImages.exception.postimage.PostImageDeleteFailedException;
import kr.co.pinup.postImages.exception.postimage.PostImageNotFoundException;
import kr.co.pinup.postImages.exception.postimage.PostImageSaveFailedException;
import kr.co.pinup.postImages.model.dto.CreatePostImageRequest;
import kr.co.pinup.postImages.model.dto.PostImageResponse;

import kr.co.pinup.postImages.model.dto.UpdatePostImageRequest;
import kr.co.pinup.postImages.repository.PostImageRepository;
import kr.co.pinup.posts.Post;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;
Expand Down Expand Up @@ -173,6 +170,11 @@ public void deleteS3QuietlyAfterCommit(List<String> imageUrls) {
}
}

@Cacheable(
value = CacheNames.POST_IMAGES,
key = "#p0", // postId
sync = true
)
@Transactional(readOnly = true)
public List<PostImageResponse> findImagesByPostId(Long postId) {
log.debug("이미지 목록 조회: postId={}", postId);
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/kr/co/pinup/posts/event/PostCacheEvent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package kr.co.pinup.posts.event;

public record PostCacheEvent(
Long postId,
Kind kind,
boolean detailChanged, // UPDATED일 때만 의미
boolean imagesChanged // UPDATED일 때만 의미
) {
public enum Kind { UPDATED, DISABLED, DELETED }

public static PostCacheEvent updated(Long postId, boolean detailChanged, boolean imagesChanged) {
return new PostCacheEvent(postId, Kind.UPDATED, detailChanged, imagesChanged);
}
public static PostCacheEvent disabled(Long postId) {
return new PostCacheEvent(postId, Kind.DISABLED, false, false);
}
public static PostCacheEvent deleted(Long postId) {
return new PostCacheEvent(postId, Kind.DELETED, false, false);
}
}
27 changes: 24 additions & 3 deletions src/main/java/kr/co/pinup/posts/service/PostService.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package kr.co.pinup.posts.service;

import kr.co.pinup.cache.CacheNames;
import kr.co.pinup.custom.logging.AppLogger;
import kr.co.pinup.custom.logging.model.dto.ErrorLog;
import kr.co.pinup.custom.logging.model.dto.InfoLog;
Expand All @@ -10,10 +11,11 @@
import kr.co.pinup.postImages.PostImage;
import kr.co.pinup.postImages.exception.postimage.PostImageUpdateCountException;
import kr.co.pinup.postImages.model.dto.CreatePostImageRequest;
import kr.co.pinup.postImages.model.dto.PostImageResponse;
import kr.co.pinup.postImages.model.dto.UpdatePostImageRequest;
import kr.co.pinup.postImages.repository.PostImageRepository;
import kr.co.pinup.postImages.service.PostImageService;
import kr.co.pinup.posts.Post;
import kr.co.pinup.posts.event.PostCacheEvent;
import kr.co.pinup.posts.exception.post.PostDeleteFailedException;
import kr.co.pinup.posts.exception.post.PostNotFoundException;
import kr.co.pinup.posts.model.dto.CreatePostRequest;
Expand All @@ -25,6 +27,8 @@
import kr.co.pinup.stores.repository.StoreRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
Expand All @@ -43,7 +47,10 @@ public class PostService {
private final PostImageService postImageService;
private final MemberRepository memberRepository;
private final StoreRepository storeRepository;
private final AppLogger appLogger;
private final PostImageRepository postImageRepository;
private final AppLogger appLogger ;
private final ApplicationEventPublisher events;


private record ChangeFlags(boolean hasText, boolean hasUpload, boolean hasDelete) {
}
Expand Down Expand Up @@ -111,6 +118,12 @@ public List<PostResponse> findByStoreIdWithCommentsAndLikes(Long storeId, boolea

}

@Cacheable(
value = CacheNames.POST_DETAIL,
key = "#p0",
condition = "!#p1",
sync = true
)
@Transactional(readOnly = true)
public PostResponse getPostById(Long id, boolean isDeleted) {
log.debug("게시글 단건 요청: postId={}, isDeleted={}", id, isDeleted);
Expand All @@ -133,6 +146,7 @@ public void deletePost(Long postId) {
}
try {
postRepository.delete(post);
events.publishEvent(PostCacheEvent.deleted(postId));
appLogger.info(new InfoLog("게시글 삭제 성공").setStatus("200").setTargetId(postId.toString()));
} catch (Exception e) {
appLogger.error(new ErrorLog("게시글 삭제 실패", e)
Expand All @@ -148,6 +162,7 @@ public void disablePost(Long postId) {
post.disablePost(true);
appLogger.info(new InfoLog("게시글 비활성화 처리").setStatus("200").setTargetId(postId.toString()));
postRepository.save(post);
events.publishEvent(PostCacheEvent.disabled(postId));
}

public Post findByIdOrThrow(Long id) {
Expand Down Expand Up @@ -235,6 +250,8 @@ private PostResponse updatePostTx(Long id,
if (!actuallyDeleted.isEmpty()) {
postImageService.deleteS3QuietlyAfterCommit(actuallyDeleted);
}
if (imagesChanged ) {
events.publishEvent(PostCacheEvent.updated(id, /*detailChanged*/ false, /*imagesChanged*/ true)); }
return PostResponse.from(post);
}

Expand All @@ -249,6 +266,10 @@ private PostResponse updatePostTx(Long id,
if (!actuallyDeleted.isEmpty()) {
postImageService.deleteS3QuietlyAfterCommit(actuallyDeleted);
}
events.publishEvent(PostCacheEvent.updated(
id,
/* detailChanged */ (textChanged || thumbChanged),/* imagesChanged */ (imagesChanged || thumbChanged)
));
}
}

Expand Down Expand Up @@ -277,7 +298,7 @@ private ImageMutationResult mutateImagesAndRefresh(
private boolean refreshThumbnailIfNeeded(Post post, Long postId, boolean imagesChanged) {
if (!imagesChanged) return false;

List<PostImageResponse> remaining = postImageService.findImagesByPostId(postId);
List<PostImage> remaining = postImageRepository.findAllByPostIdOrderByIdAsc(postId);
if (remaining.size() < 2) throw new PostImageUpdateCountException();

String current = post.getThumbnail();
Expand Down
Loading