지난달 약 10개월가량의 여정이 끝마치고 배포가 마무리되었다. 

운영을 하던 와중 CNS에서 하나의 메일이 왔는데 그게 이번 챕터의 주제 내용이다. 

메일의 답변을 쓰기위해서 찾아본 내용과 다르게 알고 있던 부분에 대해서 작성하고자 한다.


어느 날 느닷없이 CNS모니터링 팀으로부터 날아온 메일. MySQL의 Sleep Thread를 죽여도 되는지 문의를 하는 문의글이었다.

우선 답변을 하기 위해서 당시 제공된 이미지를 어떻게 얻어와야 할지 당최 알지를 못했다...

Sleep Thread는 유휴커넥션을 의미할 텐데 왜? 내가 작성한 숫자보다 많은지 이해하기 어려웠다.

 

그때 당시 아래 작성된 예시보다 훨씬 더 많은 프로세스? 의 목록들이 존재했다.

MySQL을 사용하면서 한 번도 고민해 본 적 없고, 심지어 쿼리를 작성하여 날려본 적도 없다. 어떤 역할을 하는지 확인해 보자.

MYSQL의 show processList

프로세스 목록은 서버 내에서 실행 중인 스레드 집합에서 현재 수행 중인 작업을 나타냅니다. (MySQL 공식홈페이지)
information_schema와 performance_schema 둘 다 해당 테이블이 존재하나, performance_schema를 사용할 것을 권장한다. 
information_schema는 다음 업데이트에 더 이상 사용하지 않는다고 한다.

 

MySQL의 InnoDB는 하나의 MySQL 프로세스 서버와 각각의 커넥션 스레드로 이루어져 있다. 

다시 말해 하나하나의 모든 커넥션은 스레드라는 의미가 된다.

좋다 그렇다면 보통 우리가 연결하는 db connection의 유휴커넥션을 비롯한 최대 커넥션 등은 모두 스레드의 단위로 MySQL과 연결이 된다는 사실을 알게 되었다.


잘못된 추측

 

내가 작성한 Go Application은 유휴커넥션 3과 최고 커넥션 10을 직접 설정하였다. 

심지어 공통으로 사용하는 커넥션의 코드작성을 내가 하였다. 

내가 의도한 대로라면 Go Appliaction(쿠버네티스 환경에 있으므로 Pods가 된다.)이 기동 됨과 동시에 3개의 유휴커넥션을 애플리케이션에서 가지고 있어야 한다.

아래처럼 동작한다고 생각했다.


특정 DB를 바라보고 있으며, 모든 Go Application보다 내가 작성하지 않은 Java Application이 제일 먼저 의심 갔다. 

현재 워크노드 A의 구조는 

노드A
- User Pods (Go)
- Storage Pods (Go)
- etc.... Pods (Go)
Auth Pods

 

위와 같이 구성되어 있었으며 내가 작성하지 않은 Auth Pods를 제일 먼저 의심했다. 
다른 어떤 것도 설정하지 않고 Jpa를 사용하고 있으며 검색한 결과 Hikari를 사용하고 있다는 판단이 섰으며  connection pooling을 제공하는 JDBC DataSource의 구현체이다.

열심히 구글링과 소스코드를 확인한 결과 Default Idle 연결이 10, 최대 연결이 10이다. 
(https://github.com/brettwooldridge/HikariCP/blob/dev/src/main/java/com/zaxxer/hikari/HikariConfig.java)

// https://github.com/brettwooldridge/HikariCP/blob/dev/src/main/java/com/zaxxer/hikari/HikariConfig.java

public final class HikariPool extends PoolBase implements HikariPoolMXBean, IBagStateListener
{
	// 중략
    private void validateNumerics(){
    	//중략
        if (minIdle < 0 || minIdle > maxPoolSize) {
         	minIdle = maxPoolSize;
         }
      }
    }

 

 

spring  부트를 이용하기 때문에 해당 hikari connection의 요소를 조정해 주었다.
spring.datasource.hikari.minimum-idle : 3

좀 더 확실한 결과를 위해 java_test라는 mysql 계정을 새로 생성하여 확인한 결과 아래와 같이 예상대로 동작하였다.

생각한 대로 정상작동하고 있다. 

스프링부투의 설정값을 변경하니 예상한 대로 동작하고 있다. 


모든 유휴커넥션에 대해서 올바르게 내가 예상한대로 동작하고 있다. 
그러나 go_test라는 계정을 통해 생서한 계정의 커넥션이 위의 사진에는 존재하지 않는다. 
뭐가 문제일까? 


 

 

유휴 커넥션이란? 네트워크가 연결된 상태이지만 데이터의 전송이 없는 상태를 통상적으로 말한다. 
왜 MySQL은 필요할까?  새로운 Connection을 가져가는 것보다 이미 연결된 유휴커넥션을 통해 DB 작업을 수행하는 것이 빠른 동작시간을 가지기 때문이다. 이로 인한 자원의 소모 또한 트레이드오프로 가져오고 있다.

 

내가 알고 있는 내용과 동일하다. 유휴커넥션이 설정됨이라 하면 애플리케이션에서 지속적으로 커넥션을 들고 있음을 의미해야 한다고 생각했다.
왜 내가 설정한 값이 정상동작하지 않는가. 설정 코드를 확인해 보자.

"데이터베이스.SetMaxIdleConns(3)"

뭔가 이름이 이상하다. 최소 연결개수도 아니고 최대 유휴커넥션 연결개수이다. 
해당 소스코드를 확인해 보니 

// SetMaxIdleConns sets the maximum number of connections in the idle connection pool.

최대 유휴 연결 개수의 풀을 설정한다고 한다. 

세상에 정말 잘못된 방식으로 사용하고 있는 것이 아닌가. 코드는 정상작동 한 것이다.

Go Apllication에서 사용하는 커넥션이 없으니 모든 유휴커넥션을 날려버린 것이고 

Java Application에서 사용하는 최소 유휴커넥션의 설정이 있으니 최소한의 커넥션을 유지하고 있는 것이다.


왜 위와 같은 견해차이가 발생하게 된 것일까?

유휴커넥션을 유지하는 비용보다 새로 연결하는 비용 즉 자원의 효율성을 위해 위와 같은 코드가 발생되고, 
자바의 경우 성능의 중요성 때문에 위와 같은 설정값의 견해차이가 발생되었다고 본다.


그렇다면 유휴 커넥션의 살아있는 시간과 MySQL의 wait_time 시간이 다른경우 어떤 방식으로 동작될까? 라는 의문점이 남는다.

위 사진과 같이 유휴 대기시간 과 인터렉티브 시간 모두 60초로 설정하였다.
어플리케이션 상에는 2분(120초) 으로 설정하였다.


DB 스텟의 결과 60초가 지나면

 

아직 3개의 유휴 커넥션이 존재한다. ?

반면 mysql 서버 상의 유휴 커넥션은 ?

 

위의 사진과 동일하게 존재하지 않는다.

 

만약 새로운 쿼리 작업이 발생하게 된다면 ?

보이는 것처럼 새로운 스레드를 생성하게 된다.(ID46번)

 

아무리 어플리케이션 상의 설정을 하더라도 MYSQL 서버상의 설정값이 이와 같이 상이하다면 의도한대로 동작하지 않는다.


결론 :
Go에서 구현한 라이브러리와, Spring의 HikariCP는 다르게 동작하고 있다는 사실을 명확하게 인지하지 않는다면 이번과 같은 다양한 값들을 확인하면서 VOC를 처리해야한다.

 

각 라이브러리에서 설정하는 설정값들에 대해 조금더 기민하게 받아들이고 모두 동일한 개념을 사용하고 있지는 않다는 사실을 알게 되었다.

 

MySQL 상의 다양한 설정값이 존재하고, 어플리케이션의 설정값이 존재하여 상이하게 작동하는 방식이 생겨날수 있기 때문에 기존 설정값의 가능여부와 어플리케이션의 설정값에 대해 명확하게 인지하고 사용을 해야한다.

전체코드

더보기
package config

import (
    "context"
    "database/sql"
    "fmt"
    "gorm.io/driver/mysql"
    "testing"
    "time"
)

func TestIdleConn(t *testing.T) {
    cfg := mysql.Config{
       DSN:       "test 하고자 하는 dsn",
    }
    db, err := sql.Open("mysql", cfg.DSN)
    if err != nil {
       panic(err)
    }
    db.SetMaxIdleConns(3)
    db.SetConnMaxIdleTime(2 * time.Minute)
    db.SetMaxOpenConns(10)

    ch := make(chan bool)
    done := make(chan bool)

    go func() {
       time.Sleep(5 * time.Second)
       fmt.Println("run")
       ch <- true
    }()

    query := func() {
       ctx := context.Background()
       var data string
       rows := db.QueryRowContext(ctx, "Select name from tb_contest").Scan(&data)
       if rows != nil {
          panic(err)
       }
       fmt.Println(data)
    }

    for i := 0; i < 50; i++ {
       go func() {
          query()
       }()
    }

    go func() {
       for {
          select {
          case <-ch:
             fmt.Println("hit")
             for i := 0; i < 300; i++ {
                if i == 77 {
                   query()
                }
                fmt.Printf("%+v\n", db.Stats())
                time.Sleep(1 * time.Second)
                fmt.Printf("cur time is %d \n", i+1)
             }
             done <- true
             return
          }
       }
    }()

    <-done
}

 

'Go > Gorm 삽질기' 카테고리의 다른 글

Gorm BulkUpsert ,BulkInsert, BulkUpdate  (0) 2023.09.07

Gorm 관련글은 처음 쓴다. 

우선 지난달에 이어 투표/설문조사 관련해서 이제 성능개선을 하고 있는 와중 데이터 삽입의 로직이 약간 변화되어 벌크업설트를 해야 할 일이 생겼다. 그래서 뭐가 어떻게 다른지 확인해 보기 간단한 예제를 준비했다.

type Workout struct {
	Id    uint   `gorm:"column:id;type:int;primaryKey"`
	Title string `gorm:"column:title;type:varchar(20)"`
	Raps  uint   `gorm:"column:raps;type:int"`
}

요런 테이블을 생성해주고 

이렇게 데이터를 미리 생성해서 넣어주었다.

Gorm에서 Upsert를 해서 일반적으로는 insert를, pk 의 중복이 생길시 특정 저 raps 만 업데이트하고자 한다면 아래와 같이 작성하면 된다.


Upsert [성능개선이 필요했던 부분]

func (w *Workout) Upsert(db *gorm.DB) error {
	return db.Clauses(clause.OnConflict{
		Columns: []clause.Column{{Name: "id"}},
		DoUpdates: clause.Assignments(map[string]interface{}{
			"raps": gorm.Expr("raps + ?", w.Raps),
		}),
	}).Create(w).Error
}

테스트를 돌려보면

func TestWorkoutUpsert(t *testing.T) {
	db := table.GetDB().Db

	for i := 1; i <= 3; i++ {
		w := &table.Workout{
			Id:   i,
			Raps: i * i * 3,
		}
		if err := w.Upsert(db); err != nil {
			t.Error(err)
		}
	}
    
    2023/09/07 21:41:19 /Users/guiwoopark/Desktop/personal/study/db_design/table/workout.go:28
[10.822ms] [rows:2] INSERT INTO `workout` (`title`,`raps`,`id`) VALUES ('',3,1) ON DUPLICATE KEY UPDATE `raps`=raps + 3

2023/09/07 21:41:19 /Users/guiwoopark/Desktop/personal/study/db_design/table/workout.go:28
[4.083ms] [rows:2] INSERT INTO `workout` (`title`,`raps`,`id`) VALUES ('',12,2) ON DUPLICATE KEY UPDATE `raps`=raps + 12

2023/09/07 21:41:19 /Users/guiwoopark/Desktop/personal/study/db_design/table/workout.go:28
[4.204ms] [rows:2] INSERT INTO `workout` (`title`,`raps`,`id`) VALUES ('',27,3) ON DUPLICATE KEY UPDATE `raps`=raps + 27

이런식의 쿼리가 생성된다.
for loop로 돌다 보니 확실히 커넥션을 가져오는 횟수가 늘어난다 이를 방지하기 위해 벌크 어설트를 한다면 중복된 키값에 대해 동작하는 방식을 단일되게 설정해줘야 한다.


[문제점]
커넥션을 효율적으로 사용하기위해  한번의 커넥션과 벌크 인설트가 필요하다. gorm 에서 제공하는 create 를 사용했더니 
list 의 업데이트 하고자 했던 부분이 하나의 값으로 밖에 업데이트를 할수 없는 상황이였다.

func (w *Workout) Upsert(db *gorm.DB) error {
	return db.Clauses(clause.OnConflict{
		Columns: []clause.Column{{Name: "id"}},
		DoUpdates: clause.Assignments(map[string]interface{}{
			"raps": gorm.Expr("raps + ?", "어떤 값을 집어넣어줘야 할까 ..."),
		}),
	}).Create(w).Error
}

즉 리스트 별로 각자 다른 값이 있고 업데이트를 하고 싶지만 모두 동일한 값이 아니면 넣어줄수 없다... 
제공 안 해주면 어떻게 하나, 직접 작성해야지... 

 

이를 해결하기위해 아래와 같은 방식으로 작성하였다.


BatchUpsert[변경한 부분]

func (w *Workout) BatchUpsert(db *gorm.DB, data []Workout) error {
	var (
		value     []string
		valueArgs []interface{}
	)

	for _, v := range data {
		value = append(value, ("(?,?,?)"))

		valueArgs = append(valueArgs, v.Id)
		valueArgs = append(valueArgs, v.Title)
		valueArgs = append(valueArgs, v.Raps)
	}

	prep := "insert into workout(id,title,raps) values %s on duplicate key update raps = raps+values(raps)"

	sql := fmt.Sprintf(prep, strings.Join(value, ","))

	if err := db.Exec(sql, valueArgs...).Error; err != nil {
		db.Rollback()
		return err
	}

	return nil
}

단순 sql 문을 실제로 작성해 주는 부분이다.  gorm value와 같은 도움을 받아  value 같을 매칭 시켜준다. 
sql을 실제로 찍어보면 이런 식으로 들어간다.
insert into workout(id,title,raps) values (?,?,?), (?,?,?), (?,?,?) on duplicate key update raps = raps+values(raps)

func TestWorkoutBatchUpsert(t *testing.T) {
	db := table.GetDB().Db
	list := make([]table.Workout, 0, 3)

	for i := 1; i <= 3; i++ {
		w := table.Workout{
			Id:   i,
			Raps: i * i * 3,
		}
		list = append(list, w)
	}

	var workout table.Workout

	if err := workout.BatchUpsert(db, list); err != nil {
		t.Error(err)
	}

}

insert into workout(id,title,raps) values (1,'',3),(2,'',12),(3,'',27) on duplicate key update raps = raps+values(raps)

이 결과 sql 의 ? 부분들은 생성된 value들이 매칭되어 들어가 sql 쿼리가 만들어진다. 


BatchUpdate[대안]

업설트가 아닌 위와 같이 업데이트 만 필요한 상황이라면? 업데이트 만하는 게 더 좋다고 본다.
왜냐하면 on duplicate update는 먼저 insert 이후 업데이트를 시도하기 때문이다.

func (w *Workout) BatchUpdate(db *gorm.DB, data []Workout) error {
	var (
		caseSql   []string
		whereSql  []string
		caseArgs  []interface{}
		whereArgs []interface{}
	)

	for _, v := range data {
		caseSql = append(caseSql, "when ? then raps + ?")
		caseArgs = append(caseArgs, v.Id, v.Raps)
		whereArgs = append(whereArgs, v.Id)
		whereSql = append(whereSql, "?")
	}

	prep := "update workout set raps = case id %s end where id in (%s)"

	sql := fmt.Sprintf(prep, strings.Join(caseSql, " "), strings.Join(whereSql, ","))

	caseArgs = append(caseArgs, whereArgs...)

	if err := db.Exec(sql, caseArgs...).Error; err != nil {
		return err
	}
	return nil
}

when case 문을 활용해서 작성했다. 확실히 Upsert 보다 가독성이 많이 떨어진다.

func TestWorkoutBatchUpdate(t *testing.T) {
	db := table.GetDB().Db

	list := make([]table.Workout, 0, 3)

	for i := 1; i <= 3; i++ {
		w := table.Workout{
			Id:   i,
			Raps: i * i * 3,
		}
		list = append(list, w)
	}

	var workout table.Workout

	err := workout.BatchUpdate(db, list)

	if err != nil {
		t.Error(err)
	}
}

update workout set raps = case id when 1 then raps + 3 when 2 then raps + 12 when 3 then raps + 27 end where id in (1,2,3)

원하는 방식대로 쿼리가 나간다. 


 

1000 개 비교

그렇다면 업설트와 업데이트의 차이는 어는 정도 있는지 궁금해졌다. 

[32.416ms] insert into workout(id,title,raps) values 
[6.349ms] [rows:3] update workout set raps = 

뒤에 내용은 길어서 삭제했다. 대충 여러번 돌려본 결과 약 3배 정도 ms 차이가 발생한다. 
다시 말해 update or insert의 기능이 아닌 단순 업데이트만 필요하다면? 배치 업데이트를 사용하자. 

회사에서 성능개선으로 고친부분 으로는 이 쿼리가 실행되는 시점은 절대 pk 값이 없을수가 없다. 로직 자체를 변경했다. 
따라서 위의 단순 비교로만 본다면 해당 부분에서 약 3배의 성능 이점을 얻은거로 판단된다.


SQL INJECTION [왜 ?]

 

작성하면서 ? 부분이 왜 필요한가에 대해 확인해 봤다. SQL Injection이라는 어택을 방어하기 위해서 필요한 부분이다. 
sql injection 이란 ? 사용자가 임의의 sql 문을 집어넣어서 프로그램 실행에 방해 혹은 버그를 주는 공격을 말한다.

 

사용자가 주는 값을 그대로 받지않고 ? 부분을 사용해 스트링 값이 아닌 공간을 할당하는 변수가 포함되어 있어 sql 문을 실행하기 전 원하는 값으로 대체해준다.  

예를 들어 

// 1번케이스
sql := fmt.Sprintf("select name from user where id = %s","사용자의 입력값")
// select name from user where id = (select id from item where id =123 )

// 2번 케이스
sql := fmt.Sprintf("select name from user where id = ?")
db.Exec(sql,사용자의입력값)
// select name from user where id = 'select id from item where id =123'

위와 아래는 엄청난 차이가 있다. 아래에서는 사용자의 입력값을 말그대로 숫자 혹은 스트링으로 만 넣어주는 반면 위에 처럼 한번 생성하게 되면 만약 사용자가 select id from table where id =123과 같은 값을 넣었을 때 해당 부분이 서브쿼리로 실행되고
2번째 sql 은 'select id from table where id =123' 이렇게 스트링 자체로 실행 된다.

테이블 타입, 메서드  : https://github.com/Guiwoo/go_study/blob/master/db_design/table/workout.go

테스트 코드 : https://github.com/Guiwoo/go_study/blob/master/db_design/test/workout_test.go

 

출처 - https://zhiruchen.github.io/2017/08/31/bulk-insert-bulk-query-with-gorm/

 

bulk insert, bulk query with gorm · zbbbbbblog

 

zhiruchen.github.io

 

'Go > Gorm 삽질기' 카테고리의 다른 글

MYSQL - 유휴커넥션에 대해서  (0) 2024.09.10

+ Recent posts