Goroutine을 사용해보자 (feat. Web scrapper)

Wondeok Kang (a.k.a. Wade)·2022년 4월 17일
0
post-thumbnail

1. 개요

요즘 개인적인 관심으로 Go를 열공중이다. 퍼포먼스가 잘나오는 메시지 브로커 서비스를 만들어 보고싶기도 하고 최근 인기가 높아지고있는 언어여서 다른 프로그래밍 언어와 어떤것이 다른지, 무엇이 좋은지 궁금하기도 했다.
일단 나는 줄곧 Java를 사용해서 일해왔고 제2외국어 같은 개념으로 파이썬 및 자바스크립트를 다룰 줄 안다. 이번에 Go를 공부하면서 Java, Python과 가장 크게 느껴진 점은 "쉽다" 였다. Java, Python 역시 정말 쉬운언어다 생각하지만 앞의 두 언어보다 더 빠르게 학습을 진행하였다.

가장 좋았던건 경우의 수가 없다라고 할 정도로 무엇인가 처리하기 위한 방법이 정해져있다. 예를 들면 for, forEach, While 등 하나의 루프를 돌리기위한 다양한 방법이 Go에서는 딱 for 하나로 고정되어 있다. 그래서 for 하나만 알면되는 매우매우 효율적인 언어 되시겠다. 그렇다 보니 언어를 공부하고 무엇인가 만들기 시작하려 맘먹으면 바로 시작할 수 있다. 그 외에 npm과 같은 온갖 패키지 관리자를 깔지 않아도 되는점.. 방대한 기본 라이브러리 등등이 있다.

아무튼 정말 재밌게 공부하고있다는점... 특히 Goroutine..정말 맘에든다. 그래서 이놈을 사용해서 웹 스크래퍼를 만들어 보고 Goroutine을 사용 할때와 사용하지 않을때의 성능 차이를 비교해 보려한다. 포스팅에 쓰인 웹 스크래퍼는 노마드코더 니꼬선생님의 강좌를 보고 공부했을 때 만들어 봤었던 프로젝트다.


2. Web scrapper

일단은 Goroutine을 사용하지 않고 일반적인 프로그래밍 방법으로 개발한 상태이다. 라인을 따라서 코드 한줄한줄 수행이 되는 정말 일반적인 프로그램이다. 프로그램에 쓰인 사이트는 해외 구직 사이트인 indeed이다.

https://kr.indeed.com/jobs?q=python&limit=50&start=0

2-1. main 함수

일단 main 함수이다. 과정은 정말 간단하다. 스크랩 할 페이지 수를 알아내고 페이지 수만큼 웹 페이지를 스크랩하여 구직정보만 csv로 만들어내는 프로그램이다.

func main() {
	start := time.Now()
	
    // 구직 정보 slice
	var jobs []extractJob
	
    // 스크랩할 페이지 수 
	totalPages := getPages()

	for i := 0; i < totalPages; i++ {
    
    	// 페이지 수만큼 스크랩 시작 및 struct 생성 (총 5페이지)
		extratedJobs := getPage(i)
		jobs = append(jobs, extratedJobs...)
	}
	
    // 스크랩한 정보를 csv로 export
	writeJobs(jobs)

	duration := time.Since(start)

	log.Printf("Job scrapper total execute time : %s", duration)
}

위 main 함수에서 사용된 함수들을 나열해 보면 다음과 같다. goroutine을 사용할 때와 사용하지 않을 때 코드상의 변화가 어떤지 보기 위해 나열해본다.

2-2. getPages 함수

스크랩할 페이지 수를 알아 내는 함수이다. baseUrl을 호출해서 해당 페이지에서 페이지(pagenation)를 찾아내서 몇개인지 리턴하며 goquery를 사용하였다.

var baseUrl = "https://kr.indeed.com/jobs?q=python&limit=50"

func getPages() int {
	// baseUrl을 통해 기본 페이지를 요청한다.
	res, err := http.Get(baseUrl)
	pages := 0

	checkErr(err)
	checkStatusCode(res)
	
	doc, err := goquery.NewDocumentFromReader(res.Body)
	
    // 페이지 내 페이징 정보를 찾아 개수를 세팅한다.
	doc.Find(".pagination").Each(func(i int, selection *goquery.Selection) {
		pages = selection.Find("a").Length()
	})

	defer res.Body.Close()
	return pages
}

2-3. getPage 함수

주어진 페이지에 존재하는 구직 정보를 찾아내 struct로 만들고 slice에 담아 리턴한다. 한페이지에 50개의 구직정보가 표현된다. 즉 extratJob 타입의 slice는 크기가 50이 된다.

func getPage(page int) []extractJob {
	start := time.Now()
    
	var jobs []extractJob
	pageUrl := baseUrl + "&start=" + strconv.Itoa(page*50)
	log.Println("Request URL : " + pageUrl)

	res, err := http.Get(pageUrl)
	checkErr(err)
	checkStatusCode(res)

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

	extractStart := time.Now()
	
    doc.Find(".jobCard_mainContent").Each(func(i int, card *goquery.Selection) {
		job := extractJobs(card)
		jobs = append(jobs, job)
	})
	
    log.Printf("extract job completion time. : %s", time.Since(extractStart))

	log.Printf("Page process completion time. : %s", time.Since(start))
    
    defer res.Body.Close()
	return jobs
}

2-4. extractJobs

구직정보를 extractJob sturct로 만들어주는 함수이다.

func extractJobs(card *goquery.Selection) extractJob {
	jobTitle := clearString(card.Find(".jobTitle").Text())
	companyName := clearString(card.Find(".companyName").Text())
	address := clearString(card.Find(".companyLocation").Text())

	return extractJob{title: jobTitle, address: address, companyName: companyName}
}

위와 같이 코딩 후 스크랩 작업을 수행해보았다.
총 250건의 데이터를 csv 파일로 변환하는데 대략 2.54초 정도 소요되었다. 페이지 요청 및 응답에 대략 350~390 ms가 걸렸고, struct를 구성하는 작업은 체감할수 없을만큼 빠르게 마이크로초 단위로 수행되었다. struct 구성 작업은 해당 테스트에서 의미가 없을 듯 하다.

2022/04/17 22:37:01 Request URL : https://kr.indeed.com/jobs?q=python&limit=50&start=0
2022/04/17 22:37:01 extract job completion time. : 490.386µs
2022/04/17 22:37:01 Page process completion time. : 378.770581ms
2022/04/17 22:37:01 Request URL : https://kr.indeed.com/jobs?q=python&limit=50&start=50
2022/04/17 22:37:02 extract job completion time. : 391.078µs
2022/04/17 22:37:02 Page process completion time. : 395.078696ms
2022/04/17 22:37:02 Request URL : https://kr.indeed.com/jobs?q=python&limit=50&start=100
2022/04/17 22:37:02 extract job completion time. : 372.567µs
2022/04/17 22:37:02 Page process completion time. : 440.548731ms
2022/04/17 22:37:02 Request URL : https://kr.indeed.com/jobs?q=python&limit=50&start=150
2022/04/17 22:37:02 extract job completion time. : 368.813µs
2022/04/17 22:37:02 Page process completion time. : 395.177684ms
2022/04/17 22:37:02 Request URL : https://kr.indeed.com/jobs?q=python&limit=50&start=200
2022/04/17 22:37:03 extract job completion time. : 367.81µs
2022/04/17 22:37:03 Page process completion time. : 359.591716ms
2022/04/17 22:37:03 Job scrapper total execute time : 2.540835636s

그럼 여기서 goroutine을 적용하여 같은 작업을 진행해보겠다. 전체 코드는 아래와 같다. main, getPage, extractJobs 함수의 코드의 변경점을 보면 된다. 각각 채널로 연결되어 extractJobs -> getPage -> main 순으로 통신한다.

var baseUrl = "https://kr.indeed.com/jobs?q=python&limit=50"

type extractJob struct {
	title       string
	companyName string
	address     string
}

func main() {
	start := time.Now()

	mc := make(chan []extractJob)

	var jobs []extractJob

	totalPages := getPages()

	for i := 0; i < totalPages; i++ {
		go getPage(i, mc)
	}

	for i := 0; i < totalPages; i++ {
		jobs = append(jobs, <-mc...)
	}

	writeJobs(jobs)

	log.Printf("Job scrapper total execute time : %s", time.Since(start))

}

func writeJobs(jobs []extractJob) {
	file, err := os.Create("jobs.csv")
	checkErr(err)

	w := csv.NewWriter(file)

	headers := []string{"Title", "companyName", "address"}

	wErr := w.Write(headers)
	checkErr(wErr)

	for _, job := range jobs {
		jwErr := w.Write([]string{job.title, job.companyName, job.address})
		checkErr(jwErr)
	}

	defer w.Flush()
}

func getPage(page int, mc chan<- []extractJob) {
	start := time.Now()
	var jobs []extractJob

	c := make(chan extractJob)

	pageUrl := baseUrl + "&start=" + strconv.Itoa(page*50)
	log.Println("Request URL : " + pageUrl)

	res, err := http.Get(pageUrl)
	checkErr(err)
	checkStatusCode(res)

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

	cards := doc.Find(".jobCard_mainContent")

	extractStart := time.Now()
	cards.Each(func(i int, card *goquery.Selection) {
		go extractJobs(card, c)
	})
	log.Printf("extract job completion time. : %s", time.Since(extractStart))

	for i := 0; i < cards.Length(); i++ {
		job := <-c
		jobs = append(jobs, job)
	}

	log.Printf("Page process completion time. : %s", time.Since(start))

	mc <- jobs
	defer res.Body.Close()
}

func getPages() int {
	res, err := http.Get(baseUrl)
	pages := 0

	checkErr(err)
	checkStatusCode(res)

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

	doc.Find(".pagination").Each(func(i int, selection *goquery.Selection) {
		pages = selection.Find("a").Length()
	})

	defer res.Body.Close()
	return pages
}

func extractJobs(card *goquery.Selection, c chan<- extractJob) {
	jobTitle := clearString(card.Find(".jobTitle").Text())
	companyName := clearString(card.Find(".companyName").Text())
	address := clearString(card.Find(".companyLocation").Text())

	c <- extractJob{title: jobTitle, address: address, companyName: companyName}
}

go 코드를 읽을줄 아는 상태라면 goroutine을 적용하는 방법이 정말 쉽다는것을 알 수 있다. go 키워드 하나만 앞에 넣어주고 채널로 연결해주면 끝난다. 이렇게 간단한 작업으로 인해 결과는 드라마틱하게 변하게 된다.
csv파일을 삭제하지 않고 진행 했을때에는 같은 작업이 1초정도 걸렸고 처음과 동일하게 csv파일을 삭제후 진행했을때는 977ms 소요되었다. 2.5배 빠른 성능을 보여준다. 로그를 보면 URL 호출작업이 동시에 수행 된것을 알 수 있다.

2022/04/17 22:51:09 Request URL : https://kr.indeed.com/jobs?q=python&limit=50&start=200
2022/04/17 22:51:09 Request URL : https://kr.indeed.com/jobs?q=python&limit=50&start=0
2022/04/17 22:51:09 Request URL : https://kr.indeed.com/jobs?q=python&limit=50&start=50
2022/04/17 22:51:09 Request URL : https://kr.indeed.com/jobs?q=python&limit=50&start=150
2022/04/17 22:51:09 Request URL : https://kr.indeed.com/jobs?q=python&limit=50&start=100
2022/04/17 22:51:09 extract job completion time. : 76.21µs
2022/04/17 22:51:09 Page process completion time. : 339.536346ms
2022/04/17 22:51:09 extract job completion time. : 29.958µs
2022/04/17 22:51:09 Page process completion time. : 369.220821ms
2022/04/17 22:51:09 extract job completion time. : 22.003µs
2022/04/17 22:51:09 Page process completion time. : 375.372872ms
2022/04/17 22:51:09 extract job completion time. : 23.711µs
2022/04/17 22:51:09 Page process completion time. : 404.746155ms
2022/04/17 22:51:10 extract job completion time. : 25.962µs
2022/04/17 22:51:10 Page process completion time. : 424.408056ms
2022/04/17 22:51:10 Job scrapper total execute time : 977.1021ms

다른 언어에서도 비동기 병렬 프로그래밍을 지원 하지만 go만큼 간단하게 하지는 못했던것 같다. 코루틴을 사용해보지 않아서 모르겠지만... 어찌되었건 Java와 비교해보면 쓰레드에 비해 더욱더 적은 리소스로 작업이 된다고 알고있다. 실제로 사용해보니 이것저것 해보고싶은게 많아 지는 언어이다.

profile
최고의 개발도구는 기록과 구글링이다.

0개의 댓글