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

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

 

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

 

배열 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

프로젝트를 하면 할수록, 내가 명확하게 무엇을 아는가? 에 대해서 고민하게 되었고. Ultimate Go라는 글을 읽어보고자 한다. 

사실 Go Slack에서 물어보니 이거랑, Go in Action을 추천해 주더라 먼저 Ultimate Go를 읽어보자. 

문법

- 지금 와서 생각해 보면 바이트, 비트의 개념이 생각보다 많이 없지 않았나 싶다. 

1바이트 => 8비트이다. 그렇다면 +- 부호비트를 제외하고 7자리 최댓값은 2의 7승 127까지 표현된다.  특히나 고에서는 이러한 표현에 있어 그냥 넘어가는 게 아닌 기민하게 받아들여야 한다.

생성되는 모든 변수는 초기화되어야 한다. 어떤 값으로 초기화할지 명시하지 않는다면, 제로값으로 초기화된다. 할당된 메모리의 모든 비트는 0으로 리셋된다.

- 사실 저 부분 4번 읽어봤다. 이해한 바에 따르면 언어의 변수를 생성하고 값을 명시하지 않는다. 예를 들어 

var a int64라고 가정한다면 이는 8바이트짜리 메모리 공간을 할당한다.

즉 메모리의 시작주소와 크기가 정해져 있지만 값을 할당하지 않아 8바이트 의 모든 비트는 00000000..... 0000으로 표기된다는 의미이다.

 

문자열은 uint8 타입의 연속이다. 

- 문자열 가지고 for 문 돌려보면 rune 타입으로 반한 된다, 여기서 rune 은 int32의 alias 별칭타입이다 그냥 숫자 덩어리이다. 

그러면 의문이 생긴다. uint8과 rune 은 엄연히 다른 타입 즉 메모리 사이즈 가 다르다. 왜 다를까?

 

1. 문자열 : uint8 타입의 연속이고 이는 UTF8 인코딩 된 문자를 나타낸다.

2. rune : unit32의 별칭이고, 주로 Unicode 코드포인트를 표현하는 데 사용된다.

 

다시 말하자면 utf8 인코딩 은 다양한 길이의 바이트 시퀀스를 이용해 유니코드 문자를 나타낸다. 

어떤 문자는 1바이트, 어떤 문자는 2,3,4 바이트로 나타내야 할 수도 있다. 그렇기 때문에 for 문을 이용해서 range 처리를 하게 되면

uint8로 표현을 하게 되면 올바르게 utf-8 을표현할 수가 없다. 따라서 rune을 이용해 반환하게 된다. 

 

유니코드 : 세계의 모든 문자를 컴퓨터에서 일관되게 표현하고 다룰 수 있도록 설계된 산업표준으로 코드포인트가 존재한다.
예를 들어 ) ㅇ : U+3147 , 안 
UTF-8 : 유니코드를 바이트 시퀀스로 인코딩하는 방식이다.
func Test_String_Rune(t *testing.T){
	a := "안녕"
}

위와 같이 a라는 값이 할당되면 우리의 명석한 고 컴파일러 님은 

1. 안과 녕 에 해당하는 유니코드 포인트로 해석을 하고 

2. 해석된 값을 UTF-8로 인코딩을 하고 (한글은 3바이트) 총 6바이트 길이의 배열이 필요하고 이에 메모리 할당을 하고

3. 문자열 a는 해당된 문자열 바이트 의 주소 시작값을 가지고, 6이라는 사이즈의 길이를 가진다.

이렇게 되면 len(a) 문자열의 길이를 추출하게 될 때 이는 6이라는 숫자를 반환한다.

실제 "안녕"에 대해 추출하고 싶을 때 rune을 이용하게 된다.

다시 말해 

a의 길이를 뽑으면 6이 될 것이고, rune 으로 변환해서 조회한다면 2라는 크기가 나온다.

for 루프를 이용해서 a를 조회하면 총 6번의 값을 조회해서 나타내주고, for... range를 이용해서 조회하면 유니코드 기준으로 조회되어 2번의 값을 조회해서 나타내준다.

for loop의 바이트 시퀀스 조회하는 것의 복잡성을 숨기고자 for... range는 문자열을 더 자연스럽게 순회가 가능하다.

func Test_Variable_String_Rune(t *testing.T) {
	a := "안녕"

	for i := range a {
		fmt.Printf("%v ", a[i])
	}
	fmt.Println("------------------")
	for i := 0; i < len(a); i++ {
		fmt.Printf("%v ", a[i])
	}
	fmt.Println()
}

그렇다면 for loop 의 바이트를 문자열로 조회하고자 한다면? 

func Test_Variable_String_Rune(t *testing.T) {
	a := "안녕"

	for i := 0; i < len(a); {
		r, size := utf8.DecodeRuneInString(a[i:])
		fmt.Printf("%d\t%c\n", i, r)
		i += size
	}
}

이렇게 조회를 하게 되면 "안", "녕"으로 조회가 가능하다. 해당 함수 utf8.DecodeRuneInString(s string)를 살펴보면

내부적으로 단순하게 주어진 s에 대해서 0~3까지 총 4개의 자리에 대해서 탐색을 한다. 즉 4바이트 탐색을 실시하고, 

그거에 맞춰 rune과 크기 값을 반환한다. 각 바이트 별 검사하는 로직은 비트연산과 미리 정해놓은 상수값들을 이용해 비교하고 반환을 해주는데 생각보다 코드가 어지럽지만 해당 함수가 어떻게 검사하고 반환하는지에 대해 살펴보았다.

 

구조체 선언과 구조체 패딩

- 구조체에 할당된 메모리에는 메모리 패딩이라는 것이 존재한다.

func Test_Memeory_Address(t *testing.T) {
	type ex struct {
		counter int64
		pi      float32
		flag    bool
	}

	type ex2 struct {
		flag    bool
		counter int64
		pi      float32
	}

	var e ex
	var e2 ex2

	fmt.Println(unsafe.Sizeof(e), unsafe.Sizeof(e2))
}

 

 

해당 테스트의 결괏값으로 e는 16 e2는 24의 메모리 사이즈를 가져간다 왜 동일한 구조체에 서로 다른 메모리 사이즈일까? 고 언어 에는 메모리패딩을 주어 cpu 가 각 경계별로 손쉽게 읽을 수 있도록 해주고 있다. 

 

ex1의 경우 8바이트(counter) + 4바이트(pi) + 패딩 3바이트 + 1바이트(flag) => 이렇게 총 16바이트가 된다.

ex2의 경우 1바이트(flag) + 7바이트(패딩) + 8바이트(counter) + 4바이트(pi)+4바이트(구조체패딩) => 이렇게 24바이트가 된다.

 

구조체 패딩은 구조체 내에 가장 큰 바이트 기준의 배수로 구조체가 정렬되어야 하기 때문에 4바이트의 추가 패딩이 붙는다.

구조체의 패딩을 제외한다면 실제 구조체의 패딩에 각각 3,7 바이트의 차이가 존재한다.

 

구조체를 뭐 어마무시하게 많은 필드를 넣어서 생성하지는 않겠지만 이러한 규칙이 있어 메모리의 효율적인 사용을 위한다면 가장 큰 메모리를 앞단에 위치시켜야 한다는 사시을 알아야 한다. 

 

포인터 항상 값을 전달한다

- 함수는 함수자체적으로  스택프레임을 가진다. 즉 함수 내에서 사용되고자 하는 값들에 대해 스택프레임에 위치해 해당 함수를 호출하면 모두 제로값으로 초기화되고 함수가 종료되면 스택 은알아서 정리가 된다. 이에 따라 다른 함수에서 해당 값에 접근을 할 수가 없다.

이에 값의 공유 고 루틴(고 루틴 또한 일반적으로 2K 스택메모리를 가지게 된다.) 이러한 고 루틴의 스택들 간에 값을 공유하기 위해서는?

포인터라는 값을 공유해야 한다.

func stayOnStack() user {
	u := user{
		name:  "Ho",
		email: "email",
	}
	return u
}

func escapeToHeap() *user {
	u := user{
		name:  "Ho",
		email: "email",
	}
	return &u
}

해당 함수들을 보면 stayOnStack의 경우 해당 값을 반환함과 동시에 함수 내의 u는 스택에 적재되어 있다가 한 번에 같이 사라지게 된다.

반면 esacpeToHeap을 보면 u의 주소값은 함수 밖을 나와 메모리의 값이 반환되어 스택이 아닌 힙에 적재된다.

이를 go에서는 이스케이프 분석이라고 하며, 변수의 생명주기를 컴파일러가 스택에 넣을지 힙에넣을지 여부를 판단하여 할당하는 것을 의미한다.

func Test_Pointer_Address(t *testing.T) {
	fmt.Println(stayOnStack())
	fmt.Println(escapeToHeap())
	// go test -gcflags '-m -l' advance/variable_test.go
}

go test -gcflags=' -m -l' variable_test.go

위에 테스트를 제시된 코드로 실행하게 되면 아래와 같은 결과를 받을 수 있다.

./variable_test.go:61:25: stayOnStack() escapes to heap
./variable_test.go:62:13:... argument does not escape

음? stayOnStack() 은 heap으로 빠지면 안 된다 왜 빠진 거고, 포인터를 반환하는 escapeToHeap 은 왜 이스케이프 되지 않았는가? 
fmt.Println()의 함수 인자값으로 넘길 때 해당 인자를 힙으로 이스케이프 되었고, escapeToHeap의 경우는 이미 힙으로 이스케이프 되어있기 때문에 할 필요가 없어 위와 같은 메시지를 받게 되는 것이다.

 

생각보다 모르는 부분이 많았고, 정말 언어 개발자들은 천재이지 아닌가 싶다.

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

Ultimate-Go-03  (2) 2023.09.07
Ulitmate-Go-02  (0) 2023.09.05
Go Interface, embedded  (0) 2023.02.28
Effective Go 04  (0) 2023.02.12
Effective Go 03  (0) 2023.02.10

5장은 추후 정리해서 올리고자 한다.

 

"동시성을 지원하는 언어의 장점" , OS 스레드 의 다중화를 위해 고 컴파일러는 "작업 가로채기" 전략을 사용한다 (work-strealing)

작업 가로채기 전략에 대해 알아보자.

 

1. 직관전인 스케쥴링 방법

공정한 스케쥴링을 상식적으로 한번 적용해 보자. 사용가능한 프로세스에 작업들을 나누어서 할당한다. 가장 이상적이고 직관적인 방식이다.

수행하는 프로세스 가 n 개 와 x 의 작업이 있다면 각 프로세스는 x/n 만큼 할당해서 작업을 수행하면 된다.

 

Go의 동시성 전략은 "fork-join 방식이다."

 

fork-join 모델 은 단순 어느 시점에서 분기가 나누어지며 미래의 특정 어느 합류지점이 생겨 메인 작업에 합류하는 것을 의미한다. 이러한 모델이 사용된다면 작업들이 서로 의존적일 확률이 매우 높아지게 되고 위에서 제시한 공정한 스케쥴링 방식에는 상당한 문제점이 발생된다.

P1, P2의 프로세스가 존재하고 해당 프로세스에서 각각 작업 a, b, c, d를 한다고 가정해 보자.

시간 P1 P2
  a b
n (대기) b
n+k (대기) c
n+k+m d 대기

이렇게 대기하는 시간이 생각보다 많이 발생하게 된다. 이를 해결하기 위해 fifo 대기열을 적용해서 중앙 집중식으로 구성해서 본인이 가능할 때 작업을 빼가는 방식을 적용하는 것이다.

 

2. 중앙 집중식 대기열

 

각 프로세스는 처리용량이 허용될 때 대기열에서 작업을 꺼내오고 그렇지 않으면? 조인에서 대기하고 있는다. 이는 1에서 제공하는 단순한 작업을 나누는 것보다는 나은 방법을 제공해 준다. 

하나 이에 대해 문제점이 다시 존재한다. 중앙대기열이라는 임계지점을 계속 들락날락해야 하기 때문에 이에 해당하는 비용이 든다.

뿐만 아니라 캐시의 지역성에도 문제가 발생해 캐시 미스가 자주 발생할 수 있는 가능성이 농후해진다.

 

3. 작업 대기열을 분산시키는 방법 - 작업 가로채기

위와 같이 프로세스가 자체적으로 큐를 가지는데 해당 큐는 dequeue 성질을 가지고 있다. 

고의 런타임은 포크조인 모델 방식을 따른다. 따라서 포크가 발생되는 지점이 생긴다면? 해당 스레드와 관련된 데큐의 끝 즉 꼬리에 추가된다.

 

이후 대기 중인 스레드가 작업을 가로채기를 할 때는 아래와 같은 규칙을 따르게 된다.

1. 스레드가 현재 처리할 작업이 없을 때 다른 스레드의 데큐에서 작업을 가로챈다.

2. 대기 중인 스레드는 주로 다른 스레드 데큐의 앞쪽(머리)에서 작업을 가로챈다.

 

스레드의 데큐 양쪽 이 모두 비어있는 경우

1. 조인을 지연시킨다.

- 지연을 시킴에 따라 2번 즉 다른 스레드의 데큐 앞쪽을 가로챌 수 있다.

- 합류에 대한 오버헤드를 최소화하기 위해 조인을 지연시킨다.

2. 임의의 스레드 데큐의 앞쪽 작업을 가로챈다.

 

코드로 표현해 보자.

func Test_runtime_go_01(t *testing.T) {
	var fib func(n int) <-chan int
	fib = func(n int) <-chan int {
		result := make(chan int)
		go func() {
			defer close(result)
			if n <= 2 {
				result <- 1
				return
			}
			result <- <-fib(n-1) + <-fib(n-2)
		}()
		return result
	}
	fmt.Printf("fib(4) result : %d ", <-fib(4))
}

이 프로그램이 두 개의 단일코어 가 존재하는 가상머신에서 돌아간다고 가정해 보자.

프로세스 1에 대해 T1, 프로세스 2에 대해 T2를 할당한다고 가정해 보자.

T1 호출스택 T1작업데큐 T2 호출스택 T2작업데큐
main 고루틴      
main 고루틴 fib(4)    
main 고루틴 합류지점
fib(4) -> 가로채기 성공
     
main 고루틴 합류지점
fib(4) -> 가로채기 성공
fib(3)
fib(2)
   
main 고루틴 합류지점
fib(4) -> 가로채기 성공
fib(2) fib(3)  
main 고루틴 합류지점
fib(4) -> 합류지점
fib(2) -> return 1
  fib(3)
fib(1)
main 고루틴 합류지점
fib(4) -> 합류지점
fib(2) from t2 -> return 1
  fib(3) -> 합류지점
fib(1) -> return 1


main 고루틴 합류지점
fib(4) -> 합류지점
  return 2  
main 고루틴 합류지점
fib(4) -> return 3
     
return 3      

서로의 스택에서 합류지점이 발생할 때 각각의 데큐에서 꺼내가는 것이 핵심 포인트이다.

 

4. 작업-연속 가로채기

고에서는 위에서 제시되는 일반적인 가로채기 알고리즘이 아닌 연속 가로채기 알고리즘을 구현하고 있다.

위에서 제시된 방법은 스레드가 쉬지 않고 돌아가는 장점이 있다 다만 지속적인 합류의 지점이 발생해 조인을 위한 지연이 발생된다. 

이 문제를 적절하게 해결하기 위해 연속 가로채기 알고리즘을 적용한다.

 

연속 가로채기 란 

기본적으로 가로채기의 개념을 확장한 내용이다. 하나의 작업이 아닌 연속 부분을 가로채는 것이다.

 

여기서 연속이란? 

포크 조인 모델 에는 2가지 옵션 작업, 연속의 개념이 존재한다.

작업 : 포크과정에서 분리되어 병렬로 실행되는 단위, 독립적으로 수행되는 특징

연속:  작업이 끝나고 수행되어야 하는 추가적인 단계나 계산을 의미 "두 하위 작업의 결과를 합쳐서 최종결과를 얻는 과정"

 

위와 동일한 작업을 표로 다시 한번 알아보자.

T1 호출스택 T1 작업데큐 T2 호출 스택 T2 작업데큐
main      
fib(4) main 연속    
fib(4)   main 연속(T1 작업데큐 가로챔)  
fib(3) fib(4) 연속    
fib(3)   main 연속 대기
fib(4) 연속 (T1 작업데큐 가로챔)
 
fib(2) fib(3) 연속 main 연속 대기
fib(2) (fib -2 두번째 연산)
fib4 연속
return 1   main 연속 대기
return 1
 
return 1 + fib(1)   main 연속 대기
fib4 연속
 
return 2   main 연속
fib4 연속
 
    main 연속
return 3 (T1의 결과 t2 의결과)
 
    main(3)  

이렇게 변경된 작업 가로채기는 T1의 호출스택을 보면 마치 함수의 콜스택 처럼 되어 있다 T1의 콜체인은

fib(3) fib(2) fib(1) 이 되는 방식을 주목해서 보면 되겠다.

 

이렇게 변경된 작업가로채기 에는 연속되어 있기에 실힁 순서가 보장되며, 합류지점의 지연이 없다는 장점이 존재한다.

기존 가로채기에서는 호출스택, 작업 중인 스레드의 지연된 조인 횟수 가 연속 가로채기보다 많이 존재한다.

 

해당장의 실제 페이지는 상당히 적지만 너무 추상적인 내용들이라 수십 번을 읽어보아야만 했다.

고 루틴이 작업 가로채기 방식을 적용하면서도 스레드 간의 합류 비용 즉 조인의 지연을 없애기 위해 확장한 개념 연속된 가로채기와 

왜 다른 언어에서는 할 수 없는지(go 의 컴파일러 때문에 가능함). 등에 대해 알 수 있었다.

 

위에 제공된 모든 혜택들을 우리는 고퍼이기에 go keyword를 통해  가장 효율적인 방식으로 작업이 자동으로 스케쥴링된다.

템플릿 패턴에 대해 자료를 조사하고, 예제를 만들면서 느낀 점은 확실히 전략패턴과 많이 유사한 느낌을 가지고 있다.

팀장님의 손길이 닿아있는 프로젝트 라면 라우팅 하는 대부분의 부분은 이 템플릿 메소드 부분이 적용되어 있는데 

간략한 버전의 프레임워크를 작성해보고자 한다.

 

템플릿 메서드 패턴 이란 ? 

템플릿 메소드 패턴 은 소프트웨어 공학에서 동작 상의 알고리즘의 프로그램 뼈대를 정의하는 행위 디자인 패턴이다.
 알고리즘의 구조를 변경하지 않고 알고리즘의 특정 단계들을 다시 정의할 수 있게 해 준다. - wiki- 

템플릿 메서드는 부모 클래스에서 알고리즘의 골격을 정의하지만, 해당 알고리즘의 구조를 변경하지 않고 자식 클래스들이 알고리즘의 특정 단계들을 오버라이드​(재정의)​할 수 있도록 하는 행동 디자인 패턴입니다. -wiki-

기존 전략패턴 에 대해 공부했던 지난번의 기억을 살려보면 무언가 많이 의미가 비슷하다. 

"전략패턴" : 실행 중 알고리즘을 선택할 수 있게 하는 행위라고 지난번 위키에서는 정했다. 음? 설명이 엄청 비슷하다고 느껴진다. 

 

UML을 확인해 보자.

좌측 : 전략패턴 / 우측 : 템플릿 메소드 패턴

지난번 전략패턴을 구현하는 데 있어 우리는 객체에 Strategy 인터페이스를 주입받는 알고리즘 형태에 따라 기존 객체는 변경되는 형태를 가져갔다.

그러나 이번 템플릿 메서드 패턴은 상위 구현체 ? 에서 메소드를 사용하는데 그에 대해 상속된 객체는 해당 메소드를 오버라이드 해서 작성한다. OOP 언어 라면 위와같은 오버라이드 기능이 있겠지만? 우리 고퍼 들에게는 인터페이스 의 주입이 필요하다.

이는 코드 예제를 보면서 확인하자.

 

왜 템플릿 메소드 패턴이 생겼는가?

 

1. 여러 클래스가 거의 비슷하거나 같은 로직이나 연산을 수행하지만, 다소 차이점이 존재하는 경우
2. 중복 코드를 피하고 로직을 재사용하려는 경우
3. 알고리즘의 특정 단계를 반드시 오버라이드 해야 하는 경우

 

이유가 정말 명확하다. 중복되는 로직은 상위단계로 추상화하고 서로 다른 부분의 로직을 하위 상속객체에게 위임한다.

다시 말해 SOLID의 원칙 중 IOC 제어의 역전에 있어 엄청난 강점이 있다고 생각되는 부분이다. 왜?

하위 상속 혹은 오버라이드 하는 객체에서는 해당 함수의 호출 은 상위 클래스에서 전체 흐름을 제어하기 때문이다.

 

템플릿 메서드의 장점

1. 클라이언트들이 대규모 알고리즘의 특정 부분만 오버라이드하여 다른 부분에 변경하여 발생하는 영향을 덜 받도록 할 수 있다.

2. 중복 코드를 부모 클래스로 가져올 수 있다.

 

1번의 장점을 읽다 보면 어떤 개념들과 매우 유사하다. 바로 프레임워크이다. 프레임워크는 정해진 틀에 따라 우리가 작성한 코드들을 가져다 쓴다. 그래서 스프링 프레임워크를 공부하다 보면 특징 중 하나를 IOC라고 말한다. 왜? 프레임워크 이기 때문이다. 

물론 프레임워크 들은 템플릿 메서드 패턴 과 팩토리 메소드 추상팩토리 가 복합적으로 적용된 복잡한 틀이겠지만, 왜 이런 프레임워크들의 특징 중 하나로 IOC 가 나오는지 이해할 수 있으면 된다고 생각된다.

 

예제

고 에는 다양한 웹프레임워크 가 존재하지만 자체 http 패키지도 매우 훌륭한데 이 기본 패키지를 이용해서 나만의 작은 프레임워크를 작성해 보자.

개괄적인 콜스택과 구조를 그림으로 표현했다.

http.NewServeMux를 통해 개별적인 핸들러를 가진다. 해당 핸들러는 라우팅을 커스텀하게 할 수 있고

라우팅 할 때 서비스핸들러를 이용해 각각의 요청에 따른 프로세스를 진행시켜 준다. 코드를 확인해 보자.

package template

import (
	"fmt"
	"log"
	"net/http"
	"time"
)

/**
무언가 만드는 방법이 유사하다.
*/

type ServiceTemplate interface {
	IsRequireAuth() bool
	GetRequest() error
	GetParam() error
	Process() error
}

type ServiceHandler struct {
	service ServiceTemplate
}

func (s *ServiceHandler) Init(service ServiceTemplate) *ServiceHandler {
	s.service = service
	return s
}
func (s *ServiceHandler) ParentsFeature01() {
	fmt.Println("부모의 기능01")
}
func (s *ServiceHandler) ParentsFeature02() {
	fmt.Println("부모의 기능02")
}

func (s *ServiceHandler) Run(w http.ResponseWriter, r *http.Request) {

	s.ParentsFeature01()
	s.ParentsFeature02()

	if s.service.IsRequireAuth() {
		fmt.Println("인증이 필요한경우 여기서 구현")
	}
	if err := s.service.GetRequest(); err != nil {
		fmt.Println("GetRequest 에러 처리")
	}
	if err := s.service.GetParam(); err != nil {
		fmt.Println("GetParam 에러 처리")
	}
	if err := s.service.Process(); err != nil {
		fmt.Println("Process 에러 처리")
	}
}

type ServiceGetTest struct{}

func (s ServiceGetTest) IsRequireAuth() bool {
	//TODO implement me
	panic("implement me")
}

func (s ServiceGetTest) GetRequest() error {
	//TODO implement me
	panic("implement me")
}

func (s ServiceGetTest) GetParam() error {
	//TODO implement me
	panic("implement me")
}

func (s ServiceGetTest) Process() error {
	//TODO implement me
	panic("implement me")
}

var _ ServiceTemplate = (*ServiceGetTest)(nil)

type ServicePostTest struct{}

func (s ServicePostTest) IsRequireAuth() bool {
	//TODO implement me
	panic("implement me")
}

func (s ServicePostTest) GetRequest() error {
	//TODO implement me
	panic("implement me")
}

func (s ServicePostTest) GetParam() error {
	//TODO implement me
	panic("implement me")
}

func (s ServicePostTest) Process() error {
	//TODO implement me
	panic("implement me")
}

var _ ServiceTemplate = (*ServicePostTest)(nil)

type MyContext struct {
	mux     *http.ServeMux
	service *ServiceHandler
}

func (m *MyContext) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	m.mux.ServeHTTP(w, r)
}
func (m *MyContext) InitRouting() {
	m.mux.HandleFunc("/get", m.service.Init(ServiceGetTest{}).Run)
	m.mux.HandleFunc("/post", m.service.Init(ServicePostTest{}).Run)
}

func ProcessService() {

	myHandler := &MyContext{
		mux:     http.NewServeMux(),
		service: &ServiceHandler{},
	}

	http.NewServeMux()
	myHandler.InitRouting()

	s := &http.Server{
		Addr:           ":9300",
		Handler:        myHandler,
		ReadTimeout:    5 * time.Second,
		WriteTimeout:   5 * time.Second,
		MaxHeaderBytes: 1 << 20,
	}

	log.Fatal(s.ListenAndServe())
}

ServiceTemplate라는 인터페이스를 정의하고, IsRequireAuth, GetRequest, GetParam, Process 같은 메서드들이 있다. 

이들은 템플릿 메소드 패턴에서 "템플릿 메서드"의 단계들이라 할 수 있다.

type ServiceTemplate interface {
	IsRequireAuth() bool
	GetRequest() error
	GetParam() error
	Process() error
}

 

ServiceHandler 타입은 ServiceTemplate 인터페이스를 멤버로 갖는 구조체다. ServiceHandler는 또한 초기화를 위한 Init 메서드와 ParentsFeature01, ParentsFeature02 (공통 기능을 구현하는 몇 가지 메서드), 그리고 Run 메서드를 가진다.
Run 메서드는 결국 템플릿 알고리즘을 실행하며, 각각의 인터페이스 메서드들을 호출한다.

type ServiceHandler struct {
	service ServiceTemplate
}

func (s *ServiceHandler) Init(service ServiceTemplate) *ServiceHandler {
	s.service = service
	return s
}
func (s *ServiceHandler) ParentsFeature01() {
	fmt.Println("부모의 기능01")
}
func (s *ServiceHandler) ParentsFeature02() {
	fmt.Println("부모의 기능02")
}

func (s *ServiceHandler) Run(w http.ResponseWriter, r *http.Request) {

	s.ParentsFeature01()
	s.ParentsFeature02()

	if s.service.IsRequireAuth() {
		fmt.Println("인증이 필요한경우 여기서 구현")
	}
	if err := s.service.GetRequest(); err != nil {
		fmt.Println("GetRequest 에러 처리")
	}
	if err := s.service.GetParam(); err != nil {
		fmt.Println("GetParam 에러 처리")
	}
	if err := s.service.Process(); err != nil {
		fmt.Println("Process 에러 처리")
	}
}


여기서 Init 은 포인터 자체를 반환하는데 빌더패턴의 체이닝 방식을 적용하기 위해서 위와 같이 선언하였다.

func (m *MyContext) InitRouting() {
	m.mux.HandleFunc("/get", m.service.Init(ServiceGetTest{}).Run)
	m.mux.HandleFunc("/post", m.service.Init(ServicePostTest{}).Run)
}

 

 

 

이렇게 간단한 예제를 작성했다.

 

전략패턴과 템플릿메서드 패턴의 명확한 차이가 여기서 드러난다. 또한 템플릿과 팩토리 패턴 의 밀접한 연관관계를 느낄 수도 있다. 

추상클래스 가 없는 고에서는 인터페이스를 임베딩 해서 사용해야 하는데 상당 부분이 팩토리메서드 패턴을 구현하는 부분과 유사하다.

 

위에 작성된 예제가 우리 팀장님이 작성한 코드에서 보이는 방식의 라우팅과 핸들러 함수 작성 방법이다.

이 템플릿 메서드의 단점 중 하나는 중간 인터페이스 즉 ServiceTempalte의 인터페이스 변경된다 면 엄청나게 많은 부분을 수정해야 한다. 이미 진행되었던 프로젝트 라면? 상당한 api 가 구현된 상태라면? 모조리 구현해주어야 한다. ㅠ

 

안 그래도 지난 개발에서 이 문제에 대해서 겪었고 bool 타입을 리턴해 훅? 비슷한 개념으로 하위에서 구현된 지 여부에 따라 상위 함수를 호출할지 아니면 구현체에서 호출할지 분기를 나누어 주었는데 상당히 고생했다.

 

디자인패턴을 공부하면 할수록 코드의 아키텍처 가 얼마나 중요한지 매번 느낀다. 

아키텍처에 따라 mock tdd의 여부도 가능할뿐더러, 이런 패턴의 적용 또한 분리 추상화가 손쉽게 가능하다. 그게 아니라면... 너무 많은 부분을 수정해야 하고 수정이 많으면 많을수록? 사이드 이펙트는 스노볼 마냥 커지고..

나중에는 감당하지 못해 롤백을...

 

 

 

엔진엑스를 사용하지 않고 에코에서 는 리버스 프록시 미들웨어를 제공해 준다. 해당 기능을 이용해서 작성해 보자.

그전에 프록시 에 대해 보다 명확하게 하고 넘어가자.

 

프록시 란?

프록시란? 대리라는 의미로 주로 네트워크 상에서 통신이 불가능한 두 점 사이를 통신 가능하게 만들어 주는 중계기 역할을 의미한다.

그 외에도 프록시는 보안, 로드밸런싱, 캐싱 다양한 기능을 제공한다.

먼저 그중 자주 언급되는 2가지에 대해 알아보자.

1. 로드벨런싱 :  여러 서버 사이의 트래픽을 분산시켜 서비스의 안정성과 성능을 향상한다.

예를 들어 8080 8081 이렇게 두 개의 포트로 동일한 서버가 존재하고 있다면 프로시 서버에서는 하나의 요청을 통해 어디 포트로 요청할지
특정 알고리즘을 활용해 각 서버의 트래픽 부하를 분산시켜 주는 것을 생각하면 편하다.

2. 캐싱 : 자주 요청되는 데이터 또는 특정 데이터들을 프록시 서버에 보관해 리소스를 효율적으로 사용하며 성능을 향상한다.

외부서버 와 데이터 통신을 줄여 네트워크의 병목현상을 방지하는 효과도 얻을 수 있게 된다.

 

(위 2가지의 기능을 본다면 어떤 특정 소프트웨어가 떠올라야 한다.)

 

이러한 프록시에는 2가지 종류가 있다. 여기서 보안의 목적이 나온다.

 

1. 포워드 프록시

포워드 프록시는 그림에서 보는것처럼 요청자,클라이언트 들은 인터넷에 직접 요청을 하는것이 아닌 프록시 서버가 요청을 받아 해당 인터넷 요청 결과를 전달해주는 것이다.

딱 봐도 저 박스 안에 갇혀있는 유저들을 관리하기 쉽다. 정해진 사이트의 요청만 가능하도록 제한하기 용이하며, 요청이 한 군데로 모여 결국 프록시 서버와 인터넷 이 통신을 하기때문에 비용절감에 탁월하다. 이에 기업환경에서 많이 사용된다.

 

2.  리버스 프록시

포워드 프록시와 다른점은 인터넷 과 프록시 서버의 위치가 변경되었다. 

이렇게 되면 클라이언트, 유저 들은 프록시에 연결되었다는 사실을 인지할 수 없으며 마치 요청을 최종 요청을 내부망에 보는 것과 같이 느끼게 된다. 이렇게 하는 이유는 보안이 주된 이유이다. 

내부망이 바로 인터넷과 통신하여도 문제는 없다 다면 내부망 혹은 내부 서비스를 제공하는 서버가 해킹당하거나, 털리는 경우 심각한 보안문제를 초래할 수 있다. 
그렇기에 리버스프록시를 설정하여 위와 같은 구조를 가져가게 된다.

 

두 개 중 한 개를 택해서 구현할 수도 있고 두 개를 복합적으로 선택해서 구현할 수도 있으니 이분법 적인 사고에 갇히지 말자.

 

개념을 알게 되었으니 이제 고에서 제공하는 리버스 프록시 미들웨어 구현을 바로 가보자.

func setupProxyGroup(e *echo.Echo, path string, url *url.URL, transport http.RoundTripper) {
	group := e.Group(path)
	group.Use(middleware.ProxyWithConfig(middleware.ProxyConfig{
		Balancer:  middleware.NewRoundRobinBalancer([]*middleware.ProxyTarget{{URL: url}}),
		Transport: transport,
	}))
}

func main() {
	flag.Parse()

	cfg, err := config.Init(*configFile)
	if err != nil {
		fmt.Printf("config init failure file(%s) : %s\n", *configFile, err)
		os.Exit(-1)
	}
	fmt.Println(cfg)
	utils.ProcessIgnoreSignal()

	e := echo.New()
	e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
		AllowOrigins: []string{"*"},
		AllowHeaders: []string{"*"},
		AllowMethods: []string{"*"},
	}))

	e.Use(middleware.Recover())
	e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
		Format: "[Proxy] ${status} ${method} ${host}${path} ${latency_human} ${time_rfc3339}" + "\n",
	}))

	transport := &http.Transport{
		TLSClientConfig: &tls.Config{
			InsecureSkipVerify: true,
		},
	}
	/**
	cms server
	*/
	cmsServerURL, err := url.Parse(cfg.Server.CmsServer)
	if err != nil {
		logrus.WithError(err).Errorf("cms server parse err %v", cfg.Server.CmsServer)
	}
    // 주소 생략

	/**
	api server
	*/
	apiServerURL, err := url.Parse(cfg.Server.ApiServer)
	if err != nil {
		logrus.WithError(err).Errorf("api server parse error %v", cfg.Server.ApiServer)
		os.Exit(-1)
	}
    // 주소 생략

	/**
	chat server
	*/
	chatServerURL, err := url.Parse(cfg.Server.ChatServer)
	if err != nil {
		logrus.WithError(err).Errorf("chat server parse err %v", cfg.Server.ChatServer)
	}
	setupProxyGroup(e, "/chat/live/:id", chatServerURL, transport)

	/**
	manager server
	*/
	managerServerURL, err := url.Parse(cfg.Server.ManagerServer)
	if err != nil {
		logrus.WithError(err).Errorf("chat server parse err %v", cfg.Server.ChatServer)
	}
	setupProxyGroup(e, "/chat", managerServerURL, transport)

	server := http.Server{
		Addr:    cfg.Server.Port,
		Handler: e,
		TLSConfig: &tls.Config{
			NextProtos: []string{acme.ALPNProto},
		},
	}
	log.Error(server.ListenAndServeTLS(cfg.Server.SSLCrt, cfg.Server.SSLKey))
}

리버스 프록시가 되어야 하기 때문에 CORS에 해당하는 모든 부분은 열어주었다. 왜? 내부서비스 가장 앞단에 위치해야 하기 때문이다.

중간에 보면 Recover와 Logger의 미들웨어를 추가적으로 사용했다.

우선 Recover는 http 프로토콜 통신간 어느 한 체인에서 패닉이 발생하더라도 리커버로 해당 패닉을 수습해 계속 서버를 유지하기 위해 작성했다.
실제 서비스에서 이러한 옵션은 버그를 양산할 수 있는 부분이 될 수 있으니 사용을 지양해야 한다. 
리버스프록시 서버의 목적에 맞게 옵션을 추가해서 사용하자.

 

프록시그룹 셋업 함수를 살펴보면 

func setupProxyGroup(e *echo.Echo, path string, url *url.URL, transport http.RoundTripper) {
	group := e.Group(path)
	group.Use(middleware.ProxyWithConfig(middleware.ProxyConfig{
		Balancer:  middleware.NewRoundRobinBalancer([]*middleware.ProxyTarget{{URL: url}}),
		Transport: transport,
	}))
}

이렇게 각 해당 서비스 별로 패스를 구분 지어 그룹 단위로 셋업을 진행했다. 
이중 ProxyCofig 구조체 생성간에 Balancer는 필수 값이다. 
위에서 언급한 것처럼 프록시에 는 로드밸런싱 기능이 있는데 에코에서는 이를 필수 값으로 지정해 타깃으로 정한다. 
작성된 코드는 오직 하나의 url을 이용하기 때문에 로드밸런싱의 기능은 전혀 활용하지 못하고 있다고 보면 된다.

 

Transport라는 생소한 부분이 보일 텐데 이는 Custom Tls 즉 사설 인증서를 사용하는 경우 필수 적이라고 명시되어 있다.

해당 포트 및 서버는 사설 인증을 받아 https를 테스트 용도의 목적으로 개설했기 때문에 옵션을 설정해주어야 한다.

 

모든 프록시는 동일한 http.TransPort를 받는데

	transport := &http.Transport{
		TLSClientConfig: &tls.Config{
			InsecureSkipVerify: true,
		},
	}

저기서 InsecureSkipVerify는 기본이 false 옵션이다. true 옵션을 추가하게 된다면  crypto/tls의 패키지에서는 아무 인증서나 패스해 주는 것을 의미한다. 따라서 이는 테스트 환경에서만 적용할 것을 권장한다고 주석 처리 되어 있으니 해당 옵션은 테스트 서버에서만 사용하자.

 

엔진엑스를 사용 못하고, 경량화된 프록시의 역할을 하는 무언가가 필요하다면  이렇게 에코에서 제공하는 리버스 프록시 서버를 작성해 보는 것도 좋은 방법 중에 하나가 될 수 있을 것 같다.

 

이외에도 에코 에는 구조체, 인터페이스 별로 주석이 정말 잘 달려있다. 무조건 함수 혹은 인터페이스 타고 들어가서 구현체 확인해 보자. 
구글도 알려주지 않는 것을 주석에서 알려주고 있다.

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

SSE 적용하기  (0) 2024.05.19
Ehco Https 설정하기  (0) 2023.07.12

+ Recent posts