Go에서 MySQL 다루기: database/sql 라이브러리 활용

1. 빠른 사용법

Go 언어의 database/sql 패키지는 SQL 또는 SQL류 데이터베이스를 위한 인터페이스를 제공하지만, 구체적인 구현은 포함하지 않습니다. database/sql 패키지를 사용하려면 반드시 다른 데이터베이스 드라이버가 필요합니다. 예를 들어, MySQL용 서드파티 구현: https://github.com/go-sql-driver/mysql

1.1 다운로드

go get -u github.com/go-sql-driver/mysql

1.2 빠른 연결

package main

import (
	"database/sql"
	"fmt"
	_ "github.com/go-sql-driver/mysql"
)

func main() {
	// 연결 문자열: "사용자명:비밀번호@tcp(주소:포트)/데이터베이스이름"
	dsn := "root:123@tcp(127.0.0.1:3306)/mydb"
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		fmt.Println("연결 오류:", err)
	}
	defer db.Close()
	fmt.Println(db)
}

1.3 모범 사용법

sql.Open 함수는 인자의 형식만 검증할 뿐, 실제 데이터베이스 연결을 생성하지 않을 수 있습니다. 데이터 소스 이름이 유효한지 확인하려면 Ping 메서드를 호출해야 합니다.

반환된 DB 객체는 여러 고루틴에서 안전하게 동시에 사용할 수 있으며, 자체적으로 유휴 연결 풀을 유지합니다. 따라서 Open은 한 번만 호출하고, 이 DB 객체를 닫을 필요는 거의 없습니다.

다음으로 전역 변수 db를 정의하여 데이터베이스 연결 객체를 저장합니다. 위 예제 코드를 initDB 함수로 분리하여 프로그램 시작 시 한 번만 호출함으로써 전역 변수 db를 초기화하고, 다른 함수에서는 전역 변수를 직접 사용할 수 있습니다.

package main

import (
	"database/sql"
	"fmt"
	_ "github.com/go-sql-driver/mysql"
)

var DB *sql.DB

func initDB() (err error) {
	dsn := "root:123@tcp(127.0.0.1:3306)/mydb?charset=utf8mb4"
	// 주의: DB, err := 로 선언하지 않음; err는 반환값이고 DB는 전역 변수
	DB, err = sql.Open("mysql", dsn)
	if err != nil {
		return
	}
	// 실제 데이터베이스 연결 시도
	err = DB.Ping()
	if err != nil {
		return
	}
	return nil
}

func main() {
	err := initDB()
	if err != nil {
		fmt.Println("연결 획득 오류:", err)
	}
	fmt.Println(DB)
}

sql.DB는 데이터베이스 연결 객체를 나타내며, 연결에 필요한 모든 정보를 보관합니다. 내부적으로 0개 이상의 기본 연결을 갖는 연결 풀을 관리하며, 여러 고루틴에서 안전하게 동시에 사용할 수 있습니다.

1.4 연결 풀 설정

기본적으로 연결 풀은 제한 없이 증가하며, 사용 가능한 유휴 연결이 없으면 새 연결이 생성됩니다. DB.SetMaxOpenConns를 사용하여 풀의 최대 크기를 설정할 수 있습니다. 사용되지 않는 연결은 유휴 상태로 표시되며 필요 없으면 닫힙니다. 많은 연결을 생성하고 닫는 것을 피하려면 DB.SetMaxIdleConns로 최대 유휴 연결 수를 설정할 수 있습니다.

참고: 이 설정 메서드는 Go 버전 1.2 이상에서 사용 가능합니다.

  • DB.SetMaxIdleConns(n int): 연결 풀의 최대 유휴 연결 수를 설정합니다. n이 최대 열린 연결 수보다 크면 새 최대 유휴 연결 수가 최대 열린 연결 수에 맞게 줄어듭니다. n <= 0이면 유휴 연결이 유지되지 않습니다.
  • DB.SetMaxOpenConns(n int): 데이터베이스와의 최대 연결 수를 설정합니다. n이 0보다 크고 최대 유휴 연결 수보다 작으면 최대 유휴 연결 수가 최대 열린 연결 수에 맞게 줄어듭니다. n <= 0이면 최대 연결 수가 제한되지 않으며, 기본값은 0(제한 없음)입니다.
  • DB.SetConnMaxIdleTime(time.Second * 10): 유휴 연결의 최대 대기 시간을 설정합니다.
dsn := "root:123@tcp(127.0.0.1:3306)/mydb?charset=utf8mb4"
DB, _ = sql.Open("mysql", dsn)
DB.SetMaxOpenConns(30)
DB.SetMaxIdleConns(15)

2. 데이터 조회

type Song struct {
	ID     int
	Title  string
	Year   string
	SignID int
}

2.1 단일 행 조회: db.QueryRow()

단일 행 조회 db.QueryRow()는 한 번의 쿼리를 실행하고 최대 한 행의 결과를 반환합니다. QueryRow는 항상 nil이 아닌 값을 반환하며, 반환값의 Scan 메서드가 호출될 때 지연된 오류가 반환됩니다.

func querySingle() {
	var song Song
	// QueryRow 후 반드시 Scan을 호출해야 합니다. 그렇지 않으면 보유한 데이터베이스 연결이 해제되지 않습니다.
	err := DB.QueryRow("select * from music where id = ?", 2).Scan(&song.ID, &song.Title, &song.Year, &song.SignID)
	if err != nil {
		fmt.Println("조회 오류:", err)
	}
	fmt.Println(song)
}

2.2 여러 행 조회: db.Query()

여러 행 조회 db.Query()는 한 번의 쿼리를 실행하고 여러 행의 결과(즉, Rows)를 반환합니다. 일반적으로 SELECT 명령에 사용됩니다. args는 쿼리의 플레이스홀더 매개변수입니다.

func queryMultiple() {
	sqlStr := "select * from music where id > ?"
	rows, err := DB.Query(sqlStr, 1)
	if err != nil {
		fmt.Println("조회 오류:", err)
		return
	}
	// rows를 닫아 보유한 데이터베이스 연결 해제
	defer rows.Close()
	// 결과 집합의 데이터를 반복해서 읽음
	for rows.Next() {
		var song Song
		err := rows.Scan(&song.ID, &song.Title, &song.Year, &song.SignID)
		if err != nil {
			fmt.Println("순회 오류:", err)
			return
		}
		fmt.Println(song)
	}
}

3. 데이터 삽입

삽입, 갱신, 삭제 작업은 모두 Exec 메서드를 사용합니다.

func insertMusic() {
	sqlStr := "insert into music(title, year, sign_id) values (?, ?, ?)"
	result, err := DB.Exec(sqlStr, "엄마 말 들어", 2010, 1)
	if err != nil {
		fmt.Println("삽입 오류:", err)
		return
	}
	newID, err := result.LastInsertId() // 새로 삽입된 데이터의 ID
	if err != nil {
		fmt.Println("삽입된 ID 획득 오류:", err)
		return
	}
	fmt.Println("삽입 성공, ID:", newID)
}

4. 데이터 삭제

func deleteMusic() {
	sqlStr := "delete from music where id = ?"
	result, err := DB.Exec(sqlStr, 1)
	if err != nil {
		fmt.Println("삭제 오류:", err)
		return
	}
	n, err := result.RowsAffected() // 작업에 영향을 받은 행 수
	if err != nil {
		fmt.Println("영향받은 행 수 획득 오류:", err)
		return
	}
	fmt.Println("삭제 성공, 영향받은 행 수:", n)
}

5. 데이터 갱신

func updateMusic() {
	sqlStr := "update music set title = ? where id = ?"
	result, err := DB.Exec(sqlStr, "남권마마", 4)
	if err != nil {
		fmt.Println("갱신 실패:", err)
		return
	}
	n, err := result.RowsAffected()
	if err != nil {
		fmt.Println("영향받은 행 수 획득 실패:", err)
		return
	}
	fmt.Println("갱신 성공, 영향받은 행 수:", n)
}

6. MySQL 프리페어 스테이트먼트

6.1 프리페어 스테이트먼트란?

일반 SQL 문 실행 과정:

  1. 클라이언트가 SQL 문의 플레이스홀더를 실제 값으로 대체하여 완전한 SQL 문을 구성합니다.
  2. 클라이언트가 완전한 SQL 문을 MySQL 서버에 보냅니다.
  3. MySQL 서버가 완전한 SQL 문을 실행하고 결과를 클라이언트에 반환합니다.

프리페어 스테이트먼트 실행 과정:

  1. SQL 문을 명령 부분과 데이터 부분으로 나눕니다.
  2. 명령 부분을 먼저 MySQL 서버에 보내면, MySQL 서버가 SQL 프리페어링을 수행합니다.
  3. 그런 다음 데이터 부분을 MySQL 서버에 보내면, MySQL 서버가 SQL 문의 플레이스홀더를 실제 값으로 대체합니다.
  4. MySQL 서버가 완전한 SQL 문을 실행하고 결과를 클라이언트에 반환합니다.

6.2 프리페어 스테이트먼트를 사용하는 이유

  1. MySQL 서버가 동일한 SQL을 반복 실행할 때 성능을 최적화할 수 있습니다. 미리 컴파일하여 한 번 컴파일하고 여러 번 실행함으로써 이후 컴파일 비용을 절약합니다.
  2. SQL 인젝션 문제를 방지합니다.

6.3 Go에서 MySQL 프리페어 스테이트먼트 구현

database/sql에서는 다음 Prepare 메서드를 사용하여 프리페어 스테이트먼트를 구현합니다.

func (db *DB) Prepare(query string) (*Stmt, error)

Prepare 메서드는 먼저 SQL 문을 MySQL 서버에 보내고, 이후의 쿼리나 명령을 위해 준비된 상태를 반환합니다. 반환값을 사용하여 여러 개의 쿼리와 명령을 동시에 실행할 수 있습니다.

조회 작업의 프리페어 스테이트먼트 예제:

// 프리페어 스테이트먼트 조회 예제
func prepareQueryExample() {
	sqlStr := "select id, name, age from user where id > ?"
	stmt, err := db.Prepare(sqlStr)
	if err != nil {
		fmt.Printf("prepare 실패, err:%v\n", err)
		return
	}
	defer stmt.Close()
	rows, err := stmt.Query(0)
	if err != nil {
		fmt.Printf("query 실패, err:%v\n", err)
		return
	}
	defer rows.Close()
	for rows.Next() {
		var u user
		err := rows.Scan(&u.id, &u.name, &u.age)
		if err != nil {
			fmt.Printf("scan 실패, err:%v\n", err)
			return
		}
		fmt.Printf("id:%d name:%s age:%d\n", u.id, u.name, u.age)
	}
}

삽입, 갱신, 삭제 작업의 프리페어 스테이트먼트는 매우 유사합니다. 여기서는 삽입 작업을 예로 들어 보겠습니다:

// 프리페어 스테이트먼트 삽입 예제
func prepareInsertExample() {
	sqlStr := "insert into user(name, age) values (?, ?)"
	stmt, err := db.Prepare(sqlStr)
	if err != nil {
		fmt.Printf("prepare 실패, err:%v\n", err)
		return
	}
	defer stmt.Close()
	_, err = stmt.Exec("작은 왕자", 18)
	if err != nil {
		fmt.Printf("insert 실패, err:%v\n", err)
		return
	}
	_, err = stmt.Exec("사허 나자", 18)
	if err != nil {
		fmt.Printf("insert 실패, err:%v\n", err)
		return
	}
	fmt.Println("insert 성공")
}

6.4 SQL 인젝션 문제

절대 SQL 문을 직접 조합해서는 안 됩니다!

다음은 직접 SQL을 조합하는 예제로, name 필드를 기준으로 user 테이블을 조회하는 함수입니다:

// SQL 인젝션 예제
func sqlInjectionExample(name string) {
	sqlStr := fmt.Sprintf("select id, name, age from user where name='%s'", name)
	fmt.Printf("SQL: %s\n", sqlStr)
	var u user
	err := db.QueryRow(sqlStr).Scan(&u.id, &u.name, &u.age)
	if err != nil {
		fmt.Printf("exec 실패, err:%v\n", err)
		return
	}
	fmt.Printf("user:%#v\n", u)
}

다음과 같은 입력 문자열은 SQL 인젝션을 유발할 수 있습니다:

sqlInjectionExample("xxx' or 1=1#")
sqlInjectionExample("xxx' union select * from user #")
sqlInjectionExample("xxx' and (select count(*) from user) <10 #")

7. Go에서 MySQL 트랜잭션 구현

7.1 트랜잭션의 ACID

트랜잭션은 일반적으로 4가지 조건(ACID)을 충족해야 합니다: 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability).

조건설명
원자성트랜잭션 내의 모든 작업은 전부 완료되거나 전부 완료되지 않아야 하며, 중간 단계에서 종료되지 않습니다. 트랜잭션 실행 중 오류가 발생하면 트랜잭션 시작 전 상태로 롤백됩니다.
일관성트랜잭션 시작 전과 종료 후 데이터베이스의 무결성이 유지됩니다. 즉, 쓰여진 데이터가 모든 사전 정의된 규칙을 완전히 준수해야 합니다.
격리성데이터베이스는 여러 동시 트랜잭션이 데이터를 읽고 쓸 수 있도록 허용하며, 격리성은 여러 트랜잭션이 동시에 실행될 때 교차 실행으로 인한 데이터 불일치를 방지합니다. 트랜잭션 격리에는 읽기 미커밋(Read uncommitted), 읽기 커밋(Read committed), 반복 읽기(Repeatable read), 직렬화(Serializable) 등 여러 수준이 있습니다.
지속성트랜잭션 처리가 완료된 후 데이터 변경은 영구적이며, 시스템 장애가 발생해도 손실되지 않습니다.

7.2 트랜잭션 관련 메서드

db.Begin()       // 트랜잭션 시작
db.Commit()      // 트랜잭션 커밋
db.Rollback()    // 트랜잭션 롤백

7.3 예제

func transactionExample() {
	conn, err := DB.Begin()
	if err != nil {
		fmt.Println("트랜잭션 시작 오류:", err)
		return
	}
	_, err = conn.Exec("insert into music(title, year, sign_id) values (?, ?, ?)", "동물 세계", "2022", 2)
	if err != nil {
		fmt.Println("삽입 오류:", err)
		conn.Rollback()
		return
	}
	_, err = conn.Exec("insert into music(title, year, sign_id) values (?, ?, ?)", "세상에 엄마만 좋아", "2022", 3)
	if err != nil {
		fmt.Println("삽입 오류:", err)
		conn.Rollback()
		return
	}
	conn.Commit()
}

태그: go MySQL database/sql 연결 풀 SQL 인젝션

8월 8일 18:09에 게시됨