ps [option] : 현재 실행 중인 프로세스 목록 및 상태 출력
- ps aux : 실행 중인 모든 프로세스 확인
- ps aux | grep [프로세스명] : '프로세스명'에 해당되는 프로세스 확인
ps [option] : 현재 실행 중인 프로세스 목록 및 상태 출력
코드는 깃허브에 있습니다.
요구사항에 따라 로직을 구현하다 보면, 종종 최대 구매 수량 제한과 같이 동시성 이슈에 민감한 사항들이 있습니다. 애플리케이션 단에서도 동시성을 제어할 순 있지만, 분산 환경에서는 제약적인 방법이죠.
이럴 때 레디스를 고려하면 좋은 선택이 될 수 있습니다. 레디스는 인메모리 기반으로 동작하기 때문에 성능이 매우 빠르고, 또한 싱글 쓰레드 기반으로 동작하기 때문입니다.
이번 포스팅에서는 레디스의 incr와 decr를 활용해서 동시성 이슈가 발생하지 않는지 테스트 해보려고 합니다. 실제 작성할 코드와 유사한 형태로 테스트를 진행해보려고 해요.
코드로 바로 넘어가기 전에! 단점을 먼저 파악해보면 좋을 것 같습니다.
보통 운영 환경이라면 레디스가 클러스터 구조로 이루어져 있을 텐데요. 이 경우 아래와 같은 상황에서 write 손실이 발생할 수 있습니다. 여기서 write는 incr와 decr가 될 수 있겠죠.
이를 위해 분산락이라는 기능이 존재하는데요. 간략하게 말하자면, 3대 이상의 마스터 중에서 과반수 이상이 락이 걸려야 실질적으로 락이 걸렸다고 판단하는 기능이라고 이해하면 좋을 것 같습니다. 위에 서술한 단점을 효과적으로 방어할 수 있지만, 각각의 마스터가 시간이 서로 틀어질 수 있다는 한계점이 있더라고요.
아무튼 이런 기능이 있음에도 다음의 이유로 incr, decr를 사용하려고 합니다.
서론이 길었습니다. 바로 코드를 확인해 보죠
// redis
implementation("org.springframework.boot:spring-boot-starter-data-redis")
testImplementation("com.github.codemonstur:embedded-redis:1.4.3")
// test
testImplementation("io.kotest:kotest-runner-junit5:4.4.3")
testImplementation("io.kotest:kotest-assertions-core:4.4.3")
implementation("io.kotest:kotest-extensions-spring:4.4.3")
로컬이나 도커에 레디스를 띄워서 테스트를 하는 건 개인적으로 선호하지 않기 때문에, embedded redis 의존성을 추가해 주었습니다.
test 관련 의존성은, 코틀린 환경에서 테스트 코드를 직관적으로 작성하기 위한 kotest 의존성을 추가해 주었습니다.
@TestConfiguration
class TestRedisConfiguration {
lateinit var redisServer: RedisServer
private val testRedisHost = "127.0.0.1"
private val testRedisPort = 6370
companion object {
const val DEFAULT_DATABASE = 0
}
@PostConstruct
fun postConstruct() {
println("embedded redis start!")
redisServer = RedisServer(testRedisPort)
redisServer.start()
}
@PreDestroy
fun preDestroy() {
println("embedded redis stop!")
redisServer.stop()
}
fun lettuceConnectionFactory(database: Int): LettuceConnectionFactory {
val redisConfig = RedisStandaloneConfiguration().apply {
this.hostName = testRedisHost
this.port = testRedisPort
this.database = database
}
return LettuceConnectionFactory(redisConfig).apply {
this.afterPropertiesSet()
}
}
@Bean
fun testStringRedisTemplate(): StringRedisTemplate {
return StringRedisTemplate().apply {
this.setConnectionFactory(lettuceConnectionFactory(DEFAULT_DATABASE))
this.afterPropertiesSet()
}
}
}
Jedis 대신 Lettuce를 사용해 주었습니다. 그 이유는 Lettuce vs Jedis 글을 참고하면 좋을 것 같아요.
@PostConstruct와 @PreDestroy를 사용하여 레디스 실행과 중단을 제어해 주었습니다.
@Component
class ConcurrentTaskExecutor {
fun runConcurrentTasks(numberOfThreads: Int, repeatCountPerThread: Int, task: () -> Unit) {
val startLatch = CountDownLatch(1) // 모든 쓰레드가 준비될 때까지 대기시키기 위한 용도
val doneLatch = CountDownLatch(numberOfThreads)
val executor = Executors.newFixedThreadPool(numberOfThreads)
repeat(numberOfThreads) {
executor.submit() {
try {
startLatch.await() // 모든 스레드가 준비될 때까지 대기
repeat(repeatCountPerThread) {
task()
}
} finally {
doneLatch.countDown() // 작업 완료 처리
}
}
}
startLatch.countDown() // 모든 스레드가 동시에 작업을 시작하도록 함
doneLatch.await() // 모든 스레드가 작업을 마칠 때까지 대기
executor.shutdown()
executor.awaitTermination(1, TimeUnit.MINUTES)
}
}
멀티 쓰레드 환경으로 task를 실행해 주는 로직입니다. 모든 쓰레드를 동시에 실행시키기 위해 CountDownLatch로 대기 시간을 확보한 게 포인트입니다. 단순 for문으로 실행시키면, 마지막 쓰레드가 작업을 실행하려 할 때 이미 첫 번째 쓰레드는 마쳤을 수 있기 때문이에요.
@SpringBootTest(classes = [TestRedisConfiguration::class])
class RedisConcurrencyTest {
@Autowired
private lateinit var stringRedisTemplate: StringRedisTemplate
@Autowired
private lateinit var concurrentTaskExecutor: ConcurrentTaskExecutor
private val numberOfThreads = 10
private val repeatCountPerThread = 1000
@Test
fun `증감 연산자만을 사용하면 동시성 이슈가 발생한다`() {
// given
var count = 0
// when
concurrentTaskExecutor.runConcurrentTasks(numberOfThreads, repeatCountPerThread) {
count++
}
// then
count shouldNotBe numberOfThreads * repeatCountPerThread
println(count)
}
@Test
fun `레디스 increment를 사용하면 동시성 이슈가 발생하지 않는다`() {
// given
val incrTestKey = "incr-test-key"
stringRedisTemplate.delete(incrTestKey)
// when
concurrentTaskExecutor.runConcurrentTasks(numberOfThreads, repeatCountPerThread) {
stringRedisTemplate.opsForValue().increment(incrTestKey)
}
// then
val count = stringRedisTemplate.opsForValue().get(incrTestKey)!!.toInt()
count shouldBe numberOfThreads * repeatCountPerThread
}
@Test
fun `레디스 decrement를 사용하면 동시성 이슈가 발생하지 않는다`() {
// given
val decrTestKey = "decr-test-key"
stringRedisTemplate.delete(decrTestKey)
// when
concurrentTaskExecutor.runConcurrentTasks(numberOfThreads, repeatCountPerThread) {
stringRedisTemplate.opsForValue().decrement(decrTestKey)
}
// then
val count = stringRedisTemplate.opsForValue().get(decrTestKey)!!.toInt()
count shouldBe -(numberOfThreads * repeatCountPerThread)
}
@Test
fun `레디스 incr와 decr를 사용하여 로직을 구현하면 동시성 이슈가 발생하지 않는다`() {
// given
val incrDecrTestKey = "incr-decr-test-key"
val maxCount = 100
stringRedisTemplate.delete(incrDecrTestKey)
// when
concurrentTaskExecutor.runConcurrentTasks(numberOfThreads, repeatCountPerThread) {
val successIncrement = incrementIfPossible(incrDecrTestKey, maxCount) // 실제 사용할 로직과 동일한 구조로 실행
if (successIncrement) {
Thread.sleep(Random.nextLong(0, 300)) // 0 ~ 300ms 사이의 랜덤한 시간을 멈춤. 실제 로직이 들어갈 자리
}
}
// then
val currentCount = stringRedisTemplate.opsForValue().get(incrDecrTestKey)!!.toInt()
currentCount shouldBe maxCount
}
private fun incrementIfPossible(key: String, maxCount: Int): Boolean {
val incrementedCount = stringRedisTemplate.opsForValue().increment(key)!!.toInt()
if (incrementedCount > maxCount) {
decrementCount(key)
return false
}
return true
}
private fun decrementCount(key: String) {
stringRedisTemplate.opsForValue().decrement(key)
}
마지막 테스트 코드에서 실제 작성하려는 코드와 유사하게 구현하였는데요. 10개의 쓰레드가 1000번씩 수행하더라도, maxCount를 넘지 않은 것을 확인할 수 있었습니다.
참고자료
Lettuce vs Jedis (1) | 2023.12.03 |
---|---|
Spring Data Redis 공식문서 읽어보기 (2) | 2023.12.03 |
레디스 사용 시 주의사항 (0) | 2023.10.29 |
레디스란 (0) | 2023.10.25 |
Lettuce란, 동기와 비동기 통신을 모두 지원하는 논블로킹 레디스 자바 클라이언트이다.
Jedis란 사용 편의성을 위해 설계된 레디스 자바 클라이언트이다. 동기식으로만 작동하며, 다른 Redis 클라이언트에 비해 가볍다는 특징을 가지고 있다.
즉, 성능과 쓰레드 안정성의 측면을 고려하면 Lettuce가 더 나은 선택이다.
참고자료
레디스 incr, decr를 활용해서 동시성 테스트하기(kotlin, spring) (1) | 2024.06.11 |
---|---|
Spring Data Redis 공식문서 읽어보기 (2) | 2023.12.03 |
레디스 사용 시 주의사항 (0) | 2023.10.29 |
레디스란 (0) | 2023.10.25 |
목차
스프링 데이터 레디스란, key-value 저장소를 사용한 솔루션 개발에 핵심 스프링 개념을 적용한 프로젝트이다. 메세지 전송과 수신을 위해 추상화된 템플릿을 제공한다.
스프링 데이터를 스프링의 핵심 기능을 사용하기 때문에 최소한 IoC에 익숙해야 한다.
레디스 지원의 핵셤 기능은, 스프링 컨테이너의 도움 없이 직접 호출이 가능하다는 점이다. 이는 Spring 컨테이너의 다른 서비스 없이 독립형으로 사용할 수 있는 JdbcTemplate과 매우 유사하다.
repository 지원과 같은 스프링 데이터 레디스의 모든 기능을 활용하기 위해서는, 스프링을 사용하도록 라이브러리 일부를 구성해야 한다.
스프링 데이터 레디스를 사용하면 레디스 key-value 저장소를 사용한 스프링 프로젝트를 쉽게 구축할 수 있다. 스프링 인프라 지원을 통해, 저장소와 상호작용하기 위한 보일러 플레이트 코드와 불필요한 작업들을 제거할 수 있다.
스프링 데이터가 지원하는 Key-value 저장소 중에 하나는 레디스이다. 스프링 레디스 데이터는 스프링 애플리케이션에서 레디스로의 액세스와 간단한 설정을 제공한다. 저장소 접근을 위해 저수준과 고수준 추상화를 모두 제공한다. 따라서 사용자는 인프라 우려로부터 자유로울 수 있다.
redis support는 여러 구성 요소를 제공한다. 대부분의 작업에서는 고수준의 추상화와 지원 서비스를 사용하는게 최선의 선택이다. 물론, 필요하다면 레디스와 직접 통신할 수 있는 저수준 커넥션(또는 네이티브 라이브러리)을 얻을 수 있다.
레디스와 스프링을 사용하기 위해서는 먼저 IoC 컨테이너를 통해 저장소와 연결해야 한다. 이를 자바 커넥터나 바인딩이 필요하다. 어떤 라이브러리를 선택하든 하나의 spring data redis api를 사용하면 된다. : redis.connection 하위의 RedisConnection와 RedisConnectionFactory 인터페이스.
RedisConnection 가 제공하는 핵심 빌딩 블록을 통해 레디스와 통신할 수 있다. 또한, 기본 연결 라이브러리 예외를 스프링 dao 예외로 변환해 준다.
참고로 네이티브 라이브러리 api가 필요하다면 레디스 커넥션이 제공하는 getNativeConnection 메서드를 사용하면 된다. 그러면 통신에 사용되는 원 기반 객체를 반환받을 수 있다.
활성 RedisConnection 객체는 RedisConnectionFactory를 통해 생성된다. 또한 RedisConnectionFactory는 PersistenceExceptionTranslator로써 작동하기도 한다. 그러므로 @Repository와 AOP 사용을 통해 예외 자동 변환이 가능하다.
참고로 설정에 따라, 커넥션 팩토리는 새로운 커넥션을 반환하거나 기존 커넥션을 반환하게 할 수 있다.
RedisConnectionFactory로 작업하는 가장 쉬운 방법은, IoC 컨테이너를 통해 적절한 커넥터를 구성하고 이를 사용 클래스에 주입하는 것이다.
현재 모든 커넥터들이 레디스의 모든 기능을 제공하고 있지는 않다. 지원하지 않는 기능을 호출하면 UnsupportedOperationException 예외를 던진다. 대표적인 커넥터는 Jedis와 Lettuce가 있으며, 두 커넥터가 지원하는 기능은 해당 문서를 참고한다.
대부분의 사용자들은 RedisTemplate과 해당 패키지인 org.springframework.data.redis.core 사용하려 할 것이다. 실제로, 해당 템플릿은 풍부한 기능을 제공하기 때문에 Redis 모듈의 중심 클래스이다.
RedisConnection은 이진값을 허용하고 반환하는 저수준 메서드를 제공한다. 반면에, 레디스 템플릿은 레디스 상호작용을 위한 고수준의 추상화를 제공한다. 직렬화 및 연결 관리를 처리해 주기 때문에 사용하는 이런 세부 사항을 다루지 않아도 된다. 또한 템플릿은 SetOperations나 BoundGeoOperations와 같이, 특정 유형이나 키에 대한 작업에 대해 풍부한 기능을 제공한다.
일단 템플릿을 구성해 두면, thread-safe 하면서 여러 인스턴스에서 재사용될 수 있다.
레디스 템플릿은 대부분의 작업에서, 자바 기반의 serializer를 사용한다. 즉, 템플릿에서 쓰거나 읽는 모든 객체가 자바를 통해 직렬화 및 역직렬화됨을 의미한다. 물론 템플릿에서 직렬화 메커니즘을 변경할 수 있다.
보통 레디스에 저장된 키와 값은 String인 경우가 많다. 따라서 레디스 모듈은 string에 특화된 레디스 커넥션(StringRedisConnection)과 레디스 템플릿(StringRedisTemplate)을 제공한다.
String 키를 바운딩하는 것 외에도, 템플릿과 커넥션은 StringRedisSerializer를 기반으로 사용한다. 이는 저장된 키와 값을 사람이 읽을 수 있음을 의미한다.(레디스와 코드에서 동일한 인코딩을 사용한다는 가정)
다른 스프링 템플릿과 마찬가지로, 레디스 템플릿과 스프링 레디스 템플릿 모두 RedisCallback 인터페이스를 통해 레디스와 직접 통신이 가능하다. 이 기능은 레디스 커넥션과 직접 통신하므로 완전한 제어를 제공한다.
public void useCallback() {
redisTemplate.execute(new RedisCallback<Object>() {
public Object doInRedis(RedisConnection connection) throws DataAccessException {
Long size = connection.dbSize();
// Can cast to StringRedisConnection if using a StringRedisTemplate
((StringRedisConnection)connection).set("key", "value");
}
});
}
프레임워크 관점에서, 레디스에 저장되는 데이터는 오직 바이트뿐이다. 레디스는 다양한 타입을 제공하지만, 대부분의 경우 데이터가 표현되는 것보다는 저장되는 방식을 나타낸다. 사용자는 정보가 문자열이나 다른 객체로 변환되는지 여부를 결정할 수 있다.
org.springframework.data.redis.serializer 패키지에는 두 유형의 serializers가 있다.
serializers은 아래와 같이 다양한 종류가 있다.
serializer 실습 코드
serializer 테스트 코드
@Autowired
lateinit var redisTemplate: RedisTemplate<Any, Any>
@BeforeEach
fun init() {
redisTemplate.keySerializer = StringRedisSerializer()
}
@Test
fun contextLoads() {
}
@Test
fun testJdkSerializationRedisSerializer() {
// JdkSerializationRedisSerializer는 default다.
val user: User = User("stella", 123, User.Level.GOLD)
redisTemplate.opsForValue().set("key1", user)
}
@Test
fun testStringRedisSerializer() {
// JdkSerializationRedisSerializer는 default다.
redisTemplate.valueSerializer = StringRedisSerializer()
val user: User = User("stella", 123, User.Level.GOLD)
// redisTemplate.opsForValue().set("key2", user) // user가 문자열이 아니라서 에러 발생
redisTemplate.opsForValue().set("key2", "name : stella, id : 123, level : GOLD")
}
@Test
fun testJackson2JsonRedisSerializer() {
// JdkSerializationRedisSerializer는 default다.
redisTemplate.valueSerializer = Jackson2JsonRedisSerializer(User::javaClass.javaClass)
val user: User = User("stella", 123, User.Level.GOLD)
redisTemplate.opsForValue().set("key3", user)
}
테스트 결과(터미널)
tip
참고로 기본 Serializer인 JdkSerializationRedisSerializer를 그대로 사용하는 것은 지양하는 게 좋다. 애플리케이션의 역직렬화 단계에서 조작된 입력으로 인해 원하지 않는 코드가 실행될 수 있기 때문이다. 따라서 신뢰할 수 없는 환경에서는 직렬화를 사용하지 말고, JSON이나 다른 메세지 형식을 사용한다.
Redis Repository를 사용하면, 도메인 객체를 레디스 해시로 원활하게 전환 및 저장할 수 있다. 사용자 지정 매핑 전략을 적용할 수도 있고, 보조 인덱스도 사용할 수 있다.
참고로 Redis Repository는 레디스 서버 버전 2.8 이상이 필요하다. 그리고 트랜잭션과 함께 작동하지 않으므로, 트랙잭션 지원이 비활성화된 RedisTemplate을 사용해야 한다.
crud를 할 수 있는 Repository 인터페이스는 다음과 같다.
public interface PersonRepository extends CrudRepository<Person, String> {
}
스프링 데이터 레디스를 사용하면 아래와 같이 도메인 객체를 쉽게 해시로 변환할 수 있다.
@RedisHash("people")
public class Person {
@Id String id;
String firstname;
String lastname;
Address address;
}
식별자로 사용할 필드에 @Id를 붙여준다. 그러면 save를 할 때 id가 null가 새 id를 세팅해 주고, id 값이 세팅되어 있다면 해당 Id를 그대로 사용한다. 키는 people:5d67b7e1-8640-4475-beeb-c666fab4c0e5 형태를 띨 것이다.
해당 섹션에서는 JPA와 같은 기본 데이터 저장소의 객체 매핑을 사용하지 않는 Spring Data 모듈에만 적용된다.
Spring Data 객체 매핑의 핵심 책임은 1) 도메인 객체 인스턴스를 생성하고, 2) 이를 저장소의 데이터 구조에 매핑하는 것이다. 즉, 다음의 두 가지 단계가 필요하다.
1. 불변 객체를 고수한다.
불변 객체는 생성자만으로 객체를 생성할 수 있다. 이러면 setter 메서드로 인해 도메인 객체가 흩어지는 것을 피할 수 있다. 필요한 경우에는 package protected 하게 제한을 걸어주는 게 좋다. 생성자 전용 구체화는 프로퍼티틑 채우는 속도가 최대 30%까지 빠르다.
2. all args constructor 제공
프로퍼티 채우는 과정을 스킵할 수 있기 때문에 성능이 좋다.
3. @PersistenceCreator를 피하기 위해, 생성자 오버로딩 대신 팩토리메서드를 사용한다.
최적의 성능을 위해 all args 생성자를 사용하면, 일반적으로 사용이 편하게 생성자를 오버로딩하는 경우가 많다.(ex. 식별자를 자동 생성하는 생성자) all args 생성자의 변형을 노출하기 위해, 정적 팩토리 메서드를 사용하는 것이 좋은 패턴이다.
4. 생성된 instantiator와 property accessor classes가 사용될 수 있도록, 적절한 제약조건을 준수해야 한다.
5. 식별자가 생성되기 위해서는, 필드에 final을 걸어주고 all args 생성자를 사용한다.(권장 방법) 또는, with… 메서드를 사용한다.
6. 상용구 코드에 롬복을 사용한다.
자바의 경우, lombok의 @AllArgsContructor로 반복적인 작업을 효과적으로 줄일 수 있다.
스프링 데이터는 코틀린으로의 객체 매핑을 지원한다.
4.2.2.1 코틀린 객체 생성
다음처럼 데이터 클래스로 선언한다.
data class Person(val id: String, val name: String)
다른 생성자를 추가하고 싶다면, 다음처럼 @PersistenceCreator와 사용하면 된다.
data class Person(var id: String, val name: String) {
@PersistenceCreator
constructor(id: String) : this(id, "unknown")
}
하지만 코틀린은 파라미터에 기본값을 줄 수 있기 때문에 아래처럼 사용하는 게 낫다.
data class Person(var id: String, val name: String = "unknown")
4.2.2.2 코틀린 데이터 클래스의 프로퍼티 채우기
다음처럼 데이터 클래스로 클래스를 선언하자.
data class Person(val id: String, val name: String)
이 클래스를 불변이다. 코틀린은 데이터 클래스에 copy 메서드를 자동으로 생성해 주기 때문에 새로운 인스턴스 생성이 가능하다.
스프링 데이터 레디스에서는, 기본적으로 객체와 레더스 해시 간의 매핑을 byte[] 형식으로 지원한다. 해당 방식은 단일 값, 객체, 리스트 등의 타입과 상관없이 모두 flat 형태로 저장한다.
만약 객체나 리스트, 맵 등을 json 형태로 저장하고 싶다면, Converter를 커스텀하게 구현하면 된다.
키스페이스로 Redis 해시 키의 prefix를 정의할 수 있다. 기본값으로 접두사로 getClass().getName()이 붙는다. 변경하고 싶다면 @RedisHash를 설정하거나 설정 파일에서 로직을 추가하면 된다. 참고로 @RedisHash방식이 우선순위가 높다.
설정 코드는 공식 문서를 참고한다.
보조 인덱스는 네이티브 레디스 구조 기반으로 조회 작업을 가능하게 하기 위해 사용된다. 값들은 저장될 때마다 인덱스에 매번 추가된다. 또한 객체가 제거되거나 만료될 때마다 인덱스에서 제거된다.
실습 코드
Person
@RedisHash("people")
data class Person(
@Id
var id: String?,
@Indexed var firstname: String? = null,
var lastname: String? = null,
var address: Address? = null
)
data class Address(
@Indexed val city: String? = null,
val country: String? = null
)
인덱스 테스트 코드
@Test
fun basicTest() {
val rand = Person("Junyoung", "Choi").apply {
address = Address("sillim", "korea")
}
val saveRand = personRepository.save(rand)
println(saveRand)
val findRand = personRepository.findById(saveRand.id!!).get()
println(findRand)
println(personRepository.count())
// println(personRepository.delete(rand))
}
인덱스 생성 결과
127.0.0.1:6379> keys *
1) "people:Junyoung:idx"
2) "people:address.city:sillim"
3) "people:Junyoung"
4) "people:firstname:Choi"
5) "people"
127.0.0.1:6379> smembers people
1) "Junyoung" // id값
127.0.0.1:6379> smembers people:Junyoung:idx
1) "people:address.city:sillim"
2) "people:firstname:Choi"
127.0.0.1:6379> smembers people:address.city:sillim
1) "Junyoung" // id값
127.0.0.1:6379> smembers people:firstname:Choi
1) "Junyoung" // id값
127.0.0.1:6379> hkeys people:Junyoung // 해시에 저장된 필드들
1) "_class"
2) "address.city"
3) "address.country"
4) "firstname"
5) "id"
필드에 @Indexed 없이, 아래와 같이 Configuration 파일에서 인덱스를 구성할 수 있다.
@Configuration
@EnableRedisRepositories
class RedisConfig {
//... RedisConnectionFactory and RedisTemplate Bean definitions omitted
@Bean
fun keyValueMappingContext(): RedisMappingContext {
return RedisMappingContext(
MappingConfiguration(MyIndexConfiguration(), KeyspaceConfiguration())
)
}
class MyIndexConfiguration : IndexConfiguration() {
override fun initialConfiguration(): Iterable<IndexDefinition> {
return setOf<IndexDefinition>(SimpleIndexDefinition("people", "firstname"))
}
}
}
클래스에 위치 정보를 담고 있는 Point 필드가 있다고 가정해 보자. 해당 필드에 @GeoIndexed을 걸어주면, 위경도 기반(x, y)으로 객체 조회가 가능하다.
실습 코드
Person
@RedisHash("people")
data class Person(
@Id
var id: String?,
var firstname: String? = null,
var lastname: String? = null,
var address: Address? = null
)
data class Address(
val city: String? = null,
@GeoIndexed val location: Point? = null,
val country: String? = null
)
PersonRepository
interface PersonRepository : CrudRepository<Person, String> {
fun findByAddressLocationNear(point: Point, distance: Distance): List<Person>
fun findByAddressLocationWithin(circle: Circle): List<Person?>
}
geospatial 인덱스 테스트 코드
@Test
fun geospatialIndexTest() {
val rand = Person("Junyoung", "Choi").apply {
address = Address(location = Point(13.361389, 38.115556))
}
personRepository.save(rand)
// GEORADIUS people:address:location 15.0 37.0 200.0 km 와 동일
println(personRepository.findByAddressLocationNear(Point(15.0, 37.0), Distance(200.0, Metrics.KILOMETERS)))
}
Print 결과
[Person(id=Junyoung, firstname=Choi, lastname=null, address=Address(city=null, location=Point [x=13.361389, y=38.115556], country=null))]
redis-cli
127.0.0.1:6379> keys *
1) "people:address:location"
2) "people:Junyoung"
3) "people:Junyoung:idx"
4) "people"
127.0.0.1:6379> smembers people:Junyoung:idx
1) "people:address:location"
127.0.0.1:6379> GEORADIUS people:address:location 15.0 37.0 200.0 km
1) "Junyoung" // id값
Query by Example은 간단한 인터페이스를 가진 사용자 친화 쿼리 기술이다. 동적 쿼리 생성이 가능하며, 필드 이름이 포함된 쿼리를 작성하지 않아도 된다. 그저 예시로 삼을 객체와 매칭 조건을 파라미터로 넘겨주면, 조건에 맞는 결과를 얻을 수 있다.
레디스의 데이터 만료시간을 설정하기 위해서는 Time to Live 값을 세팅해 주면 된다. 만료 시간 세팅은 @RedisHash(timeToLive=…)이나 KeyspaceSettings로 할 수 있다. 또는 @TimeToLive 애노테이션을 사용하면, 숫자 프로퍼티나 메서드 단위로 더욱 유연하게 세팅이 가능하다.
참고로 레디스는 생성, 변경 등의 이벤트를 수신하는 기능을 지원한다. 만료 이벤트도 지원하며, RedisMessageListenerContainer를 통해 수신받을 수 있다.
참조 객체를 아래와 같이 id 로만 들고 있게 할 수 있다.
_class = org.example.Person
id = e2c7dcee-b8cd-4424-883e-736ce564363e
firstname = rand
lastname = al’thor
mother = people:a9d4b3a0-50d3-4538-a2fc-f7fc2581ee56
그러기 위해서는 대상 참조 객체에 @Reference를 붙여주고, 참조 객체는 별도로 저장한다.
어떤 경우에는 레디스에 저장된 객체를 불러오지 않고 수정 작업을 처리하고 싶을 수 있다. 레디스의 부분 변경 기능을 활용하면, 객체 로딩 없이 수정과 삭제, 만료시간 업데이트 등의 작업을 수행할 수 있다.
jpa와 마찬가지로, 메서드로 쿼리를 생성하는 기능을 제공한다. 참고로 대상 필드에 인덱스가 걸려있는지 체크하자.
public interface PersonRepository extends CrudRepository<Person, String> {
List<Person> findByFirstname(String firstname);
List<Person> findByFirstnameOrderByAgeDesc(String firstname); // 정렬 기능도 제공한다.
List<Person> findByFirstname(String firstname, Sort sort); // 동적 정렬도 가능하다.
}
쿼리 메서드 기능만으로는 원하는 쿼리 생성에 어려움이 있을 수 있다. 그럴 때는 RedisCallback 기능을 고려해 보자.
레디스 클러스터 환경에서 Redis Repository를 사용하려면 몇몇 추가 구성이 필요하다. 왜냐하면 기본 세팅에서는 키들이 전체 클러스터와 그 슬롯들로 분산되기 때문이다. SINTER와 SUNION과 같은 몇몇 명령어들은 동일 슬롯에 한에서만 작동한다.
원하는 슬롯을 설정하고 싶다면 @RedisHash("{yourkeyspace}")를 사용하면 된다.
repository 인터페이스 인스턴스는 보통 컨테이너에 의해 생성된다. 또한 Spring Data로 작업할 때는 보통 스프링을 많이 선택한다.
Spring Data Redis는 커스텀 CDI 확장 기능을 제공하며, 해당 기능을 통해 CDI 환경에서 repository 추상화가 가능하다.
참고 링크
레디스 incr, decr를 활용해서 동시성 테스트하기(kotlin, spring) (1) | 2024.06.11 |
---|---|
Lettuce vs Jedis (1) | 2023.12.03 |
레디스 사용 시 주의사항 (0) | 2023.10.29 |
레디스란 (0) | 2023.10.25 |
레디스는 싱글 스레드기 때문에 한 번에 하나씩 명령에 처리할 수 있다. 따라서 데이터 양이 많은 경우 O(n) 명령어를 호출하면, 꽤 오랜 시간 동안 레디스가 다음 요청을 처리할 수 없게 된다. 그러므로 운영 환경에서는 특히나 오래 걸리는 명령어를 사용하지 말아야 한다.
대표적인 O(n) 명령어
참고로 memcached의 flush_all 명령어는 레디스보다 훨씬 빠르다. 그 이유는 동작 방식에서 차이가 있기 때문이다. 레디스는 데이터를 하나하나 지우는 방식으로 동작하기 때문에 시간 복잡도가 O(n)이다. 하지만 memcached는 모든 데이터를 지우지 않는 대신, 해당 명령어가 실행된 시간만 기록한다. 그리고 get 명령어가 호출된 시점에 유효성을 체크하고 결과를 반환하는 방식이다.(참고 링크)
레디스를 단순 캐시가 아닌 데이터 저장소로 사용하는 경우라면 디스크에 백업하는 기능이 필요할 것이다. 레디스에서는 이를 위해 RDB와 AOF 두 가지 기능을 제공한다.
여기서 RDB를 사용할 때 주의할 점이 있다.
1. 서버 재부팅 시 레디스가 자동으로 시작되도록 설정되어 있다면, 마스터에서 persistence 사용을 유지하는 것을 권장한다.
2. 마스터 노드 장애 시, 슬레이브가 마스터와 싱크 되는 것을 피하기 위해 연결을 끊을 수 있다.
3. 종종 발생하는 full sync를 위해서, 레디스는 사용자 설정과 무관하게 RDB 파일을 생성한다. fork 방식으로 RDB 파일이 생성되므로, maxmemory 설정 시 주의하자.
참고 링크
레디스 incr, decr를 활용해서 동시성 테스트하기(kotlin, spring) (1) | 2024.06.11 |
---|---|
Lettuce vs Jedis (1) | 2023.12.03 |
Spring Data Redis 공식문서 읽어보기 (2) | 2023.12.03 |
레디스란 (0) | 2023.10.25 |
목차
레디스는 오픈소스 인메모리 데이터 저장소이다. ram에 데이터를 저장하고 응답하기 때문에 성능이 매우 빠르다는 특징이 있다. 또한, 레디스는 string, set, list, hash, sorted set 등의 다양한 자료 구조를 제공한다.
이 외에도 pub/sub, 레플리케이션 등의 다양한 기능을 제공하고 있다. 이런 이유로 캐시, 데이터베이스, 스트리밍 엔진, 메시지 브로커 등의 다양한 영역에서 레디스가 많이 사용되고 있다.
Memcached는 사용 편의성과 단순성을 위해 설계된 분산 메모리 캐싱 시스템이며, 캐시나 세션 저장소로 적합하다.
Redis는 다양한 기능 세트를 제공하는 인메모리 데이터 구조 저장소이다. 캐시뿐만 아니라 데이터베이스, 메시지 브로커 및 대기열 등에 유용하다.
Redis는 데이터를 키-값 쌍으로 저장하는 오픈소스 인메모리 데이터베이스이다. 고성능을 위해 ram에 데이터를 저장하고, 추가 기능으로 디스크에 파일 백업이 가능하다.
MongoDB는 데이터를 직렬화된 JSON 형식으로 저장하는 도큐먼트 데이터베이스이다. 기본적으로 디스크에 데이터를 저장하며, 엔터프라이즈 에디션에서는 인메모리 스토리지 엔진이 포함되어 있다.
참고자료
레디스 incr, decr를 활용해서 동시성 테스트하기(kotlin, spring) (1) | 2024.06.11 |
---|---|
Lettuce vs Jedis (1) | 2023.12.03 |
Spring Data Redis 공식문서 읽어보기 (2) | 2023.12.03 |
레디스 사용 시 주의사항 (0) | 2023.10.29 |
목차
캐싱이란 자주 사용하는 데이터를 보다 빠르게 액세스 하기 위해, 캐시와 같이 가까운 장소에 임시로 저장해 두는 것을 말한다. cpu 코어와 메모리, 웹 클라이언트와 서버, 서버와 db 등 다양한 곳에서 캐싱 기술이 사용되고 있다.
만약 모든 데이터가 동일한 사용 빈도를 띈다면 캐싱이라는 기술이 큰 의미를 갖기 힘들었을 것이다. 하지만 소수의 데이터가 자주 사용되는 경향이 있기 때문에 캐싱이 큰 힘을 발휘하고 있다. 이를 캐시의 지역성이라고 하며 크게 시간 지역성과 공간 지역성으로 나뉜다.
실생활에서도 지역성을 쉽게 발견할 수 있다. 인스타그램을 예로 들면, 보통 소수의 사용자들이 많은 팔로워를 보유하고 있다. 또한 예전 게시물보다는 최근 게시물을 소비하는 빈도가 더욱 높기 때문에, 일부 게시물의 사용 빈도가 높다는 사실을 알 수 있다.
로컬 캐시란 애플리케이션 내에 자주 사용되는 데이터를 저장하는 것을 말한다.
장점
단점
원격 캐시를 사용하면 로컬 캐시의 단점을 극복할 수 있다.
원격 캐시란 별도의 전용 인스턴스에 캐시 데이터를 저장하는 것을 말한다. 일반적으로 Redis나 Memcached와 같은 key/value 저장소로 구축한다.
장점
딘점
네트워크 latency가 문제가 되는 경우에는 로컬 캐시와 함께 사용하는 방법을 적용하면 된다. 하지만 복잡하기 때문에 꼭 필요한 경우에만 사용을 고려한다.
가장 일반적으로 사용하는 방식이며, 절차는 다음과 같다.
장점
단점
cache miss가 발생한 뒤에 캐시에 적재하는 lazy loading과 달리, DB 변경이 발생하는 경우에 캐시도 함께 최신화하는 방식이다. 하지만 캐시 데이터가 없거나 만료되었을 수도 있기 때문에, 보통 lazy loading 전략과 함께 사용한다.
장점
단점
캐싱 전략을 효과적으로 사용하기 위해서는, write-through와 lazy loading 전략을 적절하게 사용해야 한다. 그리고 데이터에 만료 설정을 걸어주어야 한다.
데이터를 캐시에만 쓰고, 캐시의 데이터를 일정 주기로 DB에 업데이트하는 방식이다.
장점
단점
데이터에 ttl을 설정하여 시간 단위로 캐시 데이터의 수명을 제어할 수 있다. 제한 시간을 초과하면 캐시에서 데이터를 삭제하고, 원본 데이터에서 데이터를 새로 가져온다.
참고로 레디스에서는 논리적으로 만료되더라도 메모리에 남아있는 경우가 있다. 다만 존재하지 않는 것으로 간주되며, 해당 메모리는 결국 회수되니 문제 될 건 없다. (참고 링크)
TTL을 적용하기 위해서는 다음의 두 가지를 고려한다. 몇 분, 몇 초 단위로 ttl을 적용하더라도, 적절한 ttl은 성능과 사용자 경험에 큰 이점을 준다.
eviction은 캐시 메모리가 꽉 차거나 maxmemory 설정보다 클 때 발생한다. 가장 마지막에 사용한 키 제거와 같이, 제거 정책을 설정할 수 있다. 절차는 다음과 같다.
일반적으로 lru 정책을 가장 많이 사용하며, 정책의 종류는 다음과 같다.
제거가 발생하는 경우는 일반적으로 확장이 필요하다는 신호이다. 하지만 의도적으로 정책에 따라 키를 관리하고 있다면 무시해도 좋다.
참고 자료
1. 비관리 의존성에만 목을 사용한다. 데이터베이스는 관리 의존성이므로 목을 사용하지 않는다.
2. 비관리 의존성을 처리하는 코드는 컨트롤러뿐이다. 따라서 단위 테스트에서는 목을 사용하지 않는다.
3 시스템의 끝에서 비관리 의존성과의 상호작용을 검증한다. 이를 래핑 하거나 추상화한 인터페이스를 목으로 처리하는 것보다 회귀 방지와 리팩터링 내성이 향상된다.
4. 시스템 끝에 있는 클래스에 대해서는 스파이가 목보다 낫다. 검증 단계에서 코드를 재사용해 테스트 크기가 줄고 가독성이 개선된다.
5. 모든 비관리 의존성에 하위 호환성이 동일한 수준으로 필요한 것은 아니다. 로깅과 같이 메시지의 정확한 구조가 중요하지 않고 메시지의 존재 여부와 전달하는 정보만 검증하면, 시스템의 끝에서 비관리 의존성과의 상호 작용을 검증하라는 지침을 무시할 수 있다.
6. 테스트에서 사용된 목의 수는 상관 없다. 비관리 의존성의 수에 따라 달라질 뿐이다.
7. 비관리 의존성에 접근하는 서드파티 라이브러리 위에 어댑터를 작성하자. 기본 타입 대신 해당 어댑터를 목으로 처리한다.
단위/통합 테스트에서의 데이터베이스 (0) | 2023.09.18 |
---|---|
코드 유형과 테스트의 관계 (0) | 2023.09.17 |
단위 테스트의 세 가지 스타일 (0) | 2023.09.17 |
테스트 대역(Test Double)이란? (0) | 2023.09.17 |
좋은 단위 테스트의 4대 요소 (0) | 2023.09.17 |
테스트할 로직 중간에 데이터베이스가 끼어있는 경우가 많다. 이번 장에서는 단위 테스트와 통합 테스트 각각에서 데이터베이스를 어떻게 다루면 좋을지 학습해 보자.
먼저 프로세스 외부 의존성의 유형을 짚고 넘어가자. 모든 프로세스 외부 의존성은 두 가지 범주로 나뉜다.
다시 본론으로 돌아오자. 이전 글인 코드 유형과 테스트의 관계 글에서 언급했듯이, 단위 테스트는 도메인 도델과 알고리즘을 대상으로 하고 통합 테스트는 컨트롤러를 대상으로 한다. 만약 도메인 모델과 알고리즘에 데이터베이스라는 외부 의존성이 존재한다면, 리팩터링을 먼저 한 다음에 단위 테스트를 진행해야 한다.
물론 스텁을 사용하여 해결할 수는 있다. 하지만 db는 보통 관리 의존성이기 때문에 테스트가 구현 세부 사항에 의존하게 된다. 테스트 용이성과 가독성 측면에서도 좋지 않으므로 이를 지양하자. 예외적으로 db 또는 db의 일부가 비관리 의존성인 경우가 있다. 그럴 때는 목이나 스텁을 사용해도 무방하다.
이메일이나 푸시와 같은 비관리 의존성은 목으로 대체하고 상호작용을 검증한다. 하지만 관리 의존성을 목으로 대체하면 리팩터링 내성이 저하되고 회귀 방지도 떨어진다. 따라서 데이터베이스를 그대로 테스트하는 것이 바람직하다.
데이터베이스는 실제 운영 환경과 동일하게 하는 게 좋다. 인메모리 데이터베이스는 기능적으로 일관성이 없기 때문에 회귀 방지 측면에서 좋지 않다.
쓰기는 위험성이 높기 때문에 철저히 테스트해야 한다. 읽기는 보통 위험성이 없기 때문에 복잡하거나 중요한 작업만 테스트하고, 나머지는 무시해도 된다.
리포지토리 테스트는 유지비가 높고 회귀 방지가 떨어지기 때문에 이점이 별로 없다. 직접 테스트하기보다는 포괄적인 통합 테스트 스위트의 일부로 취급하라.
목 사용 팁 (0) | 2023.09.18 |
---|---|
코드 유형과 테스트의 관계 (0) | 2023.09.17 |
단위 테스트의 세 가지 스타일 (0) | 2023.09.17 |
테스트 대역(Test Double)이란? (0) | 2023.09.17 |
좋은 단위 테스트의 4대 요소 (0) | 2023.09.17 |
단위 테스트와 기반 코드를 서로 얽혀있다. 따라서 코드 베이스에 노력을 기울이지 않고서는 가치 있는 테스트를 만들 수 없다. 이번 장에서는 코드의 네 가지 유형을 살펴보고 어떤 유형이 테스트하기 적합한지 알아보자.
모든 제품 코드는 2차원으로 분류할 수 있다.
코드 복잡도/도메인 유의성과 협력자를 고려하면 제품 코드를 네 가지로 구분할 수 있다.
비즈니스 로직과 오케스트레이션의 분리는 다음과 같이 비즈니스 연산이 세 단계로 있을 때 가장 효과적이다.
하지만 비즈니스 로직 중간에 외부에서 추가 데이터를 조회하는 등의 작업이 있다면 어떻게 할까? 크게 세 가지 방법이 있다.
대부분의 소프트웨어 프로젝트는 성능이 매우 중요하다. 도메인 모델에 외부 의존성을 주입하면 테스트와 유지보수가 어려워진다. 따라서 의사 결정 프로세스 단계를 더 세분화하는 방법만 남는다. 컨트롤러가 복잡해지긴 하지만 이를 완화하는 방법이 있다.
컨트롤러 복잡도를 완화하는 방법
개인적인 견해로 코틀린에서는 파라미터로 함수를 넘기는게 가능하기 때문에, 해당 방법을 사용하면 외부 의존성과 의사 결정이 효과적으로 분리가 가능하다고 생각한다.
목 사용 팁 (0) | 2023.09.18 |
---|---|
단위/통합 테스트에서의 데이터베이스 (0) | 2023.09.18 |
단위 테스트의 세 가지 스타일 (0) | 2023.09.17 |
테스트 대역(Test Double)이란? (0) | 2023.09.17 |
좋은 단위 테스트의 4대 요소 (0) | 2023.09.17 |