Go와 gRPC-Gateway(V2)로 구현하는 소형 프로그램 인증 서비스: 데이터 영속화 및 테스트

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

참고 자료

태그: gRPC-Gateway go MongoDB docker unit testing

7월 18일 21:19에 게시됨