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
): 만료 시간은 만료 구현에 의해 각 항목에 대해 개별적으로 계산된다.