항상 Json의 데이터를 가지고 엔코딩, 디코딩을 할 줄 알았다. 특별한 값에서 오는 바이너리 데이터를 디코딩하고 성능개선의 과정에 대해서 작성하고자 한다.


1. go tool pprof 활용하기

- 성능개선을 위해서는 성능을 개선하기 위한 프로파일링 결과가 필요하다. Go에서는 tool로 아주 쉽게 제공해 준다.

- pprof을 설정한다. 단순하게 import 해주고, pprof으로 접근할 수 있는 포트를 제공해 주면 된다.

import _ "net/http/pprof"

go func() {
	log.Println(http.ListenAndServe("localhost:6060", nil))
}()

 


해당 페이지로 접근하면 위와 같은 이미지로 볼 수 있다.

좀 더 손쉽게 보기 위해서는 graphviz를 설치하면 손쉽게 확인할 수 있다.

-  brew install garphviz

- go tool pprof -http 0.0.0.0:[그래프로 확인할 PORT] http://0.0.0.0:6060/debug/pprof/profile

 

1분간 profile 데이터를 모으고, 제공된 포트로 web 형태를 제공한다. 위에 제공된 이미지들은 graph, flame graph이다.

CPU 작업시간, 메모리 할당 등을 확인할 수 있다.

- 위의 툴과 benchmark test를 같이 이용하고 있다.

func Benchmark테스트명(b *testing.B){
	for i:=0; i<b.N; i++ {
    	// 벤치마크 테스트를 진행하고자 하는 작업 수행
    }
}

 

실행을 하면 아래와 같은 결과를 보여준다.
BenchmarkMyFunction-8    5000000    300 ns/op    64 B/op    2 allocs/op

- 5000000 벤치마크가 실행된 횟수

- 300 ns/op 반복에 소요된 평균 시간

- 64 b/op 반복에 할당된 메모리 바이트

- 2 allocs/op 반복당 메모리 할당 횟수

 

위 2가지 도구를 활용하여 성능을 개선하고자 한다.


2. 현재상태 파악

- 프로파일링 결과를 확인해 보면 다음과 같다.

binary Read에서 생각보다 많은 cpu 점유와 메모리 할당을 진행하고 있다. 성능개선의 포인트라고 생각한다.

- 벤치마크 결과

파싱 하나 하나 하는데 힙메모리 할당이 엄청나게 많이 일어나고 이는 gc가 수거할게 많아 성능의 문제가 될 지점이라고 생각한다.


3. Binary Read는 왜 많은 CPU 점유시간을  가질까?

- binary Read 함수 내부 구현을 확인하면

func Read(r io.Reader, order ByteOrder, data any) error {
	if n := intDataSize(data); n != 0 { // 들어온 데이터의 사이즈를 설정하고,
		bs := make([]byte, n) // 설정된 숫자만큼 바이트 슬라이스를 생성한다.
		if _, err := io.ReadFull(r, bs); err != nil { // 제공된 reader로 부터 정해진 바이트 슬라이스만 읽는다.
			return err
		}
		switch data := data.(type) {
        		// 타입에 맞춰서 data에 넣어주는 작업
		}
	}

	v := reflect.ValueOf(data)
	size := -1
	switch v.Kind() {
	case reflect.Pointer:
		v = v.Elem()
		size = dataSize(v)
	case reflect.Slice:
		size = dataSize(v)
	}
	if size < 0 {
		// 에러처리
	}
	d := &decoder{order: order, buf: make([]byte, size)}
	if _, err := io.ReadFull(r, d.buf); err != nil {
		return err
	}
	d.value(v)
	return nil
}

 

- Read 내부는 우선 전달받은 데이터가 원시타입의 경우라면 switch case를 통해 필터링된다.

- 이후 io.Reader 인터페이스의 ReadFull을 통해 전달받은 버퍼의 사이즈만큼 읽어오게 되고

- reflect 패키지를 활용하여 구조체가 전달된 경우의 케이스를 해결한다.

 

따라서 원시타입의 경우라면 값의 검증을 위해 2번의 검증을 하게 되고 이것이 반복된다면 2배의 연산을 더 진행하게 되는 것이다.

파싱 하고자 하는 데이터는 모두 숫자로 이루어져 있기 때문에 이는 부적절한 함수 사용이다.

 

binary.LittleEndian의 변수를 보면 ByteOrder의 인터페이스를 모두 제공하고 있으며 내부연산은 모두 비트연산을 통해 메모리 할당 없이 반환하고 있다는 것을 확인했다.

 

binary.Read -> binary.LittleEndian의 함수호출 변경 벤치마크 테스트

- 실행횟수 160 -> 646

- 한 번의 작업당 시간 7416761 -> 1952116

- 메모리 할당 136005 -> 2

 

저 메모리 할당이 비약적으로 줄어들며 전체적으로 성능이 많이 올라갔다.

프로파일링 결과를 보면

 

binary.Read의 CPU 점유와 힙메모리 할당이 완전히 사라졌다.


4. 자주 계산되는 항목들은 메모리 캐시 처리

모자이크 된 부분의 주된 계산은 sin, cos의 값을 구하는 것이다. 매 루프마다 특정 포인트의 x, y, z 축에 대한 sin, cos 값이 계산이 되는데

생각보다 cpu 연산과 딜레이가 되는 것 같아 고정적으로 반복되는 부분들은 구조체 선언과 동시에 sin, cos의 값을 미리 계산해서 처리했다.

모든 값에 대해서 캐시 처리를 하지 않은 이유는 어느 정도 참조가 덜된다 싶으면 메모리 해제를 통해 메모리의 여유를 두어야 하기때문에 해당 부분을 구현하기 까지 시간이 조금더 필요하여 필수 값들에 대해서만 어느정도 미리 계산을 진행했다.

 

벤치마크 테스트

- 실행횟수 646 -> 998

- 한 번의 작업당 시간 1952116 -> 

1252630

- 메모리 할당 2 -> 3

 

메모리 할당이 늘어난 것은 배열로 캐시처리를 진행해서 그렇다. 


5. 구역을 나누어 Go Routine 처리

파싱 되는 부분은 이차원 바이트 배열로 for 반복문을 통해 작업이 처리되고 있다.

따라서 각 for 반복문을 독립적인 시행영역이라고 생각한다면 아래와 같은 그림으로 변경할 수 있다.

 

벤치마크 테스트 결과

- 실행횟수 998 -> 2809

- 한 번의 작업당 시간 1252630 -> 577930

- 메모리 할당 3 -> 1005

확실히 어느 정도 메모리를 할당해야 성능향상을 기대할 수 있는 것 같다.

 

프로파일링 결과

 

최초 보였던 프로파일리의 결과보다 작업이 많이 줄은 것을 확인할 수 있다.


성능개선이 생각보다 괜찮게 되었다. 다만 마지막 개선작업의 go routine은 개발기에 적용하여 안정적인 평균속도를 제공해 줄 수 있는지는 지속적으로 확인해봐야 한다. 

1. 보통 100~150ms 단위로 약 500개의 패킷이 전달된다. 그렇다면 위의 방식대로 동작한다면 순간 최대 고 루틴은 500+&가 될 수 있다는 사실이다.

2. 현재는 하나의 데이터 원천으로부터 가져오지만, 추가될 가능성이 있다는 부분

3. 1~2번의 사실을 고려했을 때 최대 고 루틴의 Pool을 두어 파싱에 사용되는 go routine의 숫자를 관리할 필요가 있다고 생각한다.


번외로 go의 standard 패키지 json의 Marshaling과 UnMarshaling이 생각보다 성능이 좋지 않다는 사실을 알게 되었다.

테스트 코드

type Address struct {
	City        string
	ZipCode     string
	PostCode    uint32
	CountryCode uint16
	CityCode    uint16
	People      uint8
}

func BenchmarkJsonParser(b *testing.B) {
	seoul := Address{
		"Seoul", "117128", 11731, 82, 02, 128,
	}
	byteData, _ := json.Marshal(seoul)
	b.Run("Standard Json Marshal", func(b *testing.B) {
		b.ResetTimer()
		for i := 0; i < b.N; i++ {
			json.Marshal(seoul)
		}
	})
	b.Run("Json Library Marshal", func(b *testing.B) {
		b.ResetTimer()
		for i := 0; i < b.N; i++ {
			fastjson.Marshal(seoul)
		}
	})
	b.Run("Standard Json UnMarshal", func(b *testing.B) {
		b.ResetTimer()
		for i := 0; i < b.N; i++ {
			json.Unmarshal(byteData, &seoul)
		}
	})
	b.Run("Json Library UnMarshal", func(b *testing.B) {
		b.ResetTimer()
		for i := 0; i < b.N; i++ {
			fastjson.Unmarshal(byteData, &seoul)
		}
	})
}

 

위와 같이 단순한 구조체를 벤치마크를 돌렸을 때 

  실행횟수 평균 작업 속도 평균 메모리 사용량 평균 메모리 할당 횟수
Standard Library 마샬 6411848 165.5 ns 144 byte 2
Go-Json 마샬 12458299 94.85 ns 144 byte 2
Standard Library 언마샬 1562784 796.6 ns 232 byte 6
Go-Json 언마샬 8492500 137.0 ns 96 byte 1

 

프로파일링 결과이다.

 

go-json이 빠른 이유는 reflect 코드의 제거와 buf의 재사용과 인터페이스 사용을 지양하여 최대한 스택 메모리 할당을 하는 방법으로 성능 개선을 했다. 

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

Env-Config (kelseyhightower/envconfig)  (2) 2023.10.16
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

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

기존에 사용했던 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

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

고에서는 메서드 즉 리시버를 선정하는 데 있어 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