MongoDB in Golang

· 6분 읽기

BSON

MongoDB의 Go 드라이버에서 사용하는 BSON(Binary JSON) 타입은 4가지가 있다.

bson.M - 범용 맵 타입

bson.Mmap[string]interface{}의 별칭으로, 순서가 보장되지 않는(unordered) 범용 타입이다.

// 일반적인 필터 쿼리
filter := bson.M{
    "name": "Kim",
    "age": bson.M{"$gte": 20},
}

// 업데이트 쿼리
update := bson.M{
    "$set": bson.M{
        "status": "active",
        "updatedAt": time.Now(),
    },
}
  • 일반적인 CRUD 작업

  • 순서가 중요하지 않은 필터나 업데이트

  • 코드 간결성이 중요할 때

bson.E - 개별 필드

bson.E는 Key-Value 쌍을 나타내며, bson.D의 원소로 사용된다.

element := bson.E{Key: "name", Value: "Kim"}

// bson.D는 bson.E의 슬라이스
doc := bson.D{
    bson.E{Key: "name", Value: "Kim"},
    bson.E{Key: "age", Value: 25},
}

// 단축 표기법
doc := bson.D{
    {"name", "Kim"},
    {"age", 25},
}

bson.D - 순서 보장 타입

bson.D[]bson.E의 별칭으로, 순서가 보장되는(ordered) 타입이다. 인덱스 생성이나 순서가 중요한 쿼리에서 필수적으로 사용된다.

indexModel := mongo.IndexModel{
    Keys: bson.D{
        {"name", 1},      // 첫 번째 정렬 기준
        {"age", -1},      // 두 번째 정렬 기준
    },
}

// 순서가 중요한 필터
filter := bson.D{
    {"name", "Kim"},
    {"age", bson.D{{"$gte", 20}}},
}

사용 시나리오:

  • 인덱스 생성 (복합 인덱스에서 필드 순서가 성능에 영향)

  • Aggregation 파이프라인

  • 정렬 순서가 중요한 쿼리

bson.A - 배열 타입

bson.A[]interface{}의 별칭으로, MongoDB 쿼리에서 배열 조건을 표현할 때 사용한다.

// $in 연산자
filter := bson.M{
    "status": bson.M{
        "$in": bson.A{"active", "pending", "processing"},
    },
}

// $or 연산자
filter := bson.M{
    "$or": bson.A{
        bson.M{"age": bson.M{"$lt": 18}},
        bson.M{"age": bson.M{"$gt": 65}},
    },
}

// 배열 필드에 값 추가
update := bson.M{
    "$push": bson.M{
        "tags": bson.M{
            "$each": bson.A{"golang", "mongodb", "backend"},
        },
    },
}

CRUD

Find / FindOne

단일 document 조회와 다중 document 조회는 각각 FindOneFind를 사용한다.

// FindOne: 단일 document 조회
var result User
filter := bson.M{"email": "test@example.com"}
err := collection.FindOne(ctx, filter).Decode(&result)
if err != nil {
    if err == mongo.ErrNoDocuments {
        // document 가 없는 경우
        fmt.Println("No document found")
    } else {
        // 기타 에러
        return err
    }
}

// Find: 다중 document 조회
cursor, err := collection.Find(ctx, bson.M{
    "age": bson.M{"$gte": 20},
})
if err != nil {
    return err
}
defer cursor.Close(ctx)

var users []User
if err = cursor.All(ctx, &users); err != nil {
    return err
}

// 또는 반복문으로 처리
for cursor.Next(ctx) {
    var user User
    if err := cursor.Decode(&user); err != nil {
        return err
    }
    // user 처리
}
  • FindOne은 첫 번째 document 를 찾으면 즉시 반환 (내부적으로 limit(1) 적용)

  • 대량 데이터 조회 시 cursor.All()보다 반복문이 메모리 효율적

  • 필요한 필드만 조회하려면 Projection 사용 (뒤에서 설명)

Insert

// User 구조체 정의 (bson 태그 사용)
type User struct {
    ID        primitive.ObjectID `bson:"_id,omitempty"`
    Name      string             `bson:"name"`
    Email     string             `bson:"email"`
    Age       int                `bson:"age"`
    CreatedAt time.Time          `bson:"createdAt"`
}

// InsertOne: 단일 document 삽입
user := User{
    Name:      "Kim",
    Email:     "kim@example.com",
    Age:       25,
    CreatedAt: time.Now(),
}
result, err := collection.InsertOne(ctx, user)
if err != nil {
    return err
}
insertedID := result.InsertedID.(primitive.ObjectID)

// InsertMany: 다중 document 삽입
users := []interface{}{
    User{Name: "Kim", Email: "kim@example.com", Age: 25, CreatedAt: time.Now()},
    User{Name: "Lee", Email: "lee@example.com", Age: 30, CreatedAt: time.Now()},
    User{Name: "Park", Email: "park@example.com", Age: 28, CreatedAt: time.Now()},
}
results, err := collection.InsertMany(ctx, users)
if err != nil {
    return err
}
// results.InsertedIDs: 삽입된 document 들의 _id 배열
  • _id 필드에 omitempty 태그를 사용하면 값이 없을 때 MongoDB가 자동 생성

  • InsertMany는 기본적으로 ordered insert (순서대로 삽입, 중간에 실패하면 중단)

  • Unordered insert가 필요하면 옵션 사용: options.InsertMany().SetOrdered(false)

Update

// UpdateOne: $set으로 특정 필드만 업데이트
filter := bson.M{"email": "kim@example.com"}
update := bson.M{
    "$set": bson.M{
        "age":       26,
        "updatedAt": time.Now(),
    },
}
result, err := collection.UpdateOne(ctx, filter, update)
if err != nil {
    return err
}
fmt.Printf("Matched: %d, Modified: %d\n", result.MatchedCount, result.ModifiedCount)

// Upsert: 없으면 삽입, 있으면 업데이트
opts := options.Update().SetUpsert(true)
update := bson.M{
    "$set": bson.M{
        "name":      "Kim",
        "email":     "kim@example.com",
        "updatedAt": time.Now(),
    },
    "$setOnInsert": bson.M{
        "createdAt": time.Now(),
    },
}
result, err := collection.UpdateOne(ctx, filter, update, opts)
if err != nil {
    return err
}
if result.UpsertedCount > 0 {
    fmt.Println("New document inserted:", result.UpsertedID)
}

// UpdateMany: 조건에 맞는 모든 document 업데이트
filter := bson.M{"status": "pending"}
update := bson.M{
    "$set": bson.M{
        "status":    "active",
        "updatedAt": time.Now(),
    },
}
result, err := collection.UpdateMany(ctx, filter, update)
  • $set: 필드 값 설정 (없으면 추가)

  • $setOnInsert: upsert 시 insert일 때만 설정

  • $unset: 필드 제거

  • $inc: 숫자 필드 증감

  • $push: 배열에 요소 추가

  • $pull: 배열에서 요소 제거

BulkWrite

많은 document 에 서로 다른 조건으로 업데이트해야 할 때, 반복문으로 UpdateOne을 호출하면 네트워크 왕복이 많아진다.

이때 BulkWrite를 사용하면 한 번의 요청으로 처리할 수 있다.

// 비효율적인 방법: 반복 UpdateOne
for _, item := range items {
    filter := bson.M{"_id": item.ID}
    update := bson.M{"$set": bson.M{"status": item.Status}}
    _, err := collection.UpdateOne(ctx, filter, update)
    // N번의 네트워크 왕복
}

// 효율적인 방법: BulkWrite
var models []mongo.WriteModel
for _, item := range items {
    model := mongo.NewUpdateOneModel().
        SetFilter(bson.M{"_id": item.ID}).
        SetUpdate(bson.M{"$set": bson.M{
            "status":    item.Status,
            "updatedAt": time.Now(),
        }})
    models = append(models, model)
}

opts := options.BulkWrite().SetOrdered(false) // 병렬 처리
result, err := collection.BulkWrite(ctx, models, opts)
if err != nil {
    return err
}
fmt.Printf("Modified: %d, Upserted: %d\n",
    result.ModifiedCount, result.UpsertedCount)
  • SetOrdered(false): 순서 보장 안 함, 병렬 처리 가능 (성능 우수)

  • SetOrdered(true): 순서 보장, 중간에 실패하면 중단 (기본값)

Delete

// DeleteOne: 조건에 맞는 첫 번째 document 만 삭제
filter := bson.M{"email": "kim@example.com"}
result, err := collection.DeleteOne(ctx, filter)
if err != nil {
    return err
}
fmt.Printf("Deleted: %d\n", result.DeletedCount)

// DeleteMany: 조건에 맞는 모든 document 삭제
filter := bson.M{"status": "inactive"}
result, err := collection.DeleteMany(ctx, filter)
if err != nil {
    return err
}
fmt.Printf("Deleted: %d\n", result.DeletedCount)

주의사항:

  • 실수로 빈 필터(bson.M{})를 전달하면 모든 document 가 삭제됨

  • 삭제 전 DeletedCount를 확인하여 의도한 만큼 삭제되었는지 검증하는 것도 좋다.

Aggregate

복잡한 데이터 변환과 분석이 필요할 때 Aggregation Pipeline을 사용.

// 나이별로 그룹핑하고 평균 점수 계산
pipeline := mongo.Pipeline{
    // Stage 1: 활성 사용자만 필터링
    {{"$match", bson.M{"status": "active"}}},

    // Stage 2: 나이별 그룹핑 + 통계 계산
    {{"$group", bson.M{
        "_id":      "$age",
        "avgScore": bson.M{"$avg": "$score"},
        "maxScore": bson.M{"$max": "$score"},
        "count":    bson.M{"$sum": 1},
    }}},

    // Stage 3: 평균 점수 기준 내림차순 정렬
    {{"$sort", bson.M{"avgScore": -1}}},

    // Stage 4: 상위 10개만 선택
    {{"$limit", 10}},
}

cursor, err := collection.Aggregate(ctx, pipeline)
if err != nil {
    return err
}
defer cursor.Close(ctx)

type AgeStats struct {
    Age      int     `bson:"_id"`
    AvgScore float64 `bson:"avgScore"`
    MaxScore int     `bson:"maxScore"`
    Count    int     `bson:"count"`
}

var results []AgeStats
if err = cursor.All(ctx, &results); err != nil {
    return err
}
  • $match: 필터링 (가능한 파이프라인 초반에 배치하여 인덱스 활용)

  • $group: 그룹핑 + 집계 연산

  • $sort: 정렬

  • $project: 필드 선택 및 변환

  • $lookup: 다른 컬렉션과 조인

  • $limit, $skip: 페이지네이션

Indexing

인덱스는 쿼리 성능을 결정하는 가장 중요한 요소이다. 적절한 인덱스 설계가 없으면 컬렉션이 커질수록 성능이 급격히 저하된다.

Single Index

가장 기본적인 인덱스 형태. 하나의 필드에 대해 인덱스를 생성한다.

// 이메일 필드에 오름차순 인덱스
indexModel := mongo.IndexModel{
    Keys: bson.M{"email": 1},  // 1: 오름차순, -1: 내림차순
}
name, err := collection.Indexes().CreateOne(ctx, indexModel)
if err != nil {
    return err
}
fmt.Println("Index created:", name)

// 인덱스 생성 시 옵션 활용
indexModel := mongo.IndexModel{
    Keys: bson.M{"email": 1},
    Options: options.Index().
        SetUnique(true).           // 유니크 제약
        SetName("email_unique").   // 인덱스 이름 지정
        SetSparse(true),           // null 값은 인덱스에서 제외
}

단일 필드 인덱스의 활용:

  • 단일 필드 검색: find({email: "test@example.com"})

  • 정렬: find().sort({email: 1})

  • 범위 쿼리: find({age: {$gte: 20, $lte: 30}})

Compound Index

여러 필드를 조합한 인덱스로, 필드의 순서가 매우 중요해진다.

// name(오름차순) + age(내림차순) 복합 인덱스
// ⚠️ 반드시 bson.D 사용 (순서 보장)
indexModel := mongo.IndexModel{
    Keys: bson.D{
        {"name", 1},   // 첫 번째 필드
        {"age", -1},   // 두 번째 필드
    },
    Options: options.Index().SetUnique(false),
}
name, err := collection.Indexes().CreateOne(ctx, indexModel)

복합 인덱스 {name: 1, age: -1}이 있다면:

  • {name: "Kim"} - name만 사용 (prefix 매칭)

  • {name: "Kim", age: 25} - 둘 다 사용 (완전 매칭)

  • {age: 25} - age만 사용 (prefix 아님, 인덱스 활용 불가)

Compound Index 에서 추천되는 설계 원칙이 있다:

  1. Equality 필터를 앞에 (예: status: "active")

  2. Sort 필드를 중간에 (예: createdAt: -1)

  3. Range 필터를 뒤에 (예: age: {$gte: 20})

예시:

// 쿼리: status가 active인 사용자를 생성일 역순으로 정렬하되,
//       나이가 20-30세인 사용자만
// 최적 인덱스: {status: 1, createdAt: -1, age: 1}
indexModel := mongo.IndexModel{
    Keys: bson.D{
        {"status", 1},      // Equality
        {"createdAt", -1},  // Sort
        {"age", 1},         // Range
    },
}

Unique Index

indexModel := mongo.IndexModel{
    Keys:    bson.M{"email": 1},
    Options: options.Index().SetUnique(true),
}
_, err := collection.Indexes().CreateOne(ctx, indexModel)
if err != nil {
    return err
}

// 이제 동일한 email로 삽입 시 에러 발생
user := User{Email: "kim@example.com"}
_, err = collection.InsertOne(ctx, user)
// 두 번째 삽입 시: duplicate key error

Index and Aggregate

Aggregation Pipeline에서도 인덱스가 활용된다.

특히 파이프라인 초반 스테이지에서 인덱스를 활용하면 전체 성능이 크게 향상된다.

// 인덱스: {status: 1, createdAt: -1}
pipeline := mongo.Pipeline{
    // Stage 1: $match - 인덱스 활용 가능!
    {{"$match", bson.M{"status": "active"}}},

    // Stage 2: $sort - createdAt 인덱스 활용 가능!
    {{"$sort", bson.M{"createdAt": -1}}},

    // Stage 3: $group - 인덱스 활용 불가 (집계 연산)
    {{"$group", bson.M{
        "_id":   "$category",
        "count": bson.M{"$sum": 1},
    }}},
}
  1. $match를 파이프라인 최대한 앞에 배치

  2. $match 후 바로 $sort 배치 (인덱스 활용 극대화)

  3. $group, $unwind 등 무거운 연산은 뒤로

쿼리 플랜 (Query Plan)

쿼리 플래너의 동작 과정

쿼리 플래너의 단계:

  1. 쿼리 분석: 쿼리 조건과 사용 가능한 인덱스 파악

  2. 후보 생성: 여러 실행 계획 후보 생성 (COLLSCAN, 각 인덱스별 IXSCAN 등)

  3. 실제 테스트: 각 후보를 일정량의 document (기본 101개)에 대해 실제 실행

  4. 최적 선택: 가장 빠른 플랜 선택

  5. 캐시 저장: 선택된 플랜을 캐시에 저장하여 재사용

explain()으로 쿼리 플랜 확인

MongoDB Shell:

// executionStats 모드로 실행 계획 확인
db.users.find({email: "test@example.com"}).explain("executionStats")

// 결과 예시
{
  "queryPlanner": {
    "winningPlan": {
      "stage": "IXSCAN",  // 인덱스 스캔 사용
      "keyPattern": {"email": 1},
      "indexName": "email_1"
    },
    "rejectedPlans": [
      {
        "stage": "COLLSCAN"  // 전체 스캔은 거부됨
      }
    ]
  },
  "executionStats": {
    "executionTimeMillis": 5,      // 실행 시간
    "totalDocsExamined": 1,        // 검사한 document 수
    "totalKeysExamined": 1,        // 검사한 인덱스 키 수
    "nReturned": 1                 // 반환된 document 수
  }
}

주요 필드 해석:

  • winningPlan.stage:

    • IXSCAN: 인덱스 스캔 (빠름)

    • COLLSCAN: 전체 컬렉션 스캔 (느림)

    • FETCH: 인덱스로 찾은 후 document 가져오기

  • executionTimeMillis: 실행 시간 (ms)

  • totalDocsExamined: 실제로 검사한 document 수

  • totalKeysExamined: 검사한 인덱스 키 수

  • nReturned: 반환된 document 수

Options: Projection

Projection은 쿼리 결과에서 필요한 필드만 선택하여 반환받는 기능이다.

// email 필드만 반환, _id는 제외
opts := options.FindOne().SetProjection(bson.M{
    "email": 1,
    "_id":   0,
})
var result User
err := collection.FindOne(ctx, filter, opts).Decode(&result)

// 여러 필드 선택
opts := options.Find().SetProjection(bson.M{
    "name":  1,
    "email": 1,
    "age":   1,
    "_id":   0,
})
cursor, err := collection.Find(ctx, filter, opts)

// 특정 필드 제외 (나머지는 모두 포함)
opts := options.Find().SetProjection(bson.M{
    "password": 0,  // password 필드만 제외
})

Projection 규칙:

  • 1: 필드 포함

  • 0: 필드 제외

  • 기본적으로 _id는 항상 포함 (명시적으로 0으로 설정해야 제외)

  • 포함과 제외를 섞어 쓸 수 없음 (예외: _id)

Registry: 커스텀 BSON 코덱

MongoDB의 타입과 Go의 타입이 일대일 매핑되지 않을 때, 커스텀 코덱을 사용하여 변환 로직을 정의할 수 있다.

예시: Decimal128 ↔ big.Int

MongoDB의 Decimal128 타입을 Go의 big.Int로 변환하는 예시입니다.

import (
    "math/big"
    "reflect"
    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/bson/bsoncodec"
    "go.mongodb.org/mongo-driver/bson/bsonrw"
    "go.mongodb.org/mongo-driver/bson/bsontype"
    "go.mongodb.org/mongo-driver/bson/primitive"
)

// BigIntCodec: big.Int ↔ Decimal128 변환
type BigIntCodec struct{}

func (c *BigIntCodec) EncodeValue(
    ec bsoncodec.EncodeContext,
    vw bsonrw.ValueWriter,
    val reflect.Value,
) error {
    if !val.IsValid() || val.Type() != reflect.TypeOf(big.Int{}) {
        return bsoncodec.ValueEncoderError{
            Name: "BigIntCodec.EncodeValue",
            Types: []reflect.Type{reflect.TypeOf(big.Int{})},
            Received: val,
        }
    }

    bigInt := val.Interface().(big.Int)
    decimal, err := primitive.ParseDecimal128(bigInt.String())
    if err != nil {
        return err
    }

    return vw.WriteDecimal128(decimal)
}

func (c *BigIntCodec) DecodeValue(
    dc bsoncodec.DecodeContext,
    vr bsonrw.ValueReader,
    val reflect.Value,
) error {
    if !val.CanSet() || val.Type() != reflect.TypeOf(big.Int{}) {
        return bsoncodec.ValueDecoderError{
            Name: "BigIntCodec.DecodeValue",
            Types: []reflect.Type{reflect.TypeOf(big.Int{})},
            Received: val,
        }
    }

    decimal, err := vr.ReadDecimal128()
    if err != nil {
        return err
    }

    bigInt, ok := new(big.Int).SetString(decimal.String(), 10)
    if !ok {
        return fmt.Errorf("failed to parse Decimal128 to big.Int")
    }

    val.Set(reflect.ValueOf(*bigInt))
    return nil
}

// Registry 생성 및 적용
func createCustomRegistry() *bsoncodec.Registry {
    rb := bson.NewRegistryBuilder()
    rb.RegisterTypeEncoder(reflect.TypeOf(big.Int{}), &BigIntCodec{})
    rb.RegisterTypeDecoder(reflect.TypeOf(big.Int{}), &BigIntCodec{})
    return rb.Build()
}

// Collection에 Registry 적용
opts := options.Collection().SetRegistry(createCustomRegistry())
collection := db.Collection("myCollection", opts)

// 이제 big.Int를 직접 사용 가능
type Product struct {
    ID    primitive.ObjectID `bson:"_id,omitempty"`
    Name  string             `bson:"name"`
    Price big.Int            `bson:"price"` // Decimal128로 저장됨
}

product := Product{
    Name:  "Laptop",
    Price: *big.NewInt(1500000),
}
_, err := collection.InsertOne(ctx, product)

커스텀 코덱이 필요한 경우:

  • MongoDB와 Go의 타입 불일치 해결

  • 특수한 직렬화/역직렬화 로직 필요

  • 레거시 데이터 형식 변환

References