새로운 프로젝트의 처음 기반부터 다지다 보니 설정 파일까지 새로 작성해야 했다.

기존에 사용했던 yaml 혹은 json parsing을 통한 방법이 아닌 새로운 envConfig 방식으로 작성하고자 한다.

 

Libraray : https://github.com/kelseyhightower/envconfig

 

GitHub - kelseyhightower/envconfig: Golang library for managing configuration data from environment variables

Golang library for managing configuration data from environment variables - GitHub - kelseyhightower/envconfig: Golang library for managing configuration data from environment variables

github.com

사실 해당 페이지에 너무 자세하게 설명되어 있어서 간단하게 사용하는 방법에 대해 작성했다.
main run을 실행할 때 아래와 같이 넣어주면 된다.

실제 서버에서는 쉘스크립트 하나 작성해서 적용하면 편리하게 사용가능하다.

# common-config
#!/bin/sh

export G_NAME=guiwoo
export G_EMAIL=guiwoo@naver.com
export G_KEY=key
export G_WORD=wordword==


# patch_main
#!/bin/sh

source ./common-config
./main > /dev/null 2>&1 &

 

// main.go
import (
	mCfg "env/config"
	"fmt"
	"github.com/kelseyhightower/envconfig"
)

var config CustomConfig

type CustomConfig struct {
	Name  string `envconfig:"NAME" required:"true"`
	Email string `envconfig:EMAIL required:"true"`
	mCfg.Secret
}

func init() {
	cfg := CustomConfig{}
	prefix := "G"
	if err := envconfig.Process(prefix, &cfg); err != nil {
		panic(err)
	}
	fmt.Printf("%+v", cfg)
}

func main() {
	fmt.Println("Config")
}

// env/config.go
type Secret struct {
	Key  string `envconfig:"KEY" required:"true"`
	Word string `envconfig:"WORD" required:"true"`
}

사실 포스팅하려고 했던 목적은 구조체 타입의 임베딩을 적용하는 경우 컨피그 항목을 읽어오지 못하는 부분이 있어 디코딩을 이용해서 작성하는 방법을 포스팅 하려고 했으나 다시 확인해 보니 컨피그의 키값을 올바르게 입력하지 못해 발생한 문제였다.. ㅠ

'Go > Go Basic' 카테고리의 다른 글

Parser 성능개선 (pprof)  (3) 2024.10.29
AES 암호화 적용 In Go  (1) 2023.10.10
Ultimate-Go-06 [에러처리]  (0) 2023.09.19
Ultimate-Go-04 [디커플링]  (0) 2023.09.13
Ultimate-Go-03  (2) 2023.09.07

이번에 들어가게 되는 신규 프로젝트에서 유저관리 부분을 담당하게 되었다. 

신규 프로젝트이다 보니 다양한 세팅에 대해서 신경 써야 했고, 그중 생소했던 암호화에 대해 글을 작성해보고자 한다.


우선 혼동했던 단어들에 대해서 정의를 내려보자.

 

1. Base64 인코딩

- 바이너리 데이터를 텍스트로 바꾸는 엔코딩을 의미한다. 64진법 ascii 문자열 들로만 이루어진 문자열로 변환하는 것

 

2. 암호화(Encryption) / 해시(Hashing)

- 암호화의 목적은 데이터의 기밀성을 보호하고, 비밀번호와 같은 개인 정보를 안전하게 저장 및 전송하기 위해 사용

- 암호화된 데이터는 암호화 알고리즘을 사용하여 암호문으로 변환한다. 변환된 암호문은 특정키를 필요로 하며, 키를 이용해 평문으로 복호화 가능하다.

 

- 해시의 목적은 데이터의 무결성을 확인하고, 데이터의 고유한 표현을 생성하기 위해 사용된다. (주로 비밀번호에 많이 사용됨)

- 해시는 해시함수를 이용하여 고정길이의 해시 값으로 변환된다. 이는 단방향 함수이며 원래 데이터로 역으로 복원 불가능하다.

 

3. Sha512, Aes256 뒤에 붙는 숫자와 차이

- Sha는 대표적인 해시함수이다. (Secure Hash Algorithm) 즉 64바이트, 32바이트 등 해시의 길이를 나타낸다. 당연히 숫자가 클수록 보안강도가 높으며 처리속도는 느리다.

- Aes는 대표적인 암호화 알고리즘이다. (Advanced Encryption Standard) 즉 암-복호화하는데 필요한 키의 길이를 나타낸다. 당연히 숫자가 클수록 보안강도가 높으며 처리속도는 느리다.

 

4. 고에서 제공되는 대표적인 암호화 알고리즘

- Aes "고급 암호화 표준" 암호화, 복호화에 동일한 키를 사용하는 대칭키 알고리즘을 의미한다.

- Des "데이터 암호화 표준" 암호화, 복화에 동일한 키를 사용하며 aes 암호화알고리즘 이전에 주로 사용되었다. (보안표준을 충족하지 못해 사용을 권장하지 않는다.)

- Rsa "비대칭 암호화" 위의 두 종류의 암호화 알고리즘과 달리 공개키, 비밀키를 이용해 암복호화를 하는 방법을 의미한다. 

ex) A 가 B에게 정보를 보낼 때 B의 공개키를 이용해 암호화를 해 보내고, B는 B의 비밀키를 이용해 복화가 가능하다.


AES 암호화 방법

 

func encryptAes(token string) (string, error) {
	cip, err := aes.NewCipher([]byte(sixteen))
	if err != nil {
		return "", err
	}
	length := (len(token) + aes.BlockSize) / aes.BlockSize
	plain := make([]byte, length*aes.BlockSize)
	copy(plain, token)
	pad := byte(len(plain) - len(token))
	for i := len(token); i < len(plain); i++ {
		plain[i] = pad
	}
	encrypted := make([]byte, len(plain))
	for bs, be := 0, cip.BlockSize(); bs <= len(token); bs, be = bs+cip.BlockSize(), be+cip.BlockSize() {
		cip.Encrypt(encrypted[bs:be], plain[bs:be])
	}
	return hex.EncodeToString(encrypted), nil
}

token 은 단순하게 json을 바이너리 데이터로 변경해서 넘긴 값을 의미한다. 

cip, err := aes.NewCipher([]byte(sixteen))

aes 암호화 알고리즘의 NewCiper는 ciper.Block 인터페이스를 반환한다.

해당 인터페이스는 암호화, 복호화 그리고 블록의 사이즈를 함수로 구현을 정의하고 있다.

왜? 대칭키 암호화 이니깐 암-복호화 함수가 필요하다.

length := (len(token) + aes.BlockSize) / aes.BlockSize
plain := make([]byte, length*aes.BlockSize)
copy(plain, token)
for i := len(token); i < len(plain); i++ {
    plain[i] = pad
}

블록의 사이즈 즉 sixteen이라는 블록의 사이즈 단위로 데이터 암호화를 진행한다.

따라서 해당 sixteen 블록 사이즈에 맞게 plain이라는 바이너리 데이터를 생성하고 남은 공간(패딩)을 채워준다.

	encrypted := make([]byte, len(plain))
	for bs, be := 0, cip.BlockSize(); bs <= len(token); bs, be = bs+cip.BlockSize(), be+cip.BlockSize() {
		cip.Encrypt(encrypted[bs:be], plain[bs:be])
	}

선정된 블록단위로 (결괏값, 입력값) cip.Encrypt에 넘겨주는데 이 Encrypt는 ciper.Block 인터페이스의 함수중 하나이다. 

 

이후 암호화된 바이트 슬라이스를 16진수 문자열로 반환한다.

 

암호화를 진행했으면 복호화도 작성해 보자.

 

Aes 복호화 방법

func decryptAes(hexStr string) (string, error) {
	encrypted, err := hex.DecodeString(hexStr)
	if err != nil {
		return "", err
	}
	cip, err := aes.NewCipher([]byte(sixteen))
	if err != nil {
		return "", err
	}

	decrypted := make([]byte, len(encrypted))
	for bs, be := 0, cip.BlockSize(); bs < len(encrypted); bs, be = bs+cip.BlockSize(), be+cip.BlockSize() {
		cip.Decrypt(decrypted[bs:be], encrypted[bs:be])
	}

	trim := 0
	if len(decrypted) > 0 {
		trim = len(decrypted) - int(decrypted[len(decrypted)-1])
	}

	return string(decrypted[:trim]), nil
}

16진수 인코딩 된 암호화 문자열을 바이트 슬라이스로 디코딩한다.

 

aes 알고리즘을 이용해 암호화를 진행했으니 복호화도 동일하게 aes의 사이퍼를 생성한다.

복호화 과정에서는 암호화 과정에서 진행한 패딩을 추가하는 작업이 필요치 않다.

이미 블록사이즈 단위로 패딩이 되어 있기 때문에 블록단위로 복호화를 진행하면 된다.

trim = len(decrypted) - int(decrypted[len(decrypted)-1])

패딩을 제거해 줄 때는 패딩의 시작점부터 패딩의 사이즈가 기록된다.

암호화 과정 중에 pad를 남는 모든 공간에 채워주는 부분이 있는데 그것을 이렇게 활용한다.


사용방법

 

type user struct {
	Id   string
	Tier string
	Win  int
	Lose int
}

func main() {
	u := user{"guiwoo", "gold", 10, 10}
	b, _ := json.Marshal(&u)

	rs, _ := encryptAes(string(b))
	fmt.Println("encrypted \n", rs)

	data, _ := decryptAes(rs)

	var found user
	json.Unmarshal([]byte(data), &found)

	fmt.Printf("user : %+v", found)
}

1. json 엔코더를 이용해 바이너리 데이터를 작성했다.

2. encryptAes를 이용해 암호화 진행 

 

결괏값

encrypted 
b56e6b2a5bb2142d57eafba52696e5ac53912f5dbcb5e9b30c23fa2764e11c65ab182f6572f22445e0a479f434be549b6a10f8e053a

 

3. decryptAes를 이용해 복호화 진행

결괏값

user : {Id:guiwoo Tier:gold Win:10 Lose:10}

 

대칭키 암호화를 선정한 이유로는 데이터의 기밀성을 유지와 안전한 전송이 암호화의 목적이었으며,

비대칭키 암호화는 전자서명 및 키교환과 같은 특정한 용도에 적합하여 대칭키 암호화를 채택하였다.

'Go > Go Basic' 카테고리의 다른 글

Parser 성능개선 (pprof)  (3) 2024.10.29
Env-Config (kelseyhightower/envconfig)  (2) 2023.10.16
Ultimate-Go-06 [에러처리]  (0) 2023.09.19
Ultimate-Go-04 [디커플링]  (0) 2023.09.13
Ultimate-Go-03  (2) 2023.09.07

대부분의 고 패키지 또는 라이브러리를 보게 되면 결괏값과 error 인터페이스를 주로 반환되는 것을 상당 부분 많이 확인할 수 있다.

실제 이러한 에러처리가 미숙해 한번 서버를 패닉으로 내려버린 적도 있다.

 

이에 대해 고의 에러처리 철학과 그 방법에 대해 알아보자.


기본에러 값

type error interface {
	Error() string
}

고에서 빌트인으로 제공하는 에러 인터페이스이다. 

해당 error 패키지 에서 New를 이용해 error 포인터 구조체를 반환한다.

(error는 unexport 타입인데 어떻게 패키지에서 import 가 가능할까?- 빌트인 함수이기에 가능)

반환된 구조체는 errorString error 인터페이스를 만족하는 구현체가 있고 Error는 단순하게 메시지를 반환한다.

이렇게 에러메세지는 디커플링 되어있다. 왜? 클라이언트 즉 error를 구현하고 받는 모든 의사소통은

인터페이스로 이루어주기 때문이다.

 

가장 기본적인 에러 핸들링방식

func TestError01(t *testing.T) {
	webCall := func() error {
		n := time.Now().Unix()
		if n%2 == 0 {
			return nil
		}
		return errors.New("지금은 홀수 입니다.")
	}

	if err := webCall(); err != nil {
		fmt.Println(err)
		panic(err)
	}
	fmt.Println("Life is Good")
}

함수를 호출하고 반환되는 함수를 처리한다.


에러변수

위처럼 인터페이스를 만족만 시킨다면? 정말 손쉽게 커스텀 에러작성이 가능해진다. 

gorm에 정의되어 있는 커스텀 에러를 확인해 보면 

var (
	// ErrInvalidTransaction invalid transaction when you are trying to `Commit` or `Rollback`
	ErrInvalidTransaction = errors.New("invalid transaction")
    --- 생략 ---
)

이런 식으로 errors 패키지에서 제공해 주는 생성자함수를 이용하면 손쉽게 에러생성이 가능하다.

 

func TestError02(t *testing.T){
    --- 생략 webCall() ---
    if err := webCall(); err != nil {
        switch err {
        case ErrBadRequest:
            fmt.Println("Bad Request")
            return
        case ErrBadPageMoved:
            fmt.Println("The Page Moved")
            return
        }
    }
}

 

타입 컨택스트

빌트인에서 제공되는 에러 인터페이스보다 더많은 컨택스트가 필요한 경우도 존재한다. 

예를 들어 네트워킹 문제는 복잡할 수 있어, 에러 변수 만으로는 부족하다. 이럴 때 사용자 정의 타입을 통해 문제를 해결할 수 있다.

 

json 패키지중 unmarshal error 가 그중 하나이다.

type InvalidUnmarshalError struct {
	Type reflect.Type
}

func (e *InvalidUnmarshalError) Error() string {
	if e.Type == nil {
		return "json: Unmarshal(nil)"
	}

	if e.Type.Kind() != reflect.Pointer {
		return "json: Unmarshal(non-pointer " + e.Type.String() + ")"
	}
	return "json: Unmarshal(nil " + e.Type.String() + ")"
}

json 값을 언마샬링 할때 포인터의 값이 들어오지 않을 경우 반환값으로 해당 구조체가 사용된다.

 

func TestError03(t *testing.T) {
	type user struct {
		Name string
	}

	var u user
	err := json.Unmarshal([]byte(`{"name":"bill"}`), u)
	switch e := err.(type) {
	case *json.InvalidUnmarshalError:
		fmt.Printf("Invalid input type %v\n", e.Type)
	default:
		fmt.Println(err)
	}

	fmt.Println("Name", u.Name)
}

 


기능을 통한  컨택스트 처리

위에서 제공된 방법은 타입을 꺼내와서 사용하기에 디커플링으로부터 멀어진다. 디커플리를 유지하면서 타입단언을 막는 방법에 대해 알아보자.

type CustomOneError struct {
	message string
}

func (c *CustomOneError) Error() string {
	return "one error" + c.message
}

type CustomTwoError struct {
	message string
}

func (c *CustomTwoError) Error() string {
	return "two error" + c.message
}

func TestError04(t *testing.T) {
	errChecker := func() error {
		n := time.Now().Unix()
		if n%2 == 0 {
			return &CustomOneError{"wrong"}
		} else {
			return &CustomTwoError{"something"}
		}
	}
	err := errChecker()
	switch e := err.(type) {
	case *CustomOneError:
		if e.IsNotInteger(){
			fmt.Println("Not Integer",e.message)
		}
	case *CustomTwoError:
		if e.IsNotInteger(){
			fmt.Println("Not Integer",e.message)
		}
	}
}

이렇게 숫자가 아닌경우의 에러를 핸들링하기 위해서  swtich-case로 위에서 제공되는 방법으로 사용하는 경우이다.

숫자가 들어오는 경우에 대해서만 신경을 쓴다고 가정한다면 해당 switch는 인터페이스로 포장할 수 있다.

func TestError04(t *testing.T) {
	--- 생략 ---
	switch e := err.(type) {
	case ErrNotInteger:
		fmt.Println("Not Integer Error ", e.IsNotInteger())
	default:
		fmt.Println("default ", err)
	}
}

에러포장

github.com/pkg/errors.Wrap()  해당 패키지를 이용하면 고에서 제공하는 일반 렙핑이 아닌 스택트레이스가 제공되는 에러를 반환할 수 있다.

func TestError05(t *testing.T) {
	thirdCall := func(i int) error {
		return errors.New("99")
	}
	secondCall := func(i int) error {
		if err := thirdCall(i); err != nil {
			return fmt.Errorf()
		}
		return nil
	}
	firstCall := func(i int) error {
		if err := secondCall(i); err != nil {
			return pkgErr.Wrap(err, "Second Call Error")
		}
		return nil
	}
	for i := 0; i < 5; i++ {
		if err := firstCall(i); err != nil {
			fmt.Println("stack trace")
			fmt.Printf("%+v\n", err)
		}
	}
}

//고 기본 패지키이용 방법
return fmt.Errorf("third call error %w", err)

기본 패키지를 이용해서 랩핑이 가능하지만, 스택트레이스 없이 에러로그를 확인하는것은 상단 한 피로감을 유발할 것으로 보인다.

이렇게 처리된 에러는 에러의 근원지를 확인할수 있는. Cause method를 제공한다.

func TestError05(t *testing.T) {
     --- 생략 --- 
	for i := 0; i < 1; i++ {
		if err := firstCall(i); err != nil {
			switch v:= pkgErr.Cause(err).(type){
			case *ErrCustomOne:
				fmt.Println("custom One error")
			}
			--- 생략 --- 
		}
	}
}

이 방법외에도 errors 기본패키지에서 as 메서드를 제공한다.

func TestError05(t *testing.T) {

	thirdCall := func(i int) error {
		return &CustomOneError{"hoit"}
	}
	secondCall := func(i int) error {
		if err := thirdCall(i); err != nil {
			return fmt.Errorf("third call error %w", err)
		}
		return nil
	}
	firstCall := func(i int) error {
		if err := secondCall(i); err != nil {
			return fmt.Errorf("second call error %w", err)
		}
		return nil
	}
	for i := 0; i < 1; i++ {
		if err := firstCall(i); err != nil {
			var v *CustomOneError
			switch {
			case errors.As(err, &v):
				fmt.Println("custom One error found")
			}

			fmt.Println("Stack Trace")
			fmt.Printf("%+v\n", err)
			fmt.Printf("Stack Done")
		}
	}
}

as를 사용하면 기본 빌트인 타입으로도 충분히 에러 발생 근원지에 대해 트랙킹 할 수 있다.

 

이렇게 다양한 error를 go 에서는 지원해주고 있다. 

왜? 명시적으로 err를 처리하기를 바라기 때문이다. 다시 말해 err 처리를 회피하지 않고 제대로 처리하길 바라는 마음에서 위와 같은 다양한 빌트인 함수를 제공해 주고, 또 계속 패키지를 발전시켜 나가는 것이 아닌가 싶다.


출처: https://github.com/hoanhan101/ultimate-go/tree/master/go/design

출처: https://ultimate-go-korean.github.io/translation/#%EC%97%90%EB%9F%AC-%ED%8F%AC%EC%9E%A5wrapping-errors

'Go > Go Basic' 카테고리의 다른 글

Env-Config (kelseyhightower/envconfig)  (2) 2023.10.16
AES 암호화 적용 In Go  (1) 2023.10.10
Ultimate-Go-04 [디커플링]  (0) 2023.09.13
Ultimate-Go-03  (2) 2023.09.07
Ulitmate-Go-02  (0) 2023.09.05

서문에 "리팩토링은 개발 주기의 일부가 되어야 한다."라고 작성이 되어있다.

 

지난달부터 계속 개발하고 리팩토링을 하고 있는 현재 프로젝트를 보면 참 많이 공감되는 말이 아니지 싶다.

 

해당 파트는 구조체 구성이라는 큰 타이틀로 시작한다. 
"제니아"라는 시스템은 데이터 베이스를 가지고 있고,
"필러"라는 프런트엔드를 가진 웹서버는 해당 제니아를 이용하고 있고, 제니아의 데이터를 필러에 옮겨보는 작업이다.


글을 읽어가다보면 함수 설정과 파라미터의 이유가 기가 막히다.

func (*Xenia) Pull(d *Data) error {
	switch rand.Intn(10) {
	case 1, 9:
		return io.EOF
	case 5:
		return errors.New("Error reading data from Xenia")
	default:
		d.Line = "Data"
		fmt.Println("In: ", d.Line)
		return nil
	}
}

함수 파라미터를 포인터 타입으로 받는다. 굳이 포인터로 받지 않고 반환값으로 data를 던져 주어도 동일하게 동작한다. 

func (*Xenia) Pull() (data,error){}

위의 작성된 함수는 값을 반환하다. 이는 다시 말해 이스케이프 스택 즉 스택메모리에서 벗어나는 상황이 발생되고 이는 런타임 시점에 힙메모리에서 관리되어야 함을 의미한다. 

그러나 위에 작성된 기존 함수를 보면 d *Data를 이용해 메모리의 주소값만 받게 되고, 함수의 종료 이후 복사된 주소 d는 스택에서 사라진다.

매번 함수 호출마다 쓸모없는 이스케이프 스택을 방지하기 위해 위와 같이 함수를 사용한다.

 

약 8개월을 고언 어를 배우고 실제로 사용하면서 이러한 부분에 있어 고민을 해본 적이 없던 것 같다. 
아 팀원이 밑에 이렇게 사용했으니깐 이렇게 해야지. 오늘도 내가 만든 수십 개의 함수 중에 이러한 고민을 단 1이라도 해보았는가... 

해당 함수의 주석을 읽으면서 많은 생각이 든다. 


[구조체 임베디드]

type System struct {
	Xenia
	Pillar
}

 

두 개의 구조체를 system으로 구조체로 합쳤다. 이렇게 되면 System에서는 Xenia의 필드, Pillar의 필드를 모두 접근가능하다. 

[자주 사용했던 임베디드 방법]
해당 임베디드 구조체는 통상 gorm의 left join을 이용할 때 많이 사용한다. 

type User struct {
 id uint `gorm:"column:id;primaryKey"`
 name string
}

type UserWithLikeContent struct {
	User
	Content uint
}

요런 식으로 프로젝트에서 사용을 많이 했다. 이렇게 안 하고 select로 찍어오면 생각보다 코드가 더러워서 이렇게 구조체 임베디드를 활용하면 생각보다 좋다.

 

System이라는 구조체를 구현체가 아닌 인터페이스의 주입으로 변경하게 되면

type System struct {
	Puller
	Storer
}

type PullStorer interface {
	Puller
	Storer
}

func Copy(ps PullStorer, batch int) error {
	data := make([]Data, batch)

	for {
		i, err := pull(ps, data)
		if i > 0 {
			if _, err := store(ps, data[:i]); err != nil {
				return err
			}
		}
		if err != nil {
			return err
		}
	}
}

이렇게 인터페이스에 의존하게 변경할 수 있다. 

어디서 많이 보던 디자인 패턴 같다. (생성하면 추상팩토리가 될 테고, 두 가지 인터페이스를 합치니깐 퍼사드도 될 수 있고..  )

 

이렇게 구성된 시스템은 Puller, Storer 가 구현된 어떤 객체든 받을 수 있는 유연한 구조체가 되었다. 


이게 GO 가 디커플링 할 수 있는 방법이다. 인터페이스를 이용해 임베딩을 활용한다. 
당연히 인터페이스 만 활용해도 디커플링이 된다. 디커플링의 계층을 더 세부적으로 나누고 싶다면 위의 방법처럼 
임베딩을 활용하면된다.

 

위의 예제는 너무 명확하게 추상계층들이 나눠져 있어 보기 쉽지만. 실제 서비스에서는 생각보다 이렇게 나누는 게 쉽지 않다.

이번에 프로젝트 작성간 이렇게 추상화를 해야할 일이 생겼다.

repository 즉 데이터 접근 관련해서 mysql과 inmemoryDB의 추상을 각각 분리하고 싶었다.
인터페이스로 각 db 들을 추상화하고 해당 디비에 접근하는 서비스에 대해 추상화 하고 각 접근하는 방법들을 추상화했다.

위의 방식처럼 임베딩으로 묶고 구조를 작성하는데 생각보다 많은 시간이 걸렸다.

기존에 없던 추상 레이어를 만들어야 하기 때문에 생각보다 많은 코드를 작성해야 했기에 오히려 추상계층을 줄였다.
왜냐하면 하나의 서비스 떄문에 이렇게 추상화를 구성하는게 맞나 싶었다.

 

디자인 패턴을 배우면서도 패턴들의 단점에 대해서 항상 명확하게 숙지하고자 했다. 공통된 특징이 바로 지나친 추상화로 인한 코드 가독성 저하에 따른 안티패턴 이 되어가는 것 분명 주의해야 한다.

 

디커플링은 분명 좋은 방식의 코드작성 이다. 확장성이 정말 좋다. 다만 처음부터 할 필요는 없는 것이다.

처음부터 앞으로 이렇게 확장될 테니 팩토리메서드를 적용한 생성자 함수를 만들자 등과 같이 필요 없는 작업은 과감하게 버리면 된다.

글의 첫 시작과 동일하게 "리팩토링 은 코드 작성 주기의 한 사이클이 되어야 한다." 다시 한번 강조하고 싶다.

출처 : https://github.com/hoanhan101/ultimate-go

 

GitHub - hoanhan101/ultimate-go: The Ultimate Go Study Guide

The Ultimate Go Study Guide. Contribute to hoanhan101/ultimate-go development by creating an account on GitHub.

github.com

 

'Go > Go Basic' 카테고리의 다른 글

AES 암호화 적용 In Go  (1) 2023.10.10
Ultimate-Go-06 [에러처리]  (0) 2023.09.19
Ultimate-Go-03  (2) 2023.09.07
Ulitmate-Go-02  (0) 2023.09.05
Ulitmate-Go-01 (string,메모리 패딩)  (3) 2023.08.19

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

메서드라는 제목의 파트이다.

고에서는 메서드 즉 리시버를 선정하는 데 있어 2가지 방법이 있다.

하나는 value receiver, pointer receiver 형태를 보자면

type user struct {
	name, email string
}

func (u user) notify() {
	fmt.Printf("Sending user email to %s <%s>\n", u.name, u.email)
}

func (u *user) changeEmail(email string) {
	u.email = email
	fmt.Printf("Changed User Email To %s\n", email)
}

 

 

어떻게 선언해서 사용하던 GO에서는 알아서 캐스팅해서 처리를 해준다.

고 내부적으로 어떻게 호출이 되는 걸까? 메서드 즉 리시버 함수는 앞에 선언된 값이 바로 첫 번째 파라미터로 동작한다는 의미이다.

u := user{"guiwoo","park.guiwoo@hotmail.com"}

u.notify() // u.notify(u)
u.changedEmail("holy") // (*u).changedEmail(&u,"holy")

// 에 표시된 부분처럼 고 내부적으로 동작한다는 의미이다. 

 

그래서 뭐 어떻게 사용하라는 건가?

라는 의문이 들 수 있다. 일반적으로 구조체의 값 즉 필드 내부가 변경되어야 한다면 포인터 리시버를 , 그게 아닌 경우에는 값 리시버를 사용하는 게 맞다고 생각할 수 있다.

 

그러나 고 랜드라는 IDE를 사용하다 보면 구조체의 리시버 함수에 대해 일관성 있게 사용하라고 나온다.

 

다시 말해 뭐가 됐든 하나의 구조체 에는 일관된 리시버 함수를 사용하라는 말이다.

 

가장 큰 이유 중 하나는 바로 인터페이스에 대해 값 또는 리시버 타입에 따라 인터페이스가 충족될 수도 있고 불충족될 수도 있다.

이로 인해 인터페이스로 추상화된 코드들이 종종 깨지는 경우가 발생한다. 

(실제로 프로젝트 수행 중 일관된 메서드로 변경하던 중 인터페이스가 깨지는 경우가 발생했다.)

그 외에도 사용자 입장에서 해당 함수가 값복사를 하는지, 아니면 메모리의 값을 변경하는지 혼란이 올 수 있다.

 

이런 고로 나는 프로젝트에서 는 대부분 * 리시버를 많이 사용하게 된다.
물론 기존에 있던 리시버 가 있다면 해당 리시버 방법을 따라가지만 만약 새로운 타입을 생성하게 된다면 포인터 리시버를 주로 사용한다.

특정 상태변경, 큰 구조체의 경우 값복사의 리소스 낭비를 이유로 포인터 리시버 가 보다 값을 관리하기 쉽다고 생각하기 때문이다. 

 

'Go > Go Basic' 카테고리의 다른 글

Ultimate-Go-06 [에러처리]  (0) 2023.09.19
Ultimate-Go-04 [디커플링]  (0) 2023.09.13
Ulitmate-Go-02  (0) 2023.09.05
Ulitmate-Go-01 (string,메모리 패딩)  (3) 2023.08.19
Go Interface, embedded  (0) 2023.02.28

+ Recent posts