예전 이와 유사한 포스팅을 한 적이 있다. (https://guiwoo.tistory.com/53)

당시 타입 변환 과 타입단언에 대해 궁금함이 생겨 작성했었는데. 이와 상당히 유사한 내용을 다룬다.


인터페이스의 변환

type Mover interface {
	Move()
}

type Locker interface {
	Lock()
	Unlock()
}

type MoveLocker interface {
	Locker
	Mover
}

type bike struct{}

func (*bike) Move() {
	fmt.Println("bike is moving")
}
func (*bike) Lock() {
	fmt.Println("Locking the bike")
}
func (*bike) Unlock() {
	fmt.Println("Unlocking the bike")
}

인터페이스 두개 mover와 locker를 설정하고  두 개를 모두 가지는 MoveLocker를 설정한다. 

bike는 제공되는 모든 인터페이스를 만족하고

func TestBike(t *testing.T){
	var (
    	ml MoveLocker
        m Mover
    )
    
    ml = &bike{}
    
    m = ml
    
    ml = m
}

위 코드에서 ml 은 bike의 구현체로 설정이 가능하다.  m = ml 까지도 인터페이스 변환이 가능하지만 m을 다시 ml로 변환은 불가능하다. 

왜? mover 타입인 m 은 MoveLocker의 인터페이스를 충족해 줄 수 없기 때문에 불가능하다. 


타입단언 

그렇지만 MoveLocker 는 bike라는 구현체로 되어있음을 알고 있기 때문에 이를 명시적으로 타입을 변환할 수 있다.

    b, ok := m.(bike)
    fmt.Println("Does m has value of bike?:", ok)

    ml = b

해당 타입 단언 때문에 지난번 포스팅을 작성했었다. 우리의 프로젝트 경우 echo 를 사용하고 미들웨어를 가져다가 컨택스트를 중간에 한번 커스텀하게 작성해서 다음 echo context로 랩핑 해서 넘겨준다. 

실제 라우팅 핸들러에서는 ehco context를 받아서 위와 같이 타입단언으로 타입을 꺼내와서 사용하게 된다.

middleware ("custom echo context wrapping next()") => routing hanlder( c,_ := c.(*customContext)) 이런식이다.


인터페이스 오염 

매번 인터페이스를 사용할 때 의문이 들곤 한다. 과연 이 인터페이스가 필요한가?라는 의문이다. 

인터페이스의 잘못된 사용을 인터페이스 오염이라고 부르고 어떠한 경우에 이게 해당되는지 몇 가지 가이드라인을 제공하고 있다.

  • 유저가 API의 실제 구현 디테일을 작성한다.
  • API가 유지보수가 필요한 다양한 구현을 가지고 있다.
  • API의 일부분이 변화할 수 있고 디커플링이 필요로 할 때 사용한다.
  • 오직 테스트를 위해서만 사용한다.
  • 변화로부터 쉽게 대응할 수 없다.
  • 인터페이스가 코드를 더 좋게 만들어주지는 않는다.

모조품 만들기

 

모킹을 함에 있어 고에서는 인터페이스가 있어야 적용이 가능하다. 

그렇다면 TDD를 하기 위해서 인터페이스를 적용해 코드를 디커플링 해야 하는가? 

/*
*
Server
*/
type PubSub struct {
	host string
}

func New(host string) *PubSub {
	return &PubSub{
		host,
	}
}

func (ps *PubSub) Publish(key string, v interface{}) error {
	fmt.Println("Actual PubSub: Publish")
	return nil
}
func (ps *PubSub) Subscribe(key string) error {
	fmt.Println("Actual PubSub: Subscribe")
	return nil
}

해당 PubSub을 모조품을 만들기 위해 이 패키지 위에 Publish와 Subscribe를 만족하는 인터페이스를 작성하고 테스트해야 할까? 

 

type publisher interface {
	Publish(key string, v interface{}) error
	Subscribe(key string) error
}

type mock struct{}

func (m *mock) Publish(key string, v interface{}) error {
	fmt.Println("Mock PubSub: Publish")
	return nil
}
func (m *mock) Subscribe(key string) error {
	fmt.Println("Mock Subscribe: subscribe")
	return nil
}

var _ publisher = (*mock)(nil)

func Test_Mocking(t *testing.T) {
	pubs := []publisher{
		&mock{},
		New("localhost"),
	}

	for _, p := range pubs {
		p.Publish("key", "value")
		p.Subscribe("key")
	}
}

이렇게 위의 코드처럼 코드를 호출하는 부에서  해당하는 인터페이스를 정의하고 사용할 수 있다. 

고 언어 이기 때문에 이러한 분리가 가능해진다. 왜? 인터페이스 선언 방식에 있다.

해당 구조체 가 인터페이스를 만족한다면 너도 쟤도 모두 인터페이스 타입이 될 수 있는 것이다.

 

인터페이스를 선언함에 있어 위에서 말한 인터페이스 오염에 가까운 방식의 사고를 해왔고 실제 작성된 코드들 중 몇몇은 저렇게 프로젝트 상에 작성했다. 

또한 이번챕터에서 주석된 부분에서 인터페이스를 주로 밖으로 내보내는 것이 아닌 인풋으로 사용하는 것이 일반적이라고 말하고 있다. 

이렇게 인터페이스를 인풋으로 사용하게 되면? 위에 작성한 코드처럼 분리가 가능해지며, 명확한 API 설계가 가능해진다. 

 

참 go라는 언어에 대해서 공부를 하면 할수록 부족한 부분이 너무 많이 느껴진다. 

그래서 더 심도 있게 공부하고 go에 매력을 느끼는 것 같다.

 

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

 

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

 

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


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

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

엄청 오랜만에 작성하는 글이다. 

프로젝트에서 중간중간 긴급하게 수정하고, 내가 부족한 부분이 많아 상당한 부분을 리팩터링 하는데 시간을 보내 개인적인 공부할 시간조차 할애하기 어려웠다. 변명은 그만하고 바로 가보자.

 

이번 챕터는 데이터 구조이다. 바로 가보자.

 

배열 Array

 

1-1. CPU 캐시

코어들은 메인 메모리에 바로 접근하지 않고, 로컬 캐시로 접근한다. 캐시의 속도는 L1, L2, L3 메모리 순으로 빠르고 "퍼포먼스"가 중요하다면 모두 캐시메모리에 접근해야 한다.

 

1-2. Cache miss

캐시 미스란 코어에서 처리하고자 하는 데이터가 캐시에 없는 상태를 말한다. 위의 CPU캐시에 없다면 메인 메모리까지 접근해야 하고 이는 성능상 캐시보다 많이 느린 성능을 제공하게 된다.

프로세스는 프래패쳐를 가지고 있는데 이는 어떤 데이터가 필요할지 예상하는 것을 말한다. 다시 말해 프리패쳐를 이용해 예측가능한 데이터 접근 패턴을 생성하는 코드를 작성하는 것 이것이 캐시미스를 줄이는 방법이다.

 

그래서 이 캐시 미스, CPU 캐시랑 Array와 도대체 무슨 관련이 있는가?라고 생각이 들 수 있다. 배열은 메모리의 연속할 등을 하게 된다. 다

시말해 배열로 할당하고 이를 순회한다면? 이는 캐시미스의 확률을 상당히 줄여주게 된다.

 

 

n*n의 큰 행렬이 있다고 할 때 이를 순회하기 위해

1. LinkedList 순회 : TLB 변환색인버퍼 페이지와 오프셋을 이용해 중간 성능을 가지게 된다.

2. 열 순회 : 열순회는 캐시 라인을 순회하지 않는다. 메모리 임의접근패턴을 가진다.

3. 행 순회 : 행순회는  캐시 라인을 순회하며 예측 가능한 접근패턴을 만든다.


성능의 우선순위를 구분하면? 3 > 1 > 2의 순서를 가진다. 

 

1-3. TLB 변환 색인버퍼

캐싱 시스템은 하드웨어로 한 번에 64바이트(기계별로 다르다) 씩 데이터를 옮긴다. 운영 체제는 4k 바이트씩 페이징 함으로써 메모리를 관리한다.

 

관리되는 모든 페이지는 가상 메모리주소를 갖게 되는데, 올바른 페이지에 매핑되고 물리적 메모리로 오프셋 하기 위해 사용된다.

여기서 위에서 linkedList 가 중간 성능을 가지는 이유를 알 수 있다.

다수의 노드가 같은 페이지에 있기 때문이다.

 

그렇다면 왜 열순위가  마지막 순위에 도달하는가? 일반적으로 캐시미스, 변환색인버퍼 미스가 둘 다 발생할 수 있는 게 열 순회의 경우이다.

 

위의 1,2,3 모두 지향하는 바는 똑같다. 데이터 지향설계

효율적인 알고리즘에 그치지 않고, 어떻게 데이터에 접근하는 것이 알고리즘 보다 성능에 좋은 영향을 미칠지 고려하는 것

 

배열을 왜 써야 하는지 배열을 쓰면 어떻게 동작하는지에 대해 알아보았다. 실질적으로 코드에서 어떻게 작성하고 선언하는지에 대해 알아보자.

var string [5]string

위와 같이 선언하게 되면 각 배열은 위의 그림과 같은 형태의 제로값으로 설정된다. 문자열은? 포인터와 길이를 표현하는 2 단어로 표현되기 때문이다.

 

fmt.Printf("\n=> Iterate over array\n")
for i, fruit := range strings {
    fmt.Println(i, fruit)
}

해당 코드블록과 같이 

Println을 호출할 때 같은 배열을 공유하는 4개의 문자열을 가지게 되는 것이다. 문자열의 주소를 함수에 전달하지 않으면 이점이 있다. 문자열의 길이를 알고 있으니 스택에 둘 수 있고, 그 덕분에 힙에 할당하여 GC를 해야 하는 부담을 덜게 된다. 문자열은 값을 전달하여 스택에 둘 수 있게 디자인되어

이런 설명이 붙는데 한참 다시 읽어 봤다.

fruit는 string의 값을 하나씩 복사해서 선언된 메모리 주소에 계속 덮어 씌운다. Println 은 Go의 함수와 같이 파라미터로 전달된 값은? value 복사를 하게 된다. 그렇기 때문에 매번 함수가 호출될 때마다 매번 다른 fruit의 값을 프린트할 수 있게 되는 것이고, 

힙에서는 공유되는 fruit에 대해서만 가비지컬렉팅이 발생되고, 프린트 함수에서는? 복사된 값을 가지기 때문에 해당 변수는 스택에 할당된다.

 

슬라이스

make([]string,5)

위의 코드를 실행하면 아래와 같은 이미지의 메모리 할당이 이뤄진다.

여기서 특이한 개념이 나오게 되는데 "길이와 용량이라는 개념이다."

make 함수를 이용해서 slice, map, channel을 생성할 수 있는데 여기서 3번째 키워드는 용량을 나타낸다. 

길이는 포인터로부터 읽고 쓸 수 있는 수를 의미하지만, 용량은 포인터부터 배열에 존재할 수 있는 총량을 의미한다. 

 

var data []string
data2 := []string{}

두 개는 다르다. data는 빈 슬라이스지만 nil 포인터를 갖고 있는 비어이 있는 슬라이스가 된다. 

nil 슬라이스 와 비어있는 슬라이스는 각기 다른 의미를 가진다. 제로값으로 설정된 참조 타입은 nil로 여길수 있다는 점이다. 
marshal에 이 nil과 비어있는 슬라이스를 넘긴다면 json에서는 null과 [] 있는 각각의 슬라이스를 반환하게 된다.

 

append 함수의 특징상 capacity에 다다르면 새로운 메모리 주소를 할당한다.

func Test_SliceReference(t *testing.T) {
	x := make([]int, 7)
	for i := 0; i < len(x); i++ {
		x[i] = i * 100
	}
	twoHundred := &x[1]
	x = append(x, 800)
	x[1]++

	fmt.Println(x[1], *twoHundred)
}

twoHundred와, x [1]은 다른 메모리 주소를 가진다. append를 사용해 7 이 넘은 슬라이스의 상태가 되어 새로운 메모리 주소가 할당된다.

 

UTF8의 경우 지난번 string에 대해서 언급한 것 string을 range로 조회할 때 단어 하나단위로 조회된다고 작성했다.

조금 더 예를 들어서 작성해 보자면

func Test_UTF8(t *testing.T) {
	s := "세계 means world"

	var buf [utf8.UTFMax]byte
	for i, r := range s {
		rl := utf8.RuneLen(r)

		si := i + rl

		copy(buf[:], s[i:si])
		fmt.Printf("%2d: %q: codepoint: %#6x encode bytes : %#v\n", i, r, r, buf[:rl])
	}
}

 0: '세': codepoint: 0xc138 encode bytes : []byte{0xec, 0x84, 0xb8}
 3: '계': codepoint: 0xacc4 encode bytes : []byte{0xea, 0xb3, 0x84}
 6: ' ': codepoint:   0x20 encode bytes : []byte{0x20}
 7: 'm': codepoint:   0x6d encode bytes : []byte{0x6d}
 8: 'e': codepoint:   0x65 encode bytes : []byte{0x65}
 9: 'a': codepoint:   0x61 encode bytes : []byte{0x61}
10: 'n': codepoint:   0x6e encode bytes : []byte{0x6e}
11: 's': codepoint:   0x73 encode bytes : []byte{0x73}
12: ' ': codepoint:   0x20 encode bytes : []byte{0x20}
13: 'w': codepoint:   0x77 encode bytes : []byte{0x77}
14: 'o': codepoint:   0x6f encode bytes : []byte{0x6f}
15: 'r': codepoint:   0x72 encode bytes : []byte{0x72}
16: 'l': codepoint:   0x6c encode bytes : []byte{0x6c}
17: 'd': codepoint:   0x64 encode bytes : []byte{0x64}

한글은 3바이트이다. 보이는가?  또한 가리키는 코드포인트 또한 다르다.

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

Ultimate-Go-04 [디커플링]  (0) 2023.09.13
Ultimate-Go-03  (2) 2023.09.07
Ulitmate-Go-01 (string,메모리 패딩)  (3) 2023.08.19
Go Interface, embedded  (0) 2023.02.28
Effective Go 04  (0) 2023.02.12

7~8월 개발일지에 투표 기능 관련해서 개발을 했었다. 

특정 프로세스 내에서 진행 중인 투표와 관련되어 고 루틴 이 생성되어 투표의 결과를 특정 통계 자료 테이블로 변환시키는 로직을 작성했다.

 

지난주까지 계속 프로세스를 종료하고 올리고, 간단한 수정사항 등이 있어 매번 프로세스를 종료하고 올리고 하게 되어 알아차리지 못했다. 내가 싸놓은 커다란 응가를.....

 

알게 된 시점은 ps aux | grep "내가 개발한 프로세스 이름"을 찍고 나서였다. 이게 웬걸 20% 가 넘는 cpu 사용량과 10% 가 넘는 메모리 사용률이 발생되고 있었다.

이렇게 무거울 수가 없다. msa 가 적용된 프로젝트이기에 다른 프로세스의 평균 cpu 사용량은 0.1 ~ 1%, 메모리 사용량도 비슷하다...

 

회사의 코드를 공개할 수 없어 내가 작성한 비슷한 시나리오를 작성하고 어떻게 해결했는가에 대해 작성하고자 한다.

우선 고 루틴 누수가 발생되고 있는 코드이다.

type list struct {
	signal chan interface{}
	name   int
}
type Handler struct {
	list map[int]list
	sync sync.Mutex
}

Handler와 list는 투표의 실제 핸들러 부분의 구조체가 되어 다양한 함수를 제공한다.

Handler의 sync는 list의 맵의 자료구조에서 키값을 지울 때 뮤텍스 락을 걸기 위해 제공되고 있고 

list 타입은 signal 즉 투표에서 종료시점을 알려주는 신호가 되겠다. name 은 단순 내가 현재 인지하고 있는 고 루틴 즉 map에 의해 관리되고 있는 고 루틴에 대해 트래킹 하기 위해 작성했다.

 

func handlerStream(done <-chan interface{}) <-chan interface{} {
	stream := make(chan interface{})
	ticker := time.NewTicker(2 * time.Second)
	go func() {
		defer func() {
			log.Println("handler stream closed")
			close(stream)
			ticker.Stop()
		}()
		// do something int stream handler
		for {
			select {
			case <-done:
				log.Println("got done signal")
				return
			case <-ticker.C:
				time.Sleep(1 * time.Second)
				stream <- "something on your mind"
			}
		}
	}()
	return stream
}

해당 함수는 실제 나의 개발에서는 데이터 이관하는 부분의 기능을 담당하고 있다. 2초 단위로 신호가 발생되고, 해당 신호에 대해 데이터 이관을 1초의 슬립으로 대체하여 작성하였다. 이관이 완료되면 stream에 데이터를 넘겨주고 있다.

 

func (h *Handler) Handle(a, b int) {
	log.Printf("got %d and %d", a, b)

	if b == 0 {
		// 고루틴 삭제
		if handler, ok := h.list[a]; ok {
			h.sync.Lock()
			close(handler.signal)
			delete(h.list, a)
			h.sync.Unlock()
		}
	} else if b == -1 {
    	// 관리되고 있는 고루틴 트래킹
		for _, v := range h.list {
			fmt.Printf("go routine runngin :%d\n", v.name)
		}
	} else {
		//생성하는 로직
		if _, ok := h.list[a]; ok {
			return
		} else {
			log.Println("create go routine")
			h.list[a] = list{make(chan interface{}), a}
		}

		go func() {
			defer log.Println("go routine done")
			for {
				_, ok := <-handlerStream(h.list[a].signal)
				if !ok {
					return
				}
			}
		}()
	}
}

echo, http를 열어서 작성하기 싫어 해당 Handle 은 커맨드 사용자 인풋에 대해 처리하는 부분이다. 

b 값에 따라 삭제, 생성 또는 현재 관리되는 고 루틴에 대해 트래킹 하는 결괏값을 반환하게 된다.

만약 생성을 하게 되면 고 루틴을 생성해 스트림 함수를 호출해 반환되는 값을 받고, 만약 채널이 닫힌다면 해당 고 루틴은 종료된다.

 

func main() {

	go func() {
		http.ListenAndServe("localhost:4000", nil)
	}()

	handler := NewHandler()
	reader := bufio.NewReader(os.Stdin)
	for {
		var a, b int
		set := make(chan interface{})
		go func() {
			defer close(set)
			fmt.Fscanln(reader, &a, &b)
			set <- "done"
		}()
		<-set
		log.Println("got input")

		handler.Handle(a, b)

		log.Println("cycle done")
	}

	log.Println("go routine done")
}

localhost:4000 번은 고루틴 프로파일링을 위한 셋업이다.

_ "net/http/pprof"

이런 방식의 임포트를 거쳐 위와 같이 선언하게 되면 4000/debug/pprof 에서 확인 가능하다.

 

핸들러를 생성해 무한 반복문으로 사용자의 데이터 값을 가져오고 핸들러의 핸들을 입력받은 값으로 호출하게 된다.

여기서 사용자의 입력은 실제 개발 코드에서 들어오는 시그널, api 요청 등의 역할하게 된다.

 

코드실행

1,2의 인풋을 넣고 고 루틴이 생성되고 한 번의 for문 사이클이 종료된다. 이후 2,2 동일한 작업과 로그가 발생되고

현재 맵에서 관리되는 고 루틴의 확인을 위해 -1 -1을 집어넣어 확인결과 2개의 고 루틴을 트래킹 하고 있다.

 

http://localhost:4000/debug/pprof/goroutine?debug=1

프로파일링의 결과를 보면 오우..... 총 66개 가 돌아가고 Handler의 Handle 은 2개가 된다.

 

Handler의 Handle 은 내가 의도한 결과이다. 2번의 인풋과 생성이 발생되어 2개의 고 루틴이 생성되어야 한다.

그러나 stream 또한 2개의 값이 필요하지만 지금 이 순간도 고 루틴은 증가되고 있다.

 

문제의 코드를 보면 Handle 함수의 생성, stream 함수의 2초마다 보내는 라인을 확인해야 한다.

아... 정말 멍청했다. 우측 for 문 안쪽에 보면 handleStream(시그널)에서 채널링 값을 받아와 해당 채널이 닫힌여부에 따라 고 루틴을 종료한다. 

 

해당 문제를 해결하기 위해 다양한 방법을 시도했다. 

1. range로 스트림 데이터 뽑아오기

이 경우 매우 제대로 작동한다. 그러나 해당 스트림에서는 데이터 값을 뽑아서 무언가를 하고 싶지 않아 기존 코드를 위와 같이 변경한 것이다.

여기서 그렇다면 done의 신호가 올바르게 받지 못해서 생성되는 건가?라는 말도 안 되는 생각을 했다. 

 

2. done의 신호 세분화 해서 작성

위와 같은 방식으로 변경하고 테스트했으나 실패했다. 기존 for 반복문을 활용한 스트림 데이터 받아오는데 계속 생성되고 있다...

여기서 멘털이 터진 건지 여러 개를 변경하고 테스트를 시도한다. 
일반적으로 생각했더라면 done의 신호가 닫힌다면 ok의 여부에 상관없이 기존코드 와 동일한 동작을 한다.

 

3. 왜 스트림 패턴을 적용했는가?

- 원인을 드디어 찾게 되었다. 스트림 패턴을 적용하게 되면 해당 함수 호출부는 호출만 해서 쓰면 된다. for 연속으로 쓸 것이 아니라...

go func() {
    defer log.Println("go routine done")
    stream := handlerStream(h.list[a].signal)
    for {
        v, ok := <-stream
        if !ok {
            log.Println(v)
            return
        }
    }
}()

단순하게 스트림의 변수 선언을 for 반복문 밖으로만 빼주면 된다.
코드를 실행해서 확인해 보자.

총 5개의 고 루틴을 트래킹 하고 있으며 핸들러, 스트림 모두 5개로 동일하다. 

 

저 위의 방식이 된다면 당연히 range 방식도 동일하게 적용된다.

두 개의 경우 모두 4번을 지워보자. 

완벽하게 지워진다. handler 4개, stream 4개

 

미숙한 고 루틴에 대해서 작성하고 관리하려다 보니 생각보다 많이 어려웠고, 내가 관리하는 고 루틴과 시스템에서 실행되는 고 루틴의 숫자는 이번 테스트 서버와 같이 다를 수 있다. 

고퍼에게 정말 말도 안 되는 기본적인 실수라고 생각된다. 정말 운이 좋아 발견되어 다행이라고 생각한다.

추후 스트림의 파이프라인으로 고 루틴을 구성할 때 정말 조심하고 사용에 있어 수십 번을 고민하자.
고 루틴 사용함에 있어 항상 제공되는 프로파일링을 적용해서 테스트를 필수로 해야겠다고 생각된다.

 

생각보다 프로파일링에서 제공해 주는 트레이스 가 너무 완벽했다. 주말에 시간 내서 고에서 작성한 프로파일링에 대해 블로그 글을 읽고 작성하고자 한다.

프로파일링에 대해 단 한 번도 고려해 본 적 이 없었는데.. 만들어놓은 이유가 있다.

정말이지 프로파일링 이 없었더라면 오늘 퇴근을 못하지 않았을까 싶다.

식은땀이 여러 번 흐르는 하루였다.

 

코드전문 : 

package main

import (
	"bufio"
	"fmt"
	"log"
	"net/http"
	_ "net/http/pprof"
	"os"
	"sync"
	"time"
)

type list struct {
	signal chan interface{}
	name   int
}
type Handler struct {
	list map[int]list
	sync sync.Mutex
}

func handlerStream(done <-chan interface{}) <-chan interface{} {
	stream := make(chan interface{})
	ticker := time.NewTicker(2 * time.Second)
	go func() {
		defer func() {
			log.Println("handler stream closed")
			close(stream)
			ticker.Stop()
		}()
		// do something int stream handler
		for {
			select {
			case _, ok := <-done:
				if !ok {
					return
				}
				log.Println("got done signal close")
				return
			case <-ticker.C:
				time.Sleep(1 * time.Second)
				stream <- "something on your mind"
			}
		}
	}()
	return stream
}

func (h *Handler) Handle(a, b int) {
	log.Printf("got %d and %d", a, b)

	if b == 0 {
		log.Println("got 0")
		if handler, ok := h.list[a]; ok {
			h.sync.Lock()
			close(handler.signal)
			delete(h.list, a)
			h.sync.Unlock()
		}
	} else if b == -1 {
		for _, v := range h.list {
			fmt.Printf("go routine runngin :%d\n", v.name)
		}
	} else {
		//생성하는 로직
		if _, ok := h.list[a]; ok {
			return
		} else {
			log.Println("create go routine")
			h.list[a] = list{make(chan interface{}), a}
		}
		go func() {
			defer log.Println("go routine done")
			for _ = range handlerStream(h.list[a].signal) {
			}
		}()
	}
}

func NewHandler() *Handler {
	return &Handler{
		list: make(map[int]list),
		sync: sync.Mutex{},
	}
}

func main() {

	go func() {
		http.ListenAndServe("localhost:4000", nil)
	}()

	handler := NewHandler()
	reader := bufio.NewReader(os.Stdin)
	for {
		var a, b int
		set := make(chan interface{})
		go func() {
			defer close(set)
			fmt.Fscanln(reader, &a, &b)
			set <- "done"
		}()
		<-set
		log.Println("got input")

		handler.Handle(a, b)

		log.Println("cycle done")
	}

	log.Println("go routine done")
}

깃 : https://github.com/Guiwoo/go_study/blob/master/concurrency/main.go

+ Recent posts