MongoDB in Golang
BSON
MongoDB의 Go 드라이버에서 사용하는 BSON(Binary JSON) 타입은 4가지가 있다.
bson.M - 범용 맵 타입
bson.M은 map[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 조회는 각각 FindOne과 Find를 사용한다.
// 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 에서 추천되는 설계 원칙이 있다:
-
Equality 필터를 앞에 (예:
status: "active") -
Sort 필드를 중간에 (예:
createdAt: -1) -
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},
}}},
}
-
$match를 파이프라인 최대한 앞에 배치 -
$match후 바로$sort배치 (인덱스 활용 극대화) -
$group,$unwind등 무거운 연산은 뒤로
쿼리 플랜 (Query Plan)
쿼리 플래너의 동작 과정
쿼리 플래너의 단계:
-
쿼리 분석: 쿼리 조건과 사용 가능한 인덱스 파악
-
후보 생성: 여러 실행 계획 후보 생성 (COLLSCAN, 각 인덱스별 IXSCAN 등)
-
실제 테스트: 각 후보를 일정량의 document (기본 101개)에 대해 실제 실행
-
최적 선택: 가장 빠른 플랜 선택
-
캐시 저장: 선택된 플랜을 캐시에 저장하여 재사용
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의 타입 불일치 해결
-
특수한 직렬화/역직렬화 로직 필요
-
레거시 데이터 형식 변환