Spring Cache

๋ž ๋œจยท2026๋…„ 2์›” 23์ผ

๐Ÿ”Ž Overview

ย ์ด์ „ ํฌ์ŠคํŒ…์„ ํ†ตํ•ด, ์บ์‹œ์— ๋Œ€ํ•œ ์ดํ•ด๋Š” ์–ด๋А์ •๋„ ๊ฐ€๋‹ฅ์ด ์žกํ˜”๋‹ค.
ย ์ด์ œ ๋ณธ๊ฒฉ์ ์œผ๋กœ ์Šคํ”„๋ง์—์„œ ์บ์‹œ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•ด ํ•„์š”ํ•œ ๊ฐœ๋…๋“ค์„ ํ•˜๋‚˜ํ•˜๋‚˜ ์ž์„ธํ•˜๊ฒŒ ์‚ดํŽด๋ณด์ž.



1. Spring Cache ์ถ”์ƒํ™” ์ธํ„ฐํŽ˜์ด์Šค


  • ์Šคํ”„๋ง์€ ์บ์‹œ๋ฅผ ๋™์ผํ•œ ๋ฐฉ์‹์œผ๋กœ ๋‹ค๋ฃฐ ์ˆ˜ ์žˆ๋„๋ก Cache Abstraction์„ ์ œ๊ณต

1๏ธโƒฃ Cache

  • ์บ์‹œ ํ•œ ๋ฉ์–ด๋ฆฌ
    • ์ผ๋ฐ˜์ ์œผ๋กœ cacheName ํ•˜๋‚˜
  • ํ•ต์‹ฌ ๋ฉ”์„œ๋“œ
    ๋ฉ”์„œ๋“œ์„ค๋ช…
    get(Object key)ํ‚ค๋กœ ์กฐํšŒ, ๊ฐ’์ด ์—†์œผ๋ฉด null ๋ฐ˜ํ™˜. ์บ์‹œ hit/miss ํŒ์ • ๊ฐ€๋Šฅ
    get(Object key, Class<T> type)ํƒ€์ž… ์ง€์ • ์กฐํšŒ
    get(Object key, Callable<T> valueLoader)์บ์‹œ ๋ฏธ์Šค ์‹œ Callable ์‹คํ–‰ ํ›„ ๊ฐ’ ์ €์žฅ. ๋™์‹œ์„ฑ ์ œ์–ด ํ•ต์‹ฌ.
    put(Object key, Object value)์บ์‹œ์— ๊ฐ’ ์ €์žฅ
    putIfAbsent(Object key, Object value)์ด๋ฏธ ๊ฐ’์ด ์—†์„ ๋•Œ๋งŒ ์ €์žฅ
    evict(Object key)ํŠน์ • ํ‚ค ์‚ญ์ œ
    clear()์บ์‹œ ์ „์ฒด ์‚ญ์ œ

๐Ÿ“Œ get(Object key, Callable<T> valueLoader ์‚ฌ์šฉ ์˜ˆ์‹œ

Cache cache = cacheManager.getCache("user");
User user = cache.get(userId, () -> userRepository.findById(userId));
  • ์บ์‹œ์— "user" ๊ฐ’์ด ์กด์žฌํ•˜๋ฉด ์ฆ‰์‹œ ๋ฐ˜ํ™˜
  • ์กด์žฌํ•˜์ง€ ์•Š์œผ๋ฉด Callable ์‹คํ–‰
    • ๊ฒฐ๊ณผ๋ฅผ ์บ์‹œ์— ์ €์žฅ ํ›„ ๋ฐ˜ํ™˜
  • ์บ์‹œ๊ฐ€ ์—†๋Š” ํ‚ค์— ๋Œ€ํ•ด ์—ฌ๋Ÿฌ ์Šค๋ ˆ๋“œ๊ฐ€ ๋™์‹œ์— Callable ํ•จ์ˆ˜๋ฅผ ์‹คํ–‰ํ•˜์ง€ ์•Š๋„๋ก ๋™์‹œ์„ฑ ์ œ์–ด ๊ฐ€๋Šฅ

2๏ธโƒฃ CacheManager

  • ์—ฌ๋Ÿฌ ๊ฐœ์˜ Cache๋ฅผ ๊ด€๋ฆฌ, ์ƒ์„ฑ, ์กฐํšŒํ•˜๋Š” ์—ญํ• 
  • ํ•ต์‹ฌ ๋ฉ”์„œ๋“œ
    ๋ฉ”์„œ๋“œ์„ค๋ช…
    getCache(String name)์ด๋ฆ„์œผ๋กœ Cache ์ธ์Šคํ„ด์Šค ๋ฐ˜ํ™˜
    getCacheNames()๋“ฑ๋ก๋œ Cache ์ด๋ฆ„ ๋ชฉ๋ก ๋ฐ˜ํ™˜

  • ๊ตฌํ˜„์ฒด ์˜ˆ์‹œ
    • CaffeineCacheManager -> JVM ๋ฉ”๋ชจ๋ฆฌ ๊ธฐ๋ฐ˜ ๋กœ์ปฌ ์บ์‹œ
    • RedisCacheManager -> ๋ถ„์‚ฐ ์บ์‹œ
    • SimpleCacheManager -> ์—ฌ๋Ÿฌ Cache๋ฅผ ์ง์ ‘ ๋“ฑ๋ก ๊ฐ€๋Šฅ

3๏ธโƒฃ CacheResolver

  • ํ˜ธ์ถœ์—์„œ ์–ด๋–ค ์บ์‹œ๋ฅผ ์“ธ์ง€ ๊ฒฐ์ •
  • ์ผ๋ฐ˜์ ์œผ๋กœ cacheName์œผ๋กœ ์ฐพ์€ ํ›„ ๋ฐ˜ํ™˜
  • ๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ, ํ™˜๊ฒฝ๋ณ„ ์บ์‹œ, ์š”์ฒญ๋ณ„ ์บ์‹œ ์„ ํƒ ์‹œ ์ปค์Šคํ…€ ๊ฐ€๋Šฅ
    • ๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ -> ํ•˜๋‚˜์˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด๋‚˜ ์‹œ์Šคํ…œ ์ธ์Šคํ„ด์Šค๋ฅผ ์—ฌ๋Ÿฌ ๋…๋ฆฝ ๊ณ ๊ฐ(tenant)์ด ๊ณต์œ ํ•˜๋ฉด์„œ๋„, ๊ฐ ๊ณ ๊ฐ์˜ ๋ฐ์ดํ„ฐ์™€ ์„ค์ •์€ ๊ฒฉ๋ฆฌํ•˜๋Š” ๊ตฌ์กฐ
  • ํ•ต์‹ฌ ๋ฉ”์„œ๋“œ
    ๋ฉ”์„œ๋“œ์„ค๋ช…
    Collection<? extends Cache> resolveCaches(CacheOperationInvocationContext<?> context)ํ˜ธ์ถœ ์ปจํ…์ŠคํŠธ์—์„œ ์บ์‹œ ๊ฒฐ์ •

4๏ธโƒฃ KeyGenerator

  • ์บ์‹œ ํ‚ค ์ƒ์„ฑ ์ •์ฑ…
  • ๊ธฐ๋ณธ์ ์œผ๋กœ๋Š” ๋ฉ”์„œ๋“œ ํŒŒ๋ผ๋ฏธํ„ฐ ๊ธฐ๋ฐ˜
  • ์„ฑ๋Šฅ ๋ฐ ์ •ํ•ฉ์„ฑ ๋ฌธ์ œ๋กœ ์ธํ•ด, ์ •๋ง ๊ฐ„๋‹จํ•œ ๊ฒฝ์šฐ๊ฐ€ ์•„๋‹ˆ๋ผ๋ฉด ๋Œ€๋ถ€๋ถ„ ์ปค์Šคํ…€ ํ‚ค ์‚ฌ์šฉ
  • ์‚ฌ์šฉ ์˜ˆ์‹œ
    @Override
    public Object generate(Object target, Method method, Object... params) {
    	return method.getName() + "#" + Arrays.deepHashCode(params);
    }
  • ๋ฉ”์„œ๋“œ ์ด๋ฆ„ + ํŒŒ๋ผ๋ฏธํ„ฐ Hash๋กœ ํ‚ค ์ƒ์„ฑ
  • ๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ, ํŠน์ • ํ•„๋“œ๋งŒ Key๋กœ ํ•˜๊ณ  ์‹ถ์„ ๋•Œ ์ปค์Šคํ…€

5๏ธโƒฃ CacheErrorHandler

  • ์บ์‹œ ๋ฐฑ์—”๋“œ(Redis ๋“ฑ) ์žฅ์•  ๋ฐœ์ƒ ์‹œ์˜ ๋™์ž‘ ์ •์˜
  • ๊ธฐ๋ณธ์ ์œผ๋กœ๋Š” ์˜ˆ์™ธ๋ฅผ ๋˜์ง
  • ์บ์‹œ ์žฅ์•  ์‹œ DB ์กฐํšŒ๋กœ ํด๋ฐฑํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ์ฃผ๋กœ ์ปค์Šคํ…€
    ๋ฉ”์„œ๋“œ์„ค๋ช…
    handleCacheGetError(RuntimeException exception, Cache cache, Object key)get ์‹œ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ
    handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value)put ์‹œ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ
    handleCacheEvictError(RuntimeException exception, Cache cache, Object key)evict ์‹œ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ
    handleCacheClearError(RuntimeException exception, Cache cache)clear ์‹œ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ

  • ์‚ฌ์šฉ ์˜ˆ์‹œ
    @Override
    public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
    	log.error("Redis ์—๋Ÿฌ: DB ํด๋ฐฑ", exception);
    	// DB ์กฐํšŒ
    }

2. @EnableCaching


  • ๋‚ด๋ถ€์ ์œผ๋กœ CachingConfigurationSelector ๋ฅผ ํ†ตํ•ด ์บ์‹œ ์ธํ”„๋ผ ๊ตฌ์„ฑ์„ importํ•˜์—ฌ ์–ด๋…ธํ…Œ์ด์…˜ ๊ธฐ๋ฐ˜ ์บ์‹ฑ ํ™œ์„ฑํ™”
  • ๊ธฐ๋ณธ์ ์œผ๋กœ Proxy ๊ธฐ๋ฐ˜์ด๋ฏ€๋กœ, ์ฃผ์˜ํ•  ์  ์กด์žฌ
    • Self-Invocation -> ๊ฐ™์€ ํด๋ž˜์Šค ๋‚ด๋ถ€์—์„œ ๋‚ด๋ถ€ ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ ์‹œ, ํ”„๋ก์‹œ๋ฅผ ๊ฑฐ์น˜์ง€ ์•Š์œผ๋ฏ€๋กœ ์บ์‹œ ๋ฏธ๋™์ž‘
      public class CacheMain {
           @Cacheable("user")
           public User getUser(Long id) { ... }
           
           public User getUser2(Long id) {
           	return getUser(id);	// ๋‚ด๋ถ€ ํ˜ธ์ถœ, ํ”„๋ก์‹œ๋ฅผ ์•ˆ ๊ฑฐ์นจ -> ์บ์‹œ ๋ฏธ์ ์šฉ
           }
      }
    • final/private ๋ฉ”์„œ๋“œ -> ํ”„๋ก์‹œ๊ฐ€ ๊ฐ€๋กœ์ฑ„์ง€ ๋ชปํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ๋งŽ์Œ

๐ŸŒŠํ˜ธ์ถœ ํ๋ฆ„

  • @Cacheable ๊ธฐ์ค€, ํ˜ธ์ถœ ํ๋ฆ„
ํด๋ผ์ด์–ธํŠธ ์š”์ฒญ
    โ†“
Service (ํ”„๋ก์‹œ)
    โ†“ (Advisor / Interceptor ๊ฐ€๋กœ์ฑ„๊ธฐ)
CacheInterceptor
    โ”œโ”€ CacheOperation ์กฐํšŒ (OperationSource)
    โ”œโ”€ CacheManager๋กœ Cache ์กฐํšŒ
    โ”œโ”€ Cache Hit โ†’ ๋ฐ”๋กœ ๋ฐ˜ํ™˜
    โ””โ”€ Cache Miss โ†’ ์‹ค์ œ Service ๋ฉ”์„œ๋“œ ์‹คํ–‰ โ†’ ๊ฒฐ๊ณผ ์บ์‹œ์— ์ €์žฅ โ†’ ๋ฐ˜ํ™˜
  • ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ์ด ๋น„์ฆˆ๋‹ˆ์Šค ์„œ๋น„์Šค ๋กœ์ง์— ๋„๋‹ฌํ•˜๊ธฐ ์ด์ „, CacheInterceptor๊ฐ€ ๋จผ์ € Hit/Miss ์ฒดํฌ ์ˆ˜ํ–‰

๐Ÿซ˜ ์ธํ”„๋ผ ๋นˆ ๊ด€์ 

  • ํ”„๋ก์‹œ ๊ธฐ๋ฐ˜ ์บ์‹œ๋Š” ์Šคํ”„๋ง ๋‚ด๋ถ€์—์„œ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ตฌ์กฐ๋กœ ์ดํ•ด ๊ฐ€๋Šฅ
    ๊ตฌ์„ฑ ์š”์†Œ์—ญํ• 
    Advisor์–ด๋–ค ๋ฉ”์„œ๋“œ์— ์บ์‹œ ์–ด๋“œ๋ฐ”์ด์Šค๋ฅผ ์ ์šฉํ• ์ง€ ๊ฒฐ์ • ( @Cacheable , @CachePut , @CacheEvict ๋“ฑ )
    Interceptor (=Advice)์‹ค์ œ ์บ์‹œ ๋™์ž‘ ์ˆ˜ํ–‰ ( CacheInterceptor )
    OperationSource@Cacheable ๋“ฑ์˜ ์–ด๋…ธํ…Œ์ด์…˜์„ CacheOperation ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜ -> ์บ์‹œ ๋™์ž‘ ์ •์˜ ์ƒ์„ฑ
    CacheManager / KeyGenerator / CacheResolver / ErrorHandler์‹ค์ œ ์บ์‹œ ์กฐํšŒ, ํ‚ค ์ƒ์„ฑ, ์„ ํƒ, ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๋“ฑ์„ ๋‹ด๋‹น

1๏ธโƒฃ Advisor

  • AOP์—์„œ๋Š” ์–ด๋–ค ์‹œ์ ์— ์–ด๋–ค Advice๋ฅผ ์ ์šฉํ• ์ง€ ๊ฒฐ์ •
  • ์Šคํ”„๋ง ์บ์‹œ์—์„œ๋Š” CacheOperationSource, CacheInterceptor๋ฅผ ๋ฌถ์–ด์„œ Advice๋กœ ๋“ฑ๋ก
  • ๋ฉ”์„œ๋“œ์— @Cacheable ์ด ์žˆ์œผ๋ฉด CacheInterceptor ์‹คํ–‰์„ ์•Œ๋ ค์ฃผ๋Š” ์ค‘๊ฐ„ ๊ด€๋ฆฌ์ž ์—ญํ• 

2๏ธโƒฃ OperationSource

  • @Cacheable , @CachePut , @CacheEvict ๊ฐ™์€ ์–ด๋…ธํ…Œ์ด์…˜์„ ๋‚ด๋ถ€์ ์œผ๋กœ CacheOperation ๊ฐ์ฒด๋กœ ๋ณ€ํ™˜
  • ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ฝ”๋“œ๋ฅผ
    @Cacheable(value = "users", key = "#id")
    public User find(Long id)
    ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๊ตฌ์กฐ๋กœ ๋ณ€ํ™˜
    CacheOperation:
     - cacheName: users
     - key: #id
     - condition: null
     - unless: null
  • ์–ด๋…ธํ…Œ์ด์…˜์„ ๊ธฐ๊ณ„๊ฐ€ ์ดํ•ดํ•  ์ˆ˜ ์žˆ๋Š” ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋กœ ๋ฐ”๊ฟ”์ฃผ๋Š” ์—ญํ• 

3๏ธโƒฃ Interceptor

  • ์‹ค์ œ ์บ์‹œ ๋กœ์ง ์ˆ˜ํ–‰์ž
  • ๋™์ž‘ ํ๋ฆ„
    1. ์บ์‹œ ํ‚ค ์ƒ์„ฑ
     2. CacheManager์—์„œ ์บ์‹œ ๊ฐ€์ ธ์˜ค๊ธฐ
     3. ์บ์‹œ์— ๊ฐ’์ด ์žˆ๋Š”์ง€ ์ฒดํฌ
       โ†’ ์žˆ์œผ๋ฉด ๋ฐ”๋กœ ๋ฐ˜ํ™˜ (๋ฉ”์„œ๋“œ ๋ฏธ์‹คํ–‰)
       โ†’ ์—†์œผ๋ฉด ์‹ค์ œ ๋ฉ”์„œ๋“œ ์‹คํ–‰
     4. ๊ฒฐ๊ณผ๋ฅผ ์บ์‹œ์— ์ €์žฅ
     5. ๋ฐ˜ํ™˜

3. ์Šคํ”„๋ง ์บ์‹œ ์–ด๋…ธํ…Œ์ด์…˜


1๏ธโƒฃ @Cacheable

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Reflective
public @interface Cacheable {

	// cacheNames: ์‚ฌ์šฉํ•  ์บ์‹œ ์ด๋ฆ„๋“ค ์ง€์ •
    String[] cacheNames() default {};
    // value: cacheNames์™€ ๋™์ผ.cacheNames์˜ ๋ณ„์นญ
    String[] value() default {};
    
    // key: ์บ์‹œ ํ‚ค๋ฅผ SpEL๋กœ ์ง€์ •
    String key() default "";
    
    // keyGenerator: KeyGenerator ๋นˆ ์ด๋ฆ„ ์ง€์ •
    String keyGenerator() default "";
    
    // cacheManager: CacheManager ๋นˆ ์ด๋ฆ„ ์ง€์ •(Resolver ๋Œ€์‹  ์ง์ ‘ ์ง€์ •ํ•˜๋Š” ๋А๋‚Œ)
    String cacheManager() default "";
    
    // cacheResolver: CacheResolver ๋นˆ ์ด๋ฆ„ ์ง€์ •(์—ฌ๋Ÿฌ ์บ์‹œ์—์„œ ๋™์  ์„ ํƒ ์‹œ)
    String cacheResolver() default "";
    
    // condition: ์บ์‹œ ์ ์šฉ ์—ฌ๋ถ€(SpEL). true์ผ ๋•Œ๋งŒ ์บ์‹œ ๋กœ์ง ๋™์ž‘
    String condition() default "";
    
    // unless: ์บ์‹œ ์ €์žฅ ์—ฌ๋ถ€(SpEL). true๋ฉด put ์Šคํ‚ต
    // ์ผ๋ฐ˜์ ์œผ๋กœ '๊ฒฐ๊ณผ ๊ธฐ๋ฐ˜'์œผ๋กœ ํ‰๊ฐ€
    String unless() default "";
    
    // sync: stampede ๋ฐฉ์ง€์šฉ ๋™๊ธฐํ™” ๋ชจ๋“œ(๊ฐ™์€ key์— ๋Œ€ํ•œ ๋™์‹œ ๋กœ๋”ฉ ๋ฐฉ์ง€)
    boolean sync() default false;
}
  • ์กฐํšŒ ์บ์‹ฑ์˜ ๊ธฐ๋ณธ
    • ์บ์‹œ์— ๊ฐ’์ด ์žˆ์œผ๋ฉด ๋ฉ”์„œ๋“œ ์‹คํ–‰ ์—†์ด ์บ์‹œ ๊ฐ’์„ ๋ฐ˜ํ™˜ํ•˜๊ณ , ์—†์œผ๋ฉด ๋ฉ”์„œ๋“œ ์‹คํ–‰ ํ›„ ์บ์‹œ์— ์ €์žฅ
  • cacheNames
    • ์‚ฌ์‹ค์ƒ ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ ๋ฐ ์ •์ฑ… ๋‹จ์œ„
    • TTL, size, serializer๋ฅผ cacheName๋ณ„๋กœ ๋‹ค๋ฅด๊ฒŒ ์ฃผ๋Š” ๊ฒŒ ์ผ๋ฐ˜์ 
  • key
    • ํ‚ค๋ฅผ SpEL๋กœ ์ง์ ‘ ๋งŒ๋“ค๋ฉด ๊ฐ•๋ ฅํ•˜์ง€๋งŒ, ๋ฌธ์ž์—ด ํ‚ค๋ฅผ ๋‚จ๋ฐœํ•˜๊ฒŒ ๋˜์–ด ์ถฉ๋Œ ๊ฐ€๋Šฅ์„ฑ๊ณผ ๊ด€๋ฆฌ ๋‚œ์ด๋„ ์ฆ๊ฐ€
    • ์ผ๋ฐ˜์ ์œผ๋กœ '๊ทœ์น™ํ™”๋œ prefix + ์‹๋ณ„์ž' ์ •๋„๋งŒ SpEL๋กœ ์‚ฌ์šฉ
      • ๋ณต์žกํ•ด์ง€๋ฉด KeyGenerator ๊ตฌํ˜„
  • condition
    • ์‚ฌ์ „ ์กฐ๊ฑด
    • ์ผ๋ฐ˜์ ์œผ๋กœ ๋ฉ”์„œ๋“œ ์‹คํ–‰ ์ „ ํ‰๊ฐ€
    • true ์ผ ๋•Œ๋งŒ ์บ์‹œ ๋กœ์ง ์ ์šฉ
    • false ๋ฉด ์บ์‹œ ๋ฌด์‹œ ํ›„ ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ
  • unless
    • ์‚ฌํ›„ ๊ฑฐ๋ถ€ ์กฐ๊ฑด(veto)
    • ๋ฉ”์„œ๋“œ ์‹คํ–‰ ํ›„ ํ‰๊ฐ€
    • ๋ฐ˜ํ™˜๊ฐ’( #result )์„ ๋ณด๊ณ  ์บ์‹ฑํ•˜์ง€ ์•Š์„ ๊ฒฐ๊ณผ ๊ฒฐ์ •
    • true ์ผ ์‹œ ์บ์‹œ์— ์ €์žฅํ•˜์ง€ ์•Š์Œ
  • sync=true
    • Stampede ๋ฐฉ์ง€์— ๋งค์šฐ ์ค‘์š”
    • ์บ์‹œ ๊ตฌํ˜„์ฒด๊ฐ€ ๋™์‹œ ๋กœ๋”ฉ์„ ์ž˜ ์ง€์›ํ•ด์•ผ ์ข‹์€ ํšจ๊ณผ ๋ฐœํœ˜
    • ๋ชจ๋“  ๊ธฐ๋Šฅ๊ณผ ์กฐํ•ฉ ์‹œ ์ œ์•ฝ์ด ์ƒ๊ธธ ์ˆ˜ ์žˆ์–ด, ํ•ต์‹ฌ ํ‚ค ๋ช‡ ๊ฐœ์—๋งŒ ์ œํ•œ์ ์œผ๋กœ ์ ์šฉํ•˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ๋งŽ์Œ
      • unless ์™€๋Š” ๊ฐ™์ด ์‚ฌ์šฉ ๋ถˆ๊ฐ€๋Šฅ

2๏ธโƒฃ @CachePut

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Reflective
public @interface CachePut {
	String[] cacheNames() default {};
    String[] value() default {};
    String key() default "";
    String keyGenerator() default "";
    String cacheManager() default "";
    String cacheResolver() default "";
    String condition() default "";
    String unless() default "";
}
  • ํ•ญ์ƒ ๋ฉ”์„œ๋“œ ์‹คํ–‰ ํ›„ ์บ์‹œ ๊ฐฑ์‹ 
    • ์“ฐ๊ธฐ ํ›„ ์บ์‹œ ๊ฐฑ์‹ ์„ ๋ฉ”์„œ๋“œ์™€ ๋ฌถ์„ ๋•Œ ์œ ์šฉ
  • DB ํŠธ๋žœ์žญ์…˜๊ณผ์˜ ์ˆœ์„œ ๋ฐ ์‹คํŒจ๋ฅผ ์กฐ์‹ฌํ•ด์•ผ ํ•จ
    • ์•ˆ์ •์„ฑ ์ธก๋ฉด์—์„œ @CacheEvict ๊ฐ€ ๋” ์„ ํ˜ธ๋˜๊ธฐ๋„ ํ•จ

3๏ธโƒฃ @CacheEvict

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Reflective
public @interface CacheEvict {
	String[] cacheNames() default {};
    String[] value() default {};
    String key() default "";
    String keyGenerator() default "";
    String cacheManager() default "";
    String cacheResolver() default "";
    String condition() default "";
    
    // allEntries=true ์‹œ, ํ•ด๋‹น ์บ์‹œ์˜ ๋ชจ๋“  ์—”ํŠธ๋ฆฌ drop
    boolean allEntries() default false;
    
    // beforeInvocation=true ์‹œ ๋ฉ”์„œ๋“œ ์‹คํ–‰ ์ „์— evict
    // ๋”ฐ๋ผ์„œ, ๋ฉ”์„œ๋“œ๊ฐ€ ์˜ˆ์™ธ๋ฅผ ๋˜์ ธ๋„ evict ์ˆ˜ํ–‰
    boolean beforeInvocation() default false;
}
  • ๋ฉ”์„œ๋“œ ์‹คํ–‰ ์ „/ํ›„์— ์บ์‹œ ํ•ญ๋ชฉ ์ œ๊ฑฐ(๋ฌดํšจํ™”)
  • allEntries=true ๋Š” ๋งค์šฐ ์œ„ํ—˜ํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ, ์ •๋ง ํ•„์š”ํ•  ๋•Œ๋งŒ ์‚ฌ์šฉ
  • beforeInvocation=true ๋Š” ์‹คํŒจํ•˜๋”๋ผ๋„ ๋ฐ˜๋“œ์‹œ ์บ์‹œ๋ฅผ ๋ฌดํšจํ™”ํ•ด์•ผ ํ•˜๋Š” ์ƒํ™ฉ์—๋งŒ ์‚ฌ์šฉ

4๏ธโƒฃ @Caching

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Reflective
public @interface Caching {
	Cacheable[] cacheable() default {};
    CachePut[] put() default {};
    CacheEvict[] evict() default {};
}
  • ์—ฌ๋Ÿฌ ์บ์‹œ ๋™์ž‘์„ ํ•œ ๋ฉ”์„œ๋“œ์— ์กฐํ•ฉ
  • 1๋ฒˆ ์—…๋ฐ์ดํŠธ์—์„œ
    • A ์บ์‹œ๋Š” evict
    • B ์บ์‹œ๋Š” put
    • ๋‘˜์„ ๋™์‹œ์— ํ•ด์•ผ๋˜๋Š” ์ƒํ™ฉ ๊ฐ™์€ ๋•Œ ์‚ฌ์šฉ

5๏ธโƒฃ @CacheConfig

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CacheConfig {
	String[] cacheNames() default {};
	String keyGenerator() default "";
	String cacheManager() default "";
	String cacheResolver() default "";
}
  • ํด๋ž˜์Šค ๋ ˆ๋ฒจ ๊ธฐ๋ณธ๊ฐ’
    • ํด๋ž˜์Šค์— ๊ณตํ†ต ์„ค์ •(cacheNames, keyGenerator, cacheManager, cacheResovler ...)์„ ๊ฑธ์–ด ์ค‘๋ณต ์ œ๊ฑฐ
  • @CacheConfig ๋งŒ ๋ถ™์ธ๋‹ค๊ณ  ์บ์‹œ๊ฐ€ ์ผœ์ง€๋Š” ๊ฒŒ ์•„๋‹ˆ๊ณ , ์˜ค์ง ๊ธฐ๋ณธ๊ฐ’๋งŒ ์ œ๊ณต
  • ์ฝ”๋“œ ์˜ˆ์‹œ
    @CacheConfig(cacheNames = "user", keyGenerator = "userKeyGenerator")
    public class UserService {
       @Cacheable	// ๋ฉ”์„œ๋“œ๋ณ„ ์ ์šฉ
       public User get(long id) { ... }
    }

4. SpEL๊ณผ ํ‰๊ฐ€ ์ปจํ…์ŠคํŠธ


  • ์Šคํ”„๋ง ์บ์‹œ ์–ด๋…ธํ…Œ์ด์…˜์€ SpEL์„ ์‚ฌ์šฉํ•ด ๋‹ค์Œ์„ ์ œ์–ด ๊ฐ€๋Šฅ
    • key: ์บ์‹œ ํ‚ค๋ฅผ ์–ด๋–ป๊ฒŒ ๋งŒ๋“ค ๊ฒƒ์ธ๊ฐ€
    • condition: ์บ์‹œ ๋™์ž‘์„ ์ ์šฉํ•  ๊ฒƒ์ธ๊ฐ€ (์‚ฌ์ „ ์กฐ๊ฑด)
    • unless: ๊ฒฐ๊ณผ๋ฅผ ๋ณด๊ณ , ์บ์‹œ ์ €์žฅ์„ ๊ฑฐ๋ถ€ํ•  ๊ฒƒ์ธ๊ฐ€ (์‚ฌํ›„ ๊ฑฐ๋ถ€ ์กฐ๊ฑด)

๐Ÿ”ญ ์–ด๋…ธํ…Œ์ด์…˜๋ณ„ ํ‰๊ฐ€ ์‹œ์  ์ฐจ์ด

1๏ธโƒฃ @Cacheable ํ‰๊ฐ€ ์ˆœ์„œ

  • ์ฝ”๋“œ
    @Cacheable(
       cacheNames = "items",
       key = "#id",
       condition = "#id > 0",		// (1) ๋จผ์ € ํ‰๊ฐ€
       unless = "#result == null"	// (2) ๋ฉ”์„œ๋“œ ์‹คํ–‰ ํ›„ ํ‰๊ฐ€
    )
    public Product find(long id) {
    	return productRepository.findById(id).orElse(null);
    }
  • Cache Hit์ธ ๊ฒฝ์šฐ
    • ๋ฉ”์„œ๋“œ ๋น„ํ˜ธ์ถœ
    • ๋”ฐ๋ผ์„œ #result ์ž์ฒด๊ฐ€ ์—†์Œ
    • ์ฆ‰, unless ๋Š” Cache Hit ํŒ๋‹จ ์กฐ๊ฑด์—์„œ ์ œ์™ธ

2๏ธโƒฃ @CachePut ํ‰๊ฐ€ ์ˆœ์„œ

  • ์ฝ”๋“œ
    @CachePut(
       cacheNames = "products",
       key = "#result.id",				// ์ƒ์„ฑ ํ›„ id ์ƒ๊ธฐ๋Š” ๊ฒฝ์šฐ ์œ ์šฉ
       condition = "#result != null",	// ๊ฒฐ๊ณผ ๊ธฐ๋ฐ˜ condition ๊ฐ€๋Šฅ
       unless = "#result.delete == true"	// ๊ฒฐ๊ณผ์— ๋”ฐ๋ผ ์บ์‹œ put ๊ฑฐ๋ถ€
    )
    public Product updateProduct(ProductUpdateDto response) {
    	return productService.update(response);
    }
  • @CachePut ์€ ๋ฉ”์„œ๋“œ๊ฐ€ ํ•ญ์ƒ ์‹คํ–‰
  • condition , unless ๋ชจ๋‘ ์‚ฌ์šฉ ๋ฐ ์ œ์–ด ๊ฐ€๋Šฅ
  • @CachePut ์—์„œ๋Š” condition ๋˜ํ•œ ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ ํ›„ ํ‰๊ฐ€๋˜๋ฉฐ, ์—ฌ๊ธฐ์„œ #result ์ฐธ์กฐ ๋˜ํ•œ ๊ฐ€๋Šฅ

3๏ธโƒฃ @CacheEvict ํ‰๊ฐ€ ์ˆœ์„œ

  • ์ฝ”๋“œ ( beforeInvocation = false )
    @CacheEvict(cacheNames = "products", key = "#id")
    public void deleteProduct(Long id) {
    	productRepository.deleteById(id);	// ์„ฑ๊ณต ํ›„ evict
    }
    • ๋ฉ”์„œ๋“œ ์„ฑ๊ณต ํ›„ ๋ฌดํšจํ™”
    • #result ์‚ฌ์šฉ ๊ฐ€๋Šฅ

  • ์ฝ”๋“œ ( beforeInvocation = true )
    @CacheEvict(cacheNames = "products", key = "#id", beforeInvocation = true)
    public void deleteProduct(Long id) {
    	productRepository.deleteById(id);	// ์‹คํ–‰ ์ „ evict
    }
    • ๋ฉ”์„œ๋“œ ์‹คํ–‰ ์ „ ๋ฌดํšจํ™”
    • ์•„์ง ๊ฒฐ๊ณผ๊ฐ€ ์—†๊ธฐ ๋•Œ๋ฌธ์— #result ์‚ฌ์šฉ ๋ถˆ๊ฐ€

๐Ÿ”– ์ž์ฃผ ์‚ฌ์šฉ๋˜๋Š” ๊ฐ’

1. #root.methodName

  • ํ˜ธ์ถœ๋œ ๋ฉ”์„œ๋“œ ์ด๋ฆ„
    key = "#root.methodName + ':' + #id"

2. #root.method

  • ํ˜ธ์ถœ๋œ ๋ฉ”์„œ๋“œ
    key = "#root.method.name + ':' + #id"

3. #root.target

  • ๋Œ€์ƒ ๊ฐ์ฒด (๋นˆ ์ธ์Šคํ„ด์Šค)
    key = "#root.targetClass.simpleName + ':' + #id"

4. #root.targetClass

  • ๋Œ€์ƒ ํด๋ž˜์Šค
    key = "#root.targetClass.name + ':' + #id"

5. #root.args

  • ์ธ์ž ๋ฐฐ์—ด
    key = "#root.args[0]"

6. #root.caches

  • ํ˜„์žฌ ์ ์šฉ ๋Œ€์ƒ ์บ์‹œ ์ปฌ๋ ‰์…˜
    key = "#root.caches[0].name + ':' + #id"

7. ํŒŒ๋ผ๋ฏธํ„ฐ ์ด๋ฆ„ ๊ธฐ๋ฐ˜ ์ฐธ์กฐ ( #id , #request.userId )

key = "#id"
 key = "#request.userId + ':' + #request.page"
  • -parameters ์ปดํŒŒ์ผ ์˜ต์…˜ ํ•„์š”

8. ์ธ๋ฑ์Šค ๊ธฐ๋ฐ˜ ์ฐธ์กฐ ( #a0 , #p0 , #root.args[0] )

  • ํŒŒ๋ผ๋ฏธํ„ฐ ์ด๋ฆ„์„ ๋ชป ๊ฐ€์ ธ์˜ค๋Š” ์ƒํ™ฉ์—์„œ๋„ ์•ˆ์ „ํ•œ ๋ฐฉ์‹
  • ์Šคํ”„๋ง ๊ณต์‹ ๋ฌธ์„œ์— ๋”ฐ๋ฅด๋ฉด, ํŒŒ๋ผ๋ฏธํ„ฐ ์ด๋ฆ„์ด ์—†์„ ์‹œ #a0 , #p0 ๊ฐ™์€ ์ธ๋ฑ์Šค ์ ‘๊ทผ์„ ์“ฐ๋ผ๊ณ  ์•ˆ๋‚ด
    - Spring Cache ๊ณต์‹ ๋ฌธ์„œ
    key = "#a0"		// ์ฒซ ๋ฒˆ์งธ ์ธ์ž
     key = "#p1"		// ๋‘ ๋ฒˆ์งธ ์ธ์ž

9. #result

  • ๋ฉ”์„œ๋“œ ๊ฒฐ๊ณผ๊ฐ’
  • @Cacheable : unless ์—์„œ ์‚ฌ์šฉ ๊ฐ€๋Šฅ
  • @CachePut : key , condition , unless ์—์„œ ๊ฒฐ๊ณผ ๊ธฐ๋ฐ˜์œผ๋กœ ์‚ฌ์šฉ ๊ฐ€๋Šฅ
  • @CacheEvict : beforeInvocation=false ์ผ ๋•Œ ์‚ฌ์šฉ ๊ฐ€๋Šฅ

๐Ÿ“’ ์ฝ”๋“œ ์˜ˆ์‹œ

1๏ธโƒฃ ์ž…๋ ฅ ์กฐ๊ฑด + null ๊ฒฐ๊ณผ ์ œ์™ธ

@Service
@RequiredArgsConstructor
public class ProductService {

	private final ProductRepository productRepository;

	/ **
      * id๊ฐ€ 0 ์ดํ•˜๋ฉด ์บ์‹œ ๋ฏธ์‚ฌ์šฉ (condition)
      * ์กฐํšŒ ๊ฒฐ๊ณผ๊ฐ€ null์ด๋ฉด ์บ์‹œ์— ๋ฏธ์ €์žฅ (unless)
      */
    @Cacheable(
    	cacheNames = "products",
        key = "#id",
        condition = "#id != null && #id > 0",
        unless = "#result == null"
    )
    public Product findById(Long id) {
    	// ์บ์‹œ miss์ผ ๋•Œ๋งŒ ์‹คํ–‰
        return findByIdFromDb(id);
    }
    
    private Product findByIdFromDb(Long id) {
    	return productRepository.findById(id).orElse(null);
    }
}

2๏ธโƒฃ ๊ฒ€์ƒ‰ ์บ์‹œ (๋ฌธ์ž์—ด ์ •๊ทœํ™” + ํŽ˜์ด์ง€ ์ œํ•œ)

@Service
@RequiredArgsConstructor
public class ProductSearchService {

	private final ProductSearchRepository productSearchRepository;

	/**
     * ๊ฒ€์ƒ‰ ์บ์‹œ๋Š” ํญ๋ฐœํ•˜๊ธฐ ์‰ฌ์›€
     * page๊ฐ€ ๋„ˆ๋ฌด ํฌ๋ฉด ์บ์‹œ ์ œ์™ธ (condition)
     * ๊ฒฐ๊ณผ๊ฐ€ ๋น„์–ด์žˆ์œผ๋ฉด ์บ์‹œ ์ €์žฅ ์ œ์™ธ (unless)
     * keyword๋Š” trim๊ณผ lowercase๋กœ ํ‚ค ์ •๊ทœํ™”
     */
	@Cacheable(
    	cacheNames = "searchProducts",
        key = "'keyword=' + #keyword.trim().toLowerCase() + ':page=' + #page + ':size=' + #size",
        condition = "#page >= 0 && #page < 20 && #size <= 100",
        unless = "#result == null || #result.isEmpty()"
    )
    public List<Product> searchProduct(String keyword, int page, int size) {
    	return searchProductFromDb(keyword, page, size);
    }
    
    private List<Product> searchProductFromDb(String keyword, int page, int size) {
    	return productSearchRepository.search(keyword, page, size);
    }
}

3๏ธโƒฃ @CachePut์—์„œ ๊ฒฐ๊ณผ ๊ธฐ๋ฐ˜ ํ‚ค/์กฐ๊ฑด ์‚ฌ์šฉ

@Service
@RequiredArgsConstructor
public class ProudctService {

	private final ProductRepository productRepository;

	/**
     * @CachePut์€ ํ•ญ์ƒ ๋ฉ”์„œ๋“œ ์‹คํ–‰
     * ์ €์žฅ/์ˆ˜์ • ํ›„ ๋ฐ˜ํ™˜๋œ ๊ฒฐ๊ณผ(result)๋ฅผ ๊ธฐ์ค€์œผ๋กœ ์บ์‹œ์— ์ €์žฅ
     */
	@CachePut(
    	cacheNames = "products",
        key = "#result.id",				// ๊ฒฐ๊ณผ id ๊ฐ’์œผ๋กœ ํ‚ค ์ถ”์ถœ
        condition = "#result != null",	// ๊ฒฐ๊ณผ๊ฐ€ ์žˆ์–ด์•ผ๋งŒ put
        unless = "#result.notSale"		// notSale ์ƒํƒœ๋ฉด put ๊ธˆ์ง€
    )
    public Product save(ProductCreateDto request) {
    	return saveProduct(request);
    }
    
    private Product saveProduct(ProductCreateDto request) {
    	return productRepository.save(request.id, request.notSale);
    }
}

4๏ธโƒฃ @CacheEvict - beforeInvocation ์ฐจ์ด

@Service
@RequiredArgsConstructor
public class ProductService {
	
    private final ProductRepository productRepository;
	
    /**
     * ๊ธฐ๋ณธ๊ฐ’ (beforeInvocation = false)
     * ๋ฉ”์„œ๋“œ๊ฐ€ ์„ฑ๊ณตํ•ด์•ผ๋งŒ ๊ทธ ํ›„์— ์บ์‹œ ์ œ๊ฑฐ
     * ์˜ˆ์™ธ ๋ฐœ์ƒ ์‹œ ์บ์‹œ๊ฐ€ ์‚ญ์ œ๋˜์ง€ ์•Š์Œ
       - ํŠธ๋žœ์žญ์…˜ ๋กค๋ฐฑ ๊ฐ€๋Šฅ์„ฑ์ด ์กด์žฌํ•  ๋•Œ, ์ผ๊ด€์„ฑ ์ธก๋ฉด์—์„œ ๋” ์•ˆ์ „ํ•œ ํŽธ
     */
    @CacheEvict(cacheNames = "products", key = "#id")
    public void deleteProduct(Long id) {
    	deleteProductFromDb(id);
    }
    
    /**
     * beforeInvocation = false
     * ๋ฉ”์„œ๋“œ ํ˜ธ์ถœ ์ „, ๋ฏธ๋ฆฌ ์บ์‹œ ์ œ๊ฑฐ
     * ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜๋”๋ผ๋„ ๊ทธ ์ „์— ์บ์‹œ๋Š” ์ œ๊ฑฐ๋œ ์ƒํƒœ
     */
    @CacheEvict(cacheNames = "products", key = "#id", beforeInvocation = true)
    public void deleteProductForce(Long id) {
    	deleteProductFromDb(id);
    }
    
    private void deleteProductFromDb(Long id) {
    	productRepository.delete(id);
    }
}

5. ํ‚ค ์„ค๊ณ„


์บ์‹œ๋Š” "๊ฐ™์€ ํ‚ค๋ฉด ๊ฐ™์€ ๊ฐ’" ์ด๋ผ๋Š” ๊ฐ•ํ•œ ๊ฐ€์ •์„ ์ „์ œ๋กœ ๋™์ž‘
  • ํ‚ค๊ฐ€ ์ž˜๋ชป๋˜๋ฉด ์•„๋ž˜์™€ ๊ฐ™์€ ๋ฌธ์ œ ๋ฐœ์ƒ ๊ฐ€๋Šฅ

    1. ์ž˜๋ชป๋œ ๋ฐ์ดํ„ฐ ๋ฐ˜ํ™˜(์˜ค์—ผ): ์„œ๋กœ ๋‹ค๋ฅธ ์š”์ฒญ์ด ๊ฐ™์€ ํ‚ค๋กœ ๋งคํ•‘(์ถฉ๋Œ)
    2. ์ •๋ณด ๋ˆ„์ถœ: ๊ถŒํ•œ/ํ…Œ๋„ŒํŠธ๋ณ„ ๊ฒฐ๊ณผ๊ฐ€ ๋‹ค๋ฆ„์—๋„, ํ‚ค์— scope๊ฐ€ ์—†์–ด์„œ ๋‚จ์˜ ๋ฐ์ดํ„ฐ ๋…ธ์ถœ ๊ฐ€๋Šฅ
    3. ์บ์‹œ ๋ฌด์šฉ์ง€๋ฌผ: ๋™์ผ ์š”์ฒญ์ž„์—๋„ ํ‚ค๊ฐ€ ๊ณ„์† ๋‹ฌ๋ผ์ ธ Cache Miss ๋ฐœ์ƒ
    4. ์šด์˜ ํ†ต์ œ ๋ถˆ๊ฐ€: ํ‚ค์˜ prefix๋‚˜ ๋ฒ„์ „์ด ์—†์œผ๋ฉด ๋กค๋ฐฑ/๋ฐฐํฌ ๋•Œ ์ „์ฒด ์บ์‹œ๋ฅผ ์ œ๊ฑฐํ•ด์•ผ ํ•จ

๐Ÿ“Œ ํ‚ค ์„ค๊ณ„ ๊ทœ์น™

1. ์งง๊ณ  ๊ฒฐ์ •์ 

  • ๊ฐ™์€ ์ž…๋ ฅ์ด๋ฉด ํ•ญ์ƒ ๊ฐ™์€ ํ‚ค
  • ๋ฆฌ์ŠคํŠธ ๊ฒ€์ƒ‰์—์„œ, ๋ฆฌ์ŠคํŠธ ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ์ •๊ทœํ™” ํ•„์ˆ˜
    • ์ •๋ ฌ, ์ค‘๋ณต ์ œ๊ฑฐ, ๊ณต๋ฐฑ ์ œ๊ฑฐ ๋ฐ ์†Œ๋ฌธ์ž ํ†ต์ผ
  • ํ‚ค ์ •๊ทœํ™” ์˜ˆ์‹œ:
    • ์ž…๋ ฅ
      [solo, male]
       [male, solo]
    • ์ •๊ทœํ™”
      [male, solo]
    • ์ •๊ทœํ™”๋œ ํ‚ค
      games:tags=male,solo

2. ์ถฉ๋Œ ๋ฐฉ์ง€

  • ๊ฒฐ๊ณผ๋ฅผ ๋ฐ”๊พธ๋Š” ๋ชจ๋“  ์š”์†Œ๋Š” ํ‚ค์— ํฌํ•จ
    • ์•„๋‹ ์‹œ ์˜ค์—ผ ๋ฐœ์ƒ
  • ํ•„๋“œ๊ฐ€ ๊ฒฐ๊ณผ์— ์˜ํ–ฅ์„ ์ฃผ๋Š”์ง€ ํ•ญ์ƒ ์ฒดํฌ
    • page, pageSize, orderBy ๋“ฑ

3. ๋ฒ„์ „

  • ์บ์‹œ๋Š” ์Šคํ‚ค๋งˆ๋‚˜ ์‘๋‹ต, ์ง๋ ฌํ™” ๋ฐฉ์‹์ด ๋ฐ”๋€Œ๋ฉด, ์ด์ „ ์บ์‹œ๊ฐ€ ํ‹€๋ฆฐ ๊ฐ’์ด ๋  ๊ฐ€๋Šฅ์„ฑ ์กด์žฌ
    • ์บ์‹œ๋Š” ์›๋ณธ DB๊ฐ€ ์•„๋‹Œ ๋ณต์ œ๋ณธ
    • ๋ฒ„์ „ ์—…๋ฐ์ดํŠธ ๋ฐฐํฌ ํ›„, ๊ฐ™์€ ์š”์ฒญ์˜ ์‘๋‹ต ๊ตฌ์กฐ๊ฐ€ ๋‹ฌ๋ผ์งˆ ์ˆ˜ ์žˆ์Œ
  • ๋ฒ„์ €๋‹์„ ํ†ตํ•ด์„œ ์ด๋ฅผ ๊ด€๋ฆฌ ๊ฐ€๋Šฅ
    • playon:v2:partyDetail:{partyId}
  • ์žฅ์ 
    • ๋ฐฐํฌ ์งํ›„ ์ด์ „ ์บ์‹œ์˜ ์ž๋™ ๋ฌดํšจํ™”
  • ๋‹จ์ 
    • ๋ฐฐํฌ ์งํ›„ Cold Start ๋ฌธ์ œ ๋ฐœ์ƒ
    • ์ด๋Š” TTL ์กฐ์ ˆ, ์›Œ๋ฐ์—…, ์Šคํƒœ๊ฑฐ ๋ฐฐํฌ ๋“ฑ์˜ ๋ฐฉ๋ฒ•์œผ๋กœ ์™„ํ™” ๊ฐ€๋Šฅ

4. ๋ฉ€ํ‹ฐํ…Œ๋„Œ์‹œ/๊ถŒํ•œ/์‚ฌ์šฉ์ž๋ณ„ ๊ฒฐ๊ณผ๋Š” Key Scope๋กœ ๊ฐ•์ œ

  • ์บ์‹œ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ๊ฐ™์€ ํ‚ค๋Š” ๊ฐ™์€ ๊ฐ’์œผ๋กœ ๋ฐ˜ํ™˜
  • ํ…Œ๋„Œ์‹œ/๊ถŒํ•œ/์‚ฌ์šฉ์ž์— ๋”ฐ๋ผ ๋‹ฌ๋ผ ๊ฒฐ๊ณผ๊ฐ€ ๋‹ฌ๋ผ์ง์—๋„ ๊ฐ™์€ ํ‚ค๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด, ๋‹ค๋ฅธ ์‚ฌ๋žŒ์˜ ๋ฐ์ดํ„ฐ๋ฅผ ๊ทธ๋Œ€๋กœ ์žฌ์‚ฌ์šฉํ•˜๋Š” ์‹ฌ๊ฐํ•œ ๋ฒ„๊ทธ ๋ฐœ์ƒ
    • ์ด๋Š” ๋‹จ์ˆœํ•œ ์บ์‹œ ๋ฒ„๊ทธ๋ฅผ ๋„˜์–ด์„  ๋ฐ์ดํ„ฐ ์œ ์ถœ ๋ฌธ์ œ
  • ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์บ์‹œ ์Šค์ฝ”ํ”„๋ฅผ ํฌํ•จ
    • ํ…Œ๋„ŒํŠธ๋ณ„ ๋ฐ์ดํ„ฐ ๋ถ„๋ฆฌ : tenantId
    • ๊ถŒํ•œ๋ณ„ ๋…ธ์ถœ ํ•„๋“œ ์ฐจ์ด : role
    • ์‚ฌ์šฉ์ž๋ณ„ ๊ฒฐ๊ณผ ์ฐจ์ด : userId

5. ํ‚ค ๊ธธ์ด ์ œํ•œ ๊ด€๋ฆฌ

๐Ÿ“‘ Redis ์‚ฌ์šฉ ์ค‘์ด๋ผ๊ณ  ๊ฐ€์ •
  • Redis๋Š” ๊ธด ํ‚ค๋„ ์ €์žฅ ๊ฐ€๋Šฅํ•˜์ง€๋งŒ, ๋‹ค์Œ๊ณผ ๊ฐ™์€ ๋ฌธ์ œ ๋ฐœ์ƒ ๊ฐ€๋Šฅ

1. ๋„คํŠธ์›Œํฌ ๋น„์šฉ ์ฆ๊ฐ€

  • Redis๋Š” ์š”์ฒญ ์‹œ๋งˆ๋‹ค ํ‚ค ๋ฌธ์ž์—ด์„ ๊ทธ๋Œ€๋กœ ์ „์†ก
  • QPS๊ฐ€ ๋†’์„์ˆ˜๋ก ๋„คํŠธ์›Œํฌ ํŠธ๋ž˜ํ”ฝ ๊ธ‰๊ฒฉํžˆ ์ฆ๊ฐ€
    • QPS : ์ดˆ๋‹น ์ฒ˜๋ฆฌ ์š”์ฒญ ์ˆ˜ ( Query Per Second )

2. ๋ฉ”๋ชจ๋ฆฌ ์˜ค๋ฒ„ํ—ค๋“œ

  • Redis๋Š” ํ‚ค ์ž์ฒด๋„ ๋ฉ”๋ชจ๋ฆฌ์— ์ €์žฅ
  • ํ‚ค๊ฐ€ ๊ธธ์–ด์ง€๋ฉด, ํŠนํžˆ ํ•„ํ„ฐ ๊ฒ€์ƒ‰์ด ๋งŽ์€ API์—์„œ ๋ฉ”๋ชจ๋ฆฌ ๋‚ญ๋น„ ๊ธ‰์ฆ

3. ํ•„ํ„ฐ ํญ๋ฐœ ๋ฌธ์ œ ๋ฐœ์ƒ ๋ฐ ์šด์˜ ๋‚œ์ด๋„ ์ฆ๊ฐ€

  • ํ•„ํ„ฐ ์ˆ˜๊ฐ€ ๋งŽ์•„์งˆ์ˆ˜๋ก ํ‚ค์— ํฌํ•จ๋˜๋Š” ๋””๋ฉ˜์ „ ์ฆ๊ฐ€
    • ์ด๋กœ ์ธํ•ด ํ‚ค ๊ธธ์ด ๋ฐ ์บ์‹œ ์—”ํŠธ๋ฆฌ ์ˆ˜ ๊ธ‰์ฆ
  • ์ด๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด ํ•„ํ„ฐ ์ •๊ทœํ™” ํ›„ ํ•ด์‹œ๋กœ ์••์ถ•ํ•˜๋Š” ๋ฐฉ์‹ ์ฃผ๋กœ ์‚ฌ์šฉ
    • ๋ฐ˜๋“œ์‹œ ์ •๊ทœํ™” ํ›„ ํ•ด์‹ฑ
  • SHA-256 ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ์‚ฌ์šฉํ•˜๋ฉด ๋‚ฎ์€ ์ถฉ๋Œ ํ™•๋ฅ , ๊ณ ์ •๋œ ๊ธธ์ด ๋ณด์žฅ ๊ฐ€๋Šฅ

๐Ÿ”‘ ํ‚ค ์ƒ์„ฑ ๋ฐฉ์‹ 3๋‹จ๊ณ„

๐ŸŽฏ ํ‚ค ์„ค๊ณ„ ๊ทœ์น™์„ ๋‹จ๊ณ„์ ์œผ๋กœ ๊ณ ๋„ํ™”ํ•ด๊ฐ€๋ฉฐ, ์บ์‹œ ํ‚ค ์„ค๊ณ„ ๊ทœ์น™์„ ํ™•์žฅํ•ด๋ณด์ž.

1๏ธโƒฃ ์ „์—ญ KeyGenerator

โ€‹@Bean
public KeyGenerator globalKeyGenerator() {
	return (target, method, params) ->
    	method.getName() + "::" + Arrays.deepToString(params);
}
  • ๋งค์šฐ ๋‹จ์ˆœํ•œ ํ˜•ํƒœ์˜ KeyGenerator ๊ตฌํ˜„ ๋ฐฉ์‹
  • ์žฅ์ 
    • ๋น ๋ฅธ ๊ตฌํ˜„
    • ์ง๊ด€์ ์ธ ๊ทœ์น™

๐Ÿ”Ž ํ˜„์žฌ ํ‚ค ์„ค๊ณ„ ์›์น™ ๊ด€์ ์—์„œ๋Š” ์—ฌ๋Ÿฌ ๋ฌธ์ œ ์กด์žฌ

1. ํ‚ค๊ฐ€ ๊ฒฐ์ •์ ์ด์ง€ ์•Š์Œ

  • ํ•„ํ„ฐ์— ์ปฌ๋ ‰์…˜์ด ํฌํ•จํ•  ๊ฒฝ์šฐ, ์ˆœ์„œ๊ฐ€ ๋งค๋ฒˆ ๋ณ€๊ฒฝ๋  ์ˆ˜ ์žˆ์Œ
    • ๊ฐ™์€ ์˜๋ฏธ์˜ ์š”์ฒญ์ž„์—๋„ ์„œ๋กœ ๋‹ค๋ฅธ ํ‚ค ์ƒ์„ฑ
    • ์ด๋กœ ์ธํ•ด Cache Miss ๋ฐœ์ƒ ๊ฐ€๋Šฅ์„ฑ ์ฆ๊ฐ€
  • DTO์˜ toString() ํฌ๋งท ๋“ฑ์ด ๋ฐ”๋€Œ๋ฉด ํ‚ค ๋ณ€๊ฒฝ

2. ์ถฉ๋Œ์„ ๋ฐฉ์ง€ํ•˜์ง€ ๋ชปํ•จ

  • method.getName() ์˜ ๊ฒฝ์šฐ, ๋™์ผํ•œ ๋ฉ”์„œ๋“œ๋ช…(์˜ค๋ฒ„๋กœ๋“œ ๋“ฑ)์œผ๋กœ ์ธํ•œ ์ถฉ๋Œ ์œ„ํ—˜ ์กด์žฌ
  • Arrays.deepToString() ์˜ ๊ฒฝ์šฐ, ์„œ๋กœ ๋‹ค๋ฅธ ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ๋™์ผํ•œ ๋ฌธ์ž์—ด์ด ๋‚˜์˜ค๋Š” ๋“ฑ ์ถฉ๋Œ ์œ„ํ—˜ ์กด์žฌ

3. ๋ฒ„์ „๋ณ„ ์บ์‹œ ๋ฌดํšจํ™” ์–ด๋ ค์›€

  • ์‘๋‹ต ์Šคํ‚ค๋งˆ๋‚˜ ์ •๊ทœํ™” ๋กœ์ง์ด ๋ณ€๊ฒฝ๋˜๋ฉด, ์ž˜๋ชป๋œ ์ด์ „ ์บ์‹œ๊ฐ’ ์‚ฌ์šฉ ๊ฐ€๋Šฅ

4. ์บ์‹œ ์Šค์ฝ”ํ”„ ๋ˆ„๋ฝ ๊ฐ€๋Šฅ์„ฑ ์กด์žฌ

  • user, tenant, role ๋“ฑ ์บ์‹œ ์Šค์ฝ”ํ”„ ์ •๋ณด๊ฐ€ ํŒŒ๋ผ๋ฏธํ„ฐ์— ํฌํ•จ๋˜์ง€ ์•Š์œผ๋ฉด ํ‚ค์— ๋ฏธ๋ฐ˜์˜
  • ์ด๋กœ ์ธํ•ด ์ž˜๋ชป๋œ ์บ์‹œ ์žฌ์‚ฌ์šฉ์ด๋‚˜ ์ •๋ณด ๋ˆ„์ถœ ์œ„ํ—˜ ๋ฐœ์ƒ

5. ํ‚ค ๊ธธ์ด๊ฐ€ ๊ธธ์–ด์งˆ ์ˆ˜ ์žˆ์Œ

  • ํ•„ํ„ฐ๊ฐ€ ๋งŽ์•„์ง€๋ฉด ํ‚ค๊ฐ€ ๊ธธ์–ด์ ธ ๋„คํŠธ์›Œํฌ ๋ฐ ๋ฉ”๋ชจ๋ฆฌ ๋น„์šฉ ์ฆ๊ฐ€

2๏ธโƒฃ ์ „์—ญ ์ •๊ทœํ™” + ํ•ด์‹œ๋กœ ์••์ถ•

โ€‹@Bean
public KeyGenerator hashKeyGenerator() {
	return (target, method, params) -> {
    	String signature = method.getDeclaringClass().getSimpleName() + "#" + method.getName();
        
        String normalized = Canonicalizer.canonicalize(params);		// ์ปฌ๋ ‰์…˜ ์ •๊ทœํ™”
        String digest = sha256Hex(signature + ":" + normalized);
        
        return signature + ":" + digest;
    }
}

static class Canonicalizer {
	static String canonicalize(Object... params) {
    	if (params == null || params.length == 0) {
        	return "[]";
        }
        
        return Arrays.stream(params)
        			 .map(Canonicalizer::normalize)
                     .collect(Collectors.joining(",", "[", "]"));
    }
    
    // ์ •๊ทœํ™”
    static String normalize(Object p) {
    	if (p == null) {
        	return "null";
        }
        
        if (p instanceof CharSequence s) {
        	return s.toString().trim();
        }
        
        if (p instanceof Number || p instanceof Boolean) {
        	return String.valueOf(p);
        }
        
        if (p instanceof Enum<?> e) {
        	return e.name();
        }
        
        // Map -> key ๊ธฐ์ค€์œผ๋กœ ์ •๋ ฌ
        if (p instanceof Map<?, ?> m) {
        	return m.entrySet().stream()
            		.map(e -> normalize(e.getKey()) + "=" + normalize(e.getValue()))
                    .sorted()
                    .collect(Collectors.joining("&", "{", "}"));
        }
        
        // Set -> ์ •๋ ฌ
        if (p instanceof Set<?> s) {
        	return s.stream()
            		.map(Canonicalizer::normalize)
                    .sorted()
                    .collect(Collectors.joining(",", "S[", "]"));
        }
        
        // List -> ๊ฐ’ ์ˆœ์„œ ์œ ์ง€
        if (p instanceof List<?> l) {
        	return l.stream()
            		.map(Canonicalizer::normalize)
                    .collect(Collectors.joining(",", "L[", "]"));
        }
        
        return p.toString().trim();
    }
}
  • ์ „์—ญ KeyGenerator๋ฅผ ์œ ์ง€ํ•˜๋˜, ๊ฒฐ์ •์  ํ‚ค ์ƒ์„ฑ๊ณผ ํ‚ค ๊ธธ์ด ์ œํ•œ ๋ฌธ์ œ ํ•ด๊ฒฐ
    • ๊ฐ™์€ ์˜๋ฏธ๋ฉด ๊ฐ™์€ ํ‚ค ์ƒ์„ฑ
    • ์ปฌ๋ ‰์…˜ ์ •๊ทœํ™” ํ›„ SHA-256 ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ํ†ตํ•ด ๊ณ ์ • ๊ธธ์ด ํ‚ค ์ƒ์„ฑ

๐Ÿ”Ž ์—ฌ์ „ํžˆ ๋‚จ์•„์žˆ๋Š” ๋ฌธ์ œ

1. ๋„๋ฉ”์ธ๋ณ„ ํ‚ค ์ •์ฑ… ์ฐจ์ด

  • ์ผ๋ถ€ List ๋Š” ์ˆœ์„œ ์œ ์ง€ ํ•„์š”, ์ผ๋ถ€๋Š” ์ •๋ ฌ ํ›„ ํ‚ค ์ƒ์„ฑ์ด ํšจ์œจ์ ์ผ ์ˆ˜ ์žˆ์Œ
  • ๊ธฐ๋ณธ๊ฐ’ ํ•„๋“œ๋Š” ํ‚ค์—์„œ ์ œ์™ธ ๊ฐ€๋Šฅ

2. ์บ์‹œ ์Šค์ฝ”ํ”„ ๋ˆ„๋ฝ ๊ฐ€๋Šฅ์„ฑ ์กด์žฌ

  • ์บ์‹œ ์Šค์ฝ”ํ”„ ์กด์žฌ ์‹œ, ์—ฌ์ „ํžˆ ๋ˆ„๋ฝ ๊ฐ€๋Šฅ์„ฑ ์กด์žฌ

3. ๋ฒ„์ „๋ณ„ ์บ์‹œ ๋ฌดํšจํ™” ์–ด๋ ค์›€

  • ์ „์—ญ KeyGenerator๋งŒ์œผ๋กœ๋Š” ํŠน์ • ์บ์‹œ ๋ฒ„์ „ ๊ด€๋ฆฌ ๋ถˆ๊ฐ€๋Šฅ

4. DTO ๊ตฌ์กฐ ๋ณ€๊ฒฝ์— ๋”ฐ๋ฅธ ๋ฌธ์ œ

  • ํ•„๋“œ ์ถ”๊ฐ€๋‚˜ ํฌ๋งท ๋ณ€๊ฒฝ ๋“ฑ์˜ ๊ตฌ์กฐ ๋ณ€๊ฒฝ ์‹œ, ํ‚ค ๋ณ€๊ฒฝ ๊ฐ€๋Šฅ
  • ๋™์ผํ•œ ์˜๋ฏธ์ž„์—๋„ ๋ถˆํ•„์š”ํ•œ Cache Miss ๋ฐœ์ƒ

3๏ธโƒฃ ์ „์—ญ KeyGenerator ์ตœ์†Œํ™” + ํ‚ค ์ค‘์•™ ์ง‘์ค‘ํ™”

  • ๊ฐ„๋‹จํ•œ ํ‚ค๋Š” @CacheConfig + KeyGenerator ๋ฐฉ์‹์„ ์‚ฌ์šฉ
  • ๋ณต์žกํ•œ ํ‚ค์˜ ๊ฒฝ์šฐ, Prefix ๋Š” Redis ์„ค์ •์œผ๋กœ, ์ •๊ทœํ™” ๋“ฑ์˜ ํ‚ค ์ •์ฑ…์€ CacheKeys ๋กœ ์ค‘์•™์ง‘์ค‘ํ™”
  • ์ด๋ฅผ ํ†ตํ•ด ์ „์—ญ KeyGenerator ๋ฐฉ์‹์˜ ํ•œ๊ณ„๋ฅผ ํ•ด๊ฒฐ

1. Prefix๋Š” RedisCacheConfiguration์œผ๋กœ ๋“ฑ๋ก

โ€‹@Bean
public RedisCacheConfiguration redisCacheConfiguration() {
	return RedisCacheConfiguration.defaultCacheConfig()
    			.computePrefixWith(cacheName -> "playon:" + cacheName + "::");
}
  • Prefix ์„ค์ •์„ ํ†ตํ•ด ์บ์‹œ ๊ทธ๋ฃน ๋‹จ์œ„๋กœ ๋ถ„์„ ๋ฐ ์‚ญ์ œ ๊ฐ€๋Šฅ
  • ํ™˜๊ฒฝ ๋ถ„๋ฆฌ๊ฐ€ ํ•„์š”ํ•˜๋‹ค๋ฉด, Environment๋ฅผ ํ†ตํ•ด profile์„ ๋ฐ›์•„์„œ prefix์— ์ถ”๊ฐ€

2. CacheNames์— ์บ์‹œ ์ด๋ฆ„ ์ƒ์ˆ˜ํ™”

โ€‹@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class CacheNames {
    public static final String PARTY_LIST = "partyList";
    public static final String PARTY_DETAIL = "partyDetail";
}
  • ์‚ฌ์šฉํ•  cacheNames๋“ค์„ ์ค‘์•™ ์ง‘์ค‘์ ์œผ๋กœ ๊ด€๋ฆฌ ๊ฐ€๋Šฅ

3. CacheKeys๋กœ ๋„๋ฉ”์ธ๋ณ„ ํ‚ค ์ •์ฑ…์„ ์บ์‹œ๋ณ„๋กœ ๋ช…์‹œ

โ€‹@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class CacheKeys {
	private static final String VERSION = "v1";
    
    public static String partyListKey(
    	int page, int pageSize, String orderBy, boolean isMacSupported, String partyAt,
        Long appId, Collection<String> genres, Collection<String> tags
    ) {
    	// 1. ์ •๊ทœํ™” ์ง„ํ–‰
        Set<String> g = normalize(genres);
        Set<String> t = normalize(tags);
        
        // 2. ํ•„ํ„ฐ ๋ฐ ๋””๋ฉ˜์ „ ์ถ”๊ฐ€
        String payload =
        	"page=" + page +
            "&size=" + pageSize +
            "&orderBy=" + trim(orderBy) +
            "&mac=" + isMacSupported +
            "&partyAt=" + trim(partyAt) +
            "&appId=" + (appId == null ? "" : appId) +
            "&genres=" + String.join(",", g) +
            "&tags=" + String.join(",", t);
        
        // 3. ํ•ด์‹œ๋กœ ์••์ถ•
        return VERSION + ":" + sha256Hex(payload);
    }
    
    // ์ปฌ๋ ‰์…˜ -> ์ •๋ ฌ์„ ํ†ตํ•œ ์ •๊ทœํ™”
    private static Set<String> normalize(Collection<String> c) {
    	TreeSet<String> ts = new TreeSet<>();
        
        if (c == null) {
        	return ts;
        }
        
        for (String v : c) {
        	String trimV = trim(v);
            
            if (!trimV.isEmpty()) {
            	ts.add(trimV);
            }
        }
        
        return ts;
    }
    
    // SHA-256 ์•Œ๊ณ ๋ฆฌ์ฆ˜์„ ํ†ตํ•œ ํ•ด์‹ฑ
    private static String sha256Hex(String s) {
    	try {
        	byte[] digest = MessageDigest.getInstance("SHA-256")
            				.digest(s.getBytes(StandardCharsets.UTF_8));
                            
            StringBuilder sb = new StringBuilder(digest.length * 2);
            
            for (byte b : digest) {
            	sb.append(String.format("%02x", b));
            }
            
            return sb.toString();
        } catch (Exception e) {
        	throw new IllegalArgumentException(e);
        }
    }
    
    // ๋นˆ ๊ฐ’ ์ฒดํฌ ๋ฐ ๊ณต๋ฐฑ ์ œ๊ฑฐ + ๋Œ€์†Œ๋ฌธ์ž ํ†ต์ผ
    private static String trim(String s) {
    	return s == null ? "" : s.trim().toLowerCase(Locale.ROOT);
    }
}
  • ๊ฒฐ์ •์„ฑ ์ค€์ˆ˜
    • ๊ฐ ์บ์‹œ์— ๋งž๊ฒŒ ์ •๊ทœํ™” ์ง„ํ–‰
  • ์ถฉ๋Œ ๋ฐฉ์ง€
    • ๊ฒฐ๊ณผ๋ฅผ ๋ฐ”๊พธ๋Š” ํ•„๋“œ๋ฅผ ๋ฐ˜๋“œ์‹œ ํฌํ•จ
  • ๋ฒ„์ „ ๊ด€๋ฆฌ
    • ๋ฒ„์ „ ๋ณ€๊ฒฝ ์‹œ, ์ด์ „ ์บ์‹œ ์ž๋™ ๋ฌดํšจํ™”
  • ์บ์‹œ ์Šค์ฝ”ํ”„
    • ํ˜„์žฌ ์ฝ”๋“œ์—๋Š” ์ถ”๊ฐ€ํ•˜์ง€ ์•Š์•˜์ง€๋งŒ, ์บ์‹œ ์Šค์ฝ”ํ”„ ์กด์žฌ ์‹œ ํ•„์š”ํ•œ ์บ์‹œ์—๋งŒ ํฌํ•จ
  • ํ‚ค ๊ธธ์ด ์ œํ•œ ์ค€์ˆ˜
    • payload๊ฐ€ ์•„๋ฌด๋ฆฌ ๊ธธ์–ด์ง€๋”๋ผ๋„, SHA-256 ํ•ด์‹ฑ์„ ํ†ตํ•ด ๊ณ ์ • ๊ธธ์ด๋กœ ์ œํ•œ

4. @Cacheable์—์„œ CacheKeys ํ˜ธ์ถœ

โ€‹@Cacheable(
	cacheNames = CacheNames.PARTY_LIST,
    key = "T(ํŒจํ‚ค์ง€๋ช….CacheKeys).partyListKey(#page, #pageSize, #orderBy, #isMacSupported, #partyAt, #request.appId(), #request.genres(), #request.tags())"
)
public PartyListResponse list(int page, int pageSize, String orderBy, boolean isMacSupported, String partyAt, GetAllPartiesRequest request) {
	// ๊ตฌํ˜„ ์ƒ๋žต
}
  • ์บ์‹œ๋ฅผ ์‚ฌ์šฉํ•  ๋ฉ”์„œ๋“œ์—์„œ, ์–ด๋…ธํ…Œ์ด์…˜์˜ ๊ฐ ํ•„๋“œ์— ๊ตฌํ˜„ ํด๋ž˜์Šค ์‚ฌ์šฉ
  • ๊ฒฐ๊ณผ์ ์œผ๋กœ ๋‹ค์Œ๊ณผ ๊ฐ™์€ ํ‚ค ์ €์žฅ
    • playon:partyList::v1:{sha256hex}
profile
๊ธฐ๋ก

0๊ฐœ์˜ ๋Œ“๊ธ€