본문 바로가기

Golang

[Golang] MongoDB wrapper

728x90

go-mongo

  • go.mongodb.org/mongo-driver 를 기반으로 실제 프로젝트에서 유용하게 사용될 수 있는 wrapper

어떻게 하다 시작했나?

회사에 입사하고 나서, golang과 몽고 DB를 주로 사용하게 되었다. mongo-driver 라이브러리를 사용하고 있었으며, 해당 라이브러리와 몽고 DB에 익숙해질 겸 라이브러리 함수를 사용하여 내가 나중에 쓸만한 코드를 만들어 둘려고 했다.

하지만 내가 golang으로 개발하면서 처음부터 그리고 지금까지도 고민되는 부분이 있었다.

go에서는 함수에서 에러가 발생하면, 다른 언어들처럼 exception(go에서는 panic)을 발생시키는 것이 아닌 해당 에러를 직접 핸들링을 하는것을 권장한다.

func fn() error {
        x, err := bar.Foo()
        if err != nil {
                return err // do not panic()
        }
        ...
}
  1. golang은 error를 직접 핸들링 해야되는데, 그러면 그 에러에 대한 로그를 어디서 어느 레벨로 찍어야하는가?
  2. 그리고 그 에러는 어디까지 올려야되는가?

그래서 함수들이 함수를 부르고, 에러를 리턴 받고 또 그것을 리턴하는 구조가 심심치 않게 발생하다 보니, 이 에러에 대한 로그는 어디에서 남겨야하는가에 대한 의문이 들었다.

물론 에러가 발생한 모든 지점에서 로그를 찍으면 가장 좋다. 하지만 그렇게 되면 로그 양이 너무나도 커질 수 있으며, 해당 에러가 어느 로그 레벨로 찍혀야되는지 모호한 경우도 많았다.

예를 들어, 함수에서 db 함수에서 어떤 document를 못찾았을 때, 이는 Error 레벨인가? 아니면 Info 레벨인가? 해당 함수 내부에서는 알 수 없다.

웹 서버 애플리케이션 관점으로 이야기 하자면, controller, service, repository의 단계로 프로젝트가 구성되어 있다고 하자. 하위 레이어에서는 본인이 내뱉을 에러 혹은 리턴 값이 어떻게 사용될 지 알 수 없고, 실제 에러인지 알 수 없다.

예를 들어, repository에서 userRepository가 있다고 하면, findUser를 하였을 때, 해당 user가 없는 경우 이것이 발생하면 안되는 상황인지 or 정상적인 상황인지는 상위 레이어(service)에서 결정된다.

그렇다면 로그는 어디서 어떻게 남겨야하는가? 에 대한 고민도 생긴다. repository에서는 에러 로그를 이를 info, warn으로 남길 것인가? 로그 레벨을 정할 수 없다.

그러면 repoistory에서는 Info 레벨로 남기고 에러인지는 service에서 판단하여 Error 레벨로 찍을 것인가? 라는 생각도 들었다. 하지만 그건 중복 로그가 남게 되는 것이며, 효율적이지 못하다고 생각했다.

또한, repoistory에서 다양한 에러(not found, duplicate key, timeout, decode error 등)가 발생할 수 있는데, service 레이어에서는 이를 어떻게 판별할 수 있을까?에 대한 생각도 필요했다.

많은 고민의 결과 다음과 같이 결정했다.

Basic concept

  1. repository 레이어에서는 로그를 남기지 않고, db 쿼리를 실행한 정보와 에러를 wrapping하여 리턴한다.
  2. service 레이어에서는 mongo driver의 에러를 핸들링하는 것이 아닌, repository내부에서 정의한 에러를 핸들링하고 로그 레벨을 판별하여 로그를 찍는다.
  3. error가 nil일 경우, 리턴되는 값(document 데이터)은 nil이 아님과 쿼리 성공을 보장한다.
  4. singleResult, Cursor 등 매번 Decode하는 중복 코드를 없앤다.

1. repository 레이어에서는 로그를 남기지 않고, db 쿼리를 실행한 정보와 에러를 wrapping하여 리턴한다.

위에서 말했듯이, repository 레이어에서는 발생한 에러가 어떤 레벨로 찍혀야하는 에러인지 알 수 없다. 이는 비즈니스 로직을 포함한 상위 레이어까지 올라가야 알 수 있기에 나는 repository에는 로그를 찍지 않는 방향으로 설계했다.

repository에서 로그를 남기지 않기로 했기에, repository는 에러에 대한 정보를 상위 레이어에 잘 넘겨줘야했었다. 그래서 에러 로깅에 필요한 내용들이 무엇일 까 생각해보았다.

우선 DB에서 발생할 수 있는 에러가 어떤 것인지 파악했다.

  • Timeout
  • Not found
  • Duplicated Key
  • Network
  • Disconnect

mongo에서 정의한 에러는 위의 5개 말고 더 있지만, 거의 사용되지 않는 에러일 것 같아서 5개로 추렸다.

그리고 update나 delete시 matched count / modified count 가 0인 경우도 우선 에러로 보았고,

  • matched count == 0 → Not found
  • modified count == 0 → Not Modified

mongo result를 decoding하는데 발생한 에러도 생각하여

  • decode

정도로 추렸다.

또한, 기본본적으로 어떤 쿼리를 실행하다가 에러가 났는지 디버깅을 위해 basicQueryInfo를 모든 에러 내용에 포함시키기로 하였다.

type basicQueryInfo struct {
	collection string
	filter     interface{}
	update     interface{}
	doc        interface{}
}

type duplicatedKeyError struct {
	basicQueryInfo
	error
}

type notModifiedError struct {
	basicQueryInfo
	error
}

type timeoutError struct {
	basicQueryInfo
	error
}

type internalError struct {
	basicQueryInfo
	error
}

type decodeError struct {
	basicQueryInfo
	error
}

내가 생각하기에 service 레이어에서 핸들링 할만한 에러들은 error struct를 정의했고, 나머지 에러는 모두 internalError 로 wrapping했다.

그리고 이 내용들을 실제 에러를 찍을 경우, 에러 메시지 + 쿼리 정보 형태로 나오도록 메시지를 정의했다.

func (e *internalError) Error() string {
	return fmt.Sprintf("mongo internal err: %s ", e.error.Error()) + getBasicInfoErrorMsg(e.basicQueryInfo)
}

func getBasicInfoErrorMsg(e basicQueryInfo) string {
	msg := "| {query info: "
	if e.filter != nil {
		msg += fmt.Sprintf(" filter: %+v", e.filter)
	}

	if e.update != nil {
		msg += fmt.Sprintf(", update: %+v", e.update)
	}

	if e.doc != nil {
		msg += fmt.Sprintf(", doc: %+v", e.doc)
	}
	msg += "}"
	return msg
}

"accounts not found. | {query info: filter: map[account_id:0]}"

2. 상위 레이어에서는 mongo driver에서 내뱉는 에러를 핸들링하는 것이 아닌, 내부에서 정의한 에러를 핸들링하고 로그 레벨을 판별하여 로그를 찍는다.

상위 레이어(service)에서는 mongo-driver에서 나오는 에러를 판별하는 것이 아닌, 앞서 repository가 쿼리 정보와 함께 에러를 wrapping한 것을 핸들링 한다.

그렇다면 상위 레이어에서는 이 에러를 어떻게 분별할 것인지가 필요했다.

나는 3가지 방법을 생각했다..

  1. errors.As()

"github.com/pkg/errors" 를 이용하여 제공한 구조체에 에러가 바인딩이 될 수 있는지 확인하는 방식이다.

func IsErrorOf(err error, target interface{}) bool {
	if errors.As(err, target) {
		return true
	}
	return false
}

하지만 이 방식은 매번 에러를 확인할 때마다 target 에러의 변수를 선언해서 넘겨줘야하는 불편함이 있었다.

func service(err error) {
	var notFoundErr notFoundError
	if IsErrorOf(err, &notFoundErr) {
		// handle error
	}
	var dupKeyErr duplicatedKeyError
	if IsErrorOf(err, &dupKeyErr) {
		// handle error
	}
	// ...
}
  1. type switch

이 방식은 golang에서 타입을 판별할 때, 가장 자주 쓰이는 방식이었다.

func IsNotFoundErr(err error) bool {
	switch err.(type) {
	case *notFoundError:
		return true
	default:
		return false
	}
}
func service(err error) {
	if IsNotFoundErr(err) {
		// handle error
	}
	if IsDuplicatedKeyErr(err) {
		// handle error
	}
	// ...
}

위 방식은 모든 error struct마다 type switch 함수를 만들어줘야하는 불편함은 있지만, 가장 직관적이고 repository에서 발생하는 error의 종류가 많아질 가능성이 거의 없기 때문에 이 방법도 괜찮아 보인다.

  1. reflect

golang의 reflect를 사용하여 에러가 해당 struct의 타입과 똑같은지 판별하는 것이다.

func GetInterfaceType(v interface{}) reflect.Type {
	var t reflect.Type
	if xt, ok := v.(reflect.Type); ok {
		t = xt
	} else {
		t = reflect.TypeOf(v)
	}
	return t
}
func IsErrorTypeOf(err error, v interface{}) bool {
	t := GetInterfaceType(v)
	errorType := reflect.TypeOf(err)

	if t == errorType {
		return true
	}
	return false
}

// IsErrorTypeOf(err, duplicatedKeyError{})

하지만 이 방식을 사용하기 위해선, error struct를 public으로 오픈해야되는 조건이 있다. 또한, 체크하려는 struct의 객체를 생성해야되기 때문에 1번의 불편함은 존재하긴 한다. 하지만 1번과 2번의 장단점의 중간 정도의 방법인 것 같아, 우선 2, 3번을 구현해놓았다.

 

3. error가 nil일 경우, 리턴되는 값(document 데이터)은 nil이 아님과 쿼리 성공을 보장한다.

golang을 사용하면서 net/http 패키지를 자주 사용했었는데 사용하면서 좋다고 생각했던 점이, error가 nil이면 리턴되는 값은 non-nil을 보장한다는 것이었다.

// net/http/client.go

// If the returned error is nil, the Response will contain a non-nil
// Body which the user is expected to close.
// ...
func (c *Client) Do(req *Request) (*Response, error) {
	return c.do(req)
}

그래서 이러한 개념을 적용하여, error가 nil로 리턴된 경우, 리턴된 document는 nil이 아님과 동시에 쿼리 성공을 보장하기로 했다.

위와 같은 개념을 정의한 이유는 바로 not found 때문이었다. 만약 find를 하였는데 not found인 경우, 리턴 형태를 *document로 정의하고 nil, 에러도 nil로 리턴할 수도 있다. (이 경우에 쿼리가 실패하지는 않았기 때문이다.)

func FindUser(userId string) (*UserDocument, error) {
	if not found {
		return nil, nil
	}
}

하지만 나는 이 방식이 좋지 못하다고 생각한 것이, 우선 mongo-driver 내에서도 not found는 error로 정의하고 있다.

// single_result.go

// ErrNoDocuments is returned by SingleResult methods when the operation that created the SingleResult did not return
// any documents.
var ErrNoDocuments = errors.New("mongo: no documents in result")

또한, nil, nil 방식으로 리턴을 한다면, 상위 함수에서는 error가 nil인지 체크할 뿐만 아니라 document가 nil인지 어느 경우에서든 매번 nil 체크를 해야한다.

물론 not found를 error로 내뱉는 경우도 체크를 해야된다. 하지만 error로 내뱉을 때의 장점은 not found도 에러(발생하면 안되는) 상황으로 취급할 때는, 굳이 not found err를 확인할 필요 없이, 그냥 error를 상위로 올려버리거나, 에러 로그를 찍고 끝내버리면 된다.

그래서 나는 not found는 error로 보기로 했고, error가 nil인 경우 쿼리 성공 + 결과 값이 nil이 아님을 보장하는 방식으로 하였다.

4. SingleResult, Cursor 등 매번 Decode하는 중복 코드를 없앤다

mongo-driver를 사용하면서 매번 불편했던 점이 decode하는 코드를 매번 작성해야된다는 것이었다.

cursor, err := userCollection.Find(ctx, bson.M{})
if err != nil {
    log.Fatal(err)
}
defer cursor.Close(ctx)
for cursor.Next(ctx) {
    var user UserDocument
    if err = cursor.Decode(&user); err != nil {
        log.Fatal(err)
    }
    fmt.Println(user)
}
singleResult := userCollection.FindOne(ctx, bson.M{})
var user UserDocument
if err := singleResult.Decode(&user); err != nil {
    log.Fatal(err)
}
fmt.Println(user)

위와 같은 중복 코드를 없애고, wrapping하는 함수에서 decode를 할 수 있는 방법을 생각해보았다.

우선 singleResult를 decode하는 것은 decode 함수를 한번 wrapping만 해주면 되었기에 간단했다.

func EvaluateAndDecodeSingleResult(result *mongo.SingleResult, v interface{}) error {
	if result == nil {
		return errorType.SingleResultErr
	}
	if err := result.Decode(v); err != nil {
		return err
	}
	return nil
}

하지만 문제는 Cursor를 decode하는 것이었다.

decode하는 type이 어떤 것이 들어올지 모르는 runtime에 결정되는 dynamic한 것이었기 때문에 쉽지 않았다.

dynamic을 추론하는 방법은 reflect 방법밖에 없었으며, 그마저도 완벽히 할 수 없었다.

func (col *Collection) FindAll(requiredExample interface{}, filter interface{}, opts ...*options.FindOptions) (interface{}, error) {
	ctx, ctxCancel := context.WithTimeout(context.Background(), DB_TIMEOUT)
	defer ctxCancel()
	cursor, err := col.findAll(ctx, filter, opts...)
	if err != nil {
		return nil, errorType.ParseAndReturnDBError(err, col.Name(), filter, nil, nil)
	}
	return DecodeCursor(cursor, util.GetInterfaceType(requiredExample)), nil
}

func DecodeCursor(cursor *mongo.Cursor, t reflect.Type) interface{} {
	slice := reflect.MakeSlice(reflect.SliceOf(t), 0, 10)
	for cursor.Next(context.Background()) {
		doc := reflect.New(t).Interface()
		if err := cursor.Decode(doc); err != nil {
			fmt.Println(err.Error())
		}
		slice = reflect.Append(slice, reflect.ValueOf(doc).Elem())
	}
	return slice.Interface()
}

FindAll() 함수에 slice로 리턴받을 예시 struct 객체를 넣으면 해당 객체가 담긴 []interface{}를 리턴해준다. 그리고 상위 함수에서는 이를 한번 더 type assertion을 해야하는 불편함이 있다.

func find() {
	all, _ := m.FindAll(data.Account{}, bson.M{})
	result := all.([]data.Account)
}

DecodeCursor() 함수 내부에서 slice를 만들지 말고, 기존 cursor decode 방식처럼 외부에서 slice를 받아서 append하는 방식을 시도해보았으나, 쉽지 않았고 구글링해도 딱히 해결책을 찾지 못했다.

그래서 mongo-driver에서 외부의 slice를 받아서 decode하는 cursor.All()은 어떻게 구현되어있는지 확인해보았다.

func (c *Cursor) All(ctx context.Context, results interface{}) error {
	resultsVal := reflect.ValueOf(results)
	if resultsVal.Kind() != reflect.Ptr {
		return fmt.Errorf("results argument must be a pointer to a slice, but was a %s", resultsVal.Kind())
	}

	sliceVal := resultsVal.Elem()
	if sliceVal.Kind() == reflect.Interface {
		sliceVal = sliceVal.Elem()
	}

	if sliceVal.Kind() != reflect.Slice {
		return fmt.Errorf("results argument must be a pointer to a slice, but was a pointer to %s", sliceVal.Kind())
	}

	elementType := sliceVal.Type().Elem()
	var index int
	var err error

	defer c.Close(ctx)

	batch := c.batch // exhaust the current batch before iterating the batch cursor
	for {
		sliceVal, index, err = c.addFromBatch(sliceVal, elementType, batch, index)
		if err != nil {
			return err
		}

		if !c.bc.Next(ctx) {
			break
		}

		batch = c.bc.Batch()
	}

	if err = replaceErrors(c.bc.Err()); err != nil {
		return err
	}

	resultsVal.Elem().Set(sliceVal.Slice(0, index))
	return nil
}

위와 같이 똑같이 reflect를 사용하고 있지만, 다른 점은 맨 마지막 줄이었다.

resultsVal.Elem().Set(sliceVal.Slice(0, index))

외부에서 받은 slice에 내부에서 만든 slice의 데이터들을 Set하는 것이었다. 이러한 이유때문에 mongo 공식 문서에서 cursor.All()은 메모리 이슈가 있다고 기술해놓았던 것이다.

Memory
If the number and size of documents returned by your query exceeds available application memory, your program will crash. 
If you except a large result set, you should consume your cursor iteratively.

그래서 결국엔 cursor.All()을 사용하는 방식과 내가 직접 만든 reflect 함수를 사용하는 방식 총 2가지를 만들었다. 만약 조회할 데이터가 크지 않아면 전자의 함수를, 크다면 불편함은 있지만 후자의 함수를 사용하도록 선택지를 주는게 좋다고 생각했다. 혹은 직접 decode하는 방식도 사용할 수 있을 것 같은데, cursor를 리턴하는 함수도 제공할 지 고민이 필요하다.

추가 고민 필요사항

  1. 트랜잭션 함수를 어떻게 처리할까

go mongo driver를 사용하면서 트랜잭션 함수 만드는게 가장 번거로웠다. 똑같은 repository 쿼리 함수인데 transaction session context의 유무때문에 2벌씩 만들어야 하는 점이 좋지 않아보였다. 그래서 이 프로젝트에서 해결할 수 있는 방법을 생각해보려 했지만, golang이 오버로딩을 제공해주지 않아 결국 코드 중복이 생길 수 밖에 없었다.

func (col *Collection) FindOne(data, filter interface{}, opts ...*options.FindOneOptions) error {
	ctx, ctxCancel := context.WithTimeout(context.Background(), DB_TIMEOUT)
	defer ctxCancel()
	singleResult := col.findOne(ctx, filter, opts...)
	if err := EvaluateAndDecodeSingleResult(singleResult, data); err != nil {
		if err == mongo.ErrNoDocuments {
			return errorType.ParseAndReturnDBError(err, col.Name(), filter, nil, nil)
		}
		return errorType.DecodeError(col.Name(), filter, nil, nil, err)
	}
	return nil
}

func (col *Collection) FindOneTS(data, filter interface{}, sessCtx *mongo.SessionContext, opts ...*options.FindOneOptions) error {
	singleResult := col.findOne(*sessCtx, filter, opts...)
	if err := EvaluateAndDecodeSingleResult(singleResult, data); err != nil {
		if err == mongo.ErrNoDocuments {
			return errorType.ParseAndReturnDBError(err, col.Name(), filter, nil, nil)
		}
		return errorType.DecodeError(col.Name(), filter, nil, nil, err)
	}
	return nil
}

session context를 받는 함수와 일반 함수를 나눠서 wrapping하고, mogno query 함수는 공통적으로 사용하도록 하였다. 이렇게 나눠서하는 방법밖에 떠오르지 않았고, 실제 사용하는 repository 함수에서는 1개로 사용할 수 있지 않을까 싶은데 깔끔하지는 않다.

func (r *userRepository) FindOne(userId string, sessCtx ...*mongo.SessionContext) (*UserDocument, error) {
	filter := bson.M{"userId": userId}

	var u UserDocument
	var err error
	if len(sessCtx) >= 1 {
		for _, s := range sessCtx {
			err = r.collection.FindOneTS(&u, &filter, s)
			break
		}
	} else {
		err = r.collection.FindOne(&u, &filter)
	}
	if err != nil {
		return nil, err
	}
	return &u, nil
}
  1. not modified, not matched를 에러로 봐야하는가?이러한 부분은 비즈니스 로직마다 보는 관점이 다 다를 것 같아서 멱등성 관점에서만 보고 에러로 보지 않기에는 어려울 것 같다. 하지만 에러로 보자니 어느 부분에서는 맞지 않고.. 애매한 지점이다..
  2. update, delete의 결과로 MatchedCount / ModifiedCount 등이 나오는데, 이 값들이 0일 경우 에러로 봐야하는지 생각해볼 문제이다.멱등성 관점에서 보면, delete의 경우 지우려는 대상이 없다(MatchedCount == 0)는 것은 해당 값이 db에서 없는 정상적인 상황이고, update의 경우 수정된 대상이 없다(MatchedCount ≠ 0 && ModifiedCount == 0)는 것은 해당 값이 이미 update 요청한 값으로 있다는 것인데..

https://github.com/kjh03160/go-mongo

 

GitHub - kjh03160/go-mongo

Contribute to kjh03160/go-mongo development by creating an account on GitHub.

github.com

아직까지 완성된 단계는 아니고 고쳐야할 부분이 많지만, 혹시라도 좋은 아이디어나 피드백이 있으시면 댓글로 첨언주시면 감사드리겠습니다.

 

 

728x90