23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
안녕하세요 전 데보션영 2기로 활동했던 강다빈입니다.
지난 6월 25일 화요일 OpenLab 백엔드 코틀린 스터디에서 공부한 것들과 후기를 공유하고자 합니다.
지난 번 스터디때는 Exposed라는 Kotlin 기반 ORM 프레임워크를 스프링 부트 프로젝트에 적용하는 방법과 테이블에서 PK 만드는 관계 등을 공부했고,
Transactional의 여러가지 propagation 방식, 스프링의 Auto Config(참고 추천 -> SpringBoot 나만의 의존성 설정하기),
Kotlin의 문법과 TDD에 필요한 어노테이션 등을 공부했습니다.
배운 것들을 바탕으로 프로젝트에 UserServiceTest 코드를 잘 돌아가게 만들어보려고 밤새 고군분투했던 기억이 떠오르네요😄
이번 5회차에서는 캐시를 만들어서 연결하는 걸 배웠습니다.
개인적으로는 학교 운영체제 수업 시간에 캐시를 처음 배우게 되었고, 그때 지식 외엔 웹 서비스 개발에서 캐시를 활용하는 것을 몰랐기에 너무 유익한 시간이었습니다.
//Spring Cahce
implementation("org.springframework.boot:sprng-boot-starter-cache")@Configuration : Bean 설정
@EnableCaching : Spring Cache 사용 설정
@Cacheable : 조회 시 캐시를 먼저 확인하고, 없는 경우 DB 조회해서 결과 생성
@CachePut : 최초 호출 시 캐시 정보 저장, 재호출 시 갱신
@CacheEvict : 캐시 정보 삭제 설정Stateless 설계는 서버 간 상태 공유가 없어 수평적 확장(Horizontal Scailng)이 쉽다는 장점이 있습니다.
근래 많이 사용하는 클라우드 네이티브와 연관이 있습니다. MSA에서는 역 정규화를 사용하는데,
이때 역정규화는 이전에 정규화된 DB에서 성능을 개선하기 위해 사용되는 전략을 의미하고, 데이터가 flat하게 보인다는 장점이 있습니다.
과거에는 데이터베이스 설계에서 정규화가 주를 이루었지만,
최근에는 DynamoDB, MongoDB, Cassandra 등 관계형 데이터 모델이나 데이터 일관성(정합성)을 엄격하게 신경 쓰지 않아도 되는 NoSQL 형태의 데이터베이스가 주목받고 있습니다.
이런 db의 성능을 최적화하기 위해 우린 Cache를 활용할 수 있습니다.
I/O를 최대한 줄이기 위해 데이터를 메모리에 캐시하여 데이터 조회 속도를 높입니다. 이를 통해 CPU 사용 시간을 증가시키고, 디스크 I/O 시간은 줄일 수 있습니다.
일반적인 DB의 구조는 index + data 의 형태입니다.
I/O는 데이터를 태우는 버스 같은 역할인 블록 단위로 이루어집니다.
하나의 블록에 인덱스가 포함되어 있고, 데이터는 정렬되지 않은 상태.
중간에 생긴 빈 공간은 마지막에 채우는 방식
멀티 스레드를 사용해 동시에 여러 블록을 읽어오는 작업
병렬 처리 -> 빠른 조회 속도
블록은 비싸기 때문에 앞쪽에 캐시를 두고 쿼리를 수행합니다.
멀티 클러스터로 구성된 분산 캐시로, 메모리 공간에서 사용하는 인메모리 데이터베이스입니다.
RAM 상에 모든 데이터를 저장하고 가져다 쓰기 때문에, 디스크보다 훨씬 빠른 연산 속도를 갖고 있어 읽고 쓰는 연산이 많이 발생할 때 효율적입니다.
String, Hash, Set, List, Sorted Set, Bitmap 등 데이터를 다양한 타입으로 저장 가능해 많이 사용됩니다.
특히, Sorted Set은 리더보드 같이 순위를 저장하는 경우에 많이 사용된다고 하네요.
AWS에서 Redis는 single, standalone, sentinel, cluster등 여러가지 방식으로 구성할 수 있습니다.
single, standalone은 싱글 서버로 주로 테스트용에 많이 사용되고, Sentinel, Cluster가 중요합니다.
마스터 노드가 존재하고, 데이터가 slave로 복제됩니다.
클러스터 health check -> Failure Detection
Quorum: 마스터 노드의 장애 발생 시, Sentinel이 투표로 새로운 마스터 노드를 결정합니다.
-> sentinel이 기본 인스턴스의 오류를 감지하면 최신 데이터가 있는 복제본을 찾고 해당 복제본을 새로운 기본 인스턴스로 승격시킵니다.
마스터 클러스터의 해시값을 갖고 있습니다.(Hash Slot)
키에 따라 다른 데이터를 분산 저장합니다 -> Sharding
각자 자신이 가진 걸 다른 클러스터에 복제합니다.
Key만 알고 있다면 sentinel보다 엄청 빠르다는 장점이 존재합니다.
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-redis")
} @Bean
fun cacheConfiguration(): RedisCacheConfiguration {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(10))
.disableCachingNullValues()
.serializeKeysWith(SerializationPair.fromSerializer(StringRedisSerializer()))
.serializeValuesWith(SerializationPair.fromSerializer(GenericJackson2JsonRedisSerializer()))
}
@Bean
fun redisCacheManagerBuilderCustomizer(): RedisCacheManagerBuilderCustomizer {
return RedisCacheManagerBuilderCustomizer {
it.withCacheConfiguration(
"userCache",
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(10))
)
it.withCacheConfiguration(
"projectCache",
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(50))
)
}
}spring.cache.type=redis
spring.data.redis.host=localhost
spring.data.redis.port=6379@Cacheable(cacheNames = ["userCache"], key="#id")
fun findUserById(id: String): User? = userRepository.findUserById(id)SpEL(Spring Expression Language)를 사용해서 key를 설정합니다. (SpEL 공식 문서)
#값, #객체명.변수명, #객체명.메서드()
오픈 소스 분산 데이터 그리드로, 메모리 내 데이터 저장 및 분산 캐시로 자주 사용됩니다.
분산 캐시 및 데이터 그리드 기능
다양한 데이터 구조 지원 -> map, queue, set 등
클러스터링 및 고가용성 지원
클러스터 형성 -> 삭제돼도 안정성을 가지나 느리다
로컬에서도 사용 가능하다
//HazelCast
implementation("com.hazelcast:hazelcast:5.3.5") //CVE 땜에 5.3.5로 변경
implementation("com.hazelcast:hazelcast-spring:5.0")CacheConfig.kt에 Hazelcast 설정을 해줍니다.
캐시 매니저, 캐시 저장소 등을 지정합니다.
//HazelCast 설정
@Bean
fun hazelcastConfig(): Config {
val config = Config()
config.networkConfig = NetworkConfig().apply {
join.tcpIpConfig.isEnabled = true
join.tcpIpConfig.addMember("127.0.0.1")
join.multicastConfig.isEnabled = true
}
config.addMapConfig(
MapConfig().apply {
name = "user_hazel"
timeToLiveSeconds = 10
backupCount = 1
}
)
config.serializationConfig.addSerializerConfig(
SerializerConfig()
.setTypeClass(User::class.java)
.setImplementation(CustomJsonSerializer())
)
return config
}
@Bean
fun hazelcastInstance(config: Config): HazelcastInstance {
return Hazelcast.newHazelcastInstance(config)
}
@Bean("hazelcast")
fun hazelCacheManager(hazelcastInstance: HazelcastInstance): HazelcastCacheManager {
return HazelcastCacheManager(hazelcastInstance)
}hazelcast는 Serialization(직렬화)가 필요하기에 Custom Serializer를 만들어줍니다.
Hazelcast needs to serialize the Java objects that you put into Hazelcast because Hazelcast is a distributed system. The data and its replicas are stored in different partitions on multiple cluster members. The data you need may not be present on the local member, and in that case, Hazelcast retrieves that data from another member. This requires serialization.
저장해야 할 key, value에 대해서 객체를 어떻게 저장할지를 지정하는 것이 serializer의 역할
스터디에서는 JSON으로 저장하도록 설정했습니다.
Serialization = JSON을 객체로, Deserializatrion = 객체를 JSON으로!
Hazelcast에서는 StreamSerializer 인터페이스를 구현하여 User 객체를 JSON 형식으로 직렬화/역직렬화 합니다.
public interface StreamSerializer<T> extends Serializer {
void write(@Nonnull ObjectDataOutput var1, @Nonnull T var2) throws IOException;
@Nonnull
T read(@Nonnull ObjectDataInput var1) throws IOException;
}Hazelcast lets you plug in a custom serializer for serializing your objects. You can use StreamSerializer and ByteArraySerializer interfaces for this purpose.
class CustomJsonSerializer : StreamSerializer<User>{
//json 처리 담당
private val objectMapper: ObjectMapper = jacksonObjectMapper()
override fun getTypeId(): Int {
return 1
}
override fun destroy() {
// No - op
// 자원 해제 코드(필요한 경우)
}
//역직렬화 바이트 -> 객체 User
override fun read(input: ObjectDataInput): User {
val readString = input.readString()
return objectMapper.readValue(readString, User::class.java)
}
override fun write(output: ObjectDataOutput, data: User) {
output.writeString(objectMapper.writeValueAsString(data))
}
}JSON Serialization을 이해할 때 Jackson ObjectMapper를 알아두면 좋습니다. (참고 - Jackson ObjectMapper)
readValue = 역직렬화(JSON -> 객체)
writeValue = 직렬화(객체 -> JSON)
@Cacheable 어노테이션을 사용해서 캐시를 적용할 수 있습니다.
@Cacheable(cacheManager = "hazelcast", cacheNames = ["user_hazel"])
fun findUserById(id: String): User? = userRepository.findUserById(id)매우 빠르고, Serialize 할 필요없다.
DB에선 네트워크, 파일 I/O가 발생하고, Redis는 네트워크 I/O, 싱글스레드 형태라 capacity가 좋지 않다.
ex) 화면 문구를 바꾸는 경우, Redis에 저장해서 불러오는 건 느리기 때문에 로컬 캐시를 사용합니다.
높은 성능을 자랑하는 Java 기반 캐시 라이브러리
Guava Cache를 개선한 버전. 짧은 지연시간, 높은 히트 비율 제공
고성능
다양한 캐시 정책 지원
최소 사용 빈도, 최대 크기, 만료 등
비동기 로딩 캐시 지원
//Caffein 라이브러리 추가
implementation("com.github.ben-manes.caffeine:caffeine:3.1.6")@Bean
fun caffeineConfig(): Caffeine<Any, Any> {
// Caffeine 빌더를 이용하기 위해서 newBuilder() 를 이용한다.
// exipreAfterWrite를 이용하여 캐시 저장 시간을 20초 으로 지정한다.
// maximumSize: 캐시 최대 저장 크기를 지정한다. (음수는 될 수 없다. 0이면 바로 삭제된다.)
return Caffeine.newBuilder()
.expireAfterWrite(20, TimeUnit.SECONDS)
.maximumSize(100)
}
@Bean("caffeine")
fun caffeineCacheManager(caffeine: Caffeine<Any, Any>): CaffeineCacheManager {
val cacheManager = CaffeineCacheManager()
cacheManager.setCaffeine(caffeine)
return cacheManager
}newBuilder() Caffeine 빌더를 이용합니다. expireAfterWrite 으로 캐시 저장 시간, maximumSize() 로 캐시 최대 저장 크기를 지정할 수 있습니다.
캐시를 사용할 대상을 지정하고, 캐시 라이프 사이클을 관리할 CacheManager도 만들어줍니다.
@Cacheable(cacheManager = "caffeine", cacheNames = ["user"])
fun findUserById(id: String): User? = userRepository.findUserById(id)@Cacheable 을 사용해서 caffeine을 캐시로 사용하는 캐시 매니저를 설정합니다. 이때 캐시될 저장소는 caffeine이 됩니다.
조회 결과 있으면 캐시에 key = id, value = User 가 됩니다.
컴퓨터 구조 수업에서 캐시에 대해 배우고 그 이후부터는 잘 몰랐는데,
웹 서비스를 개발할 때 캐시를 사용하는 방식을 제대로 배울 수 있어 너무 좋았습니다.
스터디가 얼마 남지 않았는데, 마지막까지 더 많이 배워갈 수 있으면 좋겠습니다. 🤓
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.