Golang database/sql 패키지 삽질기 - 3편 커넥션 풀

* 시작하기 앞서 이 글은 김형준 님의 경험을 필자가 전해 듣고 상대적으로 시간이 있는 필자가 단순 정리한 것임을 밝힙니다. 글감뿐만 아니라 글을 쓰는 마지막까지 검토해 주신 김형준 님에게 감사드립니다. 

애플리케이션에서 데이터베이스를 다룰 때 시스템 자원이 많이 소비되는 부분 중 하나는 데이터베이스 커넥션을 생성하는 것이다. 한 번 생성한 데이터베이스 커넥션을 버리지 않고 재사용 하여 성능을 향상시킬 수 있는데 이때 사용하는 것이 데이터베이스 커넥션 풀(이하 커넥션 풀)이다. sql/database 패키지는 기본적으로 커넥션 풀을 지원한다.

이 글은 sql/database 패키지 커넥션 풀 사용법과 한 걸음 더 나아가 커넥션 풀 내의 커넥션이 끊어지는 경우 어떻게 처리할 수 있는지 소개한다.


  1. 매개변수 표시자
  2. SQLite 메모리 데이터베이스
  3. 커넥션 풀


MaxIdleConns

재사용 가능한 커넥션을 대기 커넥션IdleConnection이라고 부른다. MaxIdleConns는 Go 1.1 버전부터 사용할 수 있으며, 대기 커넥션 최대 개수를 설정한다. 단, 커넥션 풀의 최대 커넥션 수는 제한하지 않는다.

In Go 1.1 or newer, you can usedb.SetMaxIdleConns(N)to limit the number ofidleconnections in the pool. This doesn’t limit the pool size, though. - http://go-database-sql.org/connection-pool.html

구체적인 코드를 보면서 자세히 알아보자. 아래 코드는 대기 커넥션 수를 '2'로 설정하고 데이터베이스에 쿼리를 실행하여 커넥션의 변화를 테스트한다.

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
// ...
func main() {
    db, err := sql.Open("mysql",
        "root:1111@tcp(127.0.0.1:3306)/article")
    // 대기 커넥션 수를 2개로 설정
    db.SetMaxIdleConns(2)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()
    conn1, err := db.Query("SELECT id, name FROM users") // (1)
    if err != nil {
        log.Fatal(err)
    }
    conn2, err := db.Query("SELECT id, name FROM users") // (2)
    if err != nil {
        log.Fatal(err)
    }
    conn3, err := db.Query("SELECT id, name FROM users") // (3)
    if err != nil {
        log.Fatal(err)
    }
    conn1.Close() // (4)
    conn2.Close() // (5)
    conn4, err := db.Query("SELECT id, name FROM users") // (6)
    if err != nil {
        log.Fatal(err)
    }
    conn3.Close() // (7)
    conn4.Close() // (8)
}

(1)(2) - 각각 새 커넥션 생성한다.

image2019-5-29_8-50-40

(3) - 사용 가능한 대기 커넥션이 없기 때문에 새 커넥션을 생성한다. 하지만 MaxIdleConns이 2이기 때문에 새 커넥션은 대기 커넥션으로 관리되지 않는다. 이 의미는 사용이 끝나면 재사용하지 않고 종료한다는 의미이다.

image2019-5-31_8-51-46

(4) - conn1을 닫으면 실제로는 커넥션 풀은 커넥션을 종료하지 않고 대기 커넥션으로 전환한다. 그래서 총 커넥션 수는 줄지 않고 3이다.

image2019-5-31_8-52-28

(5) - (4)번과 마찬가지로 커넥션 풀은 conn2 커넥션을 종료하지 않고 대기 커넥션으로 전환하며 커넥션 수를  3으로 유지한다.

image2019-5-31_9-1-44

(6) -  커넥션 풀에 대기 중인 커넥션이 존재하기 때문에 새 커넥션을 생성하지 않고 대기 커넥션을 사용한다.

image2019-5-31_9-2-11

(7) - conn3는 대기 커넥션으로 관리되는 커넥션이 아니므로 종료한다.  그래서 총 커넥션 수는 2다.

(8) - conn4는 대기 커넥션으로 관리되는 커넥션이므로  종료하지 않고 총 커넥션 수를 2개로 유지한다.

image2019-5-29_8-52-26

앞서 언급했던 것과 같이 MaxIdleConns은 최대 커넥션 수를 제한하지 않는다. 커넥션 수를 제한하지 않으면 데이터베이스에 부담을 줄 수 있다. 다음에 설명할 MaxOpenConns으로 최대 커넥션 수를 제한할 수 있다.

MaxOpenConns

MaxOpenConns는 Go 1.2.1 버전부터 사용할 수 있다. 최대 커넥션 개수를 제한한다.

In Go 1.2.1 or newer, you can use db.SetMaxOpenConns(N) to limit the number of total open connections to the database.  - http://go-database-sql.org/connection-pool.html

앞서 코드를 MaxOpenConns으로 변경해 보자.

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
// ...
func main() {
    db, err := sql.Open("mysql",
        "root:1111@tcp(127.0.0.1:3306)/article")
    // 최대 커넥션 수를 2개로 설정
    db.SetMaxOpenConns(2)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()
    conn1, err := db.Query("SELECT id, name FROM users") // (1)
    if err != nil {
        log.Fatal(err)
    }
    conn2, err := db.Query("SELECT id, name FROM users") // (2)
    if err != nil {
        log.Fatal(err)
    }
    conn3, err := db.Query("SELECT id, name FROM users") // (3)
    if err != nil {
        log.Fatal(err)
    }
    conn1.Close() // (4)
    conn2.Close() // (5)
    conn4, err := db.Query("SELECT id, name FROM users") // (6)
    if err != nil {
        log.Fatal(err)
    }
    conn3.Close() // (7)
    conn4.Close() // (8)
}

(1)(2)은 MaxIdleConns과 동일하게 동작한다. 하지만 (3)에서 커넥션을 모두 사용하고 있기 때문에 사용 중인 커넥션을 종료할 때까지 기다린다. 결국 최대 커넥션 수를 제한하면 최대 커넥션 수까지만 커넥션을 생성하며 커넥션을 모두 사용하고 있는 경우 커넥션을 반환할 때까지 기다린다.

MaxIdleConns과 MaxOpenConns의 관계

같은 코드를 최대 커넥션 수를 10으로 변경해 보자.

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
// ...
func main() {
    db, err := sql.Open("mysql",
        "root:1111@tcp(127.0.0.1:3306)/article")
    // 최대 커넥션 수를 10개로 설정
    db.SetMaxOpenConns(10)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()
    conn1, err := db.Query("SELECT id, name FROM users") // (1)
    if err != nil {
        log.Fatal(err)
    }
    conn2, err := db.Query("SELECT id, name FROM users") // (2)
    if err != nil {
        log.Fatal(err)
    }
    conn3, err := db.Query("SELECT id, name FROM users") // (3)
    if err != nil {
        log.Fatal(err)
    }
    conn1.Close() // (4)
    conn2.Close() // (5)
    conn4, err := db.Query("SELECT id, name FROM users") // (6)
    if err != nil {
        log.Fatal(err)
    }
    conn3.Close() // (7)
    conn4.Close() // (8)
}

커넥션 풀은 이전에 MaxIdleConns 2로 설정했던 것과 완전히 동일하게 동작한다. 즉, 최대 커넥션은 10개까지 허용하지만 재사용하는 커넥션은 2개뿐이라는 것이다. 다시 말하면 재사용되는 커넥션 2개를 제외하면 커넥션을 종료하면 데이터베이스와 연결은 끊는다는 것이다.

왜 이렇게 동작하는 것일까? 그 이유는 Golang 내부 코드를 보면 알 수 있다. MaxOpenConns만 설정하는 경우 MaxIdleConns은 기본 값으로 2로 사용한다.

image2019-5-29_9-2-40

결론적으로 커넥션을 재사용하여 성능 향상을 기대하려면 단순히 MaxOpenConns만 설정해서는 안 된다. MaxIdleConns 값을 MaxOpenConns에 같거나 혹은 가깝게 설정해야 한다.

1
2
3
4
5
6
7
8
// ...
func main() {
    db, err := sql.Open("mysql",
        "root:1111@tcp(127.0.0.1:3306)/article")
    db.SetMaxIdleConns(10)
    db.SetMaxOpenConns(10)
    // ...
}

MySQL 서버는 길게 유지하고 있는 커넥션을 강제로 끊어버리는데...

MySQL 시스템 변수 중 wait_timeout 이 있다. 이것은 활동하지 않는 커넥션을 끊을 때까지 서버가 대기하는 시간을 의미한다.

wait_timeout : The number of seconds the server waits for activity on a noninteractive connection before closing it. - https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_wait_timeout

아래 명령어로 설정된 값을 확인할 수 있다. 필자의 MySQL 서버는 8시간(28800초)이다. 즉, 8시간 동안 활동하지 않는 커넥션은 MySQL 서버가 강제로 종료한다.

1
show global variables like 'wait_timeout';

image2019-6-4_9-58-2

어떻게 해결할 것인가?

먼저 테스트를 위해서 MySQL 서버 wait_timeout을 10초로 변경해보자.

1
set GLOBAL wait_timeout = 10;

그리고 아래 코드를 실행해보자.  MySQL 서버 wait_timeout이 10초이기 때문에 앞에 생성된 두 개 커넥션은 20초 후에 MySQL 서버에 의해 커넥션이 종료된다. 오류가 날까?

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
32
33
34
35
36
37
38
package main
import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql"
    "time"
)
func main() {
    db, err := sql.Open("mysql",
        "root:1111@tcp(127.0.0.1:3306)/article")
    db.SetMaxIdleConns(2)
    db.SetMaxOpenConns(3)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()
    conn1, err := db.Query("SELECT id, name FROM users") // (1)
    if err != nil {
        log.Fatal(err)
    }
    conn2, err := db.Query("SELECT id, name FROM users") // (2)
    if err != nil {
        log.Fatal(err)
    }
    conn1.Close() // (3)
    conn2.Close() // (4)
    // 20초 대기
    time.Sleep(20 * time.Second)
    conn3, err := db.Query("SELECT id, name FROM users") // (5)
    if err != nil {
        log.Fatal(err)
    }
    conn4, err := db.Query("SELECT id, name FROM users") // (6)
    if err != nil {
        log.Fatal(err)
    }
    conn3.Close() // (7)
    conn4.Close() // (8)
}

(5)에서 invalid connection 오류가 난다. 앞서 언급했듯이 MySQL 서버가 강제로 커넥션을 끊어버렸기 때문이다.

image2019-6-7_9-11-2

이 문제를 해결하기 위한 방법은 애플리케이션에서 커넥션을 지속적으로 유지시켜 주거나 커넥션이 유효하지 않다면 새 커넥션을 생성하는 것이다.

첫 번째로 커넥션을 지속적으로 유지시키는 방법을 알아보자.

앞에서 MySQL 서버가 일정 시간 동안 활동하지 않는 커넥션을 강제로 끊어버린다고 했다. 애플리케이션에서 커넥션을 유지하는 방법은 계속 활동하도록 만드는 것이다. 아래 코드는 고루틴을 사용하여 2초마다 Ping을 실행한다. Ping은 네트워크에 작은 패킷을 보내고 받음으로써 커넥션이 유효한지 확인한다. Ping에 의해 보내진 패킷은 MySQL 서버가 커넥션을 활동 커넥션으로 판단하게 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main
import (
    "database/sql"
    _ "gopkg.in/go-sql-driver/mysql.v1"
    "time"
)
func main() {
    db, err := sql.Open("mysql",
        "root:1111@tcp(127.0.0.1:3306)/article")
    // ...
    defer db.Close()
    go func() {
        for {
            time.Sleep(time.Second * 2)
            db.Ping()
        }
    }()
    // ...
}

하지만 이 방법에는 구멍이 존재하는데 Ping()에 커넥션을 지정하지 않는다는 것이다. 만약 커넥션 풀에 10개의 커넥션이 있다면 위의 for 루프가 한 번씩 돌 때마다 순차적으로 실행한다.

두 번째 방법은 커넥션이 유효하지 않다면 새 커넥션을 생성하는 것인데 여기에도 두 가지 방법이 있다.

먼저 커넥션 풀의 ConnMaxLifetime 값을 MySQL 서버의 wait_time 보다 작게 설정함으로써 해결할 수 있다. ConnMaxLifetime은 커넥션 풀의 커넥션을 유지하는 최대 시간을 의미하며 기본 값은 시간제한이 없다.

You can also specify the maximum amount of time a connection may be reused by settingdb.SetConnMaxLifetime(duration)since reusing long lived connections may cause network issues. This closes the unused connections lazily i.e. closing expired connection may be deferred. - http://go-database-sql.org/connection-pool.html

아래 코드는 커넥션 풀의 커넥션 유지 최대 유지 시간을 1시간으로 설정한다. 커넥션 풀은 1시간이 지난 사용하지 않는 커넥션은 종료하고 커넥션이 필요할 때 커넥션을 재 생성하여 관리한다.

1
2
3
4
5
6
7
8
9
10
// ...
func main() {
    db, err := sql.Open("mysql",
        "root:1111@tcp(127.0.0.1:3306)/article")
    db.SetMaxIdleConns(2)
    db.SetMaxOpenConns(3)
    // 커넥션 유지 최대 유지 시간을 1시간으로 설정
    db.SetConnMaxLifetime(time.Hour)
    //...
}

하지만 이 방법은 여러 가지 문제(예. 데이터베이스 서버 재시작 등)로 네트워크가 끊기는 경우 해당되지 않기 때문에 본질적으로 문제를 해결하지는 못한다.

다른 방법으로는 최신 드라이버 버전을 사용함으로써 해결할 수 있다. 필자가 사용하고 있는 Go MySQL Driver 의 현재 최신 릴리즈는 v1.4.1 이다. 최근 Go MySQL Driver master 브랜치에 유효하지 않는 커넥션을 판단하여 자동으로 재 접속하는 하는 코드가 추가되었다. 따라서 master 브랜치를 사용하면 Ping 없이도 오류 없이 동작한다.

1
2
3
4
5
6
7
8
9
10
package main
import (
    "database/sql"
    // _ "gopkg.in/go-sql-driver/mysql.v1"
    _ "github.com/go-sql-driver/mysql" // 최신 master로...
    "time"
)
func main() {
    // ...
}

image2019-6-4_11-36-16

image2019-6-3_15-28-15

마치며

커넥션은 서버, 라우터, 스위치 문제 등 여러 가지 이유로 끊어질 수 있다.  따라서 커넥션 풀의 커넥션이 유효하지 않다면 다른 커넥션을 사용하고 이마저 없다면 새 커넥션을 생성해서 사용해야 한다.

Go에서는 커넥션이 유효한지 아닌지를 드라이버에서 해결하고 있다. MySQL 아니고 다른 데이터베이스를 사용한다면 드라이버가 달라질 텐데 드라이버에 재 접속하는 메커니즘이 없다면 어떻게 해야 할까?

이번 연재를 시작하며 필자가 주로 자바를 사용해 왔다고 했다. 자바는 기본적으로 커넥션 풀을 지원하지 않는다. 그래서 Apache DBCPHikariCP 등을 사용한다. 자바 커넥션 풀에는 유효하지 않는 커넥션을 커넥션 풀에서 제외하거나 재 접속하는 메커니즘이 구현되어 있다. 따라서 데이터베이스 드라이버가 변경되는 것과 상관없다.

자바에 비해 역사가 짧은 Go를 단순 비교하기는 어렵겠지만 커넥션 풀을 사용할 때 드라이버에 따라(혹은 버전에 따라) 일일이 확인해야 한다는 것은 분명 불편한 점이다.


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.