Skip to main content Link Menu Expand (external link) Document Search Copy Copied

created at 2023-01-15

현재까지 auth/chat Server 및 여러 장애대응과 모니터링 서비스를 구축했다. 서비스관련 테스트 코드는 이미 생성하였으며, 이제는 다량의 Rest api 트래픽을 테스트를 위한 클라이언트를 제작해 볼 차례이다.

아래의 코드는 Golang으로 제작하였다. 예상되는 아래의 질문에 대한 답변을 준비해봤다.

Question : 왜 Java가 아닌 Golang으로 제작했나요?

  • Answer 1 : 간단한 테스트를 위해 JVM까지 메모리에 올릴필요는 없다고 생각하기때문이다. Golang은 컴파일 언어로써 오브젝트 파일만 있으면 실행 가능하다.
  • Answer 2 : Golang은 경량 스레드인 go-routine을 사용(M:N), Java는 네이티브 스레드를 사용한다. 크기를 비교하면 Golang은 스레드 크기가 2KB인데 비해, Java는 스레드 크기가 1MB이상이다. 이는 즉, 스케쥴링에 있어 Golang이 Context Switch에서 이점을 가져가며 동시에 더 많은 스레드를 실행시킬 수 있다는 이점을 가지고 있다는 것이다. 관련된 자료는 필자의 Goroutine structure and behavior 포스팅에 있다.
package main
...
func worker(finished chan map[int]int, requestURL string, bodyReader *bytes.Reader, client *http.Client, transferRatePerSecond int) {

	// 로컬 맵 생성
	m := make(map[int]int)

	for i := 0; i < transferRatePerSecond; i++ {
		// 요청 생성
		req, err := http.NewRequest(http.MethodPost, requestURL, bodyReader)
		if err != nil {
			fmt.Printf("client: could not create request: %s\n", err)
			os.Exit(1)
		}

		// json 헤더 설정
		req.Header.Set("Content-Type", "application/json")

		// 실제 요청 전송 및 반환
		res, _ := client.Do(req)

		// 로컬 맵에 삽입
		m[res.StatusCode] += 1
	}

	// 완료 후 채널로 로컬 맵 전송
	finished <- m
}

func main() {

	argsWithProg := os.Args
	argsWithoutProg := os.Args[1:]

	// http 전송 url
	requestURL := argsWithoutProg[0]

	// json 파일 경로 ex) user
	// .json 확장자 명은 제외
	jsonReq := argsWithoutProg[1]

	// 실행횟수 설정
	transferRatePerSecond, _ := strconv.Atoi(argsWithoutProg[2])

	// JSON 파일 읽기
	jsonFile, err := os.Open(jsonReq + ".json")
	if err != nil {
		fmt.Println(err)
	}
	defer jsonFile.Close()

	// 클라이언트 설정 및 timeout
	client := &http.Client{
		Timeout: 30 * time.Second,
	}

	// json파일을 바이트로 변환
	jsonBody, _ := ioutil.ReadAll(jsonFile)
	bodyReader := bytes.NewReader(jsonBody)

	// 스레드 싱크 맵
	var contexts = sync.Map{}
	finished := make(chan map[int]int)

	// 멀티 스레드 http request
	go worker(finished, requestURL, bodyReader, client, transferRatePerSecond)

	// 스레드 별 채널을 통한 완료 확인 및 스레드의 맵 가져오기
	m := <-finished

	// 스레드 별 완료값 꺼내서 mutex lock해주는 맵 저장
	for k, v := range m {
		result, ok := contexts.Load(k)
		if ok {
			contexts.Store(k, result.(int)+v)
		} else {
			contexts.Store(k, v)
		}
	}

	// 성공한 http request 개수 확인
	contexts.Range(func(k, v interface{}) bool {
		fmt.Println("status: ", k, " count: ", v)
		return true
	})
}

[문제점1] 400 status code error

그런데 문제점이 발생했다. 위의 테스트 코드를 실행시키면 아래와 같이 결과를 받는다.

  • 실행
gyuminhwangbo@Gyuminui-MacBookPro testing % go run main.go http://localhost:8080/auth/user user 50
[http://localhost:8080/auth/user user 50]
status:  409  count:  1 <- 예상된 결과
status:  400  count:  49 <- 왜?;;
  • 서버로그
auth-server               | 2023-01-16 05:55:17.348  INFO 1 --- [nio-8085-exec-9] chatting.chat.web.UserController         : request URI=/auth/user
auth-server               | 2023-01-16 05:55:17.350 DEBUG 1 --- [nio-8085-exec-9] org.hibernate.SQL                        :
auth-server               |     select
auth-server               |         user0_.user_id as user_id1_0_0_,
auth-server               |         user0_.email as email2_0_0_,
auth-server               |         user0_.join_date as join_dat3_0_0_,
auth-server               |         user0_.login_date as login_da4_0_0_,
auth-server               |         user0_.logout_date as logout_d5_0_0_,
auth-server               |         user0_.user_name as user_nam6_0_0_,
auth-server               |         user0_.user_pw as user_pw7_0_0_
auth-server               |     from
auth-server               |         user_table user0_
auth-server               |     where
auth-server               |         user0_.user_id=?
auth-server               | 2023-01-16 05:55:17.351 TRACE 1 --- [nio-8085-exec-9] o.h.type.descriptor.sql.BasicBinder      : binding parameter [1] as [VARCHAR] - [a]
auth-server               | 2023-01-16 05:55:17.355 ERROR 1 --- [nio-8085-exec-9] c.chat.web.error.GlobalExceptionHandler  : handleCustomException throw CustomException : DUPLICATE_RESOURCE

# ------------------------------ 정상 에러 ------------------------------
nginx                     | 192.168.192.1 - - [16/Jan/2023:05:55:17 +0000] "POST /auth/user HTTP/1.1" 409 162 "-" "Go-http-client/1.1" "-"

# ------------------------------ 비정상 에러 ------------------------------
nginx                     | 192.168.192.1 - - [16/Jan/2023:05:55:17 +0000] "POST /auth/user HTTP/1.1" 400 0 "-" "Go-http-client/1.1" "-"
nginx                     | 192.168.192.1 - - [16/Jan/2023:05:55:17 +0000] "POST /auth/user HTTP/1.1" 400 0 "-" "Go-http-client/1.1" "-"
nginx                     | 192.168.192.1 - - [16/Jan/2023:05:55:17 +0000] "POST
...
HTTP client in Go by default has DefaultMaxIdleConnsPerHost value of 2

같은 ID로 auth server에 http reqeust하기 때문에, status code 409은 분명 예상된 결과이다. 하지만! 400 에러는 원하는 결과가 아니다. 애초에 nginx를 통해 auth-server로 첫 번째 request만 들어가고 나머지는 팅겨나가는 현상을 관찰할 수 있다. 그러면 test code가 문제이거나 nginx configuration이 잘못되었다라고 볼 수 있다.

[문제점1 해결] byte코드 오류

...
func worker(finished chan map[int]int, requestURL string, jsonBody []byte, client *http.Client, transferRatePerSecond int) {
  ...
	for i := 0; i < transferRatePerSecond; i++ {
	  # bodyReader은 포인터이기 때문에, 사용 이전과 이후가 다르다
		bodyReader := bytes.NewReader(jsonBody)
		...
	}
  ...
}

이전 bodyReader = &{[123 10 ...] 0 -1}

전송 이후의 bodyReader &{[123 10 ...] 89 -1}

https://go.dev/src/compress/gzip/example_test.go를 보면 아래의 레퍼런스가 있다.

Note that passing req to http.Client.Do promises that it will close the body, in this case bodyReader.

즉, 89는 이미 소비된 bodyReader을 표시하는 바이트이다. 따라서 이를 다시 resend하게 된다면 net/http 라이브러리에서 이를 읽지 않기때문에, 해당 바이트를 다시 0으로 재설정하는 과정을 가져야한다.

따라서 이를 아래와 같이 수정하여 정상에러 결과를 받아볼 수 있다.

수정된 test code

...
func worker(contexts *sync.Map, wg *sync.WaitGroup, requestURL string, jsonBody []byte, client *http.Client, transferRatePerSecond int, number_worker int) {
	defer wg.Done()
	fmt.Println("Threads start:", number_worker)
	// 로컬 맵 생성
	m := make(map[int]int)

	for i := 0; i < transferRatePerSecond; i++ {
		bodyReader := bytes.NewReader(jsonBody)
		// 요청 생성
		req, err := http.NewRequest(http.MethodPost, requestURL, bodyReader)
		if err != nil {
			fmt.Printf("client: could not create request: %s\n", err)
			os.Exit(1)
		}
		// json 헤더 설정
		req.Header.Set("Content-Type", "application/json")
		// 실제 요청 전송 및 반환
		res, _ := client.Do(req)
		// 로컬 맵에 삽입
		m[res.StatusCode] += 1
	}

	for k, v := range m {
		result, ok := contexts.Load(k)
		if ok {
			contexts.Store(k, result.(int)+v)
		} else {
			contexts.Store(k, v)
		}
	}
}

func main() {

	argsWithoutProg := os.Args[1:]
	var wg sync.WaitGroup

	number_worker := 10
	// http 전송 url
	requestURL := argsWithoutProg[0]

	// json 파일 경로 ex) user
	// .json 확장자 명은 제외
	jsonReq := argsWithoutProg[1]

	// 실행횟수 설정
	transferRatePerSecond, _ := strconv.Atoi(argsWithoutProg[2])

	// JSON 파일 읽기
	jsonFile, err := os.Open(jsonReq + ".json")
	if err != nil {
		fmt.Println(err)
	}
	defer jsonFile.Close()

	t := http.DefaultTransport.(*http.Transport).Clone()
	t.MaxIdleConns = 1000    // connection pool 크기
	t.MaxConnsPerHost = 1000 // 호스트 별 최대 할당 connection
	t.MaxIdleConnsPerHost = 1000
	t.Dial = (&net.Dialer{
		Timeout: 1 * time.Second,
	}).Dial

	// 클라이언트 설정 및 timeout
	client := &http.Client{
		Timeout:   1 * time.Second,
		Transport: t,
	}

	// json파일을 바이트로 변환
	jsonBody, _ := ioutil.ReadAll(jsonFile)

	// 스레드 싱크 맵
	var contexts = &sync.Map{}

	// 멀티 스레드 http request
	for i := 0; i < number_worker; i++ {
		wg.Add(1)
		go worker(contexts, &wg, requestURL, jsonBody, client, transferRatePerSecond/number_worker, i)
	}

	wg.Wait()

	// 성공한 http request 개수 확인
	contexts.Range(func(k, v interface{}) bool {
		fmt.Println("status: ", k, " count: ", v)
		return true
	})
}

여기서 추가된 중요한 코드는 아래와 같다.

	t := http.DefaultTransport.(*http.Transport).Clone()
	t.MaxIdleConns = 100
	t.MaxConnsPerHost = 100
	t.MaxIdleConnsPerHost = 100
	t.Dial = (&net.Dialer{
		Timeout: 5 * time.Second,
	}).Dial
	t.TLSHandshakeTimeout = 5 * time.Second

	// 클라이언트 설정 및 timeout
	client := &http.Client{
		Timeout:   10 * time.Second,
		Transport: t,
	}

기본적으로 동시 연결 가능 tcp 소켓 개수는 net/http에서는 2개로 설정되어있다. 따라서 우리는 100개의 동시 tcp 소켓으로 맞춰주고, 각각의 소켓이 5초 이내로 재 dial되지 않으면 종료하도록 설정해준다.

이 밖에 number_worker 스레드 개수는 10개로 설정하여 시뮬레이션한다.

결과

gyuminhwangbo@Gyuminui-MacBookPro testing % go run main.go http://localhost:8080/auth/user user 200
Threads start: 8
Threads start: 1
Threads start: 7
Threads start: 6
Threads start: 3
Threads start: 9
Threads start: 0
Threads start: 2
Threads start: 4
Threads start: 5
status:  409  count:  200 <-- 원하는 결과 도출