Kotlin으로 Spring Boot Cache Caffeine 만들기
개요
Kotlin 언어를 이용하여 간단한 Spring Boot Cache를 만들어 보겠다.
Spring Boot에서 공식으로 소개하고 있는 Cache의 방법은 여러가지고 있다. 그 중에서도 Caffeine이 도입 비용이 낮다.
- Caffeine 로컬 캐시 이다.
- 로컬 캐시이므로 서버가 따로 필요하지 않는다.
- TTL(Timeout)을 설정 가능하다.
흔하게 DB에서 매번 동일한 결과를 가져오는 쿼리를 던져서 가져와야 하는 경우라면 캐시를 이용해 보면 부하를 줄일 수 있다.
프로젝트 생성
아래와 같이 curl 명령어를 사용하여 Spring Boot 초기 프로젝트를 생성한다.
curl https://start.spring.io/starter.tgz \
-d bootVersion=2.5.6 \
-d dependencies=cache \
-d baseDir=spring-cache-caffeine \
-d groupId=com.devkuma \
-d artifactId=spring-cache-caffeine \
-d packageName=com.devkuma.cache.caffeine \
-d applicationName=CaffeineApplication \
-d packaging=jar \
-d language=kotlin \
-d javaVersion=11 \
-d type=gradle-project | tar -xzvf -
생성된 프로젝트 확인
프로젝트 파일 구조
제대로 생성이 되었다면 프로젝트의 파일 구조는 아래와 같이 구성된다.
.
├── HELP.md
├── build.gradle.kts
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
    ├── main
    │   ├── kotlin
    │   │   └── com
    │   │       └── devkuma
    │   │           └── cache
    │   │               └── caffeine
    │   │                   └── CaffeineApplication.kt
    │   └── resources
    │       └── application.properties
    └── test
        └── kotlin
            └── com
                └── devkuma
                    └── cache
                        └── caffeine
                            └── CaffeineApplicationTests.kt
빌드 스크립트
/build.gradle.kts
// ... 생략 ...
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-cache")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}
// ... 생략 ...
의존성 라이브러리에 스프링 캐시 라이브러리(spring-boot-starter-cache)가 포함된 것을 볼 수 있다.
Caffeine cache 설정
Caffein 라이브러리 추가
먼저 gradle에 Caffein 라이브러리(com.github.ben-manes.caffeine:caffeine를 추가한다.
/build.gradle.kts
// ... 생략 ...
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-cache")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("com.github.ben-manes.caffeine:caffeine")
    implementation("io.github.microutils:kotlin-logging:2.0.11")
    
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}
// ... 생략 ...
추가로 결과를 확인해 보기 위해 로그 라이브러리(io.github.microutils:kotlin-logging)도 추가하였다.
Caffein 설정 클래스 추가
신규로 CacheConfig 설정 클래스를 생성해서 아래와 같이 작성한다.
/src/main/kotlin/com/devkuma/cache/caffeine/config/CacheConfig.kt
package com.devkuma.cache.caffeine.config
import org.springframework.cache.annotation.EnableCaching
import org.springframework.context.annotation.Configuration
@Configuration
@EnableCaching
class CacheConfig
캐시를 사용하기 위해 클래스에 @EnableCaching을 추가한다.
어플리케이션 설정 추가
어플리케이션 설정 파일(application.yml)에 캐시 관련 추가한다.
(초기 프로젝트를 생성하면, application.properties 파일이 있을 것이다. 이 파일에 확장자만 .yml으로 변경하면 된다.)
/src/main/resources/application.yml
spring:
  cache:
    cache-names: category
    caffeine:
      spec: maximumSize=100, expireAfterAccess=60s
- cache-names는 관리되는 캐시 이름 목록이다.- expireAfterWrite
- maximumSize는 캐시의 최대 사이즈이다. 캐시 크기가 최대값에 가까워지면 캐시는 다시 사용할 가능성이 낮은 항목을 제거한다. 크기사 0이면 캐시에 로드된 직후에 제거된다.
- expireAfterWrite는 마지막 쓰기가 발생한 후에 기간이 지나면 항목이 만료된다.- 그밖에…
 
Cache 적용 관련 클래스 생성
Cache를 적용하려는 Repository 클래스 추가
/src/main/kotlin/com/devkuma/cache/caffeine/repository/CategoryRepository.kt
package com.devkuma.cache.caffeine.repository
import com.devkuma.cache.caffeine.dto.Category
import mu.KotlinLogging
import org.springframework.cache.annotation.CacheConfig
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Repository
private val log = KotlinLogging.logger {}
@Repository
@CacheConfig
class CategoryRepository {
    @Cacheable("category")
    fun findById(id: Long): Category {
        log.info("category[${id}] cache is not used.")
        return Category(id,"Book")
    }
}
Cache를 적용된 Repository를 호출하는 Service 클래스 추가
/src/main/kotlin/com/devkuma/cache/caffeine/service/CategoryService.kt
package com.devkuma.cache.caffeine.service
import com.devkuma.cache.caffeine.dto.Category
import com.devkuma.cache.caffeine.repository.CategoryRepository
import mu.KotlinLogging
import org.springframework.stereotype.Service
private val log = KotlinLogging.logger {}
@Service
class CategoryService(
    private val categoryRepository: CategoryRepository
) {
    fun getById(id: Long): Category {
        log.info("Call function categoryRepository.findAll(${id})")
        return categoryRepository.findById(id)
    }
}
어플리케이션 실행
어플리케이션 실행 클래스 생성
/src/main/kotlin/com/devkuma/cache/caffeine/service/CategoryService.kt
package com.devkuma.cache.caffeine
import com.devkuma.cache.caffeine.service.CategoryService
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.CommandLineRunner
import org.springframework.stereotype.Component
@Component
class CaffeineRunner: CommandLineRunner {
    @Autowired
    private lateinit var categoryService: CategoryService
    @Throws(Exception::class)
    override fun run(vararg args: String?) {
        categoryService.getById(1) // No hit , since this is the first request.
        categoryService.getById(2) // No hit , since this is the first request.
        categoryService.getById(1) // hit , since it is already in the cache.
        categoryService.getById(1) // hit , since it is already in the cache.
    }
}
어플리케이션 실행 확인
이제 실행을 시켜 보자.
(툴를 사용하고 있다면, CaffeineApplication를 실행하면 된다.)
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.5.6)
2021-11-12 12:46:49.034  INFO 12757 --- [           main] c.d.c.caffeine.CaffeineApplicationKt     : Starting CaffeineApplicationKt using Java 15.0.4 on 2021090004.local with PID 12757 (/Users/we/develop/tutorial/spring-tutorial/spring-cache-caffeine/build/classes/kotlin/main started by we in /Users/we/develop/tutorial/spring-tutorial/spring-cache-caffeine)
2021-11-12 12:46:49.036  INFO 12757 --- [           main] c.d.c.caffeine.CaffeineApplicationKt     : No active profile set, falling back to default profiles: default
2021-11-12 12:46:50.037  INFO 12757 --- [           main] c.d.c.caffeine.CaffeineApplicationKt     : Started CaffeineApplicationKt in 6.481 seconds (JVM running for 6.898)
2021-11-12 12:46:50.047  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 12:46:50.075  INFO 12757 --- [           main] c.d.c.c.repository.CategoryRepository    : category[1] cache is not used.
2021-11-12 12:46:50.077  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(2)
2021-11-12 12:46:50.077  INFO 12757 --- [           main] c.d.c.c.repository.CategoryRepository    : category[2] cache is not used.
2021-11-12 12:46:50.078  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 12:46:50.080  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
Process finished with exit code 0
Service 클래스에서 catagoryId 별로 findById(id) 메소드가 실행 될때 마다, Repository 클래스에서 category[${id}] cache is not used.가 실행되어 출력된 것을 확인 할수 있고,
2021-11-12 12:46:50.047  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 12:46:50.075  INFO 12757 --- [           main] c.d.c.c.repository.CategoryRepository    : category[1] cache is not used.
2021-11-12 12:46:50.077  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(2)
2021-11-12 12:46:50.077  INFO 12757 --- [           main] c.d.c.c.repository.CategoryRepository    : category[2] cache is not used.
두번째로 Service 클래스의 findById(id) 메소드가 실행 될때는 Repository 클래스에서 로그는 실행되지 않는 것을 확인할 있다.
2021-11-12 12:46:50.078  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 12:46:50.080  INFO 12757 --- [           main] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
테스트 코드로 확인
테스트 라이브러리 추가
테스트를 하기 위해 mockk 라이브러리(io.mockk:mockk, com.ninja-squad:springmockk)를 추가한다.
/build.gradle.kts
// ... 생략 ...
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-cache")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("com.github.ben-manes.caffeine:caffeine")
    implementation("io.github.microutils:kotlin-logging:2.0.11")
    
    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("io.mockk:mockk:1.12.0")
    testImplementation("com.ninja-squad:springmockk:3.0.1")
    testImplementation("org.awaitility:awaitility-kotlin:4.1.1")
}
// ... 생략 ...
테스트 코드 작성
/src/test/kotlin/com/devkuma/cache/caffeine/service/CategoryServiceTests.kt
package com.devkuma.cache.caffeine.service
import com.devkuma.cache.caffeine.config.CacheConfig
import com.devkuma.cache.caffeine.repository.CategoryRepository
import com.ninjasquad.springmockk.SpykBean
import io.mockk.verify
import mu.KotlinLogging
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
private val log = KotlinLogging.logger {}
@SpringBootTest(classes = [CategoryService::class, CategoryRepository::class, CacheConfig::class])
class CategoryServiceTests {
    @Autowired
    private lateinit var categoryService: CategoryService
    @SpykBean
    private lateinit var categoryRepository: CategoryRepository
    @Test
    fun `카테고리 ID로 조회시 캐시 사용 여부 테스트`() {
        // When
        val category11 = categoryService.getById(1)
        val category21 = categoryService.getById(2)
        val category12 = categoryService.getById(1)
        val category22 = categoryService.getById(2)
        log.info { "category1=$category11" }
        log.info { "category2=$category21" }
        log.info { "category3=$category12" }
        log.info { "category4=$category22" }
        // Then
        verify(exactly = 2) { categoryRepository.findById(any()) }
    }
}
실행을 시켜서 로그를 확인하자.
... 생략 ...
2021-11-12 14:11:25.528  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : Started CategoryServiceTests in 7.305 seconds (JVM running for 8.881)
2021-11-12 14:11:26.010  INFO 14035 --- [    Test worker] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 14:11:26.143  INFO 14035 --- [    Test worker] c.d.c.c.repository.CategoryRepository    : category[1] cache is not used.
2021-11-12 14:11:26.148  INFO 14035 --- [    Test worker] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(2)
2021-11-12 14:11:26.149  INFO 14035 --- [    Test worker] c.d.c.c.repository.CategoryRepository    : category[2] cache is not used.
2021-11-12 14:11:26.149  INFO 14035 --- [    Test worker] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(1)
2021-11-12 14:11:26.157  INFO 14035 --- [    Test worker] c.d.c.caffeine.service.CategoryService   : Call function categoryRepository.findById(2)
2021-11-12 14:11:26.165  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : category1=Category(id=1, name=Book)
2021-11-12 14:11:26.167  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : category2=Category(id=2, name=Book)
2021-11-12 14:11:26.170  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : category3=Category(id=1, name=Book)
2021-11-12 14:11:26.172  INFO 14035 --- [    Test worker] c.d.c.c.service.CategoryServiceTests     : category4=Category(id=2, name=Book)
BUILD SUCCESSFUL in 10s
4 actionable tasks: 2 executed, 2 up-to-date
2:11:26 오후: Task execution finished ':test --tests "com.devkuma.cache.caffeine.service.CategoryServiceTests.카테고리 ID로 조회시 캐시 사용 여부 테스트"'.
어플리케이션 실행과 동일하게 캐시가 사용되었다는 로그(category[$id] cache is not used.)가 2번 출력이 되고, 그 이후로는 출력이 되지 않는 것을 확인 할 수 있다.
테스트 코드에서도 실행여부를 체크하는 곳에서 2번 실행 될것이라고 예측하였고, 실제 2번이 실행되므로서 테스트를 성공하였다.
verify(exactly = 2) { categoryRepository.findById(any()) }
그밖에
상세 설정
설정에 대한 자세한 설명은 아래에서 확인 바란다.
http://static.javadoc.io/com.github.ben-manes.caffeine/caffeine/2.2.0/com/github/benmanes/caffeine/cache/Caffeine.html
캐시 만료 전략
캐시의 만료 전략은 3가지가 있다.
- 액세스 후 만료(expireAfterAccess): 마지막 읽기 또는 쓰기가 발생한 후 기간이 지나면 항목이 만료된다.
- 쓰기 후 만료(expireAfterWrite): 마지막 쓰기가 발생한 후 기간이 지나면 항목이 만료된다.
- 사용자 지정 정책(expireAfter): 만료 시간은 만료 구현에 의해 각 항목에 대해 개별적으로 계산된다.