Notice
Recent Posts
Recent Comments
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
Archives
Today
Total
관리 메뉴

SYDev

[Go 언어로 배우는 웹 애플리케이션 개발] Chapter 18. RDBMS를 사용한 데이터베이스 처리 구현하기 본문

Google Developer Groups on KHU 1th

[Go 언어로 배우는 웹 애플리케이션 개발] Chapter 18. RDBMS를 사용한 데이터베이스 처리 구현하기

시데브 2024. 11. 14. 02:09
앞장에서 구현한 HTTP handler는 데이터를 in-memory에 저장하는 간단한 구현이었다. 이번 장에서는 RDBMS를 사용해 데이터를 영구적으로 저장하도록 변경한다. 책에서 사용한대로 MySQL RDBMS 사용할 예정

Section72. MySQL 실행 환경 구축

1. 테이블 정의와 마이그레이션 방법 결정

Go는 언더스코어(_)로 시작하는 디렉터리 및 testdata 디렉터리는 패키지로 인식 X 
_tools/mysql/schema.sql에 저장
  • entity.Task type에 해당하는 task table
  • 장의 후반부에서 구현하는 로그인 사용자의 영구적 저장을 위해 user 테이블도 정의
CREATE TABLE `user`
(
    `id`       BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '사용자 식별자',
    `name`     varchar(20) NOT NULL COMMENT '사용자명',
    `password` VARCHAR(80) NOT NULL COMMENT '패스워드 해시',
    `role`     VARCHAR(80) NOT NULL COMMENT '역할',
    `created`  DATETIME(6) NOT NULL COMMENT '레코드 작성 시간',
    `modified` DATETIME(6) NOT NULL COMMENT '레코드 수정 시간',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uix_name` (`name`) USING BTREE
) Engine=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='사용자'; 

CREATE TABLE `task`
(
    `id`       BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '태스크 식별자',
    `title`    VARCHAR(128) NOT NULL COMMENT '태스크 타이틀',
    `status`   VARCHAR(20)  NOT NULL COMMENT '태스크 상태',
    `created`  DATETIME(6) NOT NULL COMMENT '레코드 작성 시간',
    `modified` DATETIME(6) NOT NULL COMMENT '레코드 수정 시간',
    PRIMARY KEY (`id`)
) Engine=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='태스크';

 

2. 마이그레이션 툴

Migration?
- 사전적 의미로 '이주'
- IT에서의 migration: DB, system, application의 데이터를 기존 시스템에서 다른 시스템으로 이전하는 과정
  • 실무에서는 한 번 정의한 테이블을 계속 사용하는 경우 드묾 -> 기능 확장이나 오류 수정 등의 이유로 migration 진행
  • Go에서는 표준 패키지나 Go에 RDBMS migration 관리하는 기능이 없음 -> 오픈소스 사용
  • 교재에서는 github.com/k0kubun/sqldef 패키지의 MySQL용 명령인 mysqldef 명령어 사용
go install github.com/k0kubun/sqldef/cmd/mysqldef@latest 
-> 책에는 이걸로 돼있는데, 유저명이 바뀐듯하다, 아래 명령어로 실행하니 잘 된다.

go get github.com/sqldef/sqldef/cmd/mysqldef@latest

 

3. 로컬에서 MySQL 컨테이너 실행하기

 

4. 로컬의 MySQL 컨테이너에 마이그레이션 실시

  • make up -> make migrate

 

깃허브 액션에서 MySQL 컨테이너 실행하기

  • 자동테스트에서도 github actions를 실행하기 위해 github actions에서 MySQL 컨테이너를 실행
  • github actions에서는 서비스 컨테이너라는 방법으로 CI/CD workflow에서 필요한 middleware contaienr를 실행할 수 있다.

-> workflow 시작 시에 service의 iamge에서 지정한 MySQL container를 실행

-> local의 docker-compose.yml 파일과 마찬가지로 ports로 설정한 포트 번호를 사용해 접속 가능

-> options에서 지정한 명령: MySQL 컨테이너가 사용할 수 있게 될 때까지 단계별 대기시간 지정

-> local과 동일하게 sqldef 명령으로 테이블을 정의하는 단계도 추가

 

Section 73. RDBMS 처리 구현

  • 간이로 구현한 store 패키지에 MySQL을 사용한 영구 저장 처리를 추가 -> github.com/jmoiron/sqlx 패키지 사용

 

1. database/sql 및 github.com/jmoiron/sqlx 패키지 비교

  • 표준 패키지인 database/sql를 사용하는 경우 데이터베이스에서 읽어온 레코드 정보를 구조체로 매핑하는 구현을 매번 해야 함

  • 반면, github.com/jmoiron/sqlx 패키지를 사용해서 구현한 경우 
    • 구조체의 각 필드에 태그를 사용해서 테이블의 컬럼명에 해당하는 메타데이터를 설정 -> SQL 쿼리를 실행하는 구현 부분에서 구조체를 초기화하지 않아도 돼서 코드가 간결해짐

-> entity/task.go의 entity.Task type의 각 필드에 db 태그를 추가

 

2. 환경 변수로부터 접속 정보 읽기

type Config struct {
	Env        string `env:"TODO_ENV" envDefault:"dev"`
	Port       int    `env:"PORT" envDefault:"80"`
	DBHost     string `env:"TODO_DB_HOST" envDefault:"127.0.0.1"`
	DBPort     int    `env:"TODO_DB_PORT" envDefault:"33306"`
	DBUser     string `env:"TODO_DB_USER" envDefault:"todo"`
	DBPassword string `env:"TODO_DB_PASSWORD" envDefault:"todo"`
	DBName     string `env:"TODO_DB_NAME" envDefault:"todo"`
}

-> config/config.go 파일의 config type 정의에 docker-compose.yml에서 정의한 MySQL 접속용 환경 변수 필드 추가

 

3. 데이터베이스 연결

  • config.Config type을 사용해서 RDBMS에 접속하는 함수 New
  • sql.Open 함수는 접속 확인은 하지 않으므로, 명시적으로 *sql.DB.PingContext method를 사용해서 연결 확인
  • 접속 옵션
    • parseTime=true를 지정하지 않으면 time.Time type 필드에 맞는 시각 정보가 저장되지 않음
  • *sql.DB type value는 RDBMS 사용 종료 후에 *sql.DB.Close method를 호출해서 연결을 해제할 필요가 있음
  • New 함수 안에서는 application 종료 시점에 맞춰서 *sql.DB.close method를 호출하는 구조를 만들 수 없음 -> New 함수를 호출한 곳에서 처리를 종료할 수 있도록 반환값으로 *sql.DB.Close method를 실행하는 익명 함수를 반환
package store

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"

	"github.com/Imsyp/go_todo_app/chapter18/section72/config"
	"github.com/jmoiron/sqlx"
)

func New(ctx context.Context, cfg *config.Config) (*sqlx.DB, func(), error) {
	// sqlx.Connect를 사용하면 내부에서 ping
	db, err := sql.Open("mysql",
		fmt.Sprintf(
			"%s:%s@tcp(%s:%d)/%s?parseTime=true",
			cfg.DBUser, cfg.DBPassword,
			cfg.DBHost, cfg.DBPort,
			cfg.DBName,
		),
	)
	if err != nill {
		return nill, func() {}, err
	}
	// Open은 실제로 접속 테스트는 하지 않는다.
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()
	if err := db.PingContext(ctx); err != nil {
		return nil, func() { _ = db.CLose() }, err
	}
	xdb := sqlx.NewDBb(db, "mysql")
	return xdb, func() { _ = db.Close() }, nil
}

 

4. 인터페이스와 Repository type 정의

  • 참조 계열의 주요 method를 모은 Queryer interface
  • 읽기 계열의 method를 모은 Execer interface
  • 등의 인터페이스를 정의하여, 해당 코드가 interface를 경유하여 실행할 수 있게 함
  • application 측면에서도, 이 method의 인수는 Queryer 인터페이스므로, MySQL의 데이터 변경하는 경우가 없을 것임을 알 수 있어 코드 해석도 쉬워짐
  • 마지막으로 Repository type을 정의 -> 이제부터 구현하는 RDBMS를 사용한 모든 영구 저장 처리는 Repository type method로 구현됨 
  • 동일 type의 method로 구현하는 이유
    • 여러 테이블을 하나의 type method로 처리 가능
    • 의존성 주입을 사용하는 경우 하나의 type으로 통일해야 쉽게 처리 가능
type Beginner interface {
	BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
}

type Preparer interface {
	PreparexContext(ctx context.Context, query string) (*sqlx.Stmt, error)
}

type Execer interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	NamedExecContext(ctx context.Context, query string, arg interface{}) (sql.Result, error)
}

type Queryer interface {
	Preparer
	QueryxContext(ctx context.Context, query string, args ...any) (*sqlx.Rows, error)
	QueryRowxContext(ctx context.Context, query string, args ...any) *sqlx.Row
	GetContext(ctx context.Context, dest interface{}, query string, args ...any) error
	SelectContext(ctx context.Context, dest interface{}, query string, args ...any) error
}

var (
	// 인터페이스가 구현되었는지 확인하기 위해 빈 인터페이스를 사용한다.
	_ Beginner = (*sqlx.DB)(nil)
	_ Preparer = (*sqlx.DB)(nil)
	_ Queryer  = (*sqlx.DB)(nil)
	_ Execer   = (*sqlx.DB)(nil)
	_ Execer   = (*sqlx.Tx)(nil)
)

type Repository struct {
	Clocker clock.Clocker
}

-> _ Beginner 코드: 인터페이스가 실제 타입의 시그니처와 일치하는지 확인

-> method명이나 인수에 차이가 있는 경우 빌드 오류로 감지 가능

 

5. clock 패키지 정의

  • Repository type의 Clocker 필드는 SQL 실행 시에 사용하는 시간 정보를 제어하기 위한 것.
  • 데이터베이스 처리할 때 시간 정보를 고정하는 것이 목적
  • Go의 time.Time type은 나노초 단위의 시간 정밀도를 가지고 있어서, 영구 저장한 데이터를 읽어서 비교하면 대부분의 경우 시간 정보가 불일치 -> 현재 시간의 변화가 테스트 결과에 영향 -> 이런 문제를 해결
  • clock.Clocker 인터페이스를 충족하는 구현
    • application에서 실제로 사용하는 time.Now 함수의 wrapper RealClocker type
    • 테스트용 고정 시간을 반환하는 FixecClocker type
package clock

import (
	"time"
)

type Clocker interface {
	Now() time.Time
}

type RealClocker struct{}

func (r RealClocker) now() time.Time {
	return time.Now()
}

type FixedClocker struct{}

func (fc FixedClocker) Now() time.Time {
	return time.Date(2022, 5, 10, 12, 34, 56, 0, time.UTC)
}

 

6. 모든 태스크를 불러오는 method

  • task table 처리는 store/task.go 파일에 구현
  • *entity.Task type value를 모두 불러오는 ListTasks 구현 -> 참조 계열 method이므로, 인수로 Queryer interface를 충족하는 type의 value를 받음
  • SelectContext method는 여러 record를 불러와서 각 record를 하나씩 구조체에 대입한 슬라이스를 반환 (jmoiron/sqlx의 확장 method)
func (r *Repository) ListTasks(
	ctx context.Context, db Queryer,
) (entity.Tasks, error) {
	tasks := entity.Tasks{}
	sql := `SELECT
		id, title,
		status, created, modified
		FROM task;`
	if err := db.SelectContext(ctx, &tasks, sql); err != nil {
		return nil, err
	}
	return tasks, nil
}

 

7. 태스크를 저장하는 method

  • task를 저장하는 처리
  • RDBMS에 INSERT를 실행하므로, 두 번째 인수는 Execer interface 사용
  • ExecContext method를 실해한 경우, 첫 번재 반환값인 sql.Result interface type의 LastInsertId method가 발행한 ID를 얻을 수 있음
  • AddTask method는 인수로 지정한 *entity.Task type vlaue의 Id field를 변경하면, 호출한 곳으로 발행된 ID를 전달
func (r *Repository) AddTask(
	ctx context.Context, db Execer, t *entity.Task,
) error {
	t.Created = r.Clocker.Now()
	t.Modified = r.Clocker.Now()
	sql := `INSERT INTO task
		(title, status, created, modified)
		VALUES (?, ?, ?, ?)`
		result, err := db.ExecContext(
			ctx, sql, t.Title, t.Status,
			t.Created, t.Modified,
		)
		if err != nil {
			return err
		}
		id, err := result.LastInsertId()
		if err != nil {
			return err
		}
		t.ID = entity.TaskID(id)
		return nil
}

 

Section 74. RDBMS 관련 기능을 테스트하기 위한 코드 구현

  • 앞서 구현한 두 개의 method를 위한 테스트 코드 작성
    • 테스트에서도 RDBMS를 사용해 쿼리를 실행하는 테스트 기법
    • RDBMS를 사용하지 않고 mock을 통해 쿼리를 검증하는 테스트 기법
Mock?
- 한글로 "모의, 가짜의"라는 의미
- 실제 객체를 만들기엔 비용과 시간이 많이 들거나, 의존성이 길게 걸쳐져 있어 구현하기 어려울 경우, 가짜 객체를 만들어 사용 -> 이것을 Mock이라 부름
- Mock은 테스트할 때 필요한 실제 객체와 동일한 모의 객체를 만들어 test의 효용성을 높이기 위해 사용

 

1. 실행 환경에 따라 접속 정보를 변경하는 테스트 헬퍼 함수

  • 실제 DB를 사용하여 테스트하기 위해 테스트 헬퍼 함수 작성
  • test code를 local, github actions에서 실행하려면 환경 별로 다른 MySQL 접속 정보가 필요
  • 환경 변수로부터 정보를 받는 것이 아닌, 하드 코딩된 정보를 환경에 따라 바꿔가며 사용하도록 헬퍼 함수를 작성하는 이유 
    • local 환경은 docker-compose.yml에서 접속 정보를 고정함
    • github-actions의 접속 정보도 github-actions의 정의 파일 내에 고정 
    • 테스트를 실행하기 위해 고정된 접속 정보를 환경 변수로부터 읽는 것은 비효율적
package testutil

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

	"github.com/jmoiron/sqlx"
)

func OpenDBForTest(t *testing.T) *sqlx.DB {
	port := 33306
	if _, defined := os.LookupEnv("CI"); defined {
		port = 3306
	}
	db, err := sql.Open(
		"mysql",
		fmt.Sprintf("todo:todo@tcp(127.0.0.1:%d)/todo?parseTime=true", port),
	)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(
		func() { _ = db.Close() },
	)
	return sqlx.NewDb(db, "mysql")
}

-> CI 환경 변수는 깃허브 액션에만 정의돼 있다고 가정

-> 지금까지 준비한 로컬 환경이나 깃허브 액션 환경에서는 포트 번호만 다르므로, 여기서도 포트 번호만 변경

 

2. 실제 RDBMS를 사용해 테스트하기

  • 실제 RDBMS를 사용해서, ListTasks method를 테스트하는 코드
  • ListTasks method는 모든 record를 불러오는 기능 -> 다른 테스트 케이스에 의해 record가 추가되는 경우 반환값이 변할 수 있음
  • 이를 위해 RDBMS 트랜잭션 기능을 사용해 테스트 코드에만 한정된 테이블 상태를 만듦
package store

import (
	"context"
	"testing"

	"github.com/DATA-DOG/go-sqlmock"
	"github.com/Imsyp/go_todo_app/clock"
	"github.com/Imsyp/go_todo_app/entity"
	"github.com/Imsyp/go_todo_app/testutil"
	"github.com/google/go-cmp/cmp"
	"github.com/jmoiron/sqlx"
)

func TestRepository_ListTasks(t *testing.T) {
	ctx := context.Background()
	// entity.Task를 작성하는 다른 테스트 케이스와 섞이면 테스트가 실패
	// 이를 위해 트랜잭션을 적용해서 테스트 케이스 내로 한정된 테이블 상태를 만듦
	tx, err := testutil.OpenDBForTest(t).BeginTxx(ctx, nil)
	// 이 테스트 케이스가 끝나면 원래 상태로 되돌림
	t.Cleanup(func() { _ = tx.Rollback() })
	if err != nil {
		t.Fatal(err)
	}
	wants := prepareTasks(ctx, t, tx)

	// system under test: 하나의 테스트에서 테스트하고자 하는 주요 대상이 되는 Unit
	sut := &Repository{}
	gots, err := sut.ListTasks(ctx, tx)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if d := cmp.Diff(gots, wants); len(d) != 0 {
		t.Errorf("differs: (-got +want)\n%s", d)
	}
}

 

  • 한 번 테이블의 레코드를 삭제한 후, 이 테스트에서 필요한 세 개의 레코드를 추가
  • TestRepository_ListTasks에서 시작한 트랜잭션을 닫고 있으므로, 다른 테스트 케이스에는 영향 X
func prepareTasks(ctx context.Context, t *testing.T, con Execer) entity.Tasks {
	t.Helper()
	//깨끗한 상태로 정리
	if _, err := con.ExecContext(ctx, "DELETE FROM task;") err != nil {
		t.Lodf("failed to initialize task: %v", err)
	}
	c := clock.FixedClocker{}
	wants := entity.Tasks{
		{
			Title: "want task 1", Status: "todo",
			Created: c.Now(), Modified: c.Now(),
		},
		{
			Title: "want task 2", Status: "todo",
			Created: c.Now(), Modified: c.Now(),
		},
		{
			Title: "want task 3", Status: "done",
			Created: c.Now(), Modified: c.Now(),
		},
	}
	result, err := con.ExecContext(ctx,
		`INSERT INTO task (title, status, created, modified)
		VALUES
			(?, ?, ?, ?),
			(?, ?, ?, ?),
			(?, ?, ?, ?);`	,
		wants[0].Title, wants[0].Status, wants[0].Created, wants[0].Modified,
		wants[1].Title, wants[1].Status, wants[1].Created, wants[1].Modified,
		wants[2].Title, wants[2].Status, wants[2].Created, wants[2].Modified,
	)
	if err != nil {
		t.Fatal(err)
	}
	id, err := result.LastInsertId()
	if err != nil {
		t.Fatal(err)
	}
	wants[0].ID = entity.TaskID(id)
	wants[1].ID = entity.TaskID(id + 1)
	wants[2].ID = entity.TaskID(id + 2)
	return wants
}

-> 1회의 INSERT문으로 세 개의 레코드 추가

-> 여러 레코드를 만들 경우 sql.Result.LastInsertId method의 반환값인 ID는 MySQL에서는 첫 번째 레코드의 ID가 되므로, 주의

 

3. Mock을 사용해 테스트하기

  • 단위 테스트에 해당하는 테스트 코드에 RDBMS에 의존하는 테스트 코드를 작성하고 싶지 않은 경우 -> github.com/DATA-DOG/go-sqlmock 패키지를 사용
  • 테스트 대상 method가 발행한 SQL 쿼리 검증 가능
  • 트랜잭션을 사용한 구현인 경우 COMMIT/ROLLBACK이 예상한 대로 실행됐는지도 검증 가능
  • 기댓값으로 mock에 설정하는 SQL 쿼리는 특정 기호를 사용하므로, escape가 필요 
  • 또한, 해당 SQL 쿼리가 구문적으로 문제가 없는지까지는 검증 X
func TestRepository_AddTask(t *testing.T) {
	t.Parallel()
	ctx := context.Background()

	c := clock.FixedClocker{}
	var wantID int64 = 20
	okTask := &entity.Task{
		Title:    "ok task",
		Status:   "todo",
		Created:  c.Now(),
		Modified: c.Now(),
	}

	db, mock, err := sqlmock.New()
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { _ = db.Close() })
	mock.ExpectExec(
		// 이스케이프 필요
		`INSERT INTO task \(title, status, created, modified\) VALUES \(\?, \?, \?, \?\)`,
	).WithArgs(okTask.Title, okTask.Status, okTask.Created, okTask.Modified).
		WillReturnResult(sqlmock.NewResult(wantID, 1))

	xdb := sqlx.NewDb(db, "mysql")
	r := &Repository{Clocker: c}
	if err := r.AddTask(ctx, xdb, okTask); err != nil {
		t.Errorf("want no error, but got %v", err)
	}
}

 

정리

  • 로컬, 자동 테스트 환경에서 MySQL 컨테이너를 실행
  • 로컬, 자동 테스트 환경에서 migration 구조 구현
  • sqlx를 사용해 MySQL용 데이터 저장, 불러오기 기능을 구현
  • MySQL 컨테이너를 사용해 실제로 SQL 쿼리를 실행하는 테스트 방법
  • Mock을 사용하여 RDBMS에 의존하지 않는 테스트 방법

참고자료

 

Mock 이란?

Mock 이란 ? Mock은 한글로 "모의, 가짜의"라는 뜻 실제 객체를 만들기엔 비용과 시간이 많이 들거나 의존성이 길게 걸쳐져 있어 제대로 구현하기 어려울 경우, 가짜 객체를 만들어 사용하는데 이것

velog.io