MongoDB Golang 드라이버의 컨텍스트와 커넥션

들어가기 앞서 이 글을 쓰는데 도움을 주신 cp949 님, 김형준 님 그리고 장재휴 님에게 감사드립니다.

필자는 커머스 코드 자산화 개발일지를 연재하고 있다. 구현 과정에서 Golang을 기반으로 마이크로서비스 아키텍처를 차용하였고 대부분의 마이크로서비스(회원, 장바구니, 결제, 주문)의 데이터 저장소로 MongoDB를 사용했다. MongoDB를 사용한 이유는 크게 두 가지였다.

  • 집합적 데이터 모델
  • 객체/관계형 패러다임 불일치

예를 들면 주문 도메인에서 주문을 아래와 같이 객체 모델링 할 수 있다. 그리고 여러 가지 이유로[1] 주문을 다룰 때에는 각각의 객체(Order, ShippingAddress, LineItem, OrderPayment)가 아니라 하나의 덩어리(객체의 무리) 즉 집합으로 다루는 것이 좋다. 도메인 주도 설계Domain-Driven Design에서는 이것을 애그리게잇AGGREGATE이라 부른다.

스크린샷 2020-06-05 오후 12.00.13

애그리게잇을 관계형 데이터베이스에 저장하려면 관계형(여러 테이블)으로 변환해야 한다. ORMObject-Relational Mapping 이 어느 정도 이 수고를 해결해 주지만 완벽하게 해결해 주지는 않는다. MongoDB는 데이터를 집합 단위로 저장하고 수정하기 때문에 집합적 데이터 모델과 잘 맞는다.

Golang이 세상에 나온지 얼마 안 되었을 때에는 MongoDB에서 공식적으로 Golang 드라이버를 지원하지 않았으나 현재는 Golang 드라이버를 지원한다. 이 글은 MongoDB Golang 드라이버를 사용하며 들었던 의문점을 정리한 글이다.

컨텍스트Context는 어디에 쓰는 것일까?

MongoDB 드라이버 GitHub에서 사용법을 보면 거의 항상 컨텍스트와 함께 사용하는 것을 확인할 수 있다.

출처 : https://github.com/mongodb/mongo-go-driver#usage

출처 : https://github.com/mongodb/mongo-go-driver#usage

Golang을 많이 써보지 않은 필자는 위와 같이 컨텍스트를 함께 쓰는 방식이 생소했다. 왜 이렇게 사용하는 것일까?

먼저 컨텍스트는 여러 용도로 쓰이는데 그중 하나가 생명 주기를 제어하기 위해 쓴다.

필자는 context.WithTimeout 함수를 많이 사용한다. 주로 네트워크 병목이 생기는 작업을 고루틴으로 실행하는 경우가 많은데, 간혹 네트워크 문제로 timeout이 발생하는 경우가 있다. 이런 경우 고루틴 안으로 context.WithTimeout 함수로 생성한 컨텍스트를 전달하여 일정한 시간이 지나면 고루틴을 자동으로 종료하도록 해서 고루틴이 무한정 길어지는 것을 막을 수 있다. - Go언어에서 Context 사용하기

1
2
3
4
ctx, _ := context.WithTimeout(context.Background(), 30*time.Second)
cur, _ := collection.Find(ctx, bson.D{})
// ...
defer cur.Close(ctx)

결국 데이터베이스 커넥션이 너무 길어지는 문제를 방지하기 위해 사용하는 것이다. 위와 같이 컨텍스트 생성 시 WithTimeout으로 30초로 생성하면 30초가 넘어가는 쿼리는 데이터베이스 응답을 기다리지 않고 커넥션을 종료한다.

Timeout에 상관없이 사용하고 싶을 때에는 아래와 같이 빈 컨텍스트(context.Background())를 생성하여 전달한다.

1
2
3
4
ctx := context.Background()
cur, _ := collection.Find(ctx, bson.D{})
// ...
defer cur.Close(ctx)

경우에 따라서 하나의 함수 내에서 데이터를 조회하고 나서 수정이나 생성을 할 수 있다. 이때 컨텍스트는 하나만 생성하여 재사용할 수 있다.

1
2
3
4
5
6
ctx := context.Background()
cur, _ := collection.Find(ctx, bson.D{})
defer cur.Close(ctx)
// ...
res, err := collection.InsertOne(ctx, bson.M{"name": "pi", "value": 3.14159})
// ...

다만 빈 컨텍스트가 아니라 Timeout 컨텍스트를 재사용할 때에는 Timeout에 따른 ‘context deadline exceeded’ 오류를 주의해야 한다.

1
2
3
4
5
6
7
8
ctx, _ := context.WithTimeout(context.Background(), 30*time.Second)
cur, _ := collection.Find(ctx, bson.D{})
defer cur.Close(ctx)
// ...
// 테스트를 위해 30초 sleep
time.Sleep(30 * time.Second)
res, err := collection.InsertOne(ctx, bson.M{"name": "pi", "value": 3.14159})
// 오류 : connection(localhost:27017[-3]) failed to write: context deadline exceeded

데이터베이스 커넥션은 어떻게 관리되나?

아래 코드를 실행하여 커넥션의 변화를 확인해 보자.

1
2
3
4
5
6
7
8
// MongoDB 접속
ctx, _ := context.WithTimeout(context.Background(), 10*time.Second)
client, err := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost:27017"))
collection := client.Database("testing").Collection("numbers")
// (1) Insert
res, err := collection.InsertOne(context.Background(), bson.M{"name": "pi", "value": 3.14159})
// (2) Insert
res, err := collection.InsertOne(context.Background(), bson.M{"name": "pi", "value": 3.14159})

(1)번 Insert 실행 시 커넥션의 아래와 같이 변화한다. 여기서 주목할 점은 Insert가 끝나도 MongoDB 드라이버는 커넥션을 끊지 않고 유지한다는 것이다.

image-20200605-004233

(2)번 Insert를 실행하면 MongoDB 드라이버는 사용하지 않는 커넥션 1개(connection1)가 존재하기 때문에 이를 사용하여 Insert를 수행하고 역시 커넥션을 끊지 않는다.

image-20200605-004308

왜 이렇게 동작할까? 결론부터 말하면 MongoDB 드라이버는 기본적으로 커넥션 풀을 사용하기 때문이다.

아래 코드는

1
2
3
// MongoDB 접속
ctx, _ := context.WithTimeout(context.Background(), 30*time.Second)
client, err := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost:27017"))

사실 아래처럼 쓰는 것과 같다.

1
2
3
4
5
6
// MongoDB 접속
clientOptions := options.Client().ApplyURI("mongodb://localhost:27017")
clientOptions.SetMaxPoolSize(100)
clientOptions.SetMinPoolSize(0)
ctx, _ := context.WithTimeout(context.Background(), 30*time.Second)
client, err := mongo.Connect(ctx, clientOptions)

MongoDB 드라이버는 커넥션 풀을 명시적으로 설정하지 않아도 커넥션 풀을 사용하고 기본 값이 MaxPoolSize 100, MinPoolSize 0 이다.[2]

웹 애플리케이션 같이 동시적으로 많은 트래픽이 몰리는 상황에서는 사용하지 않는 커넥션을 계속 점유하는 문제가 발생할 수 있다. 이 문제는 MaxConnIdleTime을 추가 설정함으로써 해결할 수 있다.

1
2
3
4
5
6
clientOptions := options.Client().ApplyURI("mongodb://localhost:27017")
clientOptions.SetMaxPoolSize(100)
clientOptions.SetMinPoolSize(10)
clientOptions.SetMaxConnIdleTime(10 * time.Second)
ctx, _ := context.WithTimeout(context.Background(), 30*time.Second)
client, err := mongo.Connect(ctx, clientOptions)

위와 같이 설정하면 최초에는 커넥션을 10개가 생성하고 최대 100개까지 늘어나며 사용하지 않고 대기하고 있는 커넥션 즉 유휴 커넥션은 10초가 지나면 종료한다.

아쉬운 점은 Mongo 드라이버의 유휴 커넥션을 종료 시키는 시점이다. 드라이버 내부의 고루틴 같은 것으로 유휴 시간을 지속적으로 확인하여 종료 시키는 것이 아니라 커넥션을 사용하는 시점(collection.InsertOne, collection.Find 등)에 커넥션 풀의 커넥션을 꺼내 확인하여 종료시킨다는 것이다. 이것은 엄밀하게 커넥션이 최대 유휴 시간이 지나도 종료되지 않을 수 있음을 의미한다.

주석

[1] https://www.popit.kr/에그리게잇-하나에-리파지토리-하나/

[2] https://docs.mongodb.com/manual/reference/connection-string/#connections-connection-options


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.