본문 바로가기
프로젝트 일지/BMT

6. Go를 이용한 스크래퍼 리팩토링하기 (2) - goquery, Goroutine, sync.WaitGroup

by 데브겸 2023. 8. 9.

저번 글에서는 왜 Go를 이용해 코드를 리팩토링하게 되었는지 그 계기와 함께 리팩토링한 코드 중 '역 코드 스크래퍼'의 HandleRequest 함수에 대해서 살짝 알아보았다.

 

이번 포스트에서는 '역 코드 스크래퍼'의 핵심이 되는 스크래핑 파트에 대해서 알아보면서 거기에 쓰인 goqeury, goroutine, waitgoup에 대해서 설명하겠다.

 

고루틴이 진짜 짱이다

 


run 함수

run 함수는 Goroutine을 활용하여 스크래퍼(scrapeNavercode)를 병렬로 돌리고, 채널로 값을 받아온 후 원하는 형태로 값을 정리하는 역할을 수행하는 함수이다. 

 

func run(num int) {
	var baseURL string = "https://pts.map.naver.com/end-subway/ends/web/"

	// 200개 돌며 네이버 코드 스크래핑 (feat. 고루틴)
	c := make(chan extractedInfo)
	for i := (num - 1) * 200; i < (num * 200); i++ {
		wg.Add(1)
		go scrapeNavercode(i, baseURL, c)
		wg.Done()
	}
	wg.Wait()

	// go 루틴에서 채널로 값 받아오기 & 받은 값을 이후에 처리하기 쉬운 형태로 가공
	for i := (num - 1) * 200; i < (num * 200); i++ {
		result := <-c
		lineNum := result.lineNum
		block := make(map[string]interface{})

		// 만약 받아온 값이 없다면 무시하고 아니라면 값 정리
		if result.stationNm == "" || result.lineNum == "" {
			continue
		} else {
			block["stationNm"] = result.stationNm
			block["naverCode"] = result.naverCode
		}

		// 만약 key에 호선이 없으면 새로운 key로 추가 후 정보 입력
		_, ok := INFO[lineNum]
		if ok == false {
			INFO[lineNum] = []map[string]interface{}{}
		}
		INFO[lineNum] = append(INFO[lineNum], block)

		// naverCode 기준으로 오름차순 정렬
		sort.Slice(INFO[lineNum], func(i, j int) bool {
			return INFO[lineNum][i]["naverCode"].(int) < INFO[lineNum][j]["naverCode"].(int)
		})
	}

}

 

 

어떻게 보면 가장 중요한 고루틴 파트!

make로 채널을 생성한 후 scrapeNavercode 함수에 인자로 넣고 함수 200개를 동시에 실행한다.

var baseURL string = "https://pts.map.naver.com/end-subway/ends/web/"

// 200개 돌며 네이버 코드 스크래핑 (feat. 고루틴)
c := make(chan extractedInfo)
for i := (num - 1) * 200; i < (num * 200); i++ {
	wg.Add(1)
	go scrapeNavercode(i, baseURL, c)
	wg.Done()
}
wg.Wait()

 

이때 독특한 점은 wg.Add(1) / wg.Done() / wg.Wait() 를 사용한다는 점인데 이는 Go에서 제공하는 고루틴을 제어하기 위한 sync.WaitGroup의 메서드들이다.

 

굳이 사용한 이유는 네이버 서버에 부담을 주지 않기 위해 19900번의 쿼리를 100개의 배치로 나눠 각 배치 진행 후 5초 동안 sleep하는 로직을 구현하고자 했고, 하나의 배치 안에서 고루틴 200개만 실행되는 것을 보장하기 위해 사용하였다. (말이 좀 꼬이긴하는데 여튼 하나의 배치 안에서 고루틴이 더도 말고 덜도 말고 딱 200개만 안정적으로 실행되게 만들기 위함이다)

 

- sync.WaitGroup: 대기 그룹 생성할 때 사용

- wg.Add(delta int): 괄호 안에 적힌 갯수만큼 대기 그룹에 고루틴을 추가한다

- wg.Done(): 고루틴이 끝났다는 것을 알려준다 (wg.Add(-1) 이랑 같다. 즉, 대기열에서 하나를 제거하는 것으로 생각해도 됨)

- wg.Wait(): 모든 고루틴이 끝날 때까지 기다린다

 

*보통 Add(1)와 Done()으로 제어하는 패턴을 많이 쓰는 것 같다.

*주의: Add와 Done으로 +-한 waitgroup counter의 갯수는 반드시 0되어야 한다. 안 그럼 panic

 

import ( 
	"sync"
)

var wg = new(sync.WaitGroup)

for i := (num - 1) * 200; i < (num * 200); i++ {
	wg.Add(1)
	go scrapeNavercode(i, baseURL, c)
	wg.Done()
}

wg.Wait()

 

 

WaitGroup에 대해서 좀 더 깊이 파고든 글이 있어서 함께 첨부 (부제목인 더하는 숫자가 중요한 게 아니라 0을 맞추는 게 중요 가 포인트인듯) (나도 맨날 다시 보면서 참고해야징)

https://johngrib.github.io/wiki/golang-waitgroup-add/

 

Go WaitGroup에 1외에 다른 값을 Add 하면?

더하는 숫자가 중요한 게 아니라 0을 맞추는 게 중요

johngrib.github.io

 

 

그 외에는 Golang에서 OR 논리 연산을 || 로 처리한다는 점도 알게 되었고

// 만약 받아온 값이 없다면 무시하고 아니라면 값 정리
if result.stationNm == "" || result.lineNum == "" {
	continue
} else {
	block["stationNm"] = result.stationNm
	block["naverCode"] = result.naverCode
}

 

연산자 관련해서는 아래 글 참고 https://pyrasis.com/book/GoForTheReallyImpatient/Unit13

 

PYRASIS.COM: 가장 빨리 만나는 Go 언어 Unit 13. 연산자 알아보기

저작권 안내 책 또는 웹사이트의 내용을 복제하여 다른 곳에 게시하는 것을 금지합니다.책 또는 웹사이트의 내용을 발췌, 요약하여 강의 자료, 발표 자료, 블로그 포스팅 등으로 만드는 것을 금

pyrasis.com

 

 

 

독특한 정렬 코드 구현도 알 수 있었다. sort.Slice로 슬라이스 정렬이 가능하지만 생각보다 복잡하다. (이런 거 보면 Python이 참 편하다는 생각이 들기도 함)

// naverCode 기준으로 오름차순 정렬
sort.Slice(INFO[lineNum], func(i, j int) bool {
	return INFO[lineNum][i]["naverCode"].(int) < INFO[lineNum][j]["naverCode"].(int)
})

 

 


scrapeNavercode 함수

다음은 goquery를 사용하는 scrapeNavercode 파트 (이제보니 변수명을 scrapeNaverCode로 했어야 했나 싶기도 하고)

 

func scrapeNavercode(code int, baseURL string, c chan<- extractedInfo) {
	pageURL := baseURL + strconv.Itoa(code) + "/home"

	// pageURL로 접속하기
	res, err := http.Get(pageURL)
	checkErr(err)
	checkCode(res)

	// 작업 끝나면 res.Body 닫아주는 명령 예약
	defer res.Body.Close()

	// html 읽기
	doc, err := goquery.NewDocumentFromReader(res.Body)
	checkErr(err)

	// 지하철 정보 파싱 후 채널로 보내기
	lineNum := doc.Find(".line_no").Text()
	stationNm := doc.Find(".place_name").Text()
	fmt.Println(code, " 확인완료")

	c <- extractedInfo{
		lineNum:   lineNum,
		stationNm: stationNm,
		naverCode: code}
}

 

우선 int로 되어 있는 역 코드를 문자로 변환하기 위해 strconv 패키지의 Itoa 사용

pageURL := baseURL + strconv.Itoa(code) + "/home"

 

패키지의 다른 함수들을 보고 싶다면 아래 공식 문서로 https://pkg.go.dev/strconv 

 

strconv package - strconv - Go Packages

Package strconv implements conversions to and from string representations of basic data types. Numeric Conversions ¶The most common numeric conversions are Atoi (string to int) and Itoa (int to string). i, err := strconv.Atoi("-42") s := strconv.Itoa(-42)

pkg.go.dev

 

 

http.Get으로 타깃 URL의 정보를 가져오고, html을 읽어 원하는 정보를 변수에 담기, 이후에 채널로 변수들 보내기

// pageURL로 접속하기
res, err := http.Get(pageURL)
checkErr(err)
checkCode(res)

// 작업 끝나면 res.Body 닫아주는 명령 예약
defer res.Body.Close()

// html 읽기
doc, err := goquery.NewDocumentFromReader(res.Body)
checkErr(err)

// 지하철 정보 파싱 후 채널로 보내기
lineNum := doc.Find(".line_no").Text()
stationNm := doc.Find(".place_name").Text()
fmt.Println(code, " 확인완료")

c <- extractedInfo{
	lineNum:   lineNum,
	stationNm: stationNm,
	naverCode: code}

 

참고로 goquery 사용 방법은 아래와 같다.

(물론 공식문서를 보는게 짱이란 것을 알고 있지만, 한국어 블로그 자료가 더 짜릿하니까 설명한다)

 

우선 http.get(pageURL)에서 response를 NewDocumentFromReader로 읽어온다

// 참고: err는 if err != nil로 처리 가능

res, err := http.Get(pageURL)
checkErr(err)

doc, err := goquery.NewDocumentFromReader(res.Body)
checkErr(err)

 

html 파싱 후 생성한 doc에서 원하는 css selector를 find를 통해 찾는다

참고: HTML 태그

- class로 찾기: '.' 사용 (예시: doc.Find(".line_no"))
- id로 찾기: '#' 사용 (예시: doc.Find("#line_no"))

 

lineNum := doc.Find(".line_no").Text()
stationNm := doc.Find(".place_name").Text()

 

res.Body는 다 썼으면 항상 Close() 해줘야 한다

defer res.Body.Close()

이유는 "It is a resource leak. It can remain open and client connection will not be reused" 라고...

https://stackoverflow.com/questions/23928983/defer-body-close-after-receiving-response

 

Defer body.close after receiving response

...... resp, err := httplib.Get(url) if err != nil { fmt.Println(err) } defer resp.Body.Close() ...... Is it necessary to close the response body every time?

stackoverflow.com

 

 

마지막으로 얻어온 정보는 extractedInfo라는 type에 담아 채널로 보낸다

type extractedInfo struct {
	lineNum   string
	stationNm string
	naverCode int
}


c <- extractedInfo{
	lineNum:   lineNum,
	stationNm: stationNm,
	naverCode: code}

 

 


 

지금까지 스크래퍼의 핵심 부분에 대해서 알아보았다.

 

다음 글에서는 (스크래퍼로 얻은) 취합된 정보를 S3에 업로드 하는지 설명할 예정이다. 진짜 이 과정도 쉽지 않았다;; 꼭 기록해둬서 나도 보고 다른 사람도 볼 수 있게 해야지