회사에 일정 주기마다 작동하는 대량 작업 스케쥴러 코드가 있다.해당 코드들은 내가 작성했지만, 시간이 지나면서 유지보수하기 번거로워졌다.
상황은 아래 그림과 같다.
서비스
패키지에는 실제 스케쥴러가 작동하는 로직의 코드를 배치했다. 그리고 컨트롤러
패키지엔 스케쥴러의 강제 작동을 위한 API를 공개하였고, 스케쥴러
패키지엔 주기마다 작동시키는 코드를 배치했다.
그에 따라 스케쥴러 로직이 필요해지면서 서비스
에 배치하는 스케쥴러 코드가 난립하게 되었다. 심지어 service라는 이름으로 명명되어 있었다. 이러한 코드는 응집성이 떨어지고 가독성이 떨어진다. 서비스
를 보고 있던 다른 개발자가 스케쥴러의 코드까지 읽어봐야 하는 번거로움이 생긴다.
그래서 다음 그림처럼 해결하였다.
서비스
레이어에 난립되었던 스케쥴러 작동 코드를 전부 스케쥴러
패키지 내의 scheduler_job_runner.go라는 파일에 배치하였다. 그리고 server.go에서 스케쥴러 잡을 실행시키는 runner 의존성을 주입할 수 있게 하였다.
이에 따라 유지보수하는 사람이 서비스
에서 스케쥴러 작동 코드가 있는 것을 보고 헷갈리지 않을 수 있고, 스케쥴러
패키지 안에서 스케쥴러 작동 코드를 찾을 수 있다. 또한 의존성을 주입하기 때문에 객체 자신이 직접 생성에 관여하지 않아 코드가 더 유연해진다. (의존성 주입으로 테스트 코드를 작성하기 쉬워진다.)
type JobRunner interface {
Execute(ctx context.Context, entClient *ent.Client) (interface{}, error)
}
// 구현체는 생략
모든 스케쥴러 작동 코드는 JobRunner
인터페이스의 Execute(ctx context.Context, entClient *ent.Client) (interface{}, error)
를 구현하게 강제했다.
package scheduler
import (
"context"
"github.com/go-faster/errors"
"github.com/punchylab/server/libs/croncore"
"time"
)
// 기타 코드 생략
type BaseScheduler struct {
cron *croncore.CronCore
}
func NewBaseScheduler() *BaseScheduler {
return &BaseScheduler{
cron: croncore.NewCronCore(),
}
}
func (s *BaseScheduler) Start(ctx context.Context) error {
go func() {
<-ctx.Done()
s.Stop()
}()
s.cron.Start()
return nil
}
type Spec int
const (
Hourly Spec = iota
Daily
MidNight
)
type (
timeSpecParser struct {
spec Spec
}
TimeSpecParser interface {
ParseSpec() (string, error)
}
)
func NewTimeSpecParser(spec Spec) TimeSpecParser {
return &timeSpecParser{
spec: spec,
}
}
func (s timeSpecParser) ParseSpec() (string, error) {
if s.spec == Hourly {
return "@every 1h", nil
} else if s.spec == Daily {
return "@every 24h", nil
} else if s.spec == MidNight {
localTimeZone, _ := time.Now().Zone()
if localTimeZone == "KST" { // KST 자정에 실행
return "0 0 0 * * *", nil
} else { // UTC 기준 KST 자정에서 9시간 전인 (즉, UTC 15:00)에 실행
return "0 0 15 * * *", nil
}
} else if s.spec == Minutely {
return "@every 1m", nil
} else {
return "", errors.New("not implemented spec")
}
}
BaseScheduler
는 cron
라이브러리를 사용하여 스케쥴러를 실제로 작동시킨다.
Spec
은 스케쥴러의 작동 주기를 나타내는 Enum이다.
TimeSpecParser
는 생성자에 주입된 Spec
을 cron
라이브러리에서 사용되는 표현식으로 파싱한다.
KST
와 UTC
는 9시간 차이가 남에 유의한다.
type EntScheduler struct {
*BaseScheduler
schedulerJobRunner JobRunner
entClient *ent.Client
parsedTimeSpec string
}
func NewEntScheduler(
entClient *ent.Client,
schedulerJobRunner JobRunner,
parser TimeSpecParser,
) *EntScheduler {
parsedSpec, err := parser.ParseSpec()
if err != nil {
//예외 처리
return nil
}
return &EntScheduler{
BaseScheduler: NewBaseScheduler(),
schedulerJobRunner: schedulerJobRunner,
entClient: entClient,
parsedTimeSpec: parsedSpec,
}
}
func (s *EntScheduler) Start(ctx context.Context) error {
_, err := s.cron.AddJob(
s.parsedTimeSpec, func() {
_, err := s.schedulerJobRunner.Execute(
ctx,
client,
)
if err != nil {
//예외처리
}
},
)
if err != nil {
//예외처리
}
return s.BaseScheduler.Start(ctx)
}
EntScheduler
의 생성자에 JobRunner
구현체와 TimeSpecParser
를 주입할 수 있게 했다. Start
에는 스케쥴러를 작동시키는 공통 코드
를 작성하였다.
func setupSchedulers(
ctx context.Context,
entClient *ent.Client,
) (*scheduler.ScheduleManager, error) {
scheduleManager := scheduler.NewSchedulerManager()
schedulers := []scheduler.Scheduler{
scheduler.NewEntScheduler(
entClient,
scheduler.NewUpdateGrammarGradeJobRunner(),
scheduler.NewTimeSpecParser(scheduler.MidNight),
),
scheduler.NewEntScheduler(
entClient,
scheduler.NewUpdateWordGradeJobRunner(),
scheduler.NewTimeSpecParser(scheduler.MidNight),
),
//이하 생략
}
for _, s := range schedulers {
scheduleManager.AddScheduler(s)
}
if err := scheduleManager.StartAll(ctx); err != nil {
return nil, fmt.Errorf("failed to start schedulers: %v", err)
}
return scheduleManager, nil
}
위 코드는 server.go에서 실제 스케쥴러를 생성하는 코드다.
생성자만 봐도, 어떤 일을 하는 스케쥴러이고 언제 작동하는 지 알 수 있다.
그리고 NewEntScheduler()
라는 코드 하나에 여러가지 조합 (언제 어떤 일을 할지)을 넣을 수 있게 되서 유연성이 높아졌다.