[Golang] gocraft/work를 이용한 백그라운드 작업

김성수·2020년 6월 3일
0

Golang

목록 보기
1/1
post-thumbnail

내가 속한 팀에서 운영하는 웹 애플리케이션의 서버는 Golang으로 작성되어 있다.
앱 안에는 실시간으로 파일을 분석해주는 기능이 있는데, 사용자가 분석할 파일을 던지면 분석 결과를 볼 수 있는 페이지로 이동하도록 되어 있다.

분석이 완료되면 결과 페이지가 뜨고, 사용자는 분석 결과를 열람할 수 있다.
어느 날 사용자가 분석 요청한 결과를 조회하지 못한다는 이슈를 받았다.

이슈의 원인을 살펴보기 위해 서버 쪽 소스를 살펴보니 프로세스는 다음과 같았다.

파일 분석 요청
-> 분석 시작
-> 사용자는 분석이 완료될 때까지 페이지에 머무른다. (서버에 주기적으로 분석 상태를 질의한다.)
-> 분석이 완료되면 분석 결과 페이지를 반환한다.

@ But, 사용자가 페이지를 벗어나면 파일분석은 완료되지만 분석 결과는 저장되지 않는다.

사용자가 페이지에 계속 머물러야 파일 분석 결과를 볼 수 있는 희안한 시스템이다.

 

사용자는 한가하지 않아

파일분석은 아무리 빨라야 1분 이상 걸리게 마련이다. 아무도 그 시간동안 멍하니 기다리고 싶어 하진 않을 것이다. 사용자는 분석을 요청하고 다른 일을 할 수 있도록, 그리고 편할 때 분석 결과를 확인할 수 있도록 해야 했다.

사용자가 요청을 던지면, 이후 추가 요청이 없어도 서버쪽에서 알아서 분석 결과를 저장한다.

사용자 요청 -> (서버: 분석 시작 -> 분석 진행 -> 분석 완료 -> 분석 결과 저장) -> 아무 때나 분석 결과 요청

어떻게 구현을 해야할까. 짱구를 굴리다가 후보를 압축해보았다.

  1. 해당 작업을 수행하는 별도의 프로그램(worker)이 있어야 한다.
  2. 클라이언트에서 들어오는 요청을 서버에서 받아 worker로 보내준다.
  3. worker는 들어온 요청을 수행한다.

1번이 가장 큰 문제였다. 어떤 방식으로 넘겨줄 것인가?

  1. UDP
  2. Cmd

모두 나쁘진 않은 방법이라고 생각했다. 어떻게든 요청을 넘겨주기만 하면 돼..! 라는 생각이었으니까. 하지만 단점은 분명 존재했다.

udp는 빠르지만 불안정하다. 수많은 요청이 오가는 레벨에서는 쓰기가 꺼려졌다.
cmd로 직접 명령을 주는 것은 안정적이지만 느리다.

공통적으로, 만약 프로그램이 어쩌다가 에러가 나서 꺼지면, 실행 중이던 작업들은 어떡하지?

생각난 건 이 두가지 뿐이라 계속 고민을 하면서 구글링을 했다.

그러던 중, gocraft/work 라이브러리를 찾았다.

 

gocraft/work

  1. 백그라운드에서 수행하고 싶은 작업들을 관리해주는 라이브러리다.
  2. 큐 방식으로 작업을 적재하고 실행한다. 작업끼리 충돌이 일어날 가능성이 거의 없다.
  3. redis에 작업을 저장한 후, 뽑아서 쓰는 방식이다. 들어온 작업들을 모두 기억하고 상태를 체크하기 때문에 특정 작업이 잘못되거나, 서버가 끊겨도 작업을 재개할 수 있다.

위의 장점들은 앞서 내가 생각했던 방식들의 단점을 모두 커버해주는 안정성 높은 방법이라 바로 테스트 후 적용해보았고, 현재까지도 잘 운용하고 있다.

아래의 내용은 위 라이브러리를 어떻게 사용하면 되는지에 대해 간단한 예제를 적어놓은 것이다.

 

작업 생성하기

작업을 생성하려면 작업을 넣어주는 스크립트를 생성해야 한다.
gocraft/work는 redis를 이용하여 작업을 저장/관리한다.
이 글에서는 redis가 이미 구축되어 있다고 가정하고 진행한다.

 
gomodule/redigo 라이브러리를 이용하여 redis connection pool을 생성해준다.
테스트용으로 쓸 데이터베이스 번호는 2번으로 설정했다.

var redisPool = &redis.Pool{
	MaxActive: 5,
	MaxIdle:   5,
	Wait:      true,
	Dial: func() (redis.Conn, error) {
		return redis.Dial("tcp", ":6379", redis.DialDatabase(2))
	},
}

 
이후에 작업을 담을 네임스페이스와 키 값을 정해준다.

var jobNameSpace = "bowl"
var jobName = "food"

 
작업을 넣는 역할을 하는 수행자와 함수도 생성해준다.

var enqueuer = work.NewEnqueuer(jobNameSpace, redisPool) // 원하는 네임스페이스와 연결 풀로 작업을 넣어줍니다.


func setJob(task int, food string) error {

	_, err := enqueuer.Enqueue(jobName, work.Q{"task": task, "food": food})
	if err != nil {
		log.Println(err)
		return err
	}
	return nil
}

 
여기까지 하면 작업을 넣을 준비가 됐다! 이제 작업을 넣어보자.

import (
	"github.com/gocraft/work"
	"github.com/gomodule/redigo/redis"
)

func main() {

	err := setJob(1, "GalbiTang") // 1과 "GalbiTang"이라는 값을 넣어 작업을 생성합니다.
	if err != nil {
		log.Println(err)
		return
	}
}

 
이렇게 작성한 스크립트를 실행하면, 작업을 생성할 수 있습니다.
이제 생성된 작업을 수행해 봅시다.

 

작업 수행하기

작업을 넣었다면 수행을 해주어 작업을 마무리 해보자.

저장된 작업을 가져와 특정 프로그램을 실행하는 스크립트를 생성한다.

 
작업은 redis에 저장해 놓았으니, 먼저 redis connection pool을 생성해준다.

var RedisPool = &redis.Pool{
	MaxActive: 5,
	MaxIdle:   5,
	Wait:      true,
	Dial: func() (redis.Conn, error) {
		return redis.Dial("tcp", ":6379", redis.DialDatabase(2))
	},
}

 
gocraft/work는 작업의 고유성을 구분하기 위해 구조체를 사용한다.
고유성의 기준은 구조체의 포인터와 구조체 필드의 값이다. 코드를 먼저 확인해보자.

type Context struct {
	task int64
}

Context라는 이름의 구조체를 설정했다. 고유성을 부여해줄 수 있는 필드값이 필요하다. 위에서 작업을 생성했을 때 썼던 task 변수를 고유값으로 쓰겠다.

task는 작업의 번호다. 작업의 번호는 매 작업을 구분하는 용도이기 때문에 작업의 고유성을 가리킬 수 있다.

 

그리고 NewWorkerPool함수를 이용하여 workerpool을 생성해준다.

import (
	"github.com/gocraft/work"
	"github.com/gomodule/redigo/redis"
)

func main() {
		
	pool := work.NewWorkerPool(Context{}, 10, "bowl", RedisPool)
}

 

작업을 가져오면, 실행하기 전 해야하는 밑작업들이 있을 수 있다.
gocratt/work에서는 Middleware라는 함수를 통해 작업을 실행하기 전, 사전작업들을 할 수 있다.
웹 애플리케이션에서 쓰이는 Middleware와 비슷한 기능을 수행한다고 생각하면 될 듯 하다.

 

나는 먼저 Context 구조체에 고유값을 저장해주고 싶다.
가져온 작업에 담겨 있는 task값을 Contexttask필드에 저장한다.

func (c *Context) FindTask(job *work.Job, next work.NextMiddlewareFunc) error {
	if _, ok := job.Args["task"]; ok {  // 가져온 작업 안에 해당 키를 가진 값이 있는지 확인
		c.task = job.ArgInt64("task")  // int로 넣은 값은 Int64 타입으로 가져옵니다.
		if err := job.ArgError(); err != nil {
			log.Printf("Error: %v, No task", err)
			return fmt.Errorf("Error: %v, No task", err)
		}
	}

	return next()
}
pool.Middleware((*Context).FindTask)

 

나는 taskContext의 필드값으로 사용하고자 하기 때문에
FindTask라는 함수를 통해 작업을 실행하기 전, task값을 가져왔다.

작업이 들어올 때마다 로그를 찍고 싶다. 로깅할 수 있는 미들웨어도 작성한다.

func (c *Context) Log(job *work.Job, next work.NextMiddlewareFunc) error {
	log.Printf("Starting job: %s. Task: #%d", job.Name, c.task)
	return next()
}
pool.Middleware((*Context).Log)

 

이제 들어온 작업으로 수행할 수 있는 함수를 만들어 보자.

func (c *Context) foodString(job *work.Job) error {

	foodName := job.ArgString("food")

	fmt.Printf("One of my favorite foods is %d: %s\n", c.task, foodName)
    
	return nil
}

작업이 들어오면 foodString 함수를 수행하고 끝내는 프로세스를 만든다.
작업에서 food라는 키값을 가진 string 타입 값을 받아 task값과 함께 출력하도록 했다.

 

작성한 스크립트를 실행해 보자

$ go run worker.go

2020/04/09 12:42:06 main.go:29: Listen job...
2020/04/09 12:42:06 main.go:57: Starting job: food. Task: #1
One of my favorite foods is 1: GalbiTang

아까 내가 넣었던 작업을 가져와 정상적으로 함수가 수행된 것을 확인할 수 있다.
이렇게 하면 백그라운드 작업은 끝난다.

내가 넣은 작업은 redis에 어떤식으로 저장되는 걸까?

 

작업 저장 형태

$ redis-cli -p 6379 -n 2

127.0.0.1:6379[2]> keys *
1) "bowl:jobs:food:max_concurrency"
2) "bowl:last_periodic_enqueue"
3) "bowl:jobs:food"
4) "bowl:known_jobs"

Redis에 들어가보면,
내가 생성했던 작업과 관련된 key들이 생성된 것을 볼 수 있습니다.

여기서 내가 확인하고 싶은 것은 내가 입력한 작업의 세부 사항이 잘 들어갔는가 이다.
그것을 확인하려면

"jobNameSpace:jobs:jobName" 형태의 key를 확인하면 된다.
위 항목 중 3)번 key("bowl:jobs:food")가 위의 조건에 맞는다.

 

key를 읽기 전, 타입을 먼저 파악해준다.

127.0.0.1:6379[2]> type "bowl:jobs:food"
list

리스트 형식의 key다. 그렇다면 food라는 jobName으로서 여러 작업을 넣을 수가 있을 것 같다.

 

방금 넣었던 작업 내용을 읽어보자.

127.0.0.1:6379[2]> lrange "bowl:jobs:food 0 1"
1) "{\"name\":\"food\",\"id\":\"6432f42e9a3485c3e0852d64\",\"t\":1586318315,\"args\":{\"food\":\"GalbiTang\",\"task\":1}}"

아까 나는 food라는 jobName으로 key: "food", value: "GalbiTang"key: "task", value: 1"을 넣었다. 내가 주문한 대로 내용이 잘 들어가 있는 것을 확인할 수 있다.

 

참조

gocraft/work
gomodule/redigo
한식 영어사전

profile
뿌리가 튼튼한 사람이 되고자 합니다.

0개의 댓글