데보션앱 소개페이지 바로가기
로그인 선택

신고하기

CLOSE
신고사유 (대표 사유 1개)
상세내용 (선택)
0/200
  • 신고한 게시글은 더 이상 보이지 않습니다.
  • 이용약관과 운영정책에 따라 신고사유에 해당하는지 검토 후 조치됩니다.
  • 허위 신고인 경우, 신고자의 서비스 이용이 제한될 수 있으니 유의하시어 신중하게 신고해 주세요.
(이 회원이 작성한 모든 댓글과 커뮤니티 게시물이 보이지 않고, 알림도 오지 않습니다.)

미리보기

커뮤니티

      1,234

      badge 23.06.15

      글 등록

      카테고리를 선택해주세요.

      DEVOTEE를 활성화 시키면
      지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.

      버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.

      임시저장함에 저장되었습니다. 저장일시 : 2022.5.17 14:29:08

      임시저장함

      제목을 선택하시면 이어서 작성이 가능하며,
      최대 20건까지 저장합니다.
      컨텐츠 유형, 제목, 저장일시, 삭제로 이뤄진 임시저장 목록
      컨텐츠 유형 제목 저장일 삭제

      데보션 블로그 게재 요청

      CLOSE
      • *
      • *

      본인인증

      효율적인 데보션 서비스 이용 및
      고객님의 소중한 개인정보보호를 위해
      본인인증을 진행해주세요. 본인인증 미 진행 시 로그인이 제한됩니다.
      본인인증 실패

      본인인증 로그인에 실패하였습니다.
      회원이 아니시거나 본인인증 등록이
      완료되지 않은 사용자입니다.

      회원정보 연결

      OpenLab - Kotlin 5회차, Cache 도입하기

      kchabin 24.06.30
      1,057 0 0
      DEVOTEE 요약
      OpenLab 백엔드 코틀린 스터디에서 Cache를 이용한 다양한 방법을 학습했습니다. 스프링 캐시 설정, Redis와 Hazelcast 설정 방법, Caffeine을 이용한 로컬 캐시에 대해 배웠습니다. 특히, Redis의 클러스터 구성 및 Hazelcast의 JSON 직렬화 설정이 인상 깊었습니다.
      DEVOTEE 추천 블로그

      안녕하세요 전 데보션영 2기로 활동했던 강다빈입니다.

      지난 6월 25일 화요일 OpenLab 백엔드 코틀린 스터디에서 공부한 것들과 후기를 공유하고자 합니다.



      Cache를 사용해봅시다

      지난 번 스터디때는 Exposed라는 Kotlin 기반 ORM 프레임워크를 스프링 부트 프로젝트에 적용하는 방법과 테이블에서 PK 만드는 관계 등을 공부했고,

      Transactional의 여러가지 propagation 방식, 스프링의 Auto Config(참고 추천 -> SpringBoot 나만의 의존성 설정하기),

      Kotlin의 문법과 TDD에 필요한 어노테이션 등을 공부했습니다.

      배운 것들을 바탕으로 프로젝트에 UserServiceTest 코드를 잘 돌아가게 만들어보려고 밤새 고군분투했던 기억이 떠오르네요😄

      이번 5회차에서는 캐시를 만들어서 연결하는 걸 배웠습니다.


      개인적으로는 학교 운영체제 수업 시간에 캐시를 처음 배우게 되었고, 그때 지식 외엔 웹 서비스 개발에서 캐시를 활용하는 것을 몰랐기에 너무 유익한 시간이었습니다.

      1. 스프링 캐시 설정
      //Spring Cahce 
      	implementation("org.springframework.boot:sprng-boot-starter-cache")
      2. 주요 어노테이션
      @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 I/O가 중요한 이유

      일반적인 DB의 구조는 index + data 의 형태입니다.

      I/O는 데이터를 태우는 버스 같은 역할인 블록 단위로 이루어집니다.

      Single Block I/O
      • 하나의 블록에 인덱스가 포함되어 있고, 데이터는 정렬되지 않은 상태.

      • 중간에 생긴 빈 공간은 마지막에 채우는 방식

      Multi Block I/O
      • 멀티 스레드를 사용해 동시에 여러 블록을 읽어오는 작업

      • 병렬 처리 -> 빠른 조회 속도

      블록은 비싸기 때문에 앞쪽에 캐시를 두고 쿼리를 수행합니다.


      Redis

      멀티 클러스터로 구성된 분산 캐시로, 메모리 공간에서 사용하는 인메모리 데이터베이스입니다.

      RAM 상에 모든 데이터를 저장하고 가져다 쓰기 때문에, 디스크보다 훨씬 빠른 연산 속도를 갖고 있어 읽고 쓰는 연산이 많이 발생할 때 효율적입니다.

      String, Hash, Set, List, Sorted Set, Bitmap 등 데이터를 다양한 타입으로 저장 가능해 많이 사용됩니다.

      특히, Sorted Set은 리더보드 같이 순위를 저장하는 경우에 많이 사용된다고 하네요.


      AWS에서 Redis는 single, standalone, sentinel, cluster등 여러가지 방식으로 구성할 수 있습니다.

      single, standalone은 싱글 서버로 주로 테스트용에 많이 사용되고, Sentinel, Cluster가 중요합니다.

      Sentinel

      • 마스터 노드가 존재하고, 데이터가 slave로 복제됩니다.

      • 클러스터 health check -> Failure Detection

      • Quorum: 마스터 노드의 장애 발생 시, Sentinel이 투표로 새로운 마스터 노드를 결정합니다.

        -> sentinel이 기본 인스턴스의 오류를 감지하면 최신 데이터가 있는 복제본을 찾고 해당 복제본을 새로운 기본 인스턴스로 승격시킵니다.

        image.png

      Cluster

      마스터 클러스터의 해시값을 갖고 있습니다.(Hash Slot)

      • 키에 따라 다른 데이터를 분산 저장합니다 -> Sharding

      • 각자 자신이 가진 걸 다른 클러스터에 복제합니다.

      Key만 알고 있다면 sentinel보다 엄청 빠르다는 장점이 존재합니다.

      Redis 설정하기

      라이브러리 설정
      dependencies {
      	implementation("org.springframework.boot:spring-boot-starter-data-redis")
      }
      CacheConfing.kt
          @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))
                  )
              }
          }

      redis 서버 설정하기

      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 공식 문서)

      • #값, #객체명.변수명, #객체명.메서드()


      Hazelcast

      오픈 소스 분산 데이터 그리드로, 메모리 내 데이터 저장 및 분산 캐시로 자주 사용됩니다.

      특징
      • 분산 캐시 및 데이터 그리드 기능

      • 다양한 데이터 구조 지원 -> 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)
          }
      JSON Serializer 만들기

      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)

      Cache 적용하기

      @Cacheable 어노테이션을 사용해서 캐시를 적용할 수 있습니다.

      @Cacheable(cacheManager = "hazelcast", cacheNames = ["user_hazel"])
      fun findUserById(id: String): User? = userRepository.findUserById(id)

      Caffeine

      로컬 캐시를 쓰는 이유
      • 매우 빠르고, Serialize 할 필요없다.

      • DB에선 네트워크, 파일 I/O가 발생하고, Redis는 네트워크 I/O, 싱글스레드 형태라 capacity가 좋지 않다.

      ex) 화면 문구를 바꾸는 경우, Redis에 저장해서 불러오는 건 느리기 때문에 로컬 캐시를 사용합니다.

      Caffeine - 메모리 전용 캐시

      • 높은 성능을 자랑하는 Java 기반 캐시 라이브러리

      • Guava Cache를 개선한 버전. 짧은 지연시간, 높은 히트 비율 제공

      특징
      • 고성능

      • 다양한 캐시 정책 지원

        • 최소 사용 빈도, 최대 크기, 만료 등

      • 비동기 로딩 캐시 지원

      라이브러리 설정

      //Caffein 라이브러리 추가
      implementation("com.github.ben-manes.caffeine:caffeine:3.1.6")

      Caffeine bean 만들기

      @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 가 됩니다.


      느낀 점

      컴퓨터 구조 수업에서 캐시에 대해 배우고 그 이후부터는 잘 몰랐는데,

      웹 서비스를 개발할 때 캐시를 사용하는 방식을 제대로 배울 수 있어 너무 좋았습니다.

      스터디가 얼마 남지 않았는데, 마지막까지 더 많이 배워갈 수 있으면 좋겠습니다. 🤓

      댓글 0

      DEVOTEE를 활성화 시키면
      지금 작성한 댓글에 AI가 댓글을 달아줍니다.

      kchabin 님의 최신 블로그

      더보기

      DEVOTEE 추천 블로그

      동영상 기고하기