고루틴은 경량화된 쓰레드를 사용하여 비동기 처리를 하는 매우 가벼운 쓰레드이며 일반적으로 Java 같은 언어에서 사용하는 쓰레드와는 조금 다르다.
경량화 쓰레드는 개당 2KB 정도의 스택 메모리를 사용하는 반면 일반적인 Java에서 사용하는 쓰레드는 2MB 정도의 스택 메모리를 사용하게 된다. 그만큼 많은 경량화 쓰레드를 생성하여 매우 가볍게 쓸 수 있게 된다.
*언어마다 쓰레드와 맵핑되는 모델이 조금 다른데 Java 같은 경우는 1:1로 맵핑을 하게 되어 멀티 코어가 지원되는 대신 컨텍스트 스위칭이 느리다는 단점이 있고, N:1 모델의 경우에는 멀티 코어가 지원이 안되지만 컨텍스트 스위칭이 빠르다는 단점이 있는데 Go에서는 두 마리의 토끼를 잡기 위해 M:N 모델을 채택하게 되었고 언어 수준에서 구현할 수 있게 잘 만들어졌기에 쉽게 사용할 수 있다는 장점이 있다.
기술 블로그 모음 사이트(Stdout)를 운영하다 보니 적지 않은 양의 데이터의 수집을 하게 되었다. 물론 처음 한 번만 쭉 받아 오면 그 이후에는 저장하지 않은 데이터만 처리를 하면 된다. 그럼에도 불구하고 Java 기반의 RSS 관련 라이브러리는 내가 원하는 곳을 시원하게 긁어주지 못하는 느낌이 들어 뭔가 아쉬웠고 초기 데이터를 가져오는 시간을 더 줄일 수 없을까 싶은 고민을 하다 보니 여기까지 오게 되었다.
우선 프로세스에 대해서 간단하게 설명하자면 아래와 같다.
1. 수집된 RSS 주소를 통해 Feed(사이트 단위라고 보면 됨)들을 가져온다.
2. 한 개의 Feed에 여러 Item들이 있고 직접 정의한 struct타입으로 데이터를 변환해주는 작업을 한다.
3. Item의 경우 GUID라는 값을 사용하여 이미 읽었던 데이터인지 쉽게 확인할 수 있다.
4. 첫 1회는 모든 데이터를 수집하게 되고, 이후에는 GUID를 통해서 업데이트가 필요한 부분만 추가적으로 Redis에 담게 되었다.(이후에는 아마 cron을 설정하여 스케쥴링을 하여 데이터를 수집하게 될 것 같다.)
5. Redis 데이터 수집이 끝났을 때 Spring 서버에서 Redis에 저장된 데이터들을 기반으로 DB에 삽입하게 된다.
Go언어는 무조건 고루틴이 적용되는 게 아니기 때문에 일반적인 함수들은 다른 언어와 다를 바 없이 순차적으로 실행하게 되기에 직접 작성을 해줘야 한다.
고루틴을 사용할 때는 go 키워드를 사용하며 sync.WaitGroup를 통해 실행 중인 고루틴을 기다릴 수 있다.
*아래의 예시에서는 여러 함수들을 제외하고 main함수와 데이터 관련 함수만 보여준다는 것을 참고하자.
var wg sync.WaitGroup
wg.Add(고루틴 추가)
wg.Done(추가된 고루틴 실행 완료)
wg.Wait(고루틴이 실행이 끝날 때까지 기다림)
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/mmcdole/gofeed"
"github.com/redis/go-redis/v9"
"golang.org/x/net/html"
)
type Item struct {
Guid string
Title string
Thumbnail string
Description string
Link string
Published string
}
var ctx = context.Background()
var wg sync.WaitGroup
func main() {
redisHost := os.Getenv("REDIS_HOST")
redisPort := os.Getenv("REDIS_PORT")
if redisHost == "" {
redisHost = "localhost"
}
if redisPort == "" {
redisPort = "6379"
}
start := time.Now()
rdb := GetRedisClient(redisHost+":"+redisPort, "", 0)
allChannels := GetAllChannels()
for key := range allChannels {
clearItems(rdb, key)
}
InsertAllChannelItems(&wg, ctx, rdb, allChannels)
wg.Wait()
end := time.Now()
fmt.Println(end.Sub(start))
}
func InsertAllChannelItems(wg *sync.WaitGroup, ctx context.Context, rdb *redis.Client, channels map[string]string) map[string][]Item {
parser := gofeed.NewParser()
result := map[string][]Item{}
for channelName := range channels {
wg.Add(1)
go func(name string) {
defer wg.Done()
items := []Item{}
feed, err := parser.ParseURL(channels[name])
if err != nil {
return
}
html, _ := FetchHtml(feed.Link)
thumbnail, err := ExtractThumbnail(html)
if err != nil {
}
recentRGUID := GetRecentGUIDInRedis(ctx, rdb, name+"_GUID")
recentFGUID := GetRecentGUIDInFeed(feed)
if feed.Items[0].GUID == recentRGUID {
fmt.Println("[No Updated Item] - " + name)
return
}
for _, item := range feed.Items {
newItem := Item{
Guid: item.GUID,
Title: item.Title,
Description: item.Description,
Thumbnail: thumbnail,
Link: item.Link,
Published: item.Published,
}
fmt.Println(item.GUID)
items = append(items, newItem)
}
jsonData, _ := json.Marshal(items)
SetRecentGUID(ctx, rdb, channelName+"_GUID", recentFGUID, 0)
SetRecentITEM(ctx, rdb, channelName+"_ITEM", jsonData, 0)
result[channelName] = items
}(channelName)
}
return result
}
1,168개의 데이터를 한 번에 가져왔을 때 걸리는 시간은 아래와 같다.
성능 향상은 약 91.67% 정도 개선되었다고 보면 된다. 물론 엄청 양이 많은 데이터가 아니라 개선된 퍼센테이지는 높을 수 있다고 본다. 혹시나 더 많은 양의 데이터를 다룰 일이 생기게 된다면 그 때도 후기를 남겨보겠다..
처음에는 무슨 나노 단위의 시간대가 나오길래 놀랬는데 알고 보니 wg.Wait()을 쓰지 않아 고루틴이 종료가 되기 전에 main 함수가 종료되어서 그렇게 나오게 되었던 거라 수정을 하게 되었다.
꼭 Add(), Done(), Wait()에 유의하자 아마 이 친구들 때문에 많은 시행착오가 있을 것이다..
Go는 문법이 그렇게 많지 않기 때문에 쉽다고 느낄 수도 있지만, C와 C++ 기반으로 만들어진 언어이기 때문에 마냥 쉽다곤 할 수 없을 것 같다. 처음에는 패키지 관리하는 게 특이해서 시간을 오래 잡아먹기도 했고 ORM 같은 경우도 매우 많은 선택지가 있어서 한동안 DB 관련 작업은 JPA를 쓰는 게 좋겠다고 판단을 할 정도였으니 참고하면 좋을 것 같다.