MongoDB 기반 데이터 영속화
Docker로 로컬 MongoDB 구축
MongoDB 4.4.5 이미지를 받아 컨테이너로 실행합니다.
docker pull mongo:4.4.5
docker run -p 27017:27017 -d mongo:4.4.5
호스트의 27017 포트가 컨테이너의 MongoDB 서비스와 연결되었는지 docker ps로 확인합니다.
VS Code용 MongoDB 확장
개발 환경에서 VS Code를 사용한다면 MongoDB 확장을 설치하면 Playground 기능으로 즉각적인 CRUD 작업이 가능합니다.
초기 데이터베이스 및 컬렉션 설정
Playground에서 다음과 같이 데이터베이스와 샘플 데이터를 구성합니다.
use('miniapp-auth');
db.account.drop();
db.account.insertMany([
{open_id: 'user_abc'},
{open_id: 'user_def'}
]);
db.account.find();
Upsert 패턴으로 계정 조회/생성
핵심 비즈니스 로직은 open_id로 계정을 조회하고, 없으면 새로 생성하는 것입니다. MongoDB의 findAndModify를 활용하면 원자적으로 처리할 수 있습니다.
db.account.findAndModify({
query: { open_id: "target_openid" },
update: {
$setOnInsert: {
_id: ObjectId("607132dcfbe32307260f728a"),
open_id: "target_openid"
}
},
upsert: true,
new: true
})
upsert: true 설정 덕분에 기존 문서가 있으면 조회만 하고, 없을 때만 $setOnInsert 블록의 필드로 새 문서를 만듭니다.
DAO 계층 구현
MongoDB 클라이언트 래
dao/mongo 패키지에 데이터 접근 객체를 정의합니다. 외부에서 주입 가능한 구조로 설계하면 테스트가 용이해집니다.
type AccountStore struct {
collection *mongo.Collection
generateID func() primitive.ObjectID
}
func NewAccountStore(db *mongo.Database) *AccountStore {
return &AccountStore{
collection: db.Collection("account"),
generateID: primitive.NewObjectID,
}
}
계정 ID 해소 로직
FindOneAndUpdate를 활용해 조회/삽입을 한 번에 처리합니다.
func (s *AccountStore) FetchOrCreateAccount(ctx context.Context, openID string) (string, error) {
newID := s.generateID()
result := s.collection.FindOneAndUpdate(
ctx,
bson.M{"open_id": openID},
bson.M{
"$setOnInsert": bson.M{
"_id": newID,
"open_id": openID,
},
},
options.FindOneAndUpdate().
SetUpsert(true).
SetReturnDocument(options.After),
)
if err := result.Err(); err != nil {
return "", fmt.Errorf("account lookup failed: %w", err)
}
var doc struct {
ID primitive.ObjectID `bson:"_id"`
}
if err := result.Decode(&doc); err != nil {
return "", fmt.Errorf("decode failure: %w", err)
}
return doc.ID.Hex(), nil
}
컨테이너 기반 통합 테스트
테스트용 MongoDB 동적 프로비저닝
Mock 대신 실제 MongoDB 인스턴스를 Docker로 띄워 테스트합니다. dao/mongo.go에 헬퍼를 작성합니다.
func SpinUpMongoForTests(m *testing.M, connectionString *string) int {
docker, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
panic(err)
}
ctx := context.Background()
resp, err := docker.ContainerCreate(ctx,
&container.Config{
Image: mongoImage,
ExposedPorts: nat.PortSet{
mongoPort: {},
},
},
&container.HostConfig{
PortBindings: nat.PortMap{
mongoPort: []nat.PortBinding{{
HostIP: "0.0.0.0",
HostPort: "0",
}},
},
},
nil, nil, "",
)
if err != nil {
panic(err)
}
containerID := resp.ID
defer func() {
_ = docker.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{Force: true})
}()
if err := docker.ContainerStart(ctx, containerID, types.ContainerStartOptions{}); err != nil {
panic(err)
}
inspect, err := docker.ContainerInspect(ctx, containerID)
if err != nil {
panic(err)
}
binding := inspect.NetworkSettings.Ports[mongoPort][0]
*connectionString = fmt.Sprintf("mongodb://%s:%s", binding.HostIP, binding.HostPort)
return m.Run()
}
테이블 기반 단위 테스트
dao/mongo_test.go에서 다양한 시나리오를 검증합니다.
func TestFetchOrCreateAccount(t *testing.T) {
ctx := context.Background()
client, err := mongo.Connect(ctx, options.Client().ApplyURI(testMongoURI))
if err != nil {
t.Fatalf("connection error: %v", err)
}
store := NewAccountStore(client.Database("miniapp-auth"))
// 시드 데이터 삽입
_, err = store.collection.InsertMany(ctx, []interface{}{
bson.M{"_id": mustObjectID("606f12ff0ba74007267bfeee"), "open_id": "existing_1"},
bson.M{"_id": mustObjectID("606f12ff0ba74007267bfeef"), "open_id": "existing_2"},
})
if err != nil {
t.Fatalf("seed data insertion failed: %v", err)
}
// ID 생성기를 고정값으로 교체
store.generateID = func() primitive.ObjectID {
return mustObjectID("606f12ff0ba74007267bfef0")
}
scenarios := []struct {
description string
openID string
expectedID string
}{
{
description: "기존 사용자 조회",
openID: "existing_1",
expectedID: "606f12ff0ba74007267bfeee",
},
{
description: "또 다른 기존 사용자",
openID: "existing_2",
expectedID: "606f12ff0ba74007267bfeef",
},
{
description: "신규 사용자 생성",
openID: "brand_new_user",
expectedID: "606f12ff0ba74007267bfef0",
},
}
for _, sc := range scenarios {
t.Run(sc.description, func(t *testing.T) {
gotID, err := store.FetchOrCreateAccount(ctx, sc.openID)
if err != nil {
t.Fatalf("unexpected error for %q: %v", sc.openID, err)
}
if gotID != sc.expectedID {
t.Errorf("ID mismatch: want %q, got %q", sc.expectedID, gotID)
}
})
}
}
func mustObjectID(hex string) primitive.ObjectID {
id, err := primitive.ObjectIDFromHex(hex)
if err != nil {
panic(err)
}
return id
}
func TestMain(m *testing.M) {
os.Exit(SpinUpMongoForTests(m, &testMongoURI))
}
서비스 계층 연동
인증 서비스에서 DAO를 주입받아 사용합니다.
type AuthHandler struct {
AccountRepo *dao.AccountStore
Logger *zap.Logger
IdentitySolver IdentitySolver
authpb.UnimplementedAuthServiceServer
}
func (h *AuthHandler) Login(ctx context.Context, req *authpb.LoginRequest) (*authtypes.LoginResponse, error) {
h.Logger.Info("authorization attempt", zap.String("code", req.Code))
openID, err := h.IdentitySolver.Resolve(ctx, req.Code)
if err != nil {
return nil, status.Errorf(codes.Unavailable, "identity resolution failed: %v", err)
}
accountID, err := h.AccountRepo.FetchOrCreateAccount(ctx, openID)
if err != nil {
h.Logger.Error("account persistence failed", zap.Error(err))
return nil, status.Error(codes.Internal, "service unavailable")
}
return &authpb.LoginResponse{
AccessToken: fmt.Sprintf("bearer_%s", accountID),
ExpiresIn: 7200,
}, nil
}
서버 구성
메인 애플리케이션에서 의존성을 조립합니다.
authpb.RegisterAuthServiceServer(grpcServer, &AuthHandler{
IdentitySolver: &wechat.Resolver{
AppID: os.Getenv("WECHAT_APPID"),
AppSecret: os.Getenv("WECHAT_SECRET"),
},
AccountRepo: dao.NewAccountStore(mongoClient.Database("miniapp-auth")),
Logger: logger,
})
실행
gRPC 서비스와 게이트웨이를 각기 실행합니다.
# gRPC 서버
go run auth/main.go
# HTTP 게이트웨이
go run gateway/main.go