Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package life.mosu.mosuserver.application.caffeine;

import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import life.mosu.mosuserver.domain.caffeine.CacheGroup;
import life.mosu.mosuserver.domain.caffeine.CacheType;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@EnableCaching
@Configuration
public class LocalCacheConfig {

@Bean
public LocalCacheManager localCacheManager() {
List<Cache> caches = Arrays.stream(CacheGroup.values())
.filter(g -> g.getCacheType() == CacheType.LOCAL
|| g.getCacheType() == CacheType.COMPOSITE)
.map(g -> new CaffeineCache(
g.getCacheName(),
Caffeine.newBuilder()
.recordStats()
.expireAfterWrite(g.getExpiredAfterWrite())
.build()
Comment on lines +28 to +31
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Bound the caches: add maximumSize to prevent unbounded memory growth

Caffeine caches are currently unbounded. Add maximumSize (ideally per CacheGroup) to avoid potential OOMs under load.

 Caffeine.newBuilder()
         .recordStats()
         .expireAfterWrite(g.getExpiredAfterWrite())
+        .maximumSize(g.getMaximumSize()) // suggest: define per CacheGroup
         .build()

If CacheGroup doesn’t expose a size, either:

  • add g.getMaximumSize() to the enum, or
  • use a sane default in this config (e.g., 100_000) and revisit per-group tuning.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/main/java/life/mosu/mosuserver/application/caffeine/LocalCacheConfig.java
around lines 28 to 31, the Caffeine cache builder lacks a maximumSize setting,
which can lead to unbounded memory growth. Fix this by adding a maximumSize
parameter to the builder; either extend the CacheGroup enum to include a
getMaximumSize() method and use that value, or apply a default maximumSize
(e.g., 100_000) directly in the config to limit cache size and prevent potential
OOM errors.

)).collect(Collectors.toList());

return new LocalCacheManager(caches);
}

@Bean
@Primary
public CacheManager appCacheManager(LocalCacheManager localCacheManager) {
return localCacheManager;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package life.mosu.mosuserver.application.caffeine;

import jakarta.annotation.Nullable;
import jakarta.annotation.PostConstruct;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;

public class LocalCacheManager implements CacheManager, UpdatableCacheManager {

private final List<Cache> caches;
private Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
private volatile Set<String> cacheNames = Collections.emptySet();

public LocalCacheManager(List<Cache> caches) {
this.caches = (caches != null) ? caches : Collections.emptyList();
}

@PostConstruct
public void init() {
Set<String> cacheNamesSet = new LinkedHashSet<>(caches.size());
Map<String, Cache> cacheMapTemp = new ConcurrentHashMap<>(16);

for (Cache cache : caches) {
String name = cache.getName();
cacheNamesSet.add(name);
cacheMapTemp.put(name, cache);
}
this.cacheMap = cacheMapTemp;
this.cacheNames = cacheNamesSet;
}
Comment on lines +17 to +37

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The fields cacheMap and cacheNames are initialized with empty collections and then reassigned in the @PostConstruct init() method. This can be simplified and the class can be made more robust by making these fields final and initializing them in the constructor. This removes the need for @PostConstruct and the volatile keyword on cacheNames, and improves the immutability of the class.

    private final Map<String, Cache> cacheMap;
    private final Collection<String> cacheNames;

    public LocalCacheManager(List<Cache> caches) {
        List<Cache> initialCaches = (caches != null) ? caches : Collections.emptyList();
        this.cacheMap = new ConcurrentHashMap<>(initialCaches.size());
        Set<String> names = new LinkedHashSet<>(initialCaches.size());
        for (Cache cache : initialCaches) {
            String name = cache.getName();
            this.cacheMap.put(name, cache);
            names.add(name);
        }
        this.cacheNames = Collections.unmodifiableSet(names);
    }

Comment on lines +35 to +37
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Expose unmodifiable cache names to prevent external mutation

getCacheNames currently returns a modifiable Set reference. Wrap it to avoid accidental external modifications.

-        this.cacheNames = cacheNamesSet;
+        this.cacheNames = Collections.unmodifiableSet(cacheNamesSet);

Also applies to: 46-48

🤖 Prompt for AI Agents
In
src/main/java/life/mosu/mosuserver/application/caffeine/LocalCacheManager.java
around lines 35-37 and 46-48, the getCacheNames method returns a modifiable Set
which can be changed externally. To fix this, wrap the cacheNames Set with
Collections.unmodifiableSet before returning it to prevent external mutation and
ensure immutability of the cache names.


@Override
@Nullable
public Cache getCache(String name) {
return cacheMap.get(name);
}

@Override
public Collection<String> getCacheNames() {
return cacheNames;
}

@Override
public void putIfAbsent(Cache cache, String key, Object value) {
Cache localCache = getCache(cache.getName());
if (localCache != null) {
localCache.putIfAbsent(key, value);
}
}
Comment on lines +51 to +56
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Align key type with Spring Cache API: use Object for keys

Spring’s Cache API accepts Object keys. Restricting to String will block non-string keys (e.g., Long IDs used by @Cacheable).

-    public void putIfAbsent(Cache cache, String key, Object value) {
+    public void putIfAbsent(Cache cache, Object key, Object value) {
         Cache localCache = getCache(cache.getName());
         if (localCache != null) {
             localCache.putIfAbsent(key, value);
         }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public void putIfAbsent(Cache cache, String key, Object value) {
Cache localCache = getCache(cache.getName());
if (localCache != null) {
localCache.putIfAbsent(key, value);
}
}
public void putIfAbsent(Cache cache, Object key, Object value) {
Cache localCache = getCache(cache.getName());
if (localCache != null) {
localCache.putIfAbsent(key, value);
}
}
🤖 Prompt for AI Agents
In
src/main/java/life/mosu/mosuserver/application/caffeine/LocalCacheManager.java
around lines 51 to 56, the putIfAbsent method currently uses String as the key
type, which conflicts with Spring Cache API that accepts Object keys. Change the
key parameter type from String to Object to support all key types compatible
with Spring's caching mechanism.



}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package life.mosu.mosuserver.application.caffeine;

import org.springframework.cache.Cache;

public interface UpdatableCacheManager {

void putIfAbsent(Cache cache, String key, Object value);
}
Comment on lines +5 to +8
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Align with atomic compute semantics; use Supplier-based computeIfAbsent.

Returning void and accepting a prebuilt value can cause double-compute and non-atomic inserts. Prefer a Supplier-based API that atomically computes if absent and returns the resulting value.

Apply this diff:

+import java.util.function.Supplier;
 import org.springframework.cache.Cache;
 
 public interface UpdatableCacheManager {
 
-    void putIfAbsent(Cache cache, String key, Object value);
+    <V> V computeIfAbsent(Cache cache, String key, Supplier<V> valueLoader);
 }

Notes:

  • Implement using cache.get(key, k -> valueLoader.get()) to leverage Spring’s atomic caching where supported (CaffeineCache supports this).
  • This avoids eager value construction and clarifies return semantics (cached or computed value).
🤖 Prompt for AI Agents
In
src/main/java/life/mosu/mosuserver/application/caffeine/UpdatableCacheManager.java
around lines 5 to 8, the current putIfAbsent method accepts a prebuilt value and
returns void, which can cause double computation and is not atomic. Change the
method signature to accept a Supplier for the value and return the cached or
computed value. Implement this using cache.get(key, k -> valueLoader.get()) to
ensure atomic compute-if-absent semantics and avoid eager value construction.

Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@
import life.mosu.mosuserver.domain.inquiry.entity.InquiryJpaEntity;
import life.mosu.mosuserver.domain.inquiry.entity.InquiryStatus;
import life.mosu.mosuserver.domain.inquiry.repository.InquiryJpaRepository;
import life.mosu.mosuserver.domain.inquiryAnswer.repository.InquiryAnswerJpaRepository;
import life.mosu.mosuserver.domain.user.entity.UserJpaEntity;
import life.mosu.mosuserver.domain.user.entity.UserRole;
import life.mosu.mosuserver.domain.user.repository.UserJpaRepository;
import life.mosu.mosuserver.global.exception.CustomRuntimeException;
import life.mosu.mosuserver.global.exception.ErrorCode;
import life.mosu.mosuserver.presentation.inquiry.dto.InquiryCreateRequest;
Expand All @@ -26,11 +24,9 @@
@RequiredArgsConstructor
public class InquiryService {

private final UserJpaRepository userJpaRepository;
private final InquiryAttachmentService inquiryAttachmentService;
private final InquiryJpaRepository inquiryJpaRepository;
private final InquiryAnswerService inquiryAnswerService;
private final InquiryAnswerJpaRepository inquiryAnswerJpaRepository;

@Transactional
public void createInquiry(UserJpaEntity user, InquiryCreateRequest request) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import life.mosu.mosuserver.presentation.notice.dto.NoticeUpdateRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
Expand All @@ -28,12 +30,15 @@ public class NoticeService {
private final NoticeJpaRepository noticeJpaRepository;
private final NoticeAttachmentService attachmentService;

@CacheEvict(cacheNames = "notice", allEntries = true)
@Transactional
public void createNotice(NoticeCreateRequest request, UserJpaEntity user) {
NoticeJpaEntity noticeEntity = noticeJpaRepository.save(request.toEntity(user));
attachmentService.createAttachment(request.attachments(), noticeEntity);
}

@Cacheable(cacheNames = "notice",
key = "'page=' + #page + ',size=' + #size")
Comment on lines +40 to +41

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Storing different types of objects (paged lists of notices and individual notice details) in the same cache "notice" can lead to key collisions and is generally not a good practice. The keys for paged results are strings like 'page=0,size=10', while keys for details are Long IDs.

It's better to use separate caches for different data structures. For example, you could use a "notices" cache for the lists and a "notice" cache for the details.

To implement this, you would need to:

  1. Define separate CacheGroup enums for notice lists and notice details in CacheGroup.java.
  2. Update the @Cacheable and @CacheEvict annotations in this service to use the appropriate cache names.

For example:

// In getNotices()
@Cacheable(cacheNames = "notices", key = "'page=' + #page + ',size=' + #size")

// In getNoticeDetail()
@Cacheable(cacheNames = "notice", key = "#noticeId")

// In createNotice(), deleteNotice(), updateNotice()
@CacheEvict(cacheNames = {"notices", "notice"}, allEntries = true)

This will make your caching strategy more robust and easier to manage.

@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
public List<NoticeResponse> getNotices(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("id"));
Expand All @@ -44,19 +49,22 @@ public List<NoticeResponse> getNotices(int page, int size) {
.toList();
}

@Cacheable(cacheNames = "notice", key = "#noticeId")
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
public NoticeDetailResponse getNoticeDetail(Long noticeId) {
NoticeJpaEntity notice = getNotice(noticeId);

return toNoticeDetailResponse(notice);
}

@CacheEvict(cacheNames = "notice", allEntries = true)
@Transactional
public void deleteNotice(Long noticeId) {
NoticeJpaEntity noticeEntity = getNotice(noticeId);
noticeJpaRepository.delete(noticeEntity);
}

@CacheEvict(cacheNames = "notice", allEntries = true)
@Transactional
public void updateNotice(Long noticeId, NoticeUpdateRequest request, UserJpaEntity user) {
NoticeJpaEntity noticeEntity = getNotice(noticeId);
Expand Down
33 changes: 33 additions & 0 deletions src/main/java/life/mosu/mosuserver/domain/caffeine/CacheGroup.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package life.mosu.mosuserver.domain.caffeine;

import java.time.Duration;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum CacheGroup {

NOTICE(
"notice",
Duration.ofMinutes(10),
CacheType.LOCAL
),

INQUIRY(
"inquiry",
Duration.ofMinutes(10),
CacheType.GLOBAL
),

COMPOSITE_ALL(
"composite",
Duration.ofMinutes(10),
CacheType.COMPOSITE
);


private final String cacheName;
private final Duration expiredAfterWrite;
private final CacheType cacheType;
}
13 changes: 13 additions & 0 deletions src/main/java/life/mosu/mosuserver/domain/caffeine/CacheType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package life.mosu.mosuserver.domain.caffeine;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public enum CacheType {
LOCAL("로컬 타입만 적용"),
GLOBAL("분산 캐시만 적용"),
COMPOSITE("로컬 + 분산 캐시 모두 적용");

private final String type;

}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import org.springframework.context.annotation.Configuration;

@Configuration
public class CaffeineCacheConfig {
public class CaffeineFilterCacheConfig {

@Bean
public Cache<String, RequestCounter> ipRequestCountsCache(IpRateLimitingProperties properties) {
Expand Down