[GO] DOTORI 프로세스 속도 개선기

배채윤·2021년 1월 20일
1
post-custom-banner

🐿 DOTORI?


DOTORI는 RD101에서 개발한 Web base 어셋라이브러리로, VFX 애니메이션 게임 등 콘텐츠 작업에 사용할 Asset들을 저장해두는 툴이다.
DOTORI 도입이 시작되면서 기존 Asset 들을 DOTORI로 옮기는 작업이 필요해졌는데, 총 용량이 족히 50TB는 되는 터라 이동 과정에서 여러 가지 문제가 발생하고 있다. 그중 가장 큰 문제는 어셋 썸네일 생성 속도다.


😰 문제점

어셋 썸네일 생성에 너무 많은 시간이 지체되고 있다. 어셋 227개를 일주일 전에 업로드 시작했는데 아직 182개밖에 완료되지 않았다.

이는 어셋 하나 당 대략 1시간 정도가 소요된다는 것을 의미한다. 앞으로 업로드 해야할 어셋은 약 10000개다. 눈대중으로만 봐도 10000개지 세보면 더 있을 것으로 예상됐다. 이 속도면 일 년 반 동안 업로드만 해도 끝내지 못한다. 시간 단축을 위한 솔루션이 필요해보인다.


🤔 해결 방법

방법 1 : proxy 시퀀스 생성을 비동기로 연산한다.

지금은 proxy 시퀀스 생성이 동기식으로 이루어진다. 예를 들어, 프록시 시퀀스 1001부터 1100까지 생성하는 프로세스라고 했을 때 1001부터 1100까지의 시퀀스 연산이 순서대로 진행된다. 1001 연산이 종료돼야 1002 연산이 시작되고, 1003 연산 또한 1002가 끝날 때까지 기다린다는 의미다. 시퀀스를 for문을 돌면서 렌더 커맨드를 날리고 있기 때문에 이런 일이 발생한다. 이때 이 커맨드 실행 부분을 goroutine을 이용해서 비동기로 수행하게 하는 것이 방법1이다.

렌더 커맨드는 Go의 Execute command를 이용해서 날려주고 있는 구조다. 위처럼 go routine으로 렌더 명령을 비동기로 실행하게 했을 때 Execute command 자체는 go routine에 의해 실행되지만 결국 자식 프로세스를 새로 생성한다. 따라서 멀티스레딩이 아니라 멀티 프로세싱으로 동시 작업을 진행할 것이다. 방법 1은 각각의 고루틴들이 생성해낸 프로세스가 동시에 수행되기 때문에 멀티 프로세싱으로 이어진다. 멀티프로세싱은 멀티스레드보다 많은 메모리 공간과 CPU 시간을 차지하는 건 맞지만 어쨋든 그냥 일반 프로세싱에서 멀티 프로세싱으로 구조를 변경할 수 있는 것이니, 시간 단축의 효과는 있을 것으로 보인다.

연산이 다 끝난 후 다음 공정으로 넘어가도 된다는 신호를 받기 위해 go channel을 함께 이용한다. 또한, 지나치게 많은 go routine이 동시에 실행되지 않도록 버퍼드 채널의 특성을 이용하여 한계치를 지정해둔다.

방법 2

방법1 관련해서 정말 고민을 많이 했지만 생각해보니 꽤 간단하게 해결할 수 있는 문제였다. dotori는 queueingItem 함수에서 10초에 한 번씩 썸네일 처리가 아직 되지 않은 아이템을 찾아, jobs channel에 넣고 있다.

dotori 웹서버를 실행하면 그와 동시에 ProcessMain() 함수가 실행된다. 아래와 같이 go routine을 이용하여 webServer() 함수와 동시에 실행된다.

go ProcessMain()
webserver()

ProcessMain()은 도토리의 프로세스 전체 흐름을 만드는 함수다. 정확히 말하자면, dotori에 업로드되는 어셋들의 썸네일 생성 연산을 멀티쓰레딩 처리해주는 함수다.

// ProcessMain 는 도토리의 프로세스 전체 흐름을 만드는 함수다.
func ProcessMain() {
	...
    
 	// 버퍼 채널을 만든다.
	jobs := make(chan Item, adminSetting.ProcessBufferSize)

	// worker 프로세스를 지정한 개수만큼 실행시킨다.
	for w := 1; w <= *flagMaxProcessNum; w++ {
		go worker(jobs)
	}

	// queueingItem을 실행시킨다.
	go queueingItem(jobs)

	select {}
}

// 아이템을 가져와서 버퍼 채널에 채우는 함수
func queueingItem(jobs chan<- Item) {
	for {
		item, err := GetFileUploadedItem()
		if err != nil {
			...
        	}
		jobs <- updatedItem
		// 10초후 다시 queueing 한다.
		time.Sleep(time.Second * 10)
	}
}

이때 실제 썸네일 연산 함수를 호출해주는 worker를 Go Routine으로 비동기로 호출했다. 단순히 dotori 실행 시, flagMaxProcessNum 의 옵션에 worker의 개수를 지정해줌으로써 멀티쓰레딩 처리를 구현했다.

// worker는 썸네일 처리 연산을 호출한다.
func worker(jobs <-chan Item) {
	for j := range jobs {
		processingItem(j)
	}
}

// processingItem 은 썸네일 처리가 필요한 item을 받아 연산 함수를 호출한다.
func processingItem(item Item) {
	...
    
	// ItemType별로 연산
	switch item.ItemType {
	case "footage":
		err = ProcessFootage(client, adminSetting, item)
		if err != nil {
			err = SetErrStatus(client, item.ID.Hex(), err.Error())
			if err != nil {
				log.Println(err)
			}
			return
		}
		return
        
   	...
}

아래와 같은 명령어를 실행하면 썸네일 연산 처리를 동시에 40까지 호출할 수 있다.

$ dotori -process -maxprocessnum 40

golang이 동시성 처리는 진짜 최고인 것 같다!🤗

📌 참고! 동시 vs 병렬

하나의 task에 대해 여러 일꾼(multi thread)이 일을 하는 것은 같다.

  • 병렬성 : 한 번에 여러 가지 일을 처리하는데, 정말 동시간대에 여러 thread가 한 번에 수행되는 것을 의미한다. 즉, 특정 시각에 N개의 thread가 heap의 특정 변수에 접근이 가능하다. > resource에 대한 동기화. unlock/lock이 중요하다.
  • 동시성 : 한 번에 여러 가지 일을 처리하는 것을 의미. 그러나 특정 시각에 수행되는 thread는 하나다. 그러나 매우 짧은 시간 동안 수행 스레드가 스위칭되면서 자원에 대한 간섭없이 일을 처리한다.

📌 참고! 멀티 프로세싱 vs 멀티 스레딩

  • 멀티프로세싱 : 하나의 프로세스가 죽더라도 다른 프로세스에는 영향을 끼치지 않고 정상적으로 수행되지만 멀티스레드보다 많은 메모리 공간과 CPU 시간을 차지한다.
  • 멀티스레딩 : 멀티프로세스마다 적은 메모리 공간(스택영역)을 차지하고 Context Switching이 빠르다는 장점이 있지만 오류로 인해 하나의 스레드가 종료되면 전체 스레드가 종료될 수 있다. 또한, 같은 메모리의 Data, heap 영역을 공유하기 때문에 자원 공유의 문제(동기화 문제)가 발생한다. 다른 스레드에서 사용 중인 변수나 자료구조에 접근하여 엉뚱한 값을 읽어오기 떄문이다.

💡 결론

방법 2로 해결했다!
서버를 운영하고 있던 vm의 코어 수를 늘리고 프로세스를 동시에 처리하도록 했다.
무용지물이던 멀티코어를 활용하도록 처리한 것이다. 우리 서버 옴청 비싼 건디... 남아도는 코어 잘 써먹어야지!
그 결과, Asset 하나 당 1시간 씩 소요되던 연산이 5분 안에 끝나는 것을 확인할 수 있었다.

Reference

profile
새로운 기술을 테스트하고 적용해보는 걸 좋아하는 서버 개발자
post-custom-banner

0개의 댓글