pokerogue 게임 서버 코드 분석하기 (4) - db 패키지 코드 분석

최창효·2025년 8월 2일
post-thumbnail

db 패키지 코드 살펴보기

이번 글에서는 db 패키지에 속한 파일들에 대해 살펴보도록 하겠습니다.

이 글에서는 코드를 필요한 부분만 가져와 설명하거나, 설명을 위해 배치를 임의로 변경하기도 합니다.
원본 코드는 https://github.com/pagefaultgames/rogueserver.git 에서 확인하실 수 있습니다.


db.go

가장 먼저 살펴볼 파일은 db.go입니다. 그 이유는 db.go가 데이터베이스와의 연결을 시작하는 코드이기도 하고, 나머지 파일은 이 연결을 바탕으로 구체적인 쿼리를 실행하는 부분이기 때문입니다.

저희는 처음 서버를 실행할 때 테이블을 생성하기 위해 주석을 해제했었습니다. 이번 분석은 실제 운영되는 환경(주석을 풀지 않은 코드)을 기준으로 분석해 보겠습니다.

package db

import (
	"context"
	"database/sql"
	"fmt"
	"os"

	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	_ "github.com/go-sql-driver/mysql"
)

var handle *sql.DB
var s3client *s3.Client

handle과 s3client라는 변수가 패키지 전역 변수로 선언되어 있습니다. 패키지 전역 변수기 때문에 해당 패키지 안의 모든 파일에서 handle과 s3client에 접근할 수 있습니다.

blank import

Go는 선언한 값을 사용하지 않으면 에러가 발생합니다. 이는 변수뿐만 아니라 패키지도 마찬가지이며, 사용하지 않는 패키지를 포함할 때는 그 패키지 앞에 빈칸지시자인 _를 붙여주면 됩니다.

사용하지 않을꺼라면 import자체를 하지 않을 수도 있는데 굳이 _를 이용해 import하는 이유가 뭘까요? 그건 바로 패키지를 불러오면(import하면) 패키지 내부에 있는 init()함수가 자동으로 실행되기 때문입니다.

그렇다면 지금 코드에서 github.com/go-sql-driver/mysql의 init메서드는 왜 필요할까요? database.sql패키지는 DB와 상호작용하기 위한 공통 API와 인터페이스만 제공하고 있으며, 직접적인 연결과 쿼리 실행에 대한 로직은 각 드라이버에 구현되어 있습니다. sql.Open("mysql",...)코드는 "mysql"이라는 키워드로 드라이버드를 찾을 것이고, 이를 위해 "mysql"이라는 드라이버가 사전에 등록되어 있어야 하지만 database.sql패키지는 이를 직접 등록해주지 않습니다.

github.com/go-sql-driver/mysql의 init메서드가 "mysql"이라는 이름의 드라이버를 전역으로 등록합니다. 아래는 github.com/go-sql-driver/mysql 패키지의 driver.go 파일에 정의되어 있는 init메서드입니다.

func init() {
	sql.Register("mysql", &MySQLDriver{})
}

github.com/go-sql-driver/mysql 패키지를 빈칸지시자로 import한 이유는 mysql드라이버를 등록하기 위함이였습니다.

Init메서드

func Init(username, password, protocol, address, database string) error {
	var err error

	handle, err = sql.Open("mysql", username+":"+password+"@"+protocol+"("+address+")/"+database)
	if err != nil {
		return fmt.Errorf("failed to open database connection: %s", err)
	}

	if os.Getenv("AWS_ENDPOINT_URL_S3") != "" {
		cfg, err := config.LoadDefaultConfig(context.TODO())
		if err != nil {
			return err
		}

		s3client = s3.NewFromConfig(cfg)
	}

	return nil
}

sql.Open은 등록된 드라이버를 사용해 데이터베이스 연결을 관리할 수 있는 핸들러를 생성합니다.
또한 S3 endpoint가 환경변수로 정의되어 있으면 패키지 전역 변수인 s3client 변수에 실질적인 클라이언트를 할당합니다.

db.go의 Init메서드는 routeserver.go의 main메서드에서 호출됩니다.

sql.Open

// Open opens a database specified by its database driver name and a
// driver-specific data source name, usually consisting of at least a
// database name and connection information.
//
// Most users will open a database via a driver-specific connection
// helper function that returns a [*DB]. No database drivers are included
// in the Go standard library. See https://golang.org/s/sqldrivers for
// a list of third-party drivers.
//
// Open may just validate its arguments without creating a connection
// to the database. To verify that the data source name is valid, call
// [DB.Ping].
//
// The returned [DB] is safe for concurrent use by multiple goroutines
// and maintains its own pool of idle connections. Thus, the Open
// function should be called just once. It is rarely necessary to
// close a [DB].
func Open(driverName, dataSourceName string) (*DB, error) {
	driversMu.RLock()
	driveri, ok := drivers[driverName]
	driversMu.RUnlock()
	if !ok {
		return nil, fmt.Errorf("sql: unknown driver %q (forgotten import?)", driverName)
	}

	if driverCtx, ok := driveri.(driver.DriverContext); ok {
		connector, err := driverCtx.OpenConnector(dataSourceName)
		if err != nil {
			return nil, err
		}
		return OpenDB(connector), nil
	}

	return OpenDB(dsnConnector{dsn: dataSourceName, driver: driveri}), nil
}

sql.Open에 주석으로 작성된 글은 다음과 같은 내용을 담고 있습니다.

  • Open은 드라이버 이름과 드라이버별 데이터 소스 이름(최소 DB이름과 연결 정보)으로 지정된 데이터베이스를 엽니다. 데이터베이스를 연다는 건 해당 DB를 다룰 수 있는 핸들러(DB)를 생성한다는 의미입니다.
  • Go의 표준 라이브러리에는 특정 드라이버가 포함되어 있지 않습니다.
  • Open은 인자들을 검증할 뿐 직접 연결을 맺지 않습니다. DB.Ping을 이용해 쿼리를 보내기 전에 DB 연결을 확인할 수 있습니다.
  • Open으로 반환된 DB는 고루틴에서 안전하게 사용할 수 있도록 동시성에 안전하게 설계되어 있습니다.
  • DB는 내부적으로 커넥션 풀을 관리합니다. 따라서 Open을 여러번 호출해 여러 DB를 만들거나, 프로그램 실행 중에 DB를 close해야 할 경우는 거의 없습니다.

DB는 데이터베이스 핸들러로 연결 풀을 관리하고 쿼리를 실행할 수 있는 인터페이스를 제공합니다. DB의 freeConn이 바로 커넥션 풀입니다.

Go의 sql.DB는 미리 여러 개의 Connection을 생성해두는 Java의 HikariCP와 달리 기본적으로 Lazy Connection 방식으로 동작합니다. 처음에는 freeConn풀에 아무런 커넥션이 없습니다. 이후 요청이 들어오면 커넥션을 만들어 처리한 뒤 이를 Close하지 않고 freeConn풀에 넣습니다. 다음 요청은 freeConn에 있는 커넥션에 의해 처리됩니다.

maxOpen과 maxIdleConns 설정값은 이 동작에 영향을 줍니다. maxOpen은 동시에 열어둘 수 있는 전체 연결 수의 최대치, maxIdleConns은 유휴 상태로 풀에 보관할 수 있는 최대 개수를 의미합니다.

maxOpen = 7,000, maxIdleConns = 500인 상태에서 동시에 10,000개의 요청이 들어온 경우를 살펴보겠습니다. 정확히 아래와 같이 동작한다기보다는 커넥션 풀을 이해하기 위한 예시입니다.

  1. 최초 커넥션 풀이 생성된 시점에 풀에는 아무런 커넥션이 들어있지 않습니다.
  2. 10,000개의 요청이 들어옵니다. maxOpen이 7,000이기 때문에 7,000개의 커넥션을 만들어 요청을 처리합니다.
  3. maxIdleConns가 500이기 때문에 7,000개의 커넥션 중 500개만 Open된 상태로 커넥션 풀에 저장됩니다. (나머지 6,500개의 커넥션은 Close됩니다)
  4. 남은 3000개의 요청을 처리합니다. 커넥션 풀에서 500개의 커넥션을 바로 활용할 수 있습니다. 그리고 신규로 2,500개의 커넥션을 만들어 요청을 처리합니다. maxOpen이 7,000이기 때문에 2,500개의 커넥션을 제약없이 만들 수 있습니다.

쿼리

game.go, savedata.go, daily.go, account.go 파일은 쿼리를 정의하고 있습니다. 이들 모두 db.go에 패키지 전역 변수로 선언된 handle과 s3client를 사용하고 있습니다.

QueryRow()와 Scan()

QueryRow는 SQL 실행 결과에서 단일 행을 가져올 때 사용하고, SQL 실행 결과가 여러 행일 경우에는 Query를 사용합니다. QueryRow는 항상 nil이 아닌 결과값을 반환하며 Scan을 호출하기 전까지 에러 여부를 알 수 없습니다.

Scan은 QueryRow의 쿼리 결과를 인자로 받은 변수(포인터 변수)에 복사하고 실패 시 error를 반환합니다. 따라서 Scan의 인자수는 쿼리의 컬럼수와 동일해야 합니다. QueryRow를 통해 질의한 결과가 여러 행일 경우 Scan은 나머지를 무시하고 첫번째 결과만 반환합니다.

Exec()

데이터 반환값이 없는 SQL을 실행할 때 사용합니다. 반환값인 Result는 LastInsertId와 RowAffected정보를 가지고 있습니다.

쿼리 예시

포켓로그는 아래와 같이 작성해 사용하고 있습니다.

삽입

func AddAccountRecord(uuid []byte, username string, key, salt []byte) error {
	_, err := handle.Exec("INSERT INTO accounts (uuid, username, hash, salt, registered) VALUES (?, ?, ?, ?, UTC_TIMESTAMP())", uuid, username, key, salt)
	if err != nil {
		return err
	}

	return nil
}
  • Exec메서드로 INSERT쿼리를 실행합니다.

단건 조회

func FetchUsernameByDiscordId(discordId string) (string, error) {
	var username string
	err := handle.QueryRow("SELECT username FROM accounts WHERE discordId = ?", discordId).Scan(&username)
	if err != nil {
		return "", err
	}

	return username, nil
}
  • QueryRow메서드로 SELECT쿼리를 실행하고 Scan으로 결과를 저장합니다.

조회한 뒤 곧바로 구조체와 매핑

func FetchAdminDetailsByUsername(dbUsername string) (AdminSearchResponse, error) {
	var username, discordId, googleId, lastActivity, registered sql.NullString
	var adminResponse AdminSearchResponse

	err := handle.QueryRow("SELECT username, discordId, googleId, lastActivity, registered from accounts WHERE username = ?", dbUsername).Scan(&username, &discordId, &googleId, &lastActivity, &registered)
	if err != nil {
		return adminResponse, err
	}

	adminResponse = AdminSearchResponse{
		Username:     username.String,
		DiscordId:    discordId.String,
		GoogleId:     googleId.String,
		LastActivity: lastActivity.String,
		Registered:   registered.String,
	}

	return adminResponse, nil
}
  • AdminSearchResponse인스턴스를 생성할 때 필요한 변수를 미리 선언해둔 뒤 쿼리 실행 결과값을 해당 변수에 담습니다. 이후 AdminSearchResponse인스턴스를 생성합니다.

다건 조회, 조회한 뒤 곧바로 구조체와 매핑

func FetchRankings(category int, page int) ([]defs.DailyRanking, error) {
	var rankings []defs.DailyRanking

	offset := (page - 1) * 10

	var query string
	switch category {
	case 0:
		query = "SELECT RANK() OVER (ORDER BY adr.score DESC, adr.timestamp), a.username, adr.score, adr.wave FROM accountDailyRuns adr JOIN dailyRuns dr ON dr.date = adr.date JOIN accounts a ON adr.uuid = a.uuid WHERE dr.date = UTC_DATE() AND a.banned = 0 LIMIT 10 OFFSET ?"
	case 1:
		query = "SELECT RANK() OVER (ORDER BY SUM(adr.score) DESC, adr.timestamp), a.username, SUM(adr.score), 0 FROM accountDailyRuns adr JOIN dailyRuns dr ON dr.date = adr.date JOIN accounts a ON adr.uuid = a.uuid WHERE dr.date >= DATE_SUB(DATE(UTC_TIMESTAMP()), INTERVAL DAYOFWEEK(UTC_TIMESTAMP()) - 1 DAY) AND a.banned = 0 GROUP BY a.username ORDER BY 1 LIMIT 10 OFFSET ?"
	}

	results, err := handle.Query(query, offset)
	if err != nil {
		return rankings, err
	}

	defer results.Close()

	for results.Next() {
		var ranking defs.DailyRanking
		err = results.Scan(&ranking.Rank, &ranking.Username, &ranking.Score, &ranking.Wave)
		if err != nil {
			return rankings, err
		}

		rankings = append(rankings, ranking)
	}

	return rankings, nil
}
  • Query메서드로 조회 후 for results.Next()로 쿼리 결과를 하나씩 처리합니다.
  • defs.DailyRanking타입의 ranking변수를 만들고 필드 포인터를 Scan의 인자로 전달해 쿼리의 결과를 구조체와 매핑합니다.

profile
기록하고 정리하는 걸 좋아하는 백엔드 개발자입니다.

0개의 댓글