이번 게시글에서는 go-redis 패키지를 사용하는 컴포넌트 구축 방법을 정리하고 유닛테스트(행위기반 테스트)와, 통합테스트(상태기반 테스트)방식의 테스트 코드 작성법(suite, mock사용)을 예제로 정리합니다.
1. redis란
key, value의 비정형 데이터를 저장해놓는 nosql입니다.
RDB는 parser, 전처리기, 옵티마이저를 타고 디스크까지 접근하므로 느리지만
redis(remote dictionary server)는 서버의 메모리에서 동작하며
조회 정책을 기본적으로 해시테이블방식을 사용하므로(시간복잡도가 O(1)) 빠릅니다.
대신 정규화가 불가능해 RDB에 비해 확장에 불리합니다.
만료시간이 있는 데이터를 저장하거나, 확장될 여지가 없는 데이터셋을 저장할때 주로 사용됩니다.
(redis는 리모트 서버의 인메모리에서 동작하지만 서버가 내려갔을때 데이터 영속성을 위해 AOF와 RDB방식을 제공한다는 특징이 있습니다. 아래 사이트에서 redis 데이터 저장 방식을 상세히 확인할 수 있습니다)
2. 사용 라이브러리
(1). go-redis
redis/go-redis는 redis에 접근하기 위한 클라이언트 라이브러리입니다.
(2). redis-mock
주입할 의존성을 가짜로 만들어 동작을 제어하기 위해 사용합니다.
의존하는 모듈의 결과를 제어할수 있으니 의존하지 않는부분만 테스트할 수 있습니다.
(3). suite
테스트 코드를 작성하고 중복이 발생한다면(junit의 @BeforeEach가 필요하다면) 중복을 줄이기 위해 사용합니다.
3. 예제
go-redis/redis mock을 사용하여 테스트코드를 작성하였으며 실제환경으로도 테스트 할 수 있도록 로컬에 redis서버를 띄워 놓은 상태기반 테스트코드도 포함시켰습니다.
main브랜치에서 확인 가능합니다.
https://github.com/jaeho310/go-redis-client
(1). redis_gateway.go
package gateway
type RedisGateway interface {
SetData(key string, value string) error
GetData(key string) (string, error)
GetKeyList() ([]string, error)
}
IOC를 위한 인터페이스를 정의한 파일입니다.
(2). redis_gateway_impl.go
package gateway
import (
"context"
"github.com/go-redis/redis/v8"
"time"
)
type RedisGatewayImpl struct {
client *redis.Client
expireTime time.Duration
}
func (redisGateway RedisGatewayImpl) New(client *redis.Client, expireTime time.Duration) *RedisGatewayImpl {
return &RedisGatewayImpl{client: client, expireTime: expireTime}
}
func (redisGateway *RedisGatewayImpl) SetData(key string, value string) error {
err := redisGateway.client.Set(context.Background(), key, value, redisGateway.expireTime).Err()
if err != nil {
return err
}
return nil
}
func (redisGateway *RedisGatewayImpl) GetData(key string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
successCh := make(chan string)
errorCh := make(chan error)
go func(successCh chan string, errorCh chan error) {
result, err := redisGateway.client.Get(ctx, key).Result()
if err != nil {
errorCh <- err
return
}
successCh <- result
}(successCh, errorCh)
select {
case <-ctx.Done():
return "", ctx.Err()
case result := <-successCh:
return result, nil
case result := <-errorCh:
return "", result
}
}
// redis는 하나의 스레드를 갖기에 keys라는 명령어는 금기
// scan을 사용
func (redisGateway *RedisGatewayImpl) GetKeyList() ([]string, error) {
var cursor uint64
var keyList []string
for {
var keys []string
var err error
keys, cursor, err = redisGateway.client.Scan(context.Background(), cursor, "*", 10).Result()
if err != nil {
return nil, err
}
for _, el := range keys {
keyList = append(keyList, el)
}
if cursor == 0 {
return keyList, nil
}
}
}
생성을 할때 redisClient와 만료시간을 받아온후 요청이 오면 redis에 데이터를 저장, 조회합니다.
조회요청은 context(https://frozenpond.tistory.com/160)를 활용하여 timeout을 적용하였습니다.
단순한 예제지만 전체 키 조회라는 기능을 개발할때 주의해야 할 점이 있어서 포함시켰습니다.
레디스 전체키 조회방식을 검색하면 keys라는 메서드가 나옵니다.
레디스는 싱글스레드가 정책이므로 keys로 모든 키를 다 조회하면서 스레드를 붙잡아버리면 다른 요청이 올 스탑됩니다(keys 사용금지)
keys를 사용하지 않고 scan으로 cursor가 0이 나올때까지 쪼개서 조회해야합니다(1000개를 조회할때 10개씩 100번 조회하면서 100번 사이사이에 다른요청을 처리할 수 있도록)
(3). main.go
package main
import (
"fmt"
"github.com/go-redis/redis/v8"
"redis-tut/gateway"
"redis-tut/service"
"time"
)
func main() {
fooService := getFooService(getRedisGateWay())
err := fooService.SetData("hello", "world")
if err != nil {
panic(err)
}
data, err := fooService.GetData("hello")
if err != nil {
panic(err)
}
fmt.Println(data)
list, err := fooService.GetKeyList()
if err != nil {
panic(err)
}
fmt.Println(list)
}
func getRedisGateWay() *gateway.RedisGatewayImpl {
redisClient := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // password
DB: 0, // namespace
})
return gateway.RedisGatewayImpl{}.New(redisClient, time.Second*5)
}
func getFooService(redisGatewayImpl *gateway.RedisGatewayImpl) *service.FooService {
return service.FooService{}.New(redisGatewayImpl)
}
비즈니스단에 gateway단의 의존성 주입을 해준후 비즈니스로직을 호출하여 레디스를 이용하는 main.go 파일입니다.
실제 환경을 구축해야하므로 로컬에 redis를 띄워줘야 합니다.(docker 이미지가 32MB로 무겁지 않습니다)
도커가 깔려있는 환경이라면 아래의 명령어로 컨테이너를 실행시킵니다.
$ docker run --name myredis -d -p 6379:6379 redis
(4). foo_service.go
package service
import "redis-tut/gateway"
type FooService struct {
redisGateway gateway.RedisGateway
}
func (fooService FooService) New(redisGateway gateway.RedisGateway) *FooService {
return &FooService{redisGateway}
}
func (fooService *FooService) GetData(key string) (string, error) {
return fooService.redisGateway.GetData(key)
}
func (fooService *FooService) SetData(key string, value string) error {
return fooService.redisGateway.SetData(key, value)
}
func (fooService *FooService) GetKeyList() ([]string, error) {
return fooService.redisGateway.GetKeyList()
}
레디스 컴포넌트를 호출하는 비즈니스 로직 레이어입니다.
(5). redis_gateway_impl_test.go
package gateway
import (
"context"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"strconv"
"testing"
"time"
)
// 실제로 테스트할 구현체와 suite.Suite를 담는 struct를 만들어줍니다.
type RedisGatewayTestSuite struct {
suite.Suite
redisGateway *RedisGatewayImpl
}
// suite.Run()이 SetUpTest()를 실행시킵니다.
func TestRedisGatewayTestSuite(t *testing.T) {
suite.Run(t, new(RedisGatewayTestSuite))
}
// 모든 함수마다 RedisGatewayImpl을 구축할 수는 없습니다.
// 테스트에서 공통으로 해야하는 행위들을 넣어줍니다.
// junit의 @beforeEach와 비슷한 기능입니다.
func (redisGatewayTestSuite *RedisGatewayTestSuite) SetupTest() {
redisClient := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // password
DB: 0, // namespace
})
ctx := context.Background()
redisClient.FlushAll(ctx) // 실제 상태를 테스트하므로 flush를 해줍니다.
redisGatewayTestSuite.redisGateway = RedisGatewayImpl{}.New(redisClient, time.Second*100)
}
// 생성 확인
func (redisGatewayTestSuite *RedisGatewayTestSuite) TestRedisGatewayNew() {
assert.NotNil(redisGatewayTestSuite.T(), redisGatewayTestSuite.redisGateway)
}
// set과 get 확인
func (redisGatewayTestSuite *RedisGatewayTestSuite) TestRedisGatewaySetAndGet() {
key := "hello"
value := "world"
err := redisGatewayTestSuite.redisGateway.SetData(key, value)
assert.NoError(redisGatewayTestSuite.T(), err)
data, err := redisGatewayTestSuite.redisGateway.GetData(key)
assert.NoError(redisGatewayTestSuite.T(), err)
assert.Equal(redisGatewayTestSuite.T(), value, data)
}
// key list 테스트
func (redisGatewayTestSuite *RedisGatewayTestSuite) TestRedisGatewayGetKeyList() {
cnt := 15
for i := 0; i < cnt; i++ {
err := redisGatewayTestSuite.redisGateway.SetData(strconv.Itoa(i), strconv.Itoa(i))
assert.NoError(redisGatewayTestSuite.T(), err)
}
res, err := redisGatewayTestSuite.redisGateway.GetKeyList()
assert.NoError(redisGatewayTestSuite.T(), err)
assert.Equal(redisGatewayTestSuite.T(), cnt, len(res))
}
실제 local에 레디스를 띄워놨을때 쓰는 테스트 코드입니다.
t *testing.T은 한번 사용됩니다.
한번 사용되는 해당 메서드에서 suite.Run을 실행시켜(suite.Run이 SetupTest를 실행) 테스트 환경을 구축시켜놓으면
모든 테스트코드를 실행할때 공통된 환경으로 테스트를 할 수 있습니다.
(6). redis_gateway_mock_test.go
package gateway
import (
"errors"
"github.com/go-redis/redismock/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"testing"
"time"
)
type MockRedisGatewayTestSuite struct {
suite.Suite
redisGateway *RedisGatewayImpl
mock redismock.ClientMock
expireTime time.Duration
}
// suite.Run()이 SetUpTest()를 실행시킵니다.
func TestMockRedisGatewayTestSuite(t *testing.T) {
suite.Run(t, new(MockRedisGatewayTestSuite))
}
// 테스트에서 공통으로 해야하는 행위들을 구조체에 넣어줍니다.
// junit의 @beforeEach와 비슷한 기능입니다.
func (redisGatewayTestSuite *MockRedisGatewayTestSuite) SetupTest() {
client, mock := redismock.NewClientMock()
redisGatewayTestSuite.expireTime = time.Second * 100
redisGatewayTestSuite.redisGateway = RedisGatewayImpl{}.New(client, redisGatewayTestSuite.expireTime)
redisGatewayTestSuite.mock = mock
}
// 생성 확인
func (redisGatewayTestSuite *MockRedisGatewayTestSuite) TestRedisGatewayNew() {
assert.NotNil(redisGatewayTestSuite.T(), redisGatewayTestSuite.redisGateway)
}
// redis set 테스트
func (redisGatewayTestSuite *MockRedisGatewayTestSuite) TestRedisGatewaySet() {
key := "hello"
value := "world"
redisGatewayTestSuite.mock.ExpectSet(key, value, time.Second*100).SetVal("SUCCESS")
err := redisGatewayTestSuite.redisGateway.SetData(key, value)
assert.NoError(redisGatewayTestSuite.T(), err)
}
// redis set 에러 테스트
func (redisGatewayTestSuite *MockRedisGatewayTestSuite) TestRedisGatewaySetWithError() {
key := "hello"
value := "world"
redisGatewayTestSuite.mock.ExpectSet(key, value, time.Second*100).SetErr(errors.New("FAIL"))
err := redisGatewayTestSuite.redisGateway.SetData(key, value)
assert.Error(redisGatewayTestSuite.T(), err, "FAIL")
}
// redis get 테스트
func (redisGatewayTestSuite *MockRedisGatewayTestSuite) TestRedisGatewayGet() {
key := "hello"
value := "world"
redisGatewayTestSuite.mock.ExpectGet(key).SetVal(value)
data, err := redisGatewayTestSuite.redisGateway.GetData(key)
assert.NoError(redisGatewayTestSuite.T(), err)
assert.Equal(redisGatewayTestSuite.T(), value, data)
}
// redis get 에러 테스트
func (redisGatewayTestSuite *MockRedisGatewayTestSuite) TestRedisGatewayGetWithError() {
key := "hello"
redisGatewayTestSuite.mock.ExpectGet(key).SetErr(errors.New("FAIL"))
_, err := redisGatewayTestSuite.redisGateway.GetData(key)
assert.EqualError(redisGatewayTestSuite.T(), err, "FAIL")
}
// redis scan 테스트
func (redisGatewayTestSuite *MockRedisGatewayTestSuite) TestGetKeyList() {
var mockResult []string
mockResult = append(mockResult, "a")
mockResult = append(mockResult, "b")
redisGatewayTestSuite.mock.ExpectScan(0, "*", 10).SetVal(mockResult, 0)
list, err := redisGatewayTestSuite.redisGateway.GetKeyList()
assert.NoError(redisGatewayTestSuite.T(), err)
assert.EqualValues(redisGatewayTestSuite.T(), mockResult, list)
}
redis-mock 라이브러리로 가짜 서버를 주입해준 테스트 코드입니다.
의존하는 레디스 클라이언트 mock client로 주입해주고 mock.ExpectXXX 으로 행위를 제어하면 레디스에 상관없이 원하는 로직만 테스트 할 수 있습니다.
'golang' 카테고리의 다른 글
[golang] go context, go-cache를 활용한 캐시 서버 만들기 (0) | 2022.01.13 |
---|---|
[golang] golang profile 적용하기(viper 예제) (0) | 2021.12.28 |
[golang] go context의 활용2(go context사용법 및 예제2) (0) | 2021.12.09 |
[golang] go context의 활용1(go context사용법 및 예제1) (0) | 2021.12.07 |
[golang] go context란 (0) | 2021.12.06 |
댓글