소개
이 프로젝트에서는 캐싱의 원리와 중요성에 대해 배우고, Go 언어를 사용하여 캐싱 컴포넌트를 설계하고 구현할 것입니다.
캐싱은 컴퓨터 시스템에서 자주 사용되는 기술로, 메모리에 자주 접근하는 데이터를 저장하여 성능을 향상시킵니다. 이를 통해 더 빠른 검색이 가능해지고, 데이터베이스나 원격 서비스와 같은 느린 데이터 소스에 접근할 필요성을 줄일 수 있습니다.
이 프로젝트에서는 캐싱의 원리와 이점에 대해 배우게 됩니다. 또한 Go 프로그래밍 언어를 사용하여 캐싱 컴포넌트를 설계하고 구현할 것입니다. 이 캐싱 컴포넌트는 캐시된 데이터 저장, 만료된 데이터 항목 관리, 데이터 가져오기 및 내보내기, CRUD (Create, Read, Update, Delete) 연산과 같은 기능을 갖게 됩니다.
이 프로젝트를 완료함으로써 캐싱 원리, 데이터 구조, Go 프로그래밍에 대한 지식과 기술을 습득하게 됩니다. 이를 통해 캐싱 기술을 효과적으로 활용하는 효율적이고 고성능의 소프트웨어 시스템을 구축할 수 있습니다.
🎯 과제
이 프로젝트에서 다음을 배우게 됩니다:
- 캐싱의 원리와 중요성을 이해하는 방법
- 메모리에 데이터를 저장하고 관리하기 위한 캐싱 시스템을 설계하는 방법
- 캐싱 시스템에 대한 CRUD 연산 및 만료 관리를 구현하는 방법
- 캐싱 시스템에서 데이터를 가져오고 내보내는 기능을 추가하는 방법
🏆 성과
이 프로젝트를 완료하면 다음을 수행할 수 있습니다:
- 캐싱의 원리와 이점을 설명할 수 있습니다.
- 건전한 설계 원칙을 기반으로 캐싱 시스템을 설계할 수 있습니다.
- 캐시 관리를 위한 효율적인 데이터 구조와 알고리즘을 구현할 수 있습니다.
- Go 에서 캐싱 시스템에 대한 CRUD 연산을 개발할 수 있습니다.
- 가져오기 및 내보내기 작업을 위해 데이터를 직렬화 및 역직렬화할 수 있습니다.
캐시란 무엇인가?
캐시는 컴퓨터 하드웨어에서 흔히 발견됩니다. 예를 들어, CPU 는 1 차 캐시, 2 차 캐시, 심지어 3 차 캐시를 가지고 있습니다. 캐시 작동 원리는 CPU 가 데이터를 읽어야 할 때, 먼저 캐시에서 필요한 데이터를 검색하는 것입니다. 만약 데이터가 발견되면, 직접 처리됩니다. 그렇지 않으면, 메모리에서 데이터를 읽습니다. CPU 내 캐시의 속도가 메모리에 비해 빠르기 때문에, 캐시를 사용하면 CPU 처리 속도를 가속화할 수 있습니다. 캐싱은 하드웨어뿐만 아니라 다양한 소프트웨어 시스템에서도 존재합니다. 예를 들어, 웹 시스템에서는 서버, 클라이언트 또는 프록시 서버에 캐시가 존재합니다. 널리 사용되는 CDN (Content Delivery Network) 또한 거대한 캐싱 시스템으로 볼 수 있습니다. 웹 시스템에서 캐싱을 사용하면 네트워크 트래픽 감소, 클라이언트 접근 지연 시간 감소, 서버 부하 감소 등 많은 이점이 있습니다.
현재 Memcache, Redis 등과 같은 많은 고성능 캐싱 시스템이 있습니다. 특히 Redis 는 현재 다양한 웹 서비스에서 널리 사용되고 있습니다. 이러한 기능이 풍부한 캐싱 시스템이 이미 존재하는데, 왜 자체 캐싱 시스템을 구현해야 할까요? 이렇게 하는 데에는 두 가지 주요 이유가 있습니다. 첫째, 직접 구현함으로써 캐싱 시스템의 작동 원리를 이해할 수 있는데, 이는 고전적인 이유입니다. 둘째, Redis 와 같은 캐싱 시스템은 독립적으로 존재합니다. 간단한 애플리케이션만 개발해야 하는 경우, 별도의 Redis 서버를 사용하는 것은 지나치게 복잡할 수 있습니다. 이 경우, 이러한 기능을 구현하는 기능이 풍부한 소프트웨어 패키지가 있다면 가장 좋습니다. 이 소프트웨어 패키지를 간단히 임포트함으로써, 별도의 Redis 서버 없이 캐싱 기능을 구현할 수 있습니다.
캐시 시스템 설계
캐시 시스템에서 캐시된 데이터는 일반적으로 메모리에 저장됩니다. 따라서, 우리가 설계하는 캐시 시스템은 메모리 내 데이터를 특정 방식으로 관리해야 합니다. 시스템이 종료되면 데이터가 손실되지 않을까요? 사실, 대부분의 경우 캐시 시스템은 메모리 내 데이터를 파일에 쓰는 것도 지원합니다. 시스템이 다시 시작되면 파일의 데이터를 메모리로 다시 로드할 수 있습니다. 이렇게 하면 시스템이 종료되더라도 캐시 데이터가 손실되지 않습니다.
동시에, 캐시 시스템은 만료된 데이터를 정리하는 메커니즘도 제공합니다. 이는 캐시의 각 데이터 항목이 수명을 갖는다는 것을 의미합니다. 데이터 항목이 만료되면 메모리에서 삭제됩니다. 결과적으로, 핫 데이터는 항상 사용 가능하며, 콜드 데이터는 캐싱할 필요가 없으므로 삭제됩니다.
캐시 시스템은 또한 시스템의 다른 구성 요소가 캐시를 사용할 수 있도록 외부 연산을 위한 인터페이스를 제공해야 합니다. 일반적으로, 캐시 시스템은 생성 (추가), 읽기, 업데이트 및 삭제를 포함하는 CRUD 연산을 지원해야 합니다.
위의 분석을 바탕으로, 캐시 시스템은 다음과 같은 기능을 가져야 함을 요약할 수 있습니다:
- 캐시된 데이터 저장
- 만료된 데이터 항목 관리
- 메모리에서 데이터 가져오기 및 내보내기
- CRUD 인터페이스 제공
개발 준비
먼저, 작업 디렉토리를 생성하고 GOPATH 환경 변수를 설정합니다:
cd ~/project/
mkdir -p golang/src
export GOPATH=~/project/golang
위 단계에서, ~/project/golang 디렉토리를 생성하고 이를 이후 실험을 위한 GOPATH로 설정합니다.
캐시 시스템 기본 구조
빠르게 접근하기 위해서는 캐시된 데이터를 메모리에 저장해야 합니다. 데이터 항목을 저장하기 위해 어떤 자료 구조를 사용해야 할까요? 일반적으로, 데이터 접근 성능을 향상시키기 위해 해시 테이블을 사용합니다. Go 언어에서는 자체 해시 테이블을 구현할 필요가 없습니다. 내장 타입 map이 이미 해시 테이블을 구현하고 있기 때문입니다. 따라서, 캐시 데이터 항목을 map에 직접 저장할 수 있습니다.
캐시 시스템은 또한 만료된 데이터를 정리하는 것을 지원하므로, 캐시 데이터 항목은 수명을 가져야 합니다. 이는 캐시 데이터 항목이 캡슐화되어 캐시 시스템에 저장되어야 함을 의미합니다. 이를 위해 먼저 캐시 데이터 항목을 구현해야 합니다. GOPATH/src 디렉토리에 새로운 cache 디렉토리를 생성하고, cache.go 소스 파일을 생성합니다:
package cache
import (
"encoding/gob"
"fmt"
"io"
"os"
"sync"
"time"
)
type Item struct {
Object interface{} // The actual data item
Expiration int64 // Lifespan
}
// Check if the data item has expired
func (item Item) Expired() bool {
if item.Expiration == 0 {
return false
}
return time.Now().UnixNano() > item.Expiration
}
위 코드에서, 두 개의 필드를 가진 Item 구조체를 정의합니다. Object는 모든 타입의 데이터 객체를 저장하는 데 사용되며, Expiration은 데이터 항목의 만료 시간을 저장합니다. 또한 Item 타입에 대한 Expired() 메서드를 제공하며, 이 메서드는 데이터 항목이 만료되었는지 여부를 나타내는 부울 값을 반환합니다. 데이터 항목의 만료 시간은 나노초 단위로 측정된 Unix 타임스탬프라는 점에 유의해야 합니다. 데이터 항목이 만료되었는지 어떻게 판단할까요? 실제로 매우 간단합니다. 각 데이터 항목의 만료 시간을 기록하고, 캐시 시스템은 각 데이터 항목을 주기적으로 확인합니다. 데이터 항목의 만료 시간이 현재 시간보다 빠르면, 해당 데이터 항목은 캐시 시스템에서 제거됩니다. 이를 위해 time 모듈을 사용하여 주기적인 작업을 구현할 것입니다.
이제 이를 통해 캐시 시스템의 프레임워크를 구현할 수 있습니다. 코드는 다음과 같습니다:
const (
// Flag for no expiration time
NoExpiration time.Duration = -1
// Default expiration time
DefaultExpiration time.Duration = 0
)
type Cache struct {
defaultExpiration time.Duration
items map[string]Item // Store cache data items in a map
mu sync.RWMutex // Read-write lock
gcInterval time.Duration // Expiration data cleaning interval
stopGc chan bool
}
// Clean expired cache data items
func (c *Cache) gcLoop() {
ticker := time.NewTicker(c.gcInterval)
for {
select {
case <-ticker.C:
c.DeleteExpired()
case <-c.stopGc:
ticker.Stop()
return
}
}
}
위 코드에서, 캐시 시스템 구조를 나타내는 Cache 구조체를 구현했습니다. items 필드는 캐시 데이터 항목을 저장하는 데 사용되는 맵입니다. 보시다시피, gcLoop() 메서드도 구현했습니다. 이 메서드는 time.Ticker를 사용하여 DeleteExpired() 메서드를 주기적으로 실행하도록 예약합니다. time.NewTicker()를 사용하여 생성된 ticker는 지정된 gcInterval 간격으로 ticker.C 채널에서 데이터를 보냅니다. 이 특성을 사용하여 DeleteExpired() 메서드를 주기적으로 실행할 수 있습니다.
gcLoop() 함수가 정상적으로 종료될 수 있도록, c.stopGc 채널에서 데이터를 수신 대기합니다. 이 채널로 데이터가 전송되면, gcLoop()의 실행을 중지합니다. 또한, NoExpiration 및 DefaultExpiration 상수를 정의합니다. 전자는 절대 만료되지 않는 데이터 항목을 나타내고, 후자는 기본 만료 시간을 가진 데이터 항목을 나타냅니다. DeleteExpired()는 어떻게 구현할까요? 아래 코드를 참조하십시오:
// Delete a cache data item
func (c *Cache) delete(k string) {
delete(c.items, k)
}
// Delete expired data items
func (c *Cache) DeleteExpired() {
now := time.Now().UnixNano()
c.mu.Lock()
defer c.mu.Unlock()
for k, v := range c.items {
if v.Expiration > 0 && now > v.Expiration {
c.delete(k)
}
}
}
보시다시피, DeleteExpired() 메서드는 매우 간단합니다. 모든 데이터 항목을 반복하고 만료된 항목을 삭제하기만 하면 됩니다.
캐시 시스템 CRUD 인터페이스 구현
이제 캐시 시스템을 위한 CRUD 인터페이스를 구현할 수 있습니다. 다음 인터페이스를 사용하여 캐시 시스템에 데이터를 추가할 수 있습니다:
// Set cache data item, overwrite if the item exists
func (c *Cache) Set(k string, v interface{}, d time.Duration) {
var e int64
if d == DefaultExpiration {
d = c.defaultExpiration
}
if d > 0 {
e = time.Now().Add(d).UnixNano()
}
c.mu.Lock()
defer c.mu.Unlock()
c.items[k] = Item{
Object: v,
Expiration: e,
}
}
// Set data item without lock operation
func (c *Cache) set(k string, v interface{}, d time.Duration) {
var e int64
if d == DefaultExpiration {
d = c.defaultExpiration
}
if d > 0 {
e = time.Now().Add(d).UnixNano()
}
c.items[k] = Item{
Object: v,
Expiration: e,
}
}
// Get data item, also need to check if item has expired
func (c *Cache) get(k string) (interface{}, bool) {
item, found := c.items[k]
if !found {
return nil, false
}
if item.Expired() {
return nil, false
}
return item.Object, true
}
// Add data item, returns error if item already exists
func (c *Cache) Add(k string, v interface{}, d time.Duration) error {
c.mu.Lock()
_, found := c.get(k)
if found {
c.mu.Unlock()
return fmt.Errorf("Item %s already exists", k)
}
c.set(k, v, d)
c.mu.Unlock()
return nil
}
// Get data item
func (c *Cache) Get(k string) (interface{}, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
item, found := c.items[k]
if !found {
return nil, false
}
if item.Expired() {
return nil, false
}
return item.Object, true
}
위 코드에서, Set() 및 Add() 인터페이스를 구현했습니다. 두 인터페이스의 주요 차이점은 전자는 캐시 시스템에 이미 데이터 항목이 있는 경우 해당 항목을 덮어쓰는 반면, 후자는 데이터 항목이 이미 존재하는 경우 오류를 발생시켜 캐시가 잘못 덮어쓰는 것을 방지한다는 것입니다. 또한 캐시 시스템에서 데이터 항목을 검색하는 Get() 메서드도 구현했습니다. 캐시 데이터 항목의 존재에 대한 진정한 의미는 항목이 존재하고 만료되지 않았다는 점에 유의하는 것이 중요합니다.
다음으로, 삭제 및 업데이트 인터페이스를 구현할 수 있습니다.
// Replace an existing data item
func (c *Cache) Replace(k string, v interface{}, d time.Duration) error {
c.mu.Lock()
_, found := c.get(k)
if !found {
c.mu.Unlock()
return fmt.Errorf("Item %s doesn't exist", k)
}
c.set(k, v, d)
c.mu.Unlock()
return nil
}
// Delete a data item
func (c *Cache) Delete(k string) {
c.mu.Lock()
c.delete(k)
c.mu.Unlock()
}
위 코드는 자체 설명적이므로 자세한 내용은 생략하겠습니다.
캐시 시스템 가져오기 및 내보내기
이전에 캐시 시스템이 데이터를 파일로 가져오고 파일에서 데이터를 로드하는 것을 지원한다고 언급했습니다. 이제 이 기능을 구현해 보겠습니다.
// Write cache data items to io.Writer
func (c *Cache) Save(w io.Writer) (err error) {
enc := gob.NewEncoder(w)
defer func() {
if x := recover(); x != nil {
err = fmt.Errorf("Error registering item types with Gob library")
}
}()
c.mu.RLock()
defer c.mu.RUnlock()
for _, v := range c.items {
gob.Register(v.Object)
}
err = enc.Encode(&c.items)
return
}
// Save data items to a file
func (c *Cache) SaveToFile(file string) error {
f, err := os.Create(file)
if err != nil {
return err
}
if err = c.Save(f); err != nil {
f.Close()
return err
}
return f.Close()
}
// Read data items from io.Reader
func (c *Cache) Load(r io.Reader) error {
dec := gob.NewDecoder(r)
items := map[string]Item{}
err := dec.Decode(&items)
if err == nil {
c.mu.Lock()
defer c.mu.Unlock()
for k, v := range items {
ov, found := c.items[k]
if !found || ov.Expired() {
c.items[k] = v
}
}
}
return err
}
// Load cache data items from a file
func (c *Cache) LoadFile(file string) error {
f, err := os.Open(file)
if err != nil {
return err
}
if err = c.Load(f); err != nil {
f.Close()
return err
}
return f.Close()
}
위 코드에서, Save() 메서드는 gob 모듈을 사용하여 바이너리 캐시 데이터를 인코딩하고 io.Writer 인터페이스를 구현하는 객체에 씁니다. 반면에, Load() 메서드는 io.Reader에서 바이너리 데이터를 읽은 다음 gob 모듈을 사용하여 데이터를 역직렬화합니다. 본질적으로, 여기서는 캐시 데이터를 직렬화 및 역직렬화하고 있습니다.
캐시 시스템의 기타 인터페이스
지금까지 전체 캐시 시스템의 기능이 완료되었으며, 대부분의 작업이 완료되었습니다. 마지막 작업을 마무리해 보겠습니다.
// Return the number of cached data items
func (c *Cache) Count() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.items)
}
// Clear the cache
func (c *Cache) Flush() {
c.mu.Lock()
defer c.mu.Unlock()
c.items = map[string]Item{}
}
// Stop cleaning expired cache
func (c *Cache) StopGc() {
c.stopGc <- true
}
// Create a new cache system
func NewCache(defaultExpiration, gcInterval time.Duration) *Cache {
c := &Cache{
defaultExpiration: defaultExpiration,
gcInterval: gcInterval,
items: map[string]Item{},
stopGc: make(chan bool),
}
// Start the expiration cleanup goroutine
go c.gcLoop()
return c
}
위 코드에서, 몇 가지 메서드를 추가했습니다. Count()는 시스템에 캐시된 데이터 항목의 수를 반환하고, Flush()는 전체 캐시 시스템을 지우며, StopGc()는 만료된 데이터 항목을 정리하는 캐시 시스템을 중지합니다. 마지막으로, NewCache() 메서드를 사용하여 새로운 캐시 시스템을 생성할 수 있습니다.
지금까지 전체 캐시 시스템이 완료되었습니다. 꽤 간단하죠? 이제 몇 가지 테스트를 수행해 보겠습니다.
캐시 시스템 테스트
~/project/golang/src/cache/sample/sample.go에 소스 코드가 있는 샘플 프로그램을 작성할 것입니다. 프로그램의 내용은 다음과 같습니다.
package main
import (
"cache"
"fmt"
"time"
)
func main() {
defaultExpiration, _ := time.ParseDuration("0.5h")
gcInterval, _ := time.ParseDuration("3s")
c := cache.NewCache(defaultExpiration, gcInterval)
k1 := "hello labex"
expiration, _ := time.ParseDuration("5s")
c.Set("k1", k1, expiration)
s, _ := time.ParseDuration("10s")
if v, found := c.Get("k1"); found {
fmt.Println("Found k1: ", v)
} else {
fmt.Println("Not found k1")
}
// Pause for 10 seconds
time.Sleep(s)
// Now k1 should have been cleared
if v, found := c.Get("k1"); found {
fmt.Println("Found k1: ", v)
} else {
fmt.Println("Not found k1")
}
}
샘플 코드는 매우 간단합니다. NewCache 메서드를 사용하여 캐시 시스템을 생성하며, 데이터 만료 정리 주기는 3 초이고 기본 만료 시간은 30 분입니다. 그런 다음 만료 시간이 5 초인 데이터 항목 "k1"을 설정합니다. 데이터 항목을 설정한 후 즉시 검색한 다음 10 초 동안 일시 중지합니다. "k1"을 다시 검색하면 삭제되어 있어야 합니다. 프로그램은 다음 명령을 사용하여 실행할 수 있습니다.
cd ~/project/golang/src/cache
go mod init
go run sample/sample.go
출력은 다음과 같습니다.
Found k1: hello labex
Not found k1
요약
이 프로젝트에서 우리는 데이터 객체를 추가, 삭제, 교체 및 쿼리할 수 있는 캐시 시스템을 개발했습니다. 또한 만료된 데이터를 삭제하는 기능도 포함되어 있습니다. Redis 에 익숙하다면, 키와 관련된 숫자 값을 증가시키는 Redis 의 기능을 알고 있을 것입니다. 예를 들어, "key"의 값이 "20"으로 설정되어 있는 경우, Increment 인터페이스에 "2" 매개변수를 전달하여 "22"로 증가시킬 수 있습니다. 이 기능을 저희 캐시 시스템을 사용하여 구현해 보시겠습니까?



