코드 생성
Node.js 생태계에서는 runtime reflection, decorator, proxy 등으로 런타임에 동적으로 행위를 만들어낸다. ORM이 decorator로 엔티티를 정의하고, DI 컨테이너가 reflection으로 의존성을 주입하고, GraphQL 라이브러리가 스키마에서 타입을 추론한다. Go는 이 문제를 다른 방식으로 푼다. 런타임에 마법을 부리는 대신 빌드 전에 코드를 생성한다. go generate가 그 도구다.
go generate
go generate는 소스 코드에 작성된 특별한 주석을 찾아 명령을 실행한다:
package main
//go:generate echo "hello from go generate"
$ go generate ./...
hello from go generate
//go:generate 뒤에 오는 것은 임의의 셸 명령이다. go build나 go run과 달리 go generate는 자동으로 실행되지 않는다. 개발자가 명시적으로 호출해야 한다. 이것이 핵심이다. 코드 생성은 빌드의 일부가 아니라 개발 과정의 일부다.
Node.js에서 비슷한 역할을 하는 것은 package.json의 scripts다:
{
"scripts": {
"generate": "graphql-codegen && prisma generate"
}
}
차이점은 Go의 생성 명령이 소스 코드 안에 선언된다는 것이다. 어떤 패키지가 코드 생성에 의존하는지 코드를 보면 바로 알 수 있다.
왜 코드 생성인가
Node.js/TypeScript에서는 런타임에 타입 정보를 활용하는 패턴이 흔하다:
// TypeScript - decorator 기반 ORM
@Entity()
class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
}
decorator는 런타임에 메타데이터를 붙이고, ORM은 그 메타데이터를 reflection으로 읽어 SQL을 생성한다. 편리하지만 대가가 있다. 런타임 오버헤드, 디버깅 어려움, 그리고 컴파일 타임에 잡을 수 있는 오류가 런타임으로 넘어간다.
Go에는 decorator가 없고, reflection(reflect 패키지)은 있지만 성능 비용이 크고 타입 안전성을 잃는다. 그래서 Go 생태계는 다른 전략을 택했다. 필요한 코드를 미리 생성해서 일반 Go 코드로 커밋한다. 생성된 코드는 타입 체크를 받고, IDE 자동완성이 되고, 디버거로 한 줄씩 따라갈 수 있다. 마법이 없다.
stringer — enum의 String() 자동 생성
Go에는 enum이 없다. 03편에서 다뤘듯이 iota로 상수를 정의한다:
package status
type Status int
const (
Pending Status = iota
Active
Inactive
)
이 상태를 출력하면 0, 1, 2가 나온다. 사람이 읽을 수 있는 문자열이 필요하다면 String() 메서드를 수동으로 작성해야 한다. 상수가 추가될 때마다 메서드도 수정해야 하고, 빠뜨리기 쉽다.
stringer는 이 메서드를 자동 생성한다:
//go:generate stringer -type=Status
$ go install golang.org/x/tools/cmd/stringer@latest
$ go generate ./...
status_string.go 파일이 생성된다:
// Code generated by "stringer -type=Status"; DO NOT EDIT.
package status
import "strconv"
func (i Status) String() string {
switch i {
case 0:
return "Pending"
case 1:
return "Active"
case 2:
return "Inactive"
}
return "Status(" + strconv.FormatInt(int64(i), 10) + ")"
}
이제 fmt.Println(Active)는 1이 아니라 Active를 출력한다. 상수를 추가하면 go generate를 다시 실행하면 된다. 생성된 코드 첫 줄의 DO NOT EDIT 주석은 Go 도구가 인식하는 표준 표기다.
mockgen — 테스트용 mock 자동 생성
16편에서 Go의 테스트를 다뤘다. interface 기반 mock을 직접 작성하는 것은 간단하지만, 메서드가 많은 interface를 mock하려면 반복 작업이 많다.
mockgen은 interface에서 mock 구현체를 자동 생성한다:
// store.go
package user
type Store interface {
Get(id string) (*User, error)
Save(u *User) error
Delete(id string) error
}
//go:generate mockgen -source=store.go -destination=mock_store_test.go -package=user
$ go install go.uber.org/mock/mockgen@latest
$ go generate ./...
생성된 mock은 테스트에서 이렇게 쓴다:
func TestService(t *testing.T) {
ctrl := gomock.NewController(t)
store := NewMockStore(ctrl)
store.EXPECT().
Get("user-1").
Return(&User{ID: "user-1", Name: "Alice"}, nil)
svc := NewService(store)
u, err := svc.FindUser("user-1")
if err != nil {
t.Fatal(err)
}
if u.Name != "Alice" {
t.Errorf("got %s, want Alice", u.Name)
}
}
Node.js에서 Jest의 jest.mock()이나 sinon.stub()이 런타임에 하는 일을 Go는 컴파일 타임 코드 생성으로 해결한다. 생성된 mock은 타입 안전하다. interface에 메서드를 추가하면 go generate를 다시 실행해야 하고, mock이 갱신되지 않으면 컴파일이 실패한다. 런타임에 조용히 무시되는 일은 없다.
protobuf/gRPC 코드 생성
Protocol Buffers(protobuf)는 Google이 만든 직렬화 포맷이다. .proto 파일에 메시지와 서비스를 정의하면 여러 언어의 코드를 생성할 수 있다. gRPC는 protobuf 위에 구축된 RPC 프레임워크다.
// user.proto
syntax = "proto3";
package user;
option go_package = "example.com/api/user";
message User {
string id = 1;
string name = 2;
string email = 3;
}
service UserService {
rpc GetUser(GetUserRequest) returns (User);
rpc CreateUser(CreateUserRequest) returns (User);
}
message GetUserRequest {
string id = 1;
}
message CreateUserRequest {
string name = 1;
string email = 2;
}
//go:generate protoc --go_out=. --go-grpc_out=. user.proto
protoc(Protocol Buffer Compiler)가 두 종류의 파일을 생성한다:
user.pb.go- 메시지 타입의 Go struct와 직렬화/역직렬화 코드user_grpc.pb.go- gRPC 서버 interface와 클라이언트 stub
생성된 서버 interface를 구현하면 gRPC 서버가 완성된다:
type server struct {
user.UnimplementedUserServiceServer
}
func (s *server) GetUser(
ctx context.Context,
req *user.GetUserRequest,
) (*user.User, error) {
return &user.User{
Id: req.Id,
Name: "Alice",
}, nil
}
Node.js에서도 protobuf/gRPC 코드 생성을 하지만, 보통 postinstall이나 빌드 스크립트로 처리한다. Go에서는 생성된 .pb.go 파일을 저장소에 커밋하는 것이 표준 관행이다.
생성된 코드는 커밋한다
Go 생태계의 중요한 관행이다. go generate로 만든 파일은 버전 관리에 포함한다. Node.js에서 node_modules를 커밋하지 않고 npm install로 재생성하는 것, prisma generate를 postinstall에 넣어 매번 실행하는 것과 대비된다.
Go가 이 방식을 택한 이유:
- 빌드 재현성.
go generate를 실행할 수 있는 환경이 없어도go build는 된다. protobuf 컴파일러가 설치되지 않은 CI 서버에서도 빌드가 가능하다. - 의존 도구 최소화. 코드를 빌드하는 데 필요한 것은 Go 컴파일러뿐이다. 코드 생성 도구는 생성하는 사람만 설치하면 된다.
- 변경 추적. 생성된 코드의 변경이 git diff에 나타난다. 코드 리뷰에서 자동 생성된 부분의 변경도 확인할 수 있다.
Node.js 프로젝트에서 postinstall로 코드를 생성하면, 같은 스키마에서 다른 버전의 도구가 다른 코드를 생성할 수 있다. 생성된 코드를 커밋하면 이 문제가 사라진다. 대신 PR에 생성된 파일의 diff가 포함되므로 리뷰할 때 자동 생성 부분은 건너뛸 수 있어야 한다. 첫 줄의 Code generated ... DO NOT EDIT 주석이 이 구분을 돕는다.
자체 코드 생성 도구 만들기
go generate는 아무 명령이나 실행할 수 있으므로, 프로젝트 전용 코드 생성기를 만드는 것도 흔하다. Go의 text/template과 go/ast 패키지를 조합하면 Go 소스 코드를 분석하고 새로운 코드를 생성하는 도구를 작성할 수 있다:
// gen.go - 간단한 코드 생성기 예시
//go:build ignore
package main
import (
"os"
"text/template"
)
var tmpl = template.Must(template.New("").Parse(`// Code generated; DO NOT EDIT.
package {{.Package}}
var All{{.TypeName}}s = []{{.TypeName}}{
{{- range .Values}}
{{.}},
{{- end}}
}
`))
func main() {
tmpl.Execute(os.Stdout, map[string]any{
"Package": "status",
"TypeName": "Status",
"Values": []string{"Pending", "Active", "Inactive"},
})
}
//go:generate go run gen.go > all_statuses.go
//go:build ignore 태그가 붙은 파일은 일반 빌드에서 제외된다. go generate에서 go run으로만 실행된다. Node.js에서 빌드 스크립트를 별도로 작성하는 것과 같은 패턴이지만, 생성 도구와 대상 코드가 같은 디렉토리에 있다는 점이 다르다.
decorator가 런타임에 조용히 수행하는 변환을 Go는 실제 소스 코드로 만들어 저장소에 넣는다. 생성된 코드를 읽을 수 있고, 디버깅할 수 있고, git에서 변경을 추적할 수 있다.