튜토리얼: 관계형 데이터베이스 접근하기

튜토리얼: 관계형 데이터베이스 접근하기

이 튜토리얼은 Go와 표준 라이브러리의 database/sql 패키지로 관계형 데이터베이스에 접근하는 기본기를 다룹니다. Go와 그 도구에 기본적으로 익숙하면 가장 많은 걸 얻어갈 수 있어요. Go가 처음이라면 Tutorial: Get started with Go부터 보는 걸 권장합니다.

database/sql 패키지는 데이터베이스에 연결하고, 트랜잭션을 실행하고, 진행 중인 작업을 취소하는 등의 타입과 함수를 포함합니다. 패키지 사용에 대한 더 자세한 내용은 Accessing databases를 참고하세요. 이 튜토리얼에서는 데이터베이스를 하나 만들고, 그 데이터베이스에 접근하는 코드를 작성할 거예요. 예시 프로젝트는 빈티지 재즈 레코드 데이터 저장소입니다.

출처: Go 공식 문서

이 튜토리얼은 다음 섹션들로 진행됩니다.

  • 코드 폴더 만들기
  • 데이터베이스 설정하기
  • 데이터베이스 드라이버 import 하기
  • 데이터베이스 핸들 얻고 연결하기
  • 여러 행 쿼리하기
  • 단일 행 쿼리하기
  • 데이터 추가하기

준비 사항 (Prerequisites)

  • MySQL 관계형 데이터베이스 관리 시스템(DBMS) 설치.
  • Go 설치. 설치는 Installing Go를 참고하세요.
  • 코드를 편집할 도구. 아무 텍스트 에디터나 잘 작동해요.
  • 명령 터미널. Go는 Linux와 Mac의 어떤 터미널에서든 잘 작동하며, Windows에서는 PowerShell이나 cmd에서 잘 돌아갑니다.

코드 폴더 만들기

시작하려면 작성할 코드를 위한 폴더를 만듭니다. 명령 프롬프트를 열고 홈 디렉터리로 이동해요. Linux나 Mac에서는:

$ cd

Windows에서는:

C:\> cd %HOMEPATH%

튜토리얼의 나머지에서 프롬프트로 $를 보여줄게요. 사용하는 명령은 Windows에서도 잘 작동합니다. 명령 프롬프트에서 data-access라는 코드 디렉터리를 만들고 이동해요.

$ mkdir data-access
$ cd data-access

튜토리얼 중 추가하게 될 의존성을 관리할 모듈을 만듭니다. go mod init 명령을 실행하면서 새 코드의 모듈 경로를 인자로 줍니다.

$ go mod init example/data-access
go: creating new go.mod: module example/data-access

이 명령은 go.mod 파일을 만들고, 앞으로 추가할 의존성이 추적을 위해 나열됩니다. 더 자세한 내용은 Managing dependencies 문서를 확인하세요.

참고: 실제 개발에서는 자신의 필요에 더 특화된 모듈 경로를 지정하게 될 거예요. 자세한 내용은 Managing dependencies를 참고하세요.

데이터베이스 설정하기

이 단계에서는 작업할 데이터베이스를 만듭니다. DBMS 자체의 CLI로 데이터베이스와 테이블을 만들고 데이터를 추가할 거예요. 빈티지 바이닐 재즈 레코드에 관한 데이터를 담은 데이터베이스를 만들 예정입니다. 여기 코드는 MySQL CLI를 쓰지만, 대부분의 DBMS에는 비슷한 기능을 가진 자체 CLI가 있어요.

새 명령 프롬프트를 열고, 커맨드 라인에서 다음 MySQL 예시처럼 DBMS에 로그인합니다.

$ mysql -u root -p
Enter password:

mysql>

mysql 프롬프트에서 데이터베이스를 만듭니다.

mysql> create database recordings;

방금 만든 데이터베이스로 전환해 테이블을 추가할 수 있게 합니다.

mysql> use recordings;
Database changed

텍스트 에디터에서 data-access 폴더에 create-tables.sql이라는 파일을 만들어 테이블을 추가하는 SQL 스크립트를 담아둡니다. 파일에 다음 SQL 코드를 붙여 넣고 저장하세요.

DROP TABLE IF EXISTS album;
CREATE TABLE album (
  id         INT AUTO_INCREMENT NOT NULL,
  title      VARCHAR(128) NOT NULL,
  artist     VARCHAR(255) NOT NULL,
  price      DECIMAL(5,2) NOT NULL,
  PRIMARY KEY (`id`)
);

INSERT INTO album
  (title, artist, price)
VALUES
  ('Blue Train', 'John Coltrane', 56.99),
  ('Giant Steps', 'John Coltrane', 63.99),
  ('Jeru', 'Gerry Mulligan', 17.99),
  ('Sarah Vaughan', 'Sarah Vaughan', 34.98);

이 SQL 코드에서:

  • album이라는 테이블을 삭제(drop)합니다. 이 명령을 먼저 실행해 두면 나중에 테이블을 처음부터 다시 시작하고 싶을 때 스크립트를 더 쉽게 다시 실행할 수 있어요.
  • title, artist, price 네 개 열로 album 테이블을 만듭니다. 각 행의 id 값은 DBMS가 자동으로 만들어 줍니다.
  • 값이 있는 행 네 개를 추가합니다.

mysql 프롬프트에서 방금 만든 스크립트를 실행합니다. source 명령을 다음 형태로 쓰면 돼요.

mysql> source /path/to/create-tables.sql

DBMS 명령 프롬프트에서 SELECT 문으로 데이터가 든 테이블을 성공적으로 만들었는지 확인합니다.

mysql> select * from album;
+----+---------------+----------------+-------+
| id | title         | artist         | price |
+----+---------------+----------------+-------+
|  1 | Blue Train    | John Coltrane  | 56.99 |
|  2 | Giant Steps   | John Coltrane  | 63.99 |
|  3 | Jeru          | Gerry Mulligan | 17.99 |
|  4 | Sarah Vaughan | Sarah Vaughan  | 34.98 |
+----+---------------+----------------+-------+
4 rows in set (0.00 sec)

다음으로 쿼리할 수 있도록 연결하는 Go 코드를 작성합니다.

데이터베이스 드라이버 찾고 import 하기

이제 데이터가 든 데이터베이스가 준비됐으니 Go 코드를 시작해 볼게요. database/sql 패키지의 함수를 통해 하는 요청을 데이터베이스가 이해하는 요청으로 변환해 줄 데이터베이스 드라이버를 찾아 import 합니다.

브라우저에서 SQLDrivers 위키 페이지를 방문해 쓸 드라이버를 식별하세요. 이 튜토리얼에서 MySQL에 접근하려면 Go-MySQL-Driver를 쓸 거예요. 드라이버의 패키지 이름을 적어둡니다 — 여기서는 github.com/go-sql-driver/mysql입니다.

텍스트 에디터로 Go 코드를 작성할 파일을 만들고, 아까 만든 data-access 디렉터리에 main.go로 저장합니다. main.go에 다음 코드를 붙여 넣어 드라이버 패키지를 import 해요.

package main

import "github.com/go-sql-driver/mysql"

이 코드에서:

  • 독립적으로 실행할 수 있도록 코드를 main 패키지에 추가합니다.
  • MySQL 드라이버 github.com/go-sql-driver/mysql을 import 합니다.

드라이버를 import 했으니 데이터베이스에 접근하는 코드를 작성하기 시작할 거예요.

데이터베이스 핸들 얻고 연결하기

이제 데이터베이스 핸들로 데이터베이스에 접근하게 해주는 Go 코드를 작성해 볼게요. 특정 데이터베이스에 대한 접근을 나타내는 sql.DB struct에 대한 포인터를 쓸 거예요.

코드 작성

main.go에서 방금 추가한 import 코드 아래에, 데이터베이스 핸들을 만드는 다음 Go 코드를 붙여 넣습니다.

var db *sql.DB

func main() {
    // Capture connection properties.
    cfg := mysql.NewConfig()
    cfg.User = os.Getenv("DBUSER")
    cfg.Passwd = os.Getenv("DBPASS")
    cfg.Net = "tcp"
    cfg.Addr = "127.0.0.1:3306"
    cfg.DBName = "recordings"

    // Get a database handle.
    var err error
    db, err = sql.Open("mysql", cfg.FormatDSN())
    if err != nil {
        log.Fatal(err)
    }

    pingErr := db.Ping()
    if pingErr != nil {
        log.Fatal(pingErr)
    }
    fmt.Println("Connected!")
}

이 코드에서:

  • *sql.DB 타입의 db 변수를 선언합니다. 이게 당신의 데이터베이스 핸들이에요.
  • db를 전역 변수로 만드는 건 이 예시를 단순화하기 위해서예요. 운영에서는 전역 변수를 피해야 합니다. 필요로 하는 함수에 변수를 전달하거나 struct로 감싸는 식으로요.
  • MySQL 드라이버의 Config와 그 타입의 FormatDSN을 사용해 연결 속성을 모으고, 연결 문자열용 DSN으로 포매팅합니다. Config struct는 연결 문자열보다 읽기 쉬운 코드를 만들어 주죠.
  • FormatDSN의 반환 값을 전달하며 sql.Open을 호출해 db 변수를 초기화합니다.
  • sql.Open에서 오류를 확인합니다. 예를 들어 데이터베이스 연결 세부 사항이 제대로 형성되지 않았다면 실패할 수 있어요.
  • 코드를 단순화하려고 log.Fatal을 호출해 실행을 끝내고 콘솔에 오류를 출력합니다. 운영 코드에서는 오류를 더 우아하게 처리하고 싶을 거예요.
  • DB.Ping을 호출해 데이터베이스에 연결하는 게 잘 되는지 확인합니다. 런타임에서 sql.Open은 드라이버에 따라 즉시 연결하지 않을 수도 있어요. 여기서는 Ping으로 database/sql 패키지가 필요할 때 연결할 수 있는지 확인하는 거예요.
  • Ping에서 오류가 있는지 확인합니다. 연결에 실패한 경우죠.
  • Ping이 성공적으로 연결되면 메시지를 출력합니다.

main.go 파일 상단, 패키지 선언 바로 아래에 방금 작성한 코드를 지원하는 데 필요한 패키지를 import 합니다. 파일의 맨 위는 이제 이렇게 보여야 해요.

package main

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

    "github.com/go-sql-driver/mysql"
)

main.go를 저장합니다.

코드 실행

MySQL 드라이버 모듈을 의존성으로 추적하기 시작합니다. go get으로 github.com/go-sql-driver/mysql 모듈을 당신 모듈의 의존성으로 추가하세요. 점(.) 인자는 "현재 디렉터리의 코드에 대한 의존성을 가져와라"는 뜻이에요.

$ go get .
go: added filippo.io/edwards25519 v1.1.0
go: added github.com/go-sql-driver/mysql v1.8.1

Go는 이전 단계에서 import 선언에 추가했기 때문에 이 의존성을 내려받았습니다. 의존성 추적에 대해 더 보려면 Adding a dependency를 참고하세요.

명령 프롬프트에서 Go 프로그램이 쓸 DBUSERDBPASS 환경 변수를 설정합니다. Linux나 Mac에서는:

$ export DBUSER=username
$ export DBPASS=password

Windows에서는:

C:\Users\you\data-access> set DBUSER=username
C:\Users\you\data-access> set DBPASS=password

main.go가 있는 디렉터리의 커맨드 라인에서, 점(.) 인자를 넣어 go run으로 "현재 디렉터리의 패키지를 실행"하라고 코드를 실행합니다.

$ go run .
Connected!

연결할 수 있네요! 다음으로 데이터를 쿼리해 볼게요.

여러 행 쿼리하기

이 섹션에서는 Go로 여러 행을 반환하도록 설계된 SQL 쿼리를 실행합니다. 여러 행을 반환할 수 있는 SQL 문에는 database/sql 패키지의 Query 메서드를 사용하고, 반환된 행을 반복합니다. (단일 행 쿼리는 나중에 "Query for a single row" 섹션에서 배워요.)

코드 작성

main.go의 func main 바로 위에, 쿼리에서 반환된 행 데이터를 담는 Album struct 정의를 붙여 넣습니다.

type Album struct {
    ID     int64
    Title  string
    Artist string
    Price  float32
}

func main 아래에, 데이터베이스를 쿼리하는 다음 albumsByArtist 함수를 붙여 넣습니다.

// albumsByArtist queries for albums that have the specified artist name.
func albumsByArtist(name string) ([]Album, error) {
    // An albums slice to hold data from returned rows.
    var albums []Album

    rows, err := db.Query("SELECT * FROM album WHERE artist = ?", name)
    if err != nil {
        return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
    }
    defer rows.Close()
    // Loop through rows, using Scan to assign column data to struct fields.
    for rows.Next() {
        var alb Album
        if err := rows.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
            return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
        }
        albums = append(albums, alb)
    }
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
    }
    return albums, nil
}

이 코드에서:

  • 정의한 Album 타입의 albums 슬라이스를 선언합니다. 반환된 행의 데이터를 담을 거예요. struct 필드 이름과 타입은 데이터베이스 열 이름과 타입에 대응합니다.
  • DB.Query로 지정한 아티스트 이름을 가진 앨범을 쿼리하는 SELECT 문을 실행합니다.
  • Query의 첫 파라미터는 SQL 문입니다. 그 뒤에는 아무 타입의 파라미터를 0개 이상 전달할 수 있어요. 이들은 SQL 문의 파라미터 값을 지정하는 자리입니다. SQL 문을 파라미터 값과 분리함으로써(예: fmt.Sprintf로 이어붙이는 대신) database/sql 패키지가 값을 SQL 텍스트와 분리해 보낼 수 있게 되어, SQL 인젝션 위험이 제거됩니다.
  • rows를 닫는 것을 defer 하여, 함수가 끝날 때 rows가 보유한 리소스가 해제되게 합니다.
  • 반환된 행을 반복하면서 Rows.Scan으로 각 행의 열 값을 Album struct 필드에 할당합니다.
  • Scan은 Go 값에 대한 포인터 목록을 받아서, 열 값이 거기에 쓰여집니다. 여기서는 & 연산자로 만든 alb 변수의 필드에 대한 포인터를 전달하죠. Scan은 포인터를 통해 struct 필드를 갱신합니다.
  • 반복 안에서, 열 값을 struct 필드로 스캔할 때의 오류를 확인합니다.
  • 반복 안에서 새 albalbums 슬라이스에 append 합니다.
  • 반복이 끝난 뒤 rows.Err로 전체 쿼리의 오류를 확인합니다. 쿼리 자체가 실패하면, 여기서 오류를 확인하는 게 결과가 불완전하다는 걸 알 수 있는 유일한 방법이에요.

albumsByArtist를 호출하도록 main 함수를 갱신합니다. func main 끝에 다음 코드를 추가해요.

albums, err := albumsByArtist("John Coltrane")
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Albums found: %v\n", albums)

새 코드에서 이제:

  • 추가한 albumsByArtist 함수를 호출하고, 그 반환 값을 새 albums 변수에 할당합니다.
  • 결과를 출력합니다.

코드 실행

main.go가 있는 디렉터리의 커맨드 라인에서 코드를 실행합니다.

$ go run .
Connected!
Albums found: [{1 Blue Train John Coltrane 56.99} {2 Giant Steps John Coltrane 63.99}]

다음으로 단일 행을 쿼리해 볼게요.

단일 행 쿼리하기

이 섹션에서는 Go로 데이터베이스의 단일 행을 쿼리합니다. 많아야 한 행을 반환할 것이라고 아는 SQL 문에는 Query 루프보다 더 단순한 QueryRow를 쓸 수 있어요.

코드 작성

albumsByArtist 아래에 다음 albumByID 함수를 붙여 넣습니다.

// albumByID queries for the album with the specified ID.
func albumByID(id int64) (Album, error) {
    // An album to hold data from the returned row.
    var alb Album

    row := db.QueryRow("SELECT * FROM album WHERE id = ?", id)
    if err := row.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
        if err == sql.ErrNoRows {
            return alb, fmt.Errorf("albumsById %d: no such album", id)
        }
        return alb, fmt.Errorf("albumsById %d: %v", id, err)
    }
    return alb, nil
}

이 코드에서:

  • DB.QueryRow로 지정한 ID의 앨범을 쿼리하는 SELECT 문을 실행합니다.
  • QueryRowsql.Row를 반환합니다. 호출하는 코드(당신의 코드!)를 단순화하기 위해 QueryRow는 오류를 반환하지 않아요. 대신 쿼리 오류(예: sql.ErrNoRows)를 나중에 Rows.Scan에서 반환하도록 배치합니다.
  • Row.Scan으로 열 값을 struct 필드에 복사합니다.
  • Scan의 오류를 확인합니다.
  • 특별한 오류 sql.ErrNoRows는 쿼리가 행을 반환하지 않았다는 뜻입니다. 보통 그 오류는 여기서 "no such album" 같은 더 구체적인 텍스트로 바꿀 만해요.

albumByID를 호출하도록 main을 갱신합니다. func main 끝에 다음 코드를 추가해요.

// Hard-code ID 2 here to test the query.
alb, err := albumByID(2)
if err != nil {
    log.Fatal(err)
}
fmt.Printf("Album found: %v\n", alb)

새 코드에서 이제:

  • 추가한 albumByID 함수를 호출합니다.
  • 반환된 앨범 ID를 출력합니다.

코드 실행

main.go가 있는 디렉터리의 커맨드 라인에서 코드를 실행합니다.

$ go run .
Connected!
Albums found: [{1 Blue Train John Coltrane 56.99} {2 Giant Steps John Coltrane 63.99}]
Album found: {2 Giant Steps John Coltrane 63.99}

다음으로 데이터베이스에 앨범을 추가해 볼게요.

데이터 추가하기

이 섹션에서는 Go로 SQL INSERT 문을 실행해 데이터베이스에 새 행을 추가합니다. 데이터를 반환하는 SQL 문에는 QueryQueryRow를 쓰는 걸 봤죠. 데이터를 반환하지 않는 SQL 문을 실행할 때는 Exec을 씁니다.

코드 작성

albumByID 아래에, 데이터베이스에 새 앨범을 삽입하는 다음 addAlbum 함수를 붙여 넣고 main.go를 저장합니다.

// addAlbum adds the specified album to the database,
// returning the album ID of the new entry
func addAlbum(alb Album) (int64, error) {
    result, err := db.Exec("INSERT INTO album (title, artist, price) VALUES (?, ?, ?)", alb.Title, alb.Artist, alb.Price)
    if err != nil {
        return 0, fmt.Errorf("addAlbum: %v", err)
    }
    id, err := result.LastInsertId()
    if err != nil {
        return 0, fmt.Errorf("addAlbum: %v", err)
    }
    return id, nil
}

이 코드에서:

  • DB.Exec으로 INSERT 문을 실행합니다.
  • Query와 마찬가지로, Exec은 SQL 문 뒤에 그 SQL 문의 파라미터 값을 받아요.
  • INSERT 시도의 오류를 확인합니다.
  • Result.LastInsertId로 삽입된 데이터베이스 행의 ID를 가져옵니다.
  • ID 가져오기 시도의 오류를 확인합니다.

addAlbum 함수를 호출하도록 main을 갱신합니다. func main 끝에 다음 코드를 추가해요.

albID, err := addAlbum(Album{
    Title:  "The Modern Sound of Betty Carter",
    Artist: "Betty Carter",
    Price:  49.99,
})
if err != nil {
    log.Fatal(err)
}
fmt.Printf("ID of added album: %v\n", albID)

새 코드에서 이제:

  • 새 앨범으로 addAlbum을 호출하고, 추가하는 앨범의 ID를 albID 변수에 할당합니다.

코드 실행

main.go가 있는 디렉터리의 커맨드 라인에서 코드를 실행합니다.

$ go run .
Connected!
Albums found: [{1 Blue Train John Coltrane 56.99} {2 Giant Steps John Coltrane 63.99}]
Album found: {2 Giant Steps John Coltrane 63.99}
ID of added album: 5

결론 (Conclusion)

축하합니다! Go로 관계형 데이터베이스에서 간단한 작업들을 수행했습니다. 다음으로 추천하는 주제들:

완성된 코드 (Completed code)

이 섹션은 이 튜토리얼로 만든 애플리케이션의 코드를 담고 있어요.

package main

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

    "github.com/go-sql-driver/mysql"
)

var db *sql.DB

type Album struct {
    ID     int64
    Title  string
    Artist string
    Price  float32
}

func main() {
    // Capture connection properties.
    cfg := mysql.NewConfig()
    cfg.User = os.Getenv("DBUSER")
    cfg.Passwd = os.Getenv("DBPASS")
    cfg.Net = "tcp"
    cfg.Addr = "127.0.0.1:3306"
    cfg.DBName = "recordings"

    // Get a database handle.
    var err error
    db, err = sql.Open("mysql", cfg.FormatDSN())
    if err != nil {
        log.Fatal(err)
    }

    pingErr := db.Ping()
    if pingErr != nil {
        log.Fatal(pingErr)
    }
    fmt.Println("Connected!")

    albums, err := albumsByArtist("John Coltrane")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Albums found: %v\n", albums)

    // Hard-code ID 2 here to test the query.
    alb, err := albumByID(2)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Album found: %v\n", alb)

    albID, err := addAlbum(Album{
        Title:  "The Modern Sound of Betty Carter",
        Artist: "Betty Carter",
        Price:  49.99,
    })
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("ID of added album: %v\n", albID)
}

// albumsByArtist queries for albums that have the specified artist name.
func albumsByArtist(name string) ([]Album, error) {
    // An albums slice to hold data from returned rows.
    var albums []Album

    rows, err := db.Query("SELECT * FROM album WHERE artist = ?", name)
    if err != nil {
        return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
    }
    defer rows.Close()
    // Loop through rows, using Scan to assign column data to struct fields.
    for rows.Next() {
        var alb Album
        if err := rows.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
            return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
        }
        albums = append(albums, alb)
    }
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("albumsByArtist %q: %v", name, err)
    }
    return albums, nil
}

// albumByID queries for the album with the specified ID.
func albumByID(id int64) (Album, error) {
    // An album to hold data from the returned row.
    var alb Album

    row := db.QueryRow("SELECT * FROM album WHERE id = ?", id)
    if err := row.Scan(&alb.ID, &alb.Title, &alb.Artist, &alb.Price); err != nil {
        if err == sql.ErrNoRows {
            return alb, fmt.Errorf("albumsById %d: no such album", id)
        }
        return alb, fmt.Errorf("albumsById %d: %v", id, err)
    }
    return alb, nil
}

// addAlbum adds the specified album to the database,
// returning the album ID of the new entry
func addAlbum(alb Album) (int64, error) {
    result, err := db.Exec("INSERT INTO album (title, artist, price) VALUES (?, ?, ?)", alb.Title, alb.Artist, alb.Price)
    if err != nil {
        return 0, fmt.Errorf("addAlbum: %v", err)
    }
    id, err := result.LastInsertId()
    if err != nil {
        return 0, fmt.Errorf("addAlbum: %v", err)
    }
    return id, nil
}

더 알아보기 (Learn more)