Go Routine을 사용하지 않은 URL Checker 

지금까지 배운 Go 사용법으로 URL Checker를 만들어 보려고한다.

package main

import (
	"errors"
	"net/http"
)

var errRequestFailed = errors.New("Request is failed")

func main() {
	urls := []string{
		"https://www.naver.com/",
		"https://www.google.com/",
		"https://www.amazon.com/",
		"https://www.facebook.com/",
		"https://www.instagram.com/",
		"https://www.naver.com/",
	}
	for _, url := range urls {
		fmt.Println(hitURL(url))
	}
}

func hitURL(url string) error {
	resp, err := http.Get(url)
	if err != nil || resp.StatusCode >= 400 {
		return errRequestFailed
	}
	return nil
}

main.go

일단 여러 url들을 배열안에 넣어두고 for문을 통해 url이 정상적인 응답을 주는지 확인해보았다. http의 경우 Go에서 기본적으로 지원하는 라이브러리를 사용했다. 링크를 통해 사용법을 배웠다.

resp, err := http.Get("http://example.com/")
if err != nil {
	// handle error
}

짤막하게 설명하자면 http method 중 하나인 Get을 사용하기 위해서는 http.Get 메소드에 url을 넣어서 요청하면 response와 error를 반환한다. error를 받은 경우 개발자가 스스로 error handling을 할 수 있도록 구현해두었다.

 

위의 사용법을 기반으로 아래와 같은 hitURL이라는 함수를 작성했다.

func hitURL(url string) error {
	resp, err := http.Get(url)
	if err != nil || resp.StatusCode >= 400 {
		return errRequestFailed
	}
	return nil
}

url에 요청을 보내고 받은 response와 error를 받아오고 err가 발생했거나 응답의 상태코드가 400이상이면 request가 실패했다는 error를 return하고 아닐 시 nil을 return 하도록 했다.

 

프로젝트를 돌려보면 6개의 응답 모두 nil을 return하여 문제없는 url임을 확인할 수 있다. 조금 더 결과물을 보기 좋게하기위해 map을 사용해보자.

package main

import (
	"errors"
	"fmt"
	"net/http"
)

var errRequestFailed = errors.New("Request is failed")

func main() {
	results := map[string]string{}			// ADD
	urls := []string{
		"https://www.naver.com/",
		"https://www.google.com/",
		"https://www.amazon.com/",
		"https://www.facebook.com/",
		"https://www.instagram.com/",
		"https://www.naver.com/",
		"https://www.airbnb.com/",
		"https://www.reddit.com/",
	}
	for _, url := range urls {
		result := "OK"			// ADD
		err := hitURL(url)
		if err != nil {			// ADD
			result = "FAILED"			// ADD
		}			// ADD
		results[url] = result			// ADD
	}
	// ADD
	for url, result := range results {
		fmt.Println(url, result)
	}
}

func hitURL(url string) error {
	fmt.Println("Checking: ", url)			// ADD
	resp, err := http.Get(url)
	if err != nil || resp.StatusCode >= 400 {
		return errRequestFailed
	}
	return nil
}

출력

string 배열에있는 모든 url을 확인한 뒤, 결과물인 성공, 실패 여부를 map에 저장하여 출력해 준 모습이다. 문제없는 url만 저장해서인지 FAILED가 하나도 없다.

 

하지만, 이러한 URL Checker를 만드는 데에는 다른 언어와의 큰 차이점을 느낄 수 없다. 현재는 모든 URL을 하나하나하나 Checking하며, 이전 작업이 완료되어야만 다음 작업을 수행하고 있다. Go에는 이러한 작업을 한 번에 수행할 수 있도록 해주는 Go Routine이라는 기술이 있는데, 현재 사용해보면 좋을 것 같아서 적용해보려고 한다.

tip!

go에서는 make()라는 함수가 있다.
// The make built-in function allocates and initializes an object of type
// slice, map, or chan (only). Like new, the first argument is a type, not a
// value. Unlike new, make's return type is the same as the type of its
// argument, not a pointer to it. The specification of the result depends on
// the type:
//
//	Slice: The size specifies the length. The capacity of the slice is
//	equal to its length. A second integer argument may be provided to
//	specify a different capacity; it must be no smaller than the
//	length. For example, make([]int, 0, 10) allocates an underlying array
//	of size 10 and returns a slice of length 0 and capacity 10 that is
//	backed by this underlying array.
//	Map: An empty map is allocated with enough space to hold the
//	specified number of elements. The size may be omitted, in which case
//	a small starting size is allocated.
//	Channel: The channel's buffer is initialized with the specified
//	buffer capacity. If zero, or the size is omitted, the channel is
//	unbuffered.
func make(t Type, size ...IntegerType) Type

command+make()라는 함수를 클릭하면 위와같은 builtin.go라는 파일에 코드가 생성되어 있다. go에서 제공해주는 함수인데, make()라는 함수는 주석을 정리해보면 slice, map, chan과 같은 타입의 object를 할당 및 초기화 해준다고 설명되어 있다.  사용법도 각각의 object 별로 주석으로 설명이 잘 되어있기 때문에 읽어보고 사용해봐도 좋다.

 

위 map을 make()라는 함수를 통해 만들어보자.

// Map: An empty map is allocated with enough space to hold the
// specified number of elements. The size may be omitted, in which case
// a small starting size is allocated.
results := map[string]string{}

기존 key와 value가 string인 map을 생성할 때 위와같이 작생했다.

results := make(map[string]string)

make를 사용하면 위와같은 코드로 만들 수 있다.

 

음.... map을 생성할 때는 make를 굳이 사용할 필요가 있을까? 라는 의문이 든다. {} -> make() 의 차이점은 오히려 코드를 많이 적게 만드는 것이 아닌가?라는 의문이 들지만, 가독성을 생각한다면 사용할만한 가치가 있다고 생각한다. 코드상의 큰 차이점은 없지만, make라는 의미를 부여하기 때문이다. map[string]string이라는 object를 생성한다는 것을 보다 직관적으로 알 수 있기 때문이다.

results := make(map[string]string)
results := map[string]string{}

위 코드를 보며, 개인적으로 보다 나은 방식을 선택하면 좋을 것 같다.


Go Routine

다른 함수와 동시에 실행시키는 함수

Go에서 최적화하는 방법은 동시에 작업을 처리하는 것이다. Go에서는 이러한 동시 작업 처리를 위해서는 Go routine이라는 컨셉을 이해해야 한다. 

func main() {
	testCount("test1")
	testCount("test2")
}

func testCount(person string) {
	for i := 0; i < 10; i++ {
		fmt.Println(person, "is test", i)
		time.Sleep(time.Second)
	}
}

main.go

테스트를 보다 쉽게하기 위해 조금 더 이해해보기 쉽게 main.go를 위와같이 작성했다.

출력

순서대로 출력되는 모습을 확인할 수 있다. Go routine을 한 번 적용해보자. Go routine을 적용하는 방법은 아주 간단하다. 함수를 불러오는 코드에 함수 앞에 go만 붙이면 된다. 예를들어, testCount()를 go testCount()로 작성하면 된다.

func main() {
	go testCount("test1")
	testCount("test2")
}

func testCount(person string) {
	for i := 0; i < 10; i++ {
		fmt.Println(person, "is test", i)
		time.Sleep(time.Second)
	}
}

출력

test1, test2가 같이실행되는 것을 볼 수 있다. 그렇다면, 두 함수 모두 Go routine으로 처리하면 어떻게 될까?

func main() {
	go testCount("test1")
	go testCount("test2")
}

func testCount(person string) {
	for i := 0; i < 10; i++ {
		fmt.Println(person, "is test", i)
		time.Sleep(time.Second)
	}
}

코드를 위와같이 수정하고 돌려지만, 출력되는 결과물은 없었다. 그 이유는 main()에 작성된 두 함수에 접근하며 아래작성 된 코드를 실행하는데, testCount("test1")과 testCount("test2")를 돌리는 중간에 main() 함수가 종료되었기 때문이다. Go routine은 프로그램이 작동하는 동안만 유효하기 때문에 이 점을 유념해서 코드를 작성해야 한다.

 

그렇다면, go routine을 쓰면 해당 작업이 끝나는 시간을 고려해서 이후 코드들을 작성해야하는가..?  google 개발자들이 이렇게 허술하게 만들었을리가 없다고 생각했다면 역시나 이를 고려해서 channel이라는 기능을 만들었다. 

 

Channel

goroutine 간 통신을 위한 메커니즘으로, 데이터를 안전하게 전달하고 동기화하는 데 사용

ChatGPT에 의하면 Channel은 아래와 같은 기능을 위해 존재한다고 말한다.

  1. 동시성과 통신: channel은 고루틴 간의 통신을 위해 디자인되었습니다. 여러 고루틴이 병렬로 실행되는 환경에서 안전하게 데이터를 주고받을 수 있도록 합니다.
  2. 데이터 전달: channel을 사용하여 데이터를 안전하게 전달할 수 있습니다. 데이터를 보낼 때는 <- 연산자를 사용하며, 데이터를 받을 때는 <- 연산자를 반대로 사용합니다.
  3. 동기화: channel은 데이터를 전달할 때 발신자와 수신자 간에 동기화를 제공합니다. 즉, 데이터를 주고 받는 과정에서 발신자가 데이터를 보낼 때까지 대기하게 되며, 수신자는 데이터를 받을 때까지 대기하게 됩니다.

여기서 우리가 봐야할 건, 동기화에 있는 수신자는 데이터를 받을 때까지 대기하게 됩니다.이다. 즉, main에서 channel을 생성하여 goroutine을 사용하면, 수신자인 main에서 데이터를 받을 때까지 대기한다는 말이다.

 

실제로 적용해보자.

func main() {
	c := make(chan string)
	people := [2]string{"test1", "test2"}
	for _, person := range people {
		go testCount(person, c)
	}
	result := <- c
	fmt.Println(result)
}

func testCount(person string, c chan string) {
	c <- person
}

출력

string으로 channel을 생성하고 testCount()에 포함하여 보내주고, channel로 person이라는 string 데이터를 보냈다. 그 후 channel에서 받은 데이터를 result에 넣어주고 출력해주었다. 이전 아무런 출력이 일어나지 않은 것과는 다르게 test2라는 string이 출력된 모습을 볼 수 있다. test1, test2 두개의 string을 보냈는데 test2만 출력되는 이유는 channel은 queue나 pipe와 비슷한 형태로 1개씩 데이터를 보내고 받을 수 있기 때문이다. 

func main() {
	c := make(chan string)
	people := [2]string{"test1", "test2"}
	for _, person := range people {
		go testCount(person, c)
	}
	fmt.Println(<- c)			// 데이터 꺼내기
	fmt.Println(<- c)			// 데이터 꺼내기
}

func testCount(person string, c chan string) {
	c <- person
}

출력

위와같이 두 번 데이터를 꺼내면 전달한 두 개의 데이터를 모두 받을 수 있다.

그렇다면, 보낸 데이터가 없을 때 꺼낸다면 어떻게 될까?

func main() {
	c := make(chan string)
	people := [2]string{"test1", "test2"}
	for _, person := range people {
		go testCount(person, c)
	}
	fmt.Println(<- c)			// 있는 데이터 꺼내기
	fmt.Println(<- c)			// 있는 데이터 꺼내기
	fmt.Println(<- c)			// 없는 데이터 꺼내기
}

func testCount(person string, c chan string) {
	c <- person
}

출력

2개의 데이터를 꺼내고 교착상태가 일어난 모습을 볼 수 있다. 모든 goroutines은 모두 종료되어 sleep상태가 되었지만, main에서 데이터를 기다리고 있기때문에 deadlock이 발생한 상황이다. 

 

이러한 문제를 막기 위해서는 다양한 방법이 존재한다. 그 중 하나는 sync.WaitGroup을 사용해서 막는 것이다. goroutine이 종료되면 channel을 닫아주면 된다.

func main() {
	c := make(chan string)
	people := [2]string{"test1", "test2"}
	var wg sync.WaitGroup

	for _, person := range people {
		wg.Add(1)
		go testCount(person, c, &wg)
	}

	go func() {
		wg.Wait()
		close(c)
	}()

	for msg := range c {
		fmt.Println(msg)
	}
}

func testCount(person string, c chan string, wg *sync.WaitGroup) {
	defer wg.Done()
	c <- person
}

goroutine을 1개 부를 때마다 wg.Add()를 추가하고 goroutine이 종료되면 wg.Done()으로 추가한 것을 하나씩 제거한다. wg.Wait()으로 모든 goroutine이 종료됨을 확인하고 다 종료되었다면 close(c)를 통해 channel을 닫아준다. 그 후 channel의 범위만큼 반복문을 돌린다면 이전과 같은 상황을 막을 수 있다.

 

아니면 goroutine을 사용하는 수만큼 반복문을 돌려도 된다.

func main() {
	c := make(chan string)
	people := [2]string{"test1", "test2"}

	for _, person := range people {
		go testCount(person, c)
	}

	for i := 0; i < len(people); i++{
		fmt.Println(<-c)
	}
}

func testCount(person string, c chan string) {
	c <- person
}

people의 배열 크기만큼 goroutine의 수를 늘리기 때문에 배열 크기만큼 데이터를 받아오는 것이다. 그렇다면, 이전 URL Checker에 Go Routine을 적용해보자. 

 

 

URL Checker + Go Routine

type requestResult struct {
	url string
	status string			// OK, FAILED
}

먼저, channel로 전달할  url과 성공, 실패 여부 필드를 담은 struct를 추가했다.

c := make(chan requestResult)

만든 struct type의 chan을 생성했다.

	for _, url := range urls {
		go hitURL(url, c)
	}

main에서 hitURL을 goroutine으로 부르고

func hitURL(url string, c chan requestResult) {
	fmt.Println("Checking: ", url)
	resp, err := http.Get(url)
	status := "OK"
	if err != nil || resp.StatusCode >= 400 {
		status = "FAILED"
	}
	c <- requestResult{url: url, status: status}
}

hitURL에서 channel에 성공 여부를 struct에 담아서 보냈다.

	for i := 0; i < len(urls); i++ {
		fmt.Println(<-c)
	}

main에서 channel을 통해 받은 result struct를 출력했다.

출력

package main

import (
	"errors"
	"fmt"
	"net/http"
)

type requestResult struct {
	url string
	status string			// OK, FAILED
} 

var errRequestFailed = errors.New("Request is failed")

func main() {
	c := make(chan requestResult)
	urls := []string{
		"https://www.naver.com/",
		"https://www.google.com/",
		"https://www.amazon.com/",
		"https://www.facebook.com/",
		"https://www.instagram.com/",
		"https://www.naver.com/",
		"https://www.airbnb.com/",
		"https://www.reddit.com/",
	}
	for _, url := range urls {
		go hitURL(url, c)
	}

	for i := 0; i < len(urls); i++ {
		fmt.Println(<-c)
	}
}

func hitURL(url string, c chan requestResult) {
	fmt.Println("Checking: ", url)
	resp, err := http.Get(url)
	status := "OK"
	if err != nil || resp.StatusCode >= 400 {
		status = "FAILED"
	}
	c <- requestResult{url: url, status: status}
}

전체코드이다. goroutine을 사용하지 않았을 때와 비교가 크게 될 정도로 빠르게 종료되는 모습을 볼 수 있을 것이다. 병렬적으로 수행하기 때문. 정말 편리하게 병렬적으로 함수를 실행할 수 있는 것이 Go의 엄청 큰 장점이지 않을까 싶다.

 

 

 

'Go' 카테고리의 다른 글

Job Scrapper  (0) 2024.01.29
method를 활용한 map 구현(Search, Add, Delete, Update)  (0) 2024.01.10
struct/public,private/String()/error handling  (0) 2024.01.07
Struct  (0) 2024.01.04
Maps  (0) 2023.12.30

+ Recent posts