MongoDB in golang

MongoDB Query

MongoDB

MongoDB는 document 지향적인 NoSQL 데이터베이스이다.

다양한 어플리케이션에서 MongoDB를 사용하는 이유는 그 확장성과 개발의 용이성 때문이다.

Simple Query

간단한 CRUD Query 에 대해서 살펴보자.

혼합하여 사용하는 방법도 있고, 그 예제가 매우 다양하다.

자세한 것은 공식 문서참고하자.

Find

bson.M 을 사용해도 되고, map[string]interface{} 혹은 map[string]any 와 같이 필터 조건을 추가할 수 있다.

	ctx := context.TODO()
	collection.Find(ctx, bson.M{"name": "primrose"})
	collection.Find(ctx, map[string]interface{}{"name": "primrose"})
	collection.FindOne(ctx, bson.M{"name": "primrose"})
	collection.FindOne(ctx, map[string]interface{}{"name": "primrose"})

Insert

구조체를 사용해도 되고, 단순한 document 형태라면 bson.M 을 그대로 사용해도 괜찮다.

	ctx = context.TODO()
	collection.InsertOne(ctx, bson.M{"name": "primrose"})
	collection.InsertMany(ctx, []interface{}{bson.M{"name": "primrose"}})

	type User struct {
		Name string `bson:"name"`
	}

	ctx = context.TODO()
	collection.InsertOne(ctx, User{Name: "primrose"})
	collection.InsertMany(ctx, []interface{}{User{Name: "primrose"}})

Update

MongoDB에서 데이터를 업데이트하는 방법은 크게 단일 문서 업데이트(UpdateOne)와 여러 문서 동시 업데이트(UpdateMany), 그리고 대량 업데이트(BulkWrite)를 포함한다.

각각의 사용 예를 살펴보며, 특정 상황에서 어떻게 최적화할 수 있는지 알아보자.

	ctx = context.TODO()
	// 단일 문서를 업데이트 할 때는 UpdateOne 메서드를 사용한다. 
	// 이 메서드는 첫 번째 매개변수로 필터 조건을, 두 번째 매개변수로 업데이트할 내용을 받는다.
	collection.UpdateOne(ctx, bson.M{"name": "primrose"}, bson.M{"$set": bson.M{"name": "primrose"}})

	filter := bson.M{"name": "primrose"}
	update := bson.M{"$set": bson.M{"name": "primrose"}}
	ctx = context.TODO()
	collection.UpdateOne(ctx, filter, update)
	// 여러 문서를 동시에 업데이트 할 때는 UpdateMany 메서드를 사용한다. 
	// 이 방법은 주로 동일한 필터 조건에 부합하는 여러 문서에 동일한 변경을 적용할 때 유용하다.
	collection.UpdateMany(ctx, filter, update)

UpdateMany 는 동일한 필터로 여러 개의 document 를 수정해야 할 때 사용된다.

그런데 500개의 문서를 각각 조건에 맞게 업데이트 해야 한다면 어떨까?

500 번의 UpdateOne 보다는, BulkWrite 를 추천한다.

models := []mongo.WriteModel{
	mongo.NewUpdateOneModel().SetFilter(bson.M{"name": "primrose"}).SetUpdate(bson.M{"$set": bson.M{"name": "rosie"}}),
	mongo.NewUpdateOneModel().SetFilter(bson.M{"name": "daisy"}).SetUpdate(bson.M{"$set": bson.M{"name": "sunflower"}}),
	// 추가적인 업데이트 모델을 포함할 수 있다.
}

results, err := collection.BulkWrite(ctx, models)
if err != nil {
	log.Fatal(err)
}
fmt.Printf("Operations completed: %v\n", results.ModifiedCount)

이렇게 대량 업데이트를 진행할 때는 각 문서에 적합한 필터와 업데이트 조건을 명시해야 하며, 이는 네트워크 호출과 데이터베이스 부하를 줄이는 데 도움이 된다.

특수 업데이트 옵션 활용

  • $set: 문서의 특정 필드를 설정하거나 업데이트 한다.

  • $setOnInsert: 문서가 삽입될 때만 특정 필드를 설정한다.

이 옵션들을 활용하여, 조건에 맞는 문서가 존재하지 않을 때 삽입 작업을 수행하면서 특정 필드를 설정하려면 다음과 같이 upsert 옵션을 사용한다.

updateOptions := options.Update().SetUpsert(true)
updateResult, err := collection.UpdateOne(ctx, filter, update, updateOptions)
if err != nil {
	log.Fatal(err)
}
if updateResult.UpsertedCount > 0 {
	fmt.Printf("One document was upserted.\n")
}

Delete

MongoDB에서 데이터를 삭제하는 연산은 주로 DeleteOneDeleteMany 메서드를 통해 수행된다.

각각의 메서드는 특정 조건을 만족하는 문서를 삭제하는데 사용된다.

단일 문서 삭제: DeleteOne

DeleteOne 메서드는 주어진 필터에 맞는 첫 번째 문서 하나만을 삭제한다. 이 방법은 특정 도큐먼트를 정확히 지정할 수 있을 때 사용하며, 필터는 일반적으로 유니크한 필드를 기반으로 설정된다.

ctx := context.TODO()
filter := bson.M{"name": "rosie"}
result, err := collection.DeleteOne(ctx, filter)
if err != nil {
	log.Fatal(err)
}
fmt.Printf("Deleted %v documents.\n", result.DeletedCount)

여러 문서 삭제: DeleteMany

DeleteMany 메서드는 주어진 필터 조건을 만족하는 모든 문서를 삭제한다. 이 방법은 특정 조건을 공유하는 여러 문서를 한 번에 삭제하고자 할 때 유용하다.

filter := bson.M{"status": "inactive"}
deleteResult, err := collection.DeleteMany(ctx, filter)
if err != nil {
	log.Fatal(err)
}
fmt.Printf("Deleted %v documents.\n", deleteResult.DeletedCount)

Aggregate

Aggregate 연산은 MongoDB의 강력한 데이터 처리 기능 중 하나로, 여러 단계의 파이프라인을 통해 데이터를 변환하고 요약하는 복잡한 쿼리를 수행할 수 있다.

기본 Aggregate 사용 예

다음은 간단한 Aggregate 예제로, 사용자의 나이에 따른 그룹을 만들고 각 그룹의 평균 점수를 계산하는 쿼리이다.

pipeline := mongo.Pipeline{
	{{"$match", bson.D{{"age", bson.D{{"$gte", 18}}}}}},
	{{"$group", bson.D{{"_id", "$age"}, {"averageScore", bson.D{{"$avg", "$score"}}}}}},
}

ctx := context.TODO()
cursor, err := collection.Aggregate(ctx, pipeline)
if err != nil {
	log.Fatal(err)
}

var results []bson.M
if err = cursor.All(ctx, &results); err != nil {
	log.Fatal(err)
}
fmt.Println("Age Groups and Average Scores:")
for _, result := range results {
	fmt.Println(result)
}

Aggregate vs. Find

  • Find는 간단한 문서 검색에 사용되며, 특정 조건에 부합하는 문서들을 반환한다.

  • Aggregate는 데이터를 조작하고 요약하여 더 복잡한 쿼리와 데이터 분석을 가능하게 한다. 예를 들어, 문서를 그룹화하고 각 그룹에 대해 계산을 수행할 수 있다.

Aggregate 연산은 Find 연산보다 훨씬 더 유연하고 강력하지만, 더 많은 리소스를 소모할 수 있으므로 사용 시 상황에 맞게 적절히 선택하는 것이 중요하다.

BSON

MongoDB의 데이터 형식인 BSON(Binary JSON)은 JSON과 유사하지만, 이진 형식으로 표현되며 훨씬 더 많은 데이터 타입을 지원한다.

Golang에서는 mongo-go-driver 라이브러리를 통해 bson.E, bson.M, bson.A, bson.D 등 다양한 BSON 타입을 사용할 수 있다.

이들 각각의 타입과 사용 시의 특징을 이해하면, MongoDB 데이터 작업을 보다 효율적으로 수행할 수 있다.

BSON 타입들

  1. bson.M: 가장 일반적인 BSON 타입으로, map[string]interface{} 타입이다. 문서의 필드가 문자열 키와 관련된 값으로 구성되며, 순서를 보장하지 않는다. 일반적인 CRUD 작업에 널리 사용된다.

    // type M map[string]interface{}
    doc := bson.M{"name": "John", "age": 30}
  2. bson.D: 문서의 필드 순서가 중요할 때 사용되는 타입으로, []bson.E의 별칭이다. 이 타입은 쿼리와 인덱스 생성 시 순서가 중요한 경우 유용하다.

    // type D []E
    // type E struct {
    //	Key   string
    //	Value interface{}
    // }
    doc := bson.D{{"name", "John"}, {"age", 30}}
  3. bson.A: 배열을 나타내며, []interface{}와 유사하다. BSON 배열을 생성할 때 사용되고, 여러 값의 리스트를 저장할 때 유용하다.

    // type A []interface{}
    arr := bson.A{"apple", "banana", "cherry"}
  4. bson.E: 문서의 한 필드를 나타내는 타입으로, 키와 값의 쌍을 저장한다. 주로 bson.D 내에서 사용된다.

    // type E struct {
    //	Key   string
    //	Value interface{}
    // }
    element := bson.E{Key: "name", Value: "John"}

각 타입의 사용 시점과 특징

  • bson.M은 간단하고 직관적인 문서 작업에 적합하며, 필드 순서를 고려할 필요가 없는 경우 유리하다. CRUD 작업에서의 데이터 표현과 같이 복잡하지 않은 쿼리에 주로 사용된다.

  • bson.D는 필드 순서가 중요한 경우, 예를 들어 인덱스 생성이나 복합 쿼리에서 필드 순서에 의존할 때 사용된다. 순서가 결과에 영향을 미치는 작업에서 매우 유용하다.

  • bson.A는 배열 데이터를 다룰 때 필요하며, MongoDB에서 배열 필드를 쿼리하거나 업데이트할 때 사용된다.

  • bson.E는 보통 bson.D를 구성할 때 사용되며, 개별 필드를 명시적으로 표현할 때 사용된다.

사용 예제

MongoDB에서 조건에 따라 필드 순서가 중요한 쿼리를 실행하는 경우 bson.D의 사용 예를 들 수 있다.

filter := bson.D{{"age", bson.D{{"$gt", 30}}}, {"name", "John"}}
update := bson.D{{"$set", bson.D{{"age", 32}}}}
result, err := collection.UpdateOne(ctx, filter, update)
if err != nil {
	log.Fatal(err)
}

이 예제에서는 bson.D를 사용하여 필터와 업데이트 문서의 필드 순서를 정확히 제어한다.

이는 MongoDB의 쿼리 플래너가 인덱스를 보다 효과적으로 활용하게 해 주어 성능 개선을 도모할 수 있다.

이러한 각각의 BSON 타입들을 이해하고 적절하게 활용하면, MongoDB 에서의 데이터 작업이 보다 유연하고 효율적으로 이루어질 수 있다.

Index

MongoDB에서 인덱스는 데이터 검색 속도를 향상시키는 중요한 도구이다.

인덱스를 적절히 사용하면, 대규모 데이터셋에서도 빠르게 정보를 검색할 수 있으며, 쿼리 성능을 크게 개선할 수 있다.

이 섹션에서는 MongoDB에 인덱스를 적용하는 방법과 인덱싱된 쿼리를 FindAggregate 작업에 어떻게 사용하는지에 대해 알아보자.

인덱스 적용 방법

MongoDB에서 인덱스를 생성하는 기본적인 방법은 createIndex 메서드를 사용하는 것이다.

이 메서드는 컬렉션에 인덱스를 추가하며, 여러 옵션을 설정하여 다양한 유형의 인덱스를 구성할 수 있다.

예시: 단일 필드 인덱스 생성

ctx := context.TODO()
collection := client.Database("testdb").Collection("documents")

indexModel := mongo.IndexModel{
    Keys: bson.M{"name": 1}, // 1은 오름차순 인덱싱을 의미
}
_, err := collection.Indexes().CreateOne(ctx, indexModel)
if err != nil {
    log.Fatal(err)
}

예시: 복합 인덱스 생성

indexModel = mongo.IndexModel{
    Keys: bson.D{
        {Key: "lastname", Value: 1},
        {Key: "firstname", Value: 1},
    },
    Options: options.Index().SetUnique(true), // 중복을 허용하지 않는 유니크 인덱스
}
_, err = collection.Indexes().CreateOne(ctx, indexModel)
if err != nil {
    log.Fatal(err)
}

인덱싱된 쿼리 사용

인덱스는 쿼리가 데이터베이스를 훑는 대신, 효율적으로 필요한 데이터를 찾을 수 있도록 돕는다.

FindAggregate 함수에 인덱스를 활용하여 성능을 개선할 수 있다.

Find 쿼리에서 인덱스 사용

인덱스를 사용하면 Find 쿼리의 실행 시간을 줄일 수 있다. 예를 들어, "name" 필드에 인덱스가 있다면 다음과 같이 인덱스를 활용하여 데이터를 검색할 수 있다.

filter := bson.M{"name": "John Doe"}
cursor, err := collection.Find(ctx, filter)
if err != nil {
    log.Fatal(err)
}

Aggregate 쿼리에서 인덱스 사용

Aggregate 쿼리에서도 인덱스를 사용할 수 있다.

인덱스는 특히 $match$sort 단계에서 유용하다.

예를 들어, "age" 필드에 인덱스가 있고, 나이를 기준으로 정렬하는 쿼리를 실행하는 경우 인덱스가 쿼리 성능을 개선한다.

pipeline := mongo.Pipeline{
    {{"$match", bson.D{{"age", bson.D{{"$gte", 30}}}}}},
    {{"$sort", bson.D{{"age", 1}}}},
}
cursor, err := collection.Aggregate(ctx, pipeline)
if err != nil {
    log.Fatal(err)
}

MongoDB Query Plan

MongoDB의 쿼리 플랜은 데이터베이스가 어떻게 쿼리를 실행할지 결정하는 중요한 부분이다.

이 플랜은 최적의 쿼리 성능을 보장하기 위해 여러 가능한 경로 중에서 최선의 선택을 하도록 설계되었다.

쿼리 플랜을 이해하는 것은 데이터베이스 성능 최적화에 중요하며, MongoDB는 이를 위해 여러 내부 메커니즘과 최적화 도구를 제공한다.

쿼리 플래너의 작동 방식

MongoDB는 쿼리를 받으면 먼저 쿼리 플래너를 통해 실행 계획을 만든다. 쿼리 플래너는 다음과 같은 단계로 작동한다:

  1. 쿼리 분석: MongoDB는 주어진 쿼리를 분석하여 사용 가능한 인덱스를 평가한다. 이때 쿼리에 사용된 조건과 필드를 기반으로 인덱스의 유용성을 판단한다.

  2. 플랜 후보 생성: 여러 인덱스 중에서 적합한 후보를 선정하여 가능한 실행 계획을 만든다. 이 계획은 인덱스 스캔, 컬렉션 스캔 등 다양한 접근 방식을 포함할 수 있다.

  3. 플랜 평가: 생성된 후보들 중에서 최적의 성능을 보일 것으로 예상되는 플랜을 선택하기 위해, MongoDB는 각 플랜을 일정 시간 동안 실행해 본다(이를 플랜 캐싱이라 함). 이 과정에서 실제 데이터에 대한 쿼리 실행 시간과 자원 사용량을 평가한다.

  4. 최종 플랜 선택: 가장 효율적인 플랜이 선택되어 쿼리가 그에 따라 실행된다. 한 번 선택된 플랜은 일정 기간 동안 캐시되어, 동일하거나 유사한 쿼리가 다시 실행될 때 빠르게 처리할 수 있다.

쿼리 플랜의 실행 및 최적화

쿼리 실행 계획 확인

MongoDB에서는 explain() 메소드를 사용하여 특정 쿼리의 실행 계획을 볼 수 있다.

이 메소드는 쿼리가 어떻게 실행될지, 어떤 인덱스가 사용되는지 등의 정보를 제공한다.

db.collection.find({age: {$gt: 30}}).explain("executionStats")

이 명령은 쿼리가 실행되는 방식과 관련된 통계와 함께 실행 계획의 세부 정보를 반환한다.

이 정보를 통해 개발자는 쿼리 성능의 병목 현상을 식별하고 필요한 인덱스를 추가하거나 쿼리 자체를 수정할 수 있다.

쿼리 최적화

쿼리 성능을 최적화하기 위해 인덱스를 조정하거나 쿼리 구조를 변경하는 것 외에도, MongoDB는 쿼리 패턴을 학습하여 반복적인 쿼리에 대해 더 빠르게 응답할 수 있도록 플랜을 조정한다.

이러한 동적 최적화는 데이터베이스의 전반적인 효율을 크게 향상시킬 수 있다.

Options

MongoDB의 Golang 드라이버에서 options 패키지는 다양한 데이터베이스 작업 설정을 가능하게 해준다.

이 중 Projection을 통해 검색 결과에서 특정 필드만 추출하는 방법을 중점적으로 다룬다.

Projection

Projection은 검색된 문서에서 특정 필드만 반환하도록 설정하는 데 사용된다.

이 기능을 사용하면 네트워크 전송 데이터량을 줄이고, 처리 속도를 향상시킬 수 있다.

다음 예제에서는 name 필드가 "primrose"인 문서에서 email 필드만 조회하는 방법을 보여준다.

ctx := context.TODO()
collection := client.Database("your_database").Collection("your_collection")

// Find를 사용한 Projection
opts := options.Find().SetProjection(bson.M{"email": 1, "_id": 0})
cursor, err := collection.Find(ctx, bson.M{"name": "primrose"}, opts)
if err != nil {
	log.Fatal(err)
}
defer cursor.Close(ctx)

// FindOne을 사용한 Projection
optsOne := options.FindOne().SetProjection(bson.M{"email": 1, "_id": 0})
result := collection.FindOne(ctx, bson.M{"name": "primrose"}, optsOne)
if err := result.Err(); err != nil {
	log.Fatal(err)
}

var user bson.M
if err := result.Decode(&user); err != nil {
	log.Fatal(err)
}

Registry

Registry는 BSON 타입과 Go 타입 간의 변환을 커스터마이즈할 수 있게 해주는 기능이다.

특히, primitive.Decimal128 타입을 Go의 BigInt로 변환하는 커스텀 코덱을 등록하여 사용하는 방법을 살펴본다.

bsoncodec

bsoncodec를 사용하여 MongoDB의 Decimal128 데이터 타입을 Go의 big.Int로 변환할 수 있다.

다음은 Decimal128 타입을 BigInt로 변환하는 커스텀 코덱을 구현하고, 이를 Registry에 등록하는 과정을 보여준다.

예시가 BigInt 일 뿐이고, 다른 형태도 가능하다. 각자 응용해서 사용한다면 좋은 document 를 디자인 할 수 있다.

type BigIntCodec struct{}

func (c *BigIntCodec) DecodeValue(decodeContext bsoncodec.DecodeContext, reader bsonrw.ValueReader, value reflect.Value) error {
	decimal128, err := reader.ReadDecimal128()
	if err != nil {
		return err
	}
	bi, ok := new(big.Int).SetString(decimal128.String(), 10)
	if !ok {
		return fmt.Errorf("could not convert Decimal128 to BigInt")
	}
	value.Set(reflect.ValueOf(bi))
	return nil
}

func (c *BigIntCodec) EncodeValue(encodeContext bsoncodec.EncodeContext, writer bsonrw.ValueWriter, value reflect.Value) error {
	if !value.IsValid() || value.Type() != reflect.TypeOf((*big.Int)(nil)) {
		return bsoncodec.ValueEncoderError{Name: "BigIntCodec.EncodeValue", Types: []reflect.Type{reflect.TypeOf((*big.Int)(nil))}, Received: value}
	}
	decimal128, err := primitive.ParseDecimal128(value.Interface().(*big.Int).String())
	if err != nil {
		return err
	}
	return writer.WriteDecimal128(decimal128)
}

먼저 위 BigInt Codec 을 구현했다.

단순히 Decimal128 을 읽어서 BigInt 로 변경하도록 했다.

bigIntCodec := &BigIntCodec{}
opts := bson.NewRegistry()
opts.RegisterTypeEncoder(reflect.TypeOf((*big.Int)(nil)), bigIntCodec)
opts.RegisterTypeDecoder(reflect.TypeOf((*big.Int)(nil)), bigIntCodec)

collection := client.Database("test").Collection("test", options.Collection().
    SetRegistry(opts),
)

컬렉션에 연결할 때 위와같이 옵션을 주면 된다.

type BigIntValue struct {
	Value *big.Int
}

if _, err = collection.InsertOne(ctx, BigIntValue{Value: big.NewInt(1234567890123456789)}); err != nil {
	panic(err)
}

var result BigIntValue
if err = collection.FindOne(ctx, bson.M{"value": big.NewInt(1234567890123456789)}).Decode(&result); err != nil {
	panic(err)
}

fmt.Println(result)
// {1234567890123456789}

Last updated