Skip to main content

Golang, 그대들은 어떻게 할 것인가 - 2. MongoDB Go Driver 추상화

About 7 minGoMongoDBArticle(s)blogd2.naver.comgogolangmongodb

Golang, 그대들은 어떻게 할 것인가 - 2. MongoDB Go Driver 추상화 관련

Go > Article(s)

Article(s)

Golang, 그대들은 어떻게 할 것인가 - 2. MongoDB Go Driver 추상화 | NAVER D2
Golang, 그대들은 어떻게 할 것인가 - 2. MongoDB Go Driver 추상화

클로바노트 V1의 주요 서버들은 Golang(v1.14)으로 개발되었고 MongoDB를 메인 DB로 사용하고 있습니다. MongoDB Go Driver (mongodb/mongo-go-driver)open in new window라는 라이브러리를 사용하고 있는데, 이는 DB 쿼리(raw query)를 간편하게 작성할 수 있게 하고 struct에 매핑을 도와주는 매퍼 형식의 DB 라이브러리라고 생각하시면 될 것 같습니다.

앞 글에서 이야기한 error에 대한 고민 중 DB 레이어에서 고민이 필요한 부분은 다음과 같았습니다.

  • 로그는 어디서 남겨야 하는가? 남긴다면 어떤 레벨로 남겨야 하는가?

어떤 document에 대한 find를 실행했는데 해당 document가 없는 경우, 이것이 발생하면 안 되는 상황인지 정상적인 상황인지는 상위 레이어에서 결정됩니다. 그렇다면 DB 레이어에서는 로그를 Info, Warn 중 어떤 레벨로 남겨야 할까요? DB 레이어에서는 로그 레벨을 결정할 수가 없습니다. 그러면 DB 레이어에서는 Info 레벨로 로그를 남기고 상위 함수에서 error 여부를 판단하여 Error 레벨로 로그를 남겨야 할까요? 하지만 그러면 로그가 중복되고 효율적이지 못한 것 같았습니다.

이 밖에도 DB에서 다양한 error(찾을 수 없음, 중복된 키, 시간 초과, 디코딩 오류 등)가 발생할 수 있는데 상위 레이어에서는 이를 어떻게 판별할지에 대한 고민도 필요했습니다.


기존 문제점

V1 서버에서 DB 레이어 구조만 보면 다음과 같습니다.

클로바노트 V1을 만들 때는 서비스를 만들어내는 속도가 중요한 상황이어서 일관성 없이 각 서버에서 각자의 방식으로 MongoDB Go Driver를 사용하고 있었는데, 이러한 구조를 없애고 모든 서버가 같은 구조로 일관성 있게 동작하게 개선하고 싶었습니다.

document를 가져오는 코드의 구조는 다음과 같았습니다.

  1. DB collection 객체 생성
  2. 시간 초과(timeout) 설정
  3. 쿼리 작성(filter, update 등)
  4. slow 쿼리 로깅
  5. document 디코딩

3번을 제외한 코드는 대부분 각 서버에서 작성해 코드가 중복되어 있었고, 그에 따라 코드의 양이 많아지고 동작이 다르거나 누락된 경우도 많아 공통화에 대한 고민도 했습니다.

코드 예
type MyCollectionManager struct {  
    authSource string
    collection string
    client  *mongoDB.Client
}

// 1. collection 객체 생성
func MyCollection(client *mongo.Client) *MyCollectionManager {  
    manager := &MyCollectionManager{}

    dbConfig := config.GetDatabaseConfig()
    manager.authSource = dbConfig.DatabaseName
    manager.collection = "myCollection"
    manager.client = client
    return manager
}

// 실제 쿼리 조건 세팅 및 디코딩
func (manager *MyCollectionManager) GetDocument() (*MyDocument, int, error) {  
    document := &MyDocument{}

    // 3. 쿼리 조건 세팅
    filter := bson.M{}

    startTime := time.Now()
    singleResult := manager.client.FindOne(manager.authSource, manager.collection, &filter)

    // 4. slow 쿼리 로깅
    if time.Since(startTime) > slowQueryLimit {
            log.Error(...)
    }

    if singleResult == nil {
        msg := fmt.Sprintf("FindOne document is failed")
        log.Error(msg)
        return document, ERROR_INTERNAL_SERVER, errors.New(msg)
    }

    // 5. 디코딩
    var document MyDocument
    if err := singleResult.Decode(&document); err != nil {
        log.Error(err)
        if err == mongo.ErrNoDocuments {
                return document, ERROR_NOT_FOUND, err
        }
        return &document, ERROR_INTERNAL_SERVER, err
    }

    return &document, notecommon.SUCCESS, nil
}

// 2. 시간 초과 설정 및 쿼리 수행
func (client *mongo.Client) FindOne(databaseName string, collectionName string, filter *bson.M) *mongo.SingleResult {  
    collection := client.getCollection(databaseName, collectionName)
    if collection != nil {
        ctx, ctxCancel := context.WithTimeout(context.Background(), timeoutLimit)
        defer ctxCancel()
        res := collection.FindOne(ctx, filter)
        return res
    }
    return nil
}

개선 방향

저는 개선 방향을 다음과 같이 정했습니다.

  1. DB 레이어에서는 로그를 남기지 않고 DB 쿼리 실행 정보와 error를 래핑하여 반환한다.
  2. error 값이 nil(=Null)이면, 반환되는 값(document)은 nil이 아니며 쿼리 성공을 보장한다.
  3. singleResult, Cursor 등을 디코딩하는 중복 코드를 공통화한다.
  4. 상위 레이어에서는 MongoDB Go Driver의 error를 처리하는 것이 아니라 DB 레이어 내부에서 정의한 error를 처리하고 로그 레벨을 판단해 로그를 남긴다.

방향을 이렇게 정한 이유와 그 결과를 하나씩 설명하겠습니다.

DB 쿼리 실행 정보와 error를 래핑하여 반환

글의 초반에서 언급한 대로, DB 레이어에서는 error의 심각성을 판단할 수 없습니다. 실제로 어떤 레벨의 error인지는 비즈니스 로직을 포함한 상위 레이어까지 올라가야만 알 수 있기 때문에, DB 레이어는 로그를 남가지 않도록 설계 방향을 잡았습니다.

DB 레이어에서 로그를 기록하지 않기로 결정했으므로, DB 레이어는 상위 레이어에 error 정보를 정확하게 전달해야 했습니다. 그러면 error에 어떤 정보를 포함해야 할지 생각해보았습니다.

우선, DB에서 발생할 수 있는 error를 파악했습니다.

  • 시간 초과(timeout)
  • 찾을 수 없음(not found)
  • 중복된 키(duplicated key)
  • 네트워크 오류(network)
  • 연결 끊김(disconnect)

MongoDB Go Driver에서 정의한 error는 위의 5개 외에도 더 있지만 크게 위와 같이 분류했고, 결과를 디코딩하는 중 발생한 오류도 고려하여 디코딩 error까지 총 6개의 error를 추렸습니다.

그 다음에는 어떤 정보를 error에 포함할지 정했습니다. DB 오류가 발생한 경우 디버깅에 어떤 정보가 필요할지 고민했습니다.

  • 필터링 조건
  • update/insert 내용
  • 대상 collection
  • Mongo 내부 오류 메시지

위와 같은 정보가 필요하다고 생각했고, 해당 내용을 담을 수 있는 struct를 다음과 같이 정의했습니다.

package errorUtils

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

type notFoundError struct {  
    basicQueryInfo
}

type duplicatedKeyError struct {  
    basicQueryInfo
    error
}

/* ... 생략 ... */
부가 함수
func NotFoundError(col string, filter, update, doc interface{}) error {  
    err := &notFoundError{}
    err.setBasicError(col, filter, update, doc)
    return err
}

/* ... 생략 ... */

func (err *basicQueryInfo) setBasicError(col string, filter, update, doc interface{}) {  
    err.filter = filter
    err.collection = col
    err.update = update
    err.doc = doc
}

그리고 로그를 남길 때 앞에서 정의한 struct의 정보가 보이도록 문자열을 작성했습니다.

func (e *notFoundError) Error() string {  
    return fmt.Sprintf("%s not found. ", e.collection) + 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
}

이 코드의 error의 로그는 다음과 같이 error 종류, Mongo 오류 내용, 쿼리 내용의 구조로 남겨집니다.

// accounts collection에서 특정 document를 찾지 못함
"accounts not found. | {query info: filter: map[account_id: 123]}"

error 값이 nil이면, 반환되는 값(document 데이터)은 nil이 아니며 쿼리 성공을 보장

Golang을 사용하면서 외부 API를 호출할 때 net/httpopen in new window 패키지를 자주 사용했는데, error 값이 nil이면 반환 값이 항상 non-nil이라는 점이 괜찮다고 생각했습니다. 개발자는 error 값이 nil이면 반환 값을 검증 없이 사용할 수 있었습니다.

net/http/client.go
// 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이 아니며 동시에 쿼리 성공을 보장하도록 했습니다.

이러한 결정을 한 주요한 이유는 document를 찾을 수 없는 상황 때문이었습니다. find의 결과로 document를 찾을 수 없는 경우, 쿼리가 실패하지는 않았기 때문에 다음과 같이 결과물을 nil로, errornil로 반환할 수 있습니다.

코드 예
func FindSomething() (*MyDocument, error) {  
    if not found {
        return nil, nil
    }
}

하지만 저는 다음과 같은 이유로 이 방식을 선호하지 않았습니다.

  • 만약 nil이 반환된다면 상위 함수에서 document가 nil인지 확인해야 한다.
  • MongoDB Go Driver에서는 document를 찾을 수 없는 상황을 error(ErrNoDocuments)로 정의한다.
mongo/single_result.go
// 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")  

물론 document를 찾을 수 없는 상황을 error로 반환하는 경우도 확인해야 합니다.

하지만 document를 찾을 수 없는 상황을 error로 취급한다면, 굳이 error 내용을 확인할 필요 없이 상위 레이어로 올리거나 error 로그를 남기기만 하면 된다는 장점이 있습니다.

그래서 저는 not found는 error로 보기로 했고, error 값이 nil인 경우에는 결과 값은 nil이 아니며 쿼리 성공을 보장하는 방식을 도입했습니다.

singleResult, Cursor 등을 디코딩하는 중복 코드를 공통화

MongoDB Go Driver를 사용하면서 불편한 점은 매번 디코딩 코드를 작성해야 한다는 것이었습니다.

코드 예
// find all 1
cursor, err := collection.Find(ctx, bson.M{})  
if err != nil {  
    /* ... 생략 ... */
}
defer cursor.Close(ctx)  
var results []MyDocument  
for cursor.Next(ctx) {  
    var doc MyDocument
    if err = cursor.Decode(&doc); err != nil {
        /* ... 생략 ... */
    }
    results = append(result, doc)
}

// find all 2
var results []MyDocument  
if err = cursor.All(context.TODO(), &results); err != nil {  
    /* ... 생략 ... */
}

// find one
singleResult := collection.FindOne(ctx, bson.M{})  
var doc MyDocument  
if err := singleResult.Decode(&doc); err != nil {  
    /* ... 생략 ... */
}
fmt.Println(doc)  

위와 같은 중복 코드를 제거하고 공통 함수에서 디코딩할 수 있는 방법을 고민했습니다.

먼저, singleResult 디코딩은 디코딩 함수를 한 번 래핑하는 것으로 간단히 해결할 수 있었습니다.

package mongo

var SingleResultErr  = errors.New("single result is nil")

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

하지만 문제는 Cursor 디코딩이었습니다. 어떤 타입을 디코딩해야 하는지는 런타임에 결정되어, 동적으로 추론하는 방법은 reflect 밖에 없었고 그것도 완벽하지 않았습니다.

reflect를 사용하여 디코딩하는 예
func (col *Collection) FindAll(requiredExample interface{}, filter interface{}, opts ...*options.FindOptions) (interface{}, error) {  
    /* ... 생략 ... */
    cursor, err := col.findAll(ctx, filter, opts...)
    if err != nil {
        /* ... 생략 ... */
    }
    return DecodeCursor(cursor, GetInterfaceType(requiredExample)), nil
}

func DecodeCursor(cursor *mongo.Cursor, t reflect.Type) interface{} {  
    // 타입에 맞춰 slice 생성
    slice := reflect.MakeSlice(reflect.SliceOf(t), 0, 10)

    for cursor.Next(context.Background()) {
        // struct 초기화
        doc := reflect.New(t).Interface()
        // 디코딩
        if err := cursor.Decode(doc); err != nil {
            /* ... 생략 ... */
        }
        // 디코딩 결과 slice append
        slice = reflect.Append(slice, reflect.ValueOf(doc).Elem())
    }
    // slice return
    return slice.Interface()
}

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
}

이 코드는 다음과 같은 단계를 거칩니다.

  1. FindAll() 함수에 slice로 반환받을 예시 struct 객체를 넣으면 해당 객체가 담긴 interface{}를 반환
  2. 상위 함수에서는 이를 한 번 더 type assertion

reflect를 사용하다 보니 코드를 바로 이용하기가 쉽지 않았고, interface{}로 반환되므로 다시 타입을 변환해야 하는 불편함이 있었습니다.

reflect 방식 사용 예
func find() {  
  // FindAll(Account 타입, 쿼리 조건)
    all, _ := m.FindAll(types.Account{}, bson.M{})
    result := all.([]types.Account)
}

DecodeCursor() 함수 내부에서 slice를 만들지 않고 기존의 커서 디코딩 방식처럼 외부에서 slice를 받아서 append하는 방식을 시도해보았으나 쉽지 않았고 구글링으로도 해결책을 찾지 못했습니다.

그래서 기존 MongoDB Go Driver에서는 cursor.All()은 어떻게 구현되어 있는지 확인해보았습니다.

mongo/cursor.go
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에 담는 작업인데, 이렇게 작성하면 All() 내부에 2개의 slice가 존재하게 됩니다. 이에 대해 공식 문서에서는 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.

출처: Retrieve All Documentsopen in new window

그래서 결국 cursor.All()을 사용하는 방식과 제가 직접 만든 reflect 함수를 사용하는 방식, 이렇게 두 가지를 만들어, 만약 조회할 데이터가 크지 않다면 전자의 함수를, 크다면 불편함은 있지만 후자의 함수를 사용하는 것으로 마무리하려고 했었습니다.

하지만 이러한 고민을 해결해줄 Go 1.18 버전이 2022년 3월 15일에 공개되었고 Generic이 도입되었습니다. Generic을 이용한 해결을 시도해보았는데, 디코딩할 때 함수에 Generic 타입을 넘겨주면 아주 간단하게 처리할 수 있었습니다.

// cursor
func DecodeCursor[T any](cursor *mongo.Cursor) ([]T, error) {  
    defer cursor.Close(context.Background())
    slice := make([]T, 0) // nil이 아님을 보장하기 위해 slice intialize
    for cursor.Next(context.Background()) {
        var doc T
        if err := cursor.Decode(&doc); err != nil {
            return nil, err
        }
        slice = append(slice, doc)
    }
    return slice, nil
}

// single result
func EvaluateAndDecodeSingleResult[T any](result *mongo.SingleResult) (*T, error) {  
    if result == nil {
        return nil, errorType.SingleResultErr
    }
    var v T
    if err := result.Decode(&v); err != nil {
        return nil, err
    }
    return &v, nil
}

이 방법으로, 이제는 reflect를 사용한 경우 caller에서 type assertion을 할 필요가 없었으며, cursor.All()의 메모리 이슈도 해결할 수 있었습니다.

상위 레이어에서는 DB 레이어 내부에서 정의한 error를 처리하고 로그 레벨을 판단해 로깅

마지막으로, 상위 레이어에서 MongoDB Go Driver의 error를 처리하는 것이 아니라 앞서 DB 레이어가 쿼리 실행 정보와 함께 래핑한 error를 처리하게 하는 일이 남았습니다.

이를 위해서는 상위 레이어에서 error를 구별할 방법이 필요했고, 다음의 총 3가지 방법이 있었습니다.

  • errors.As()
  • reflect
  • type swtich

1. errors.As()

github.com/pkg/errors를 사용하여 특정 구조체에 error가 바인딩될 수 있는지 확인하는 방법입니다.

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

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

코드 예
func service(err error) {  
    var notFoundErr notFoundError
    if IsErrorOf(err, &notFoundErr) {
        // handle error
    }
    var dupKeyErr duplicatedKeyError
    if IsErrorOf(err, &dupKeyErr) {
        // handle error
    }
    /* ... 생략 ... */
}

2. reflect

Golang의 reflect를 사용하여 error가 해당 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의 객체를 생성해야 하므로 이로 인한 불편함도 존재합니다.

3. type switch

이 방식은 Golang에서 타입을 판별할 때 가장 자주 사용되는 방식입니다.

func IsDBInternalErr(err error) bool {  
    for err != nil {
        switch err.(type) {
        case *internalError,
            *timeoutError,
            *dbClientError:
            return true
        }
        err = errors.Unwrap(err)
    }
    return false
}

(Golang에서 error는 여러 겹으로 래핑될 수 있기 때문에 양파 껍질을 까듯이 확인하도록 만들었습니다. 래핑에 관한 내용은 다음 글에서 자세히 설명하겠습니다.)

위 방식은 모든 error struct마다 타입 switch 함수를 만들어야 하는 불편함은 있지만 가장 직관적이고, DB에서 발생하는 error의 종류가 늘어날 가능성이 거의 없기 때문에 이 방법도 괜찮아 보였습니다. 또한, IsDBInternalErr()와 같이 여러 error를 1개로 처리되도록 할 수 있었습니다.

상위 함수에서는 다음과 같이 사용될 수 있습니다.

사용 예
func service(err error) {  
    if IsNotFoundErr(err) {
        // handle error
    }
    if IsDBInternalErr(err) {
        // handle error
    }
/* ... 생략 ... */
}

위의 상황을 전체적으로 고려할 때, 팀 내에서는 세 번째 방식이 가장 좋을 것으로 결론을 내렸으며, 타입 switch 방식으로 상위 함수에서 처리하도록 설정했습니다.


공통화

error를 정의하고 처리하고 쿼리 결과를 디코딩하는 것까지 완성하고, V1에서 중복되어 있던 다음과 같은 코드를 공통화하는 작업을 진행했습니다.

  • collection 객체 singleton
  • slow 쿼리 로깅
  • 시간 초과
  • 디코딩
  • error 타입 생성

collection 객체 singleton

기존 V1에서는 MongoDB client 객체에서 매번 Collection 객체를 생성하고 있었습니다.

코드 예
func (client *mongo.Client) getCollection(database string, collection string) *mongo.Collection {  
    return client.Database(database).Collection(collection)
}

func (manager *mongo.Client) FindOne(databaseName string, collectionName string, filter *bson.M) *mongo.SingleResult {  
    collection := manager.getCollection(databaseName, collectionName)
    /* ... 생략 ... */
}

쿼리마다 객체를 생성하는 비용이 있기 때문에, 이를 줄이기 위해 객체를 공유해서 사용해도 되는지 MongoDB Go Driver 공식 문서를 찾아보았습니다. 확인 결과, Collection 객체는 goroutine safeopen in new window하여 singleton으로 사용해도 무방하다는 내용을 찾을 수 있었습니다.

mongo/collection.go
// Collection is a handle to a MongoDB collection. It is safe for concurrent use by multiple goroutines.
type Collection struct {  
    /* ... 생략 ... */
}

그래서 V2에서는 Collection 객체는 singleton으로 사용하기로 하고, Collection 객체에 디코딩할 때 사용할 struct 타입을 Generic 타입으로 받아, 앞에서 언급한 디코딩 함수에 넘기도록 했습니다.

package mongo

type Collection[T any] struct {  
    *mongo.Collection
}

func MakeCollection[T any](mongoManager *MongoDBClient, databaseName, collectionName string) *Collection[T] {  
    collection := mongoManager.GetCollection(databaseName, collectionName)
    return &Collection[T]{Collection: collection}
}

slow 쿼리 로깅

이전에 생성한 Collection 객체의 메서드로 각 쿼리 함수를 래핑하여, 여기에 slow 쿼리 로깅을 할 수 있도록 했습니다(이해를 돕기 위해 따로 함수 추출은 하지 않음).

const slowQueryLimit = 1 * time.Second // slow 쿼리 기준 값

func (col *Collection[T]) findAll(ctx context.Context, filter interface{}, opts ...*options.FindOptions) (*mongo.Cursor, error) {  
    startTime := time.Now()
    cursor, err := col.Collection.Find(ctx, filter, opts...)
    // slow 쿼리 로깅
    if time.Since(startTime) > slowQueryLimit {
        log.Errorf("%s, filter: %+v", "findAll", filter)
    }
    return singleResult
}

시간 초과 처리

또한, DB 응답이 없으면 시간 초과 error를 내면서 함수를 종료하도록 해야 했습니다.

const timeoutLimit = 15 * time.Second // 시간 초과 기준 값

func (col *Collection[T]) FindAll(filter interface{}, opts ...*options.FindOptions) (*T, error) {  
    ctx, ctxCancel := context.WithTimeout(context.Background(), timeoutLimit)
    defer ctxCancel()
    cursor, err := col.findAll(ctx, filter, opts...)
    /* ... 생략 ... */
}

Golang의 내장 라이브러리인 context를 활용하여 , 기준 시간 동안 findAll의 결과가 없으면 err 변수에 context Deadline exceed라는 error가 나오도록 했습니다(MongoDB Golang 클라이언트 설정open in new window으로 글로벌하게 설정할 수도 있습니다). 시간 초과 설정에 대한 더 자세한 내용은 MongoDB Go Driver 문서open in new window를 참고하시기 바랍니다.

디코딩 및 error 타입 생성

모든 error에 대한 정의는 끝났으니, 이제는 error 타입을 매핑해야 했습니다.

func ParseAndReturnDBError(err error, collection string, filter, update, doc interface{}) error {  
    if errors.Is(err, mongo.ErrNoDocuments) || errors.Is(err, NotMatchedAnyErr) {
        return NotFoundError(collection, filter, update, doc)
    }

    if mongo.IsDuplicateKeyError(err) {
        return DuplicatedKeyError(collection, filter, update, doc, err)
    }

    if mongo.IsTimeout(err) || errors.Is(err, context.DeadlineExceeded) {
        return TimeoutError(collection, filter, update, doc, err)
    }

    return InternalError(collection, filter, update, doc, err)
}

err 변수를 받아서 각각 정의한 error 객체를 매핑하여 반환하도록 함수를 만들고, 다음과 같이 실제 쿼리 결과 함수에 적용했습니다.

func (col *Collection[T]) FindAll(filter interface{}, opts ...*options.FindOptions) (*T, error) {  
    ctx, ctxCancel := context.WithTimeout(context.Background(), timeoutLimit)
    defer ctxCancel()
    cursor, err1 := col.findAll(ctx, filter, opts...)
    if err != nil {
            return nil, ParseAndReturnDBError(err, col.Name(), filter, nil, nil)
    }
    resultSlice, err2 := DecodeCursor[T](cursor)
    if err != nil {
        return nil, DecodeError(col.Name(), filter, nil, nil, err)
    }
    return resultSlice, nil
}

쿼리 결과에서 나온 err1 변수에는 쿼리에서 발생한 error가 담기며, 이를 넘겨서 분류에 따른 error가 반환되게 했습니다.

쿼리에서 발생한 error가 없다면 커서를 받아서 디코딩하고, 디코딩에서 나온 err2DecodeError로 매핑했습니다. 커서에서 DecodeError는 struct의 타입이 정상적이라면 거의 나오지 않습니다.

하지만 singleResult에서는 디코딩할 때 총 3가지 error를 따로 분류해야 했습니다.

  • no document found
  • deadline exceed
  • duplicated key error(replace, update 등에서 발생)

위 error는 따로 처리하여 DecodeError로 분류되지 않도록 했습니다.

적용 코드
func (col *Collection[T]) FindOneAndModify(filter interface{}, update interface{}, opts ...*options.FindOneAndUpdateOptions) (*T, error) {  
    /* ... 생략 ... */
    singleResult := col.findOneAndModify(commonHead, ctx, filter, update, opts...)

    doc, err := EvaluateAndDecodeSingleResult[T](singleResult)
    if err != nil {
        if errors.Is(err,mongo.ErrNoDocuments) || errors.Is(err, context.DeadlineExceeded) || mongo.IsDuplicateKeyError(err) {
            return nil, errorType.ParseAndReturnDBError(err, col.Name(), filter, nil, nil)
        }
        return nil, errorType.DecodeError(col.Name(), filter, nil, nil, err)
    }
    return doc, nil
}

대부분의 DB CRUD에서의 시간 초과, slow 쿼리 로깅, error 분류까지 모두 마쳤지만, 한 가지 남은 고민이 있었습니다.

error 분류에 대한 고민

update, delete의 결과 MatchedCount/ModifiedCount 값이 0이면 error로 봐야하는지 고민해볼 필요가 있었습니다.

멱등성 관점에서는 다음과 같이 볼 수 있습니다.

  • delete의 경우, 지우려는 대상이 없다는 것은(MatchedCount == 0) 해당 값이 DB에 없는 정상적인 상황이다.
  • update의 경우, 수정된 대상이 없다는 것은(MatchedCount ≠ 0 & ModifiedCount == 0) 해당 값이 이미 update 요청한 값이다.

하지만 비즈니스 로직마다 관점이 다르므로, 멱등성 관점만 고려하여 error로 보지 않기는 어려울 것 같았습니다.

그래서 팀 내 리뷰 시간에 이와 같은 고민을 나눴고, 다음과 같은 이유로 not matchednot found로 남겨두고, not modified는 error는 보지 않기로 결론 내렸습니다.

  • not matched의 경우, 대부분의 사용자(개발자)는 update/delete 쿼리를 사용할 때 해당 값이 있는 것을 기대하고 사용하기 때문에, not found에 대한 error 처리가 필요할 수도 있다(삭제된 개수가 0인 경우도 not found로 간주).
  • not modified의 경우, 이미 해당 값으로 db에 저장되어 있다는 것이기 때문에, 사용자의 기대 혹은 의도와 일치하므로 error 처리가 필요하지 않다.
반영된 코드
var NotMatchedAnyErr = errors.New("no documents have been matched")

func (col *Collection[T]) UpdateOne(filter interface{}, update interface{}, opts ...*options.UpdateOptions) (*mongo.UpdateResult, error) {  
    /* ... 생략 ... */
    updateResult, err := col.updateOne(ctx, filter, update, opts...)
    if err != nil {
        return nil, errorType.ParseAndReturnDBError(err, col.Name(), filter, update, nil)
    }
    if updateResult.MatchedCount == 0 & updateResult.UpsertedCount == 0 {
        return updateResult, errorType.ParseAndReturnDBError(errorType.NotMatchedAnyErr, col.Name(), filter, update, nil)
    }
    return updateResult, nil
}

func (col *Collection[T]) DeleteOne(filter interface{}, opts ...*options.DeleteOptions) (*mongo.DeleteResult, error) {  
    /* ... 생략 ... */
    deleteResult, err := col.deleteOne(ctx, filter, opts...)
    if err != nil {
        return nil, errorType.ParseAndReturnDBError(err, col.Name(), filter, nil, nil)
    }
    if deleteResult.DeletedCount == 0 {
        return deleteResult, errorType.ParseAndReturnDBError(errorType.NotMatchedAnyErr, col.Name(), filter, nil, nil)
    }
    return deleteResult, nil
}

여기서 조심해야 하는 부분은, update에서 upsert가 수행된 경우에는 MatchedCount가 0이 된다는 것입니다. 이때 UpsertedCount는 양수인데 이 값까지 확인하지 않으면 not found error로 분류될 수 있으므로 주의가 필요합니다.


마치며

로그는 어디서 남겨야 하는가, 어느 레벨로 남겨야 하는가 하는 고민에서 시작된, 가장 하위 레이어인 DB 레이어를 개편한 과정이었습니다.

결국 위에 대한 고민에 대한 답은, 여기서는 판단이 불가하기 때문에 'DB 레이어에서는 로그를 남기지 않는다'입니다. 대신 다음과 같은 장치를 마련하여, 로그를 남기지 않더라도 충분한 기능을 제공하도록 했습니다.

  • DB 레이어에서는 로그를 남기지 않고 DB 쿼리 실행 정보와 error를 래핑하여 반환한다.
  • error 값이 nil이면, 반환되는 값(document)은 nil이 아니며 쿼리 성공을 보장한다.
  • DB 레이어 내부에서 정의한 error를 처리하고 로그 레벨을 판단해 로그를 남길 수 있도록 error 타입을 정의하고 판별 함수를 제공한다.

그리고 각 서버마다 구현했던 다음과 같은 부분을 공통 저장소로 옮겨 일관되게 로직을 수행하고 error를 분류하게 했습니다.

  • collection 객체 singleton 유지
  • slow 쿼리 로깅
  • 시간 초과 처리
  • singleResult, Cursor 등 디코딩

그 결과 V2 코드는 다음과 같이 줄어들었습니다.

type MyCollectionManager struct {  
    collection *commonMongoDB.Collection[MyDocument]
}

func MyCollection(client *mongo.Client) *MyCollectionManager {  
    manager := &MyCollectionManager{}
    dbConfig := config.GetDatabaseConfig()
    manager.collection = commonMongoDB.MakeCollection[MyDocument](client, dbConfig.DatabaseName, "myCollection")
    return manager
}

func (manager *MyCollectionManager) GetDocument() (*MyDocument, error) {  
    filter := bson.M{}

    doc, err := manager.collection.FindOne(&filter)
    if err != nil {
        return nil, err
    }
    return doc, nil
}

다음 글에서는 여기서 발생한 error를 상위에 어떻게 전달할 수 있을지에 대한 고민을 이야기해보려 합니다.


참고

slow 쿼리 로깅과 같은 부분은 monitor 혹은 logger를 이용하면 좀 더 정확하고 실제 MongoDB raw query를 받아 볼 수도 있으니, 관심이 있다면 참고하시기 바랍니다.

title

desc
title

desc

이찬희 (MarkiiimarK)
Never Stop Learning.