업무에 손쉽게 Golang 적용하기: 로케이션 코어팀 백엔드 개발자가 일하는 방식
안녕하세요! 로케이션 코어팀의 백엔드 개발자 샐리예요.
로케이션 코어팀은 당근이 하이퍼로컬 앱으로 나아가는 데 기반이 되는 위치 기반 서비스와 지도 플랫폼을 개발하고 운영하는 팀이에요. 저는 팀 내에서 지도를 안정적으로 제공할 수 있는 파이프라인을 구축하고, 리소스를 관리할 수 있는 플랫폼을 개발하고 있어요.
당근은 팀마다 서로 다른 기술 스택을 사용하고 있어요. 그 중에서도 로케이션 코어팀은 Go 언어로 서버를 개발하고 있는데요.
저는 당근에 입사하기 이전에 Go를 사용해본 경험이 없었어요. 로케이션 코어팀에 합류하고 업무를 진행하면서 Go를 온몸으로 마주하게 되었죠.
오늘은 그 과정에서 제가 마주했던 고민들을 공유해보려 해요. 더불어 로케이션 코어팀이 업무 효율성을 높이기 위해 Go를 어떠한 방식으로 사용하고 있는지, 그 방식이 저에게 어떻게 긍정적으로 작용했는지까지 함께 소개해볼게요! Golang이 궁금하셨던, 그리고 당근에서 Golang을 업무에 사용하는 방식이 궁금하셨던 분들에게 도움이 되는 글이기를 바라요 🙏🏻
신입 Go-pher가 바라보는 Go 언어는..
공식 홈페이지에서 Go를 소개하는 문장이에요.
Build simple, secure, scalable systems with Go (Golang Official Catchphrase)
Go는 다른 프로그래밍 언어에 비해 키워드 수가 적고 문법이 단순하여 배우기 쉽고, (simple
) 컴파일러와 강타입(strongly-typed) 시스템을 기반으로 프로그램의 안전성 (secure
)을 높일 수 있어요. 고루틴을 통해 동시성을 손쉽게 관리할 수 있어 대규모 서버와 분산 시스템에서 사용하기에도 유리해요. (scalable
)
이처럼 사용하기가 쉽고 자율성이 높다는 장점이 있지만 그만큼 낯설고 모호한 부분도 존재했어요.
클래스가 없는 객체 지향 언어?
Go언어는 보편적인 객체 지향 언어와 다른 성격을 띄고 있어요.
놀랍게도, 클래스가 존재하지 않아요. 구조체(struct
)를 사용하여 비슷한 역할을 만들어낼 수는 있지만 클래스처럼 메서드를 내장하지는 않아요. 대신 메서드를 정의한 인터페이스(interface
)를 생성하고, 메서드가 해당 타입의 인스턴스에 ‘속해 있는’ 것처럼 구현하여 다형성을 적용해요. 이때 func 키워드와 함수명 사이에 구조체를 선언하여 구조체와 메서드를 연결할 수 있는데, 이를 리시버(receiver
)라고 불러요.
type SampleInterface interface {
SampleMethod()
}
type SampleStruct struct {
Name string
Data map[string]string
}
// 리시버로 구조체와 인터페이스의 메서드를 연결하여 다형성을 적용
func (s SampleStruct) SampleMethod() {
fmt.Println("implement sample method")
}
func (s SampleStruct) GetName() string {
return s.Name
}
func main() {
s := SampleStruct{Name: "name", Data: make(map[string]string)}
var i SampleInterface
i = s
i.SampleMethod() // 인터페이스에 정의된 메서드 호출
fmt.Println(i.GetName()) // ERROR
}
개발 방법론의 유연함
Go언어는 표준화된 개발 방법론이 서비스 개발에 강제되지 않아요. Go 자체의 컨벤션이나 여러 가이드들은 존재하지만, 사람마다 코드 작성 방식이 다를 수 있어요.
특히 제가 입사를 했을 때인 약 1년 전까지만 해도 로케이션 코어팀 대부분의 팀원이 합류한지 3개월이 채 되지 않은 상태였고 운영 중인 프로젝트들도 각기 다른 맥락과 구조를 가지고 있었어요. 따라서 표준화된 구조를 가져가기보다는 각자 관리 중인 프로젝트의 특성에 맞게 유연한 개발을 진행하는 것이 더 효율적이었죠.
하지만 그러다 보니 동일한 기능을 수행하는 코드 베이스임에도 불구하고 레포마다 각기 다른 라이브러리를 기반으로 구현되어 있어 히스토리를 파악하기 어렵거나 특정 프로젝트에서만 알 수 없는 버그가 발생하는 등의 문제도 있었어요.
로직에 집중해야 하는데, 고민해야 할 것이 너무 많다?
입사 당시 저는 모노레포로 관리 중인 저장소에 새로운 서버를 구성하는 과제를 2달 간 진행했는데요. 테크 스펙을 작성할 때 위에서 언급한 Go의 여러 특성들 때문에 아래와 같이 방법론적으로 고민해야 할 부분이 많았어요.
그러다보니 로직 구성과 같은 정말 중요한 부분을 놓치고 있는 것 같다는 생각이 들기 시작했어요.
우리 팀에서는 이런 방법을 사용했어요
공유 가능한 Go 라이브러리 구현
이러한 고민을 저만 하고 있던 것이 아니었던 것 같아요.
왜냐하면 비슷한 맥락에서 코드의 일관성을 적정 레벨로 유지하며, 반복되는 작업을 최소화하여 생산성을 향상시키기 위해 팀 내에서 (일부 프로젝트에서만) 부분적으로 적용하고 있던 방식이 있었거든요.
바로 공유 가능한 Go 라이브러리를 구현하고, 각 어플리케이션에서 필요에 따라 패키지나 모듈을 독립적으로 호출하여 사용하는 방식이에요.
Fx라는 Go의 의존성 주입 라이브러리를 감싸 공유가 가능한 최소 단위의 모듈을 구현했어요. 구현된 모듈은 Database, Message Queue, Redis, Logger, gRPC Server 등 어플리케이션에서 필요한 여러 컴포넌트들을 제공해요.
Fx는 Uber에서 개발한 Go 언어용 의존성 주입 라이브러리로 어플리케이션의 구성 요소들 사이의 의존성을 관리하고, hook에 정의한 lifecycle에 따라 객체를 생성 및 연결해주는 기능을 제공해줘요.
아래와 같이 모듈을 생성할 수 있어요.
- 독립적으로 호출할
fx.Module
변수를 정의해요.
var Module = fx.Module("pg",
2. fx.Provide
구문으로 module에 필요한 생성자들을 추가해요.
var Module = fx.Module("server",
fx.Provide(parseConfig), // ex. 설정값을 parsing하는 함수
fx.Provide(newSql),
)
func newSql(lc fx.Lifecycle, c *Config) (*sql.DB, error) {
db, err := New(c) // 컴포넌트 생성
if err != nil {
return nil, err
}
lc.Append(fx.Hook{ // lifecycle 정의
OnStart: func(ctx context.Context) error {
return db.PingContext(ctx)
},
OnStop: func(ctx context.Context) error {
return db.Close()
},
})
return db, nil
}
3. 항상 수행되어야 하는 기능이 포함된 함수는 fx.Invoke
구문으로 module에 추가해요.
var Module = fx.Module("server",
fx.Provide(parseConfig), // ex. 설정값을 parsing하는 함수
fx.Provide(newSql),
fx.Invoke(startServer),
)
func startServer(db *sql.DB) error {
// do something
return nil
}
4. 어플리케이션에서는 주입할 모듈의 설정을 하나의 yaml 파일에 생성하고, 필요한 모듈을 호출하여 Fx 컨테이너에 추가하면 별도의 설정이나 코드 작성 없이 손쉽게 적용이 가능해요.
log_level: debug
pg:
user: postgres
password: postgres
host: localhost
port: 5432
database: database
kafka:
brokers:
- localhost:9092
consumer_group: consumer-group
import (
"go.uber.org/fx"
pgfx "github.com/daangn/fx-modules/database/pg"
kafkafx "github.com/daangn/fx-modules/event/kafka"
)
func main() {
fx.New(
pgfx.Module, // postgresql db
kafkafx.Module, // message publisher, subscriber
fx.Invoke(run),
).Run()
}
func run(db *sql.db, publisher message.Publisher, subscriber message.subscriber) error {
// do something
return nil
}
로직에 집중하면서 코드 안정성도 챙길 수 있는 환경 만들기
이렇게 공유 가능한 라이브러리를 사용함으로써
- 반복적이고 표준화된 코드를 작성하는 시간을 줄이고, 비즈니스 로직에 집중할 수 있었어요.
2. 초기화 단계에서는 환경변수를 통합하여 관리하고, 실행 단계에서는 컴포넌트들의 lifecycle을 정의한 뒤 lifecycle에 커넥션을 열고 닫는 부분을 일괄적으로 Hook으로 적용하여 버그 발생 가능성을 줄일 수 있었어요.
// hook을 통한 lifecycle 관리
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
return db.PingContext(ctx)
},
OnStop: func(ctx context.Context) error {
return db.Close()
},
})
3. 여러 컴포넌트들을 한 곳에서 정의하여 일관성을 유지하고, 각 컴포넌트 간의 결합은 최소화하여 유연성을 향상시킬 수 있었어요.
package main
func main() {
fx.New(
pgfx.Module,
redisfx.Module,
kafkafx.Module,
grpcfx.Server,
grpcfx.RegisterService(handler.NewEchoHandler, mapsv1.RegisterEchoServiceServer),
grpcfx.RegisterGateway(
mapsv1.RegisterEchoServiceHandlerFromEndpoint,
),
).Run()
}
여전히 고민해봐야 할 부분들은 존재해요
물론 이 방법이 모든 문제를 마법처럼 해결해주지는 않아요. 여전히 고민해봐야 하는 부분들이 존재해요.
- 라이브러리 자체에서 버그가 발생하는 경우가 있어요. 따라서 반복 작업 최소화를 위한 모듈화 뿐만 아니라 사용하는 라이브러리에 대한 검증도 고려해 볼 필요가 있어요.
2. 모듈에서 제공하는 기본 설정 외의 설정값을 사용할 경우 모듈 자체에 반영하는 작업을 추가적으로 거쳐야 해요.
마치며
이렇게 제가 새로운 언어를 접하며 시야를 넓혀나갔던 경험, 그리고 로케이션 코어팀이 크고 작은 과제들을 각자 또 함께 고민하며 지속 가능하고 확장성 있는 기반을 만들기 위해 노력한 과정을 소개해봤어요!
팀의 업무 방식과 문화에 대한 맥락을 이해하고 이를 발전시켜 나가는 과정 속에서 팀의 일원으로서, 그리고 개인으로서 성장하는 자신을 발견할 수 있었던 것 같아요. 앞으로도 팀원들과 적극적으로 소통하면서 로케이션 코어만의 표준을 만들어나가고, 동시에 저 또한 유의미한 인사이트를 줄 수 있는 팀원이 될 수 있도록 더욱 더 노력하고자 해요 💪🏻
이 글이 함께 개발 문화를 만들어나가고 여러 기술적인 과제들을 함께 해결해나가는 것에 재미를 느끼시는 분들께 도움이 되었길 바라면서 글을 마칠게요.