Java to Go: #5. 프로젝트 구조와 생태계
이 글은 Claude Opus 4.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
지금까지 Go의 철학, 문법, 에러 처리, 동시성을 살펴봤다. 이제 실제 프로젝트를 시작하는 데 필요한 것들을 다룬다. Maven/Gradle에서 Go Modules로, JUnit에서 testing 패키지로, Spring에서 Go 라이브러리 조합으로 전환하는 방법을 알아보자.
1. Go Modules 기초#
go.mod와 go.sum#
Go 1.11부터 도입된 Go Modules가 공식 의존성 관리 시스템이다.
# 새 모듈 초기화
mkdir myproject && cd myproject
go mod init github.com/username/myproject
생성된 go.mod:
module github.com/username/myproject
go 1.21
의존성을 추가하면:
go get github.com/gin-gonic/gin
go.mod가 업데이트되고 go.sum이 생성된다:
module github.com/username/myproject
go 1.21
require github.com/gin-gonic/gin v1.9.1
go.sum은 의존성의 체크섬을 저장한다. package-lock.json이나 gradle.lockfile과 유사한 역할이다.
Maven/Gradle과의 비교#
| Maven/Gradle | Go Modules |
|---|---|
pom.xml / build.gradle |
go.mod |
~/.m2/repository |
$GOPATH/pkg/mod |
mvn install |
go build (자동 다운로드) |
mvn dependency:tree |
go mod graph |
| Central Repository | proxy.golang.org |
주요 명령어:
# 의존성 추가
go get github.com/pkg/errors
# 특정 버전 지정
go get github.com/gin-gonic/gin@v1.9.0
# 사용하지 않는 의존성 제거
go mod tidy
# 의존성 그래프 보기
go mod graph
# 의존성 로컬 캐시에 다운로드
go mod download
# vendor 디렉토리 생성
go mod vendor
버전 관리와 의존성 업데이트#
Go Modules는 Semantic Versioning을 따른다.
# 모든 의존성 업데이트 (minor/patch)
go get -u ./...
# 특정 의존성만 업데이트
go get -u github.com/gin-gonic/gin
# major 버전 업그레이드 (v2 이상은 경로가 달라짐)
go get github.com/gin-gonic/gin/v2
v2 이상의 major 버전 규칙:
// v1은 그냥 import
import "github.com/example/foo"
// v2 이상은 경로에 버전 포함
import "github.com/example/foo/v2"
2. 프로젝트 레이아웃#
표준 디렉토리 구조#
Go 커뮤니티에서 널리 사용하는 레이아웃이다. 강제는 아니지만 대부분의 오픈소스가 따른다.
myproject/
├── cmd/ # 실행 가능한 애플리케이션
│ ├── api/
│ │ └── main.go
│ └── worker/
│ └── main.go
├── internal/ # 내부 패키지 (외부 import 불가)
│ ├── config/
│ │ └── config.go
│ ├── handler/
│ │ └── user_handler.go
│ └── repository/
│ └── user_repository.go
├── pkg/ # 외부에서 사용 가능한 라이브러리
│ └── validator/
│ └── validator.go
├── api/ # API 정의 (OpenAPI, protobuf 등)
│ └── openapi.yaml
├── configs/ # 설정 파일
│ └── config.yaml
├── scripts/ # 빌드/배포 스크립트
│ └── build.sh
├── test/ # 추가 테스트 (통합 테스트 등)
│ └── integration_test.go
├── go.mod
├── go.sum
└── README.md
각 디렉토리 설명#
cmd/: 메인 애플리케이션. 각 하위 디렉토리가 하나의 실행 파일이 된다.
// cmd/api/main.go
package main
func main() {
// 최소한의 코드만, 실제 로직은 internal에
config := config.Load()
server := server.New(config)
server.Run()
}
internal/: 핵심 비즈니스 로직. 이 디렉토리의 패키지는 같은 모듈 내에서만 import 가능하다.
internal/
├── domain/ # 도메인 모델
├── repository/ # 데이터 접근
├── service/ # 비즈니스 로직
└── handler/ # HTTP 핸들러
pkg/: 다른 프로젝트에서도 사용할 수 있는 라이브러리 코드. 없어도 되고, 최근에는 사용하지 않는 추세.
패키지 설계 원칙#
1. 패키지 이름은 짧고 명확하게
// 좋음
package user
package http
package config
// 나쁨
package userservice
package httphandler
package configmanager
2. 순환 import 금지
Go는 순환 의존성을 허용하지 않는다.
// 불가능
package A imports package B
package B imports package A
해결책: 인터페이스를 별도 패키지로 분리하거나, 의존성 방향을 재설계한다.
3. 기능별로 패키지 분리 (계층별 아님)
// 권장하지 않음 (Java 스타일 계층 구조)
├── controllers/
├── services/
├── repositories/
└── models/
// 권장 (기능별 구조)
├── user/
│ ├── handler.go
│ ├── service.go
│ └── repository.go
├── order/
│ ├── handler.go
│ ├── service.go
│ └── repository.go
3. 테스트#
testing 패키지 기본#
Go의 테스트는 _test.go 파일에 작성하고 go test로 실행한다.
// calculator.go
package calculator
func Add(a, b int) int {
return a + b
}
// calculator_test.go
package calculator
import "testing"
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d; want 5", result)
}
}
# 현재 패키지 테스트
go test
# 모든 패키지 테스트
go test ./...
# 상세 출력
go test -v
# 특정 테스트만
go test -run TestAdd
테이블 기반 테스트 (Table-driven tests)#
Go에서 가장 권장하는 테스트 패턴이다.
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -1, -2, -3},
{"zero", 0, 0, 0},
{"mixed", -1, 5, 4},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}
Java JUnit 5 비교:
@ParameterizedTest
@CsvSource({
"2, 3, 5",
"-1, -2, -3",
"0, 0, 0"
})
void testAdd(int a, int b, int expected) {
assertEquals(expected, Calculator.add(a, b));
}
Mock과 Interface 활용#
Go에서 mocking은 인터페이스를 활용한다.
// repository.go
type UserRepository interface {
FindByID(id string) (*User, error)
Save(user *User) error
}
type userRepository struct {
db *sql.DB
}
func (r *userRepository) FindByID(id string) (*User, error) {
// 실제 DB 쿼리
}
// service.go
type UserService struct {
repo UserRepository // 인터페이스에 의존
}
func (s *UserService) GetUser(id string) (*User, error) {
return s.repo.FindByID(id)
}
// service_test.go
type mockUserRepository struct {
users map[string]*User
}
func (m *mockUserRepository) FindByID(id string) (*User, error) {
if user, ok := m.users[id]; ok {
return user, nil
}
return nil, errors.New("not found")
}
func (m *mockUserRepository) Save(user *User) error {
m.users[user.ID] = user
return nil
}
func TestGetUser(t *testing.T) {
mock := &mockUserRepository{
users: map[string]*User{
"1": {ID: "1", Name: "Alice"},
},
}
service := &UserService{repo: mock}
user, err := service.GetUser("1")
if err != nil {
t.Fatal(err)
}
if user.Name != "Alice" {
t.Errorf("expected Alice, got %s", user.Name)
}
}
Mock 라이브러리:
인터페이스에서 mock을 자동 생성하는 도구들:
JUnit과의 비교#
| JUnit | Go testing |
|---|---|
@Test |
func TestXxx(t *testing.T) |
@BeforeEach |
직접 구현 또는 TestMain |
assertEquals |
if got != want { t.Errorf(...) } |
assertThrows |
에러 반환값 체크 |
@ParameterizedTest |
Table-driven tests |
| Mockito | 인터페이스 + 수동 mock 또는 mockery |
TestMain으로 setup/teardown:
func TestMain(m *testing.M) {
// 전체 테스트 전 setup
setup()
code := m.Run() // 모든 테스트 실행
// 전체 테스트 후 teardown
teardown()
os.Exit(code)
}
벤치마크 테스트#
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
go test -bench=.
# BenchmarkAdd-8 1000000000 0.25 ns/op
4. 빌드와 배포#
단일 바이너리의 장점#
Go는 정적 링크된 단일 바이너리를 생성한다.
go build -o myapp ./cmd/api
# 결과: 의존성 없는 단일 실행 파일
./myapp
Java처럼 JRE가 필요 없고, Python처럼 인터프리터가 필요 없다. 바이너리 하나만 복사하면 어디서든 실행된다.
크로스 컴파일#
다른 OS/아키텍처용 바이너리를 현재 시스템에서 빌드할 수 있다.
# Linux AMD64 용
GOOS=linux GOARCH=amd64 go build -o myapp-linux-amd64
# macOS ARM (M1/M2) 용
GOOS=darwin GOARCH=arm64 go build -o myapp-darwin-arm64
# Windows 용
GOOS=windows GOARCH=amd64 go build -o myapp.exe
Java 비교:
Java는 JRE만 있으면 어디서든 실행되지만, JRE 자체가 필요하다. Go는 바이너리에 런타임이 포함되어 있어 별도 설치가 불필요하다.
Docker 이미지 최적화#
멀티스테이지 빌드:
# 빌드 스테이지
FROM golang:1.21 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /myapp ./cmd/api
# 실행 스테이지
FROM scratch
COPY --from=builder /myapp /myapp
ENTRYPOINT ["/myapp"]
결과:
- 빌드 이미지: ~1GB (Go 도구체인 포함)
- 실행 이미지: ~10-20MB (바이너리만)
Java 비교:
# Spring Boot 최적화 이미지도 150MB+
FROM eclipse-temurin:21-jre-alpine
COPY target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
빌드 플래그#
# 디버그 정보 제거 (바이너리 크기 감소)
go build -ldflags="-s -w" -o myapp
# 버전 정보 주입
go build -ldflags="-X main.version=1.0.0" -o myapp
// main.go
var version = "dev"
func main() {
fmt.Println("Version:", version)
}
5. 개발 도구#
go fmt#
Go의 공식 포맷터. 코드 스타일 논쟁이 없다.
# 파일 포맷
go fmt main.go
# 전체 프로젝트
go fmt ./...
Java의 Checkstyle, Google Java Format 같은 도구가 언어 자체에 내장되어 있다. 모든 Go 코드가 동일한 스타일을 따른다.
go vet#
정적 분석으로 잠재적 버그를 찾는다.
go vet ./...
잡아내는 것들:
- Printf 포맷 불일치
- 사용되지 않는 결과
- 불가능한 조건
- 잘못된 struct 태그
golangci-lint#
여러 린터를 통합한 도구. 프로덕션 프로젝트의 표준.
# 설치
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
# 실행
golangci-lint run
.golangci.yml로 설정:
linters:
enable:
- gofmt
- govet
- errcheck
- staticcheck
- gosimple
- ineffassign
- unused
linters-settings:
errcheck:
check-blank: true
IDE 설정#
GoLand (JetBrains):
- IntelliJ 기반으로 Java 개발자에게 익숙
- 리팩토링, 디버깅, 테스트 통합 우수
- 유료
VS Code + Go Extension:
- 무료
- gopls (Go Language Server) 기반
- 대부분의 기능 지원
필수 VS Code 설정:
{
"go.formatTool": "goimports",
"go.lintTool": "golangci-lint",
"editor.formatOnSave": true,
"[go]": {
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
}
}
6. 주요 라이브러리/프레임워크#
HTTP: net/http, Gin, Echo#
표준 라이브러리 (net/http):
func main() {
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!")
})
http.ListenAndServe(":8080", nil)
}
Gin (가장 인기 있는 프레임워크):
func main() {
r := gin.Default()
r.GET("/users/:id", func(c *gin.Context) {
id := c.Param("id")
c.JSON(200, gin.H{"id": id})
})
r.POST("/users", func(c *gin.Context) {
var user User
if err := c.ShouldBindJSON(&user); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(201, user)
})
r.Run(":8080")
}
Echo:
func main() {
e := echo.New()
e.GET("/users/:id", func(c echo.Context) error {
return c.JSON(200, map[string]string{"id": c.Param("id")})
})
e.Start(":8080")
}
Spring MVC 비교:
| Spring MVC | Gin/Echo |
|---|---|
@RestController |
라우터 등록 |
@GetMapping |
r.GET() |
@RequestBody |
c.ShouldBindJSON() |
@PathVariable |
c.Param() |
ResponseEntity |
c.JSON() |
ORM: GORM, sqlx#
GORM:
type User struct {
ID uint `gorm:"primaryKey"`
Name string
Email string `gorm:"uniqueIndex"`
CreatedAt time.Time
}
func main() {
db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
db.AutoMigrate(&User{})
// Create
db.Create(&User{Name: "Alice", Email: "alice@example.com"})
// Read
var user User
db.First(&user, 1)
db.Where("email = ?", "alice@example.com").First(&user)
// Update
db.Model(&user).Update("Name", "Bob")
// Delete
db.Delete(&user, 1)
}
sqlx (SQL 직접 작성):
type User struct {
ID int `db:"id"`
Name string `db:"name"`
Email string `db:"email"`
}
func main() {
db := sqlx.MustConnect("postgres", "...")
var users []User
db.Select(&users, "SELECT * FROM users WHERE status = $1", "active")
var user User
db.Get(&user, "SELECT * FROM users WHERE id = $1", 1)
}
JPA/Hibernate 비교:
| JPA | GORM |
|---|---|
@Entity |
struct + gorm 태그 |
EntityManager |
*gorm.DB |
@Id @GeneratedValue |
gorm:"primaryKey" |
| Lazy Loading | Preload (Eager) |
| JPQL | GORM 체인 또는 Raw SQL |
설정 관리: Viper#
import "github.com/spf13/viper"
func main() {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
// 환경변수 바인딩
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
log.Fatal(err)
}
dbHost := viper.GetString("database.host")
dbPort := viper.GetInt("database.port")
}
의존성 주입: Wire#
Google의 컴파일 타임 DI 도구.
// wire.go
//go:build wireinject
func InitializeApp() (*App, error) {
wire.Build(
NewConfig,
NewDatabase,
NewUserRepository,
NewUserService,
NewApp,
)
return nil, nil
}
wire gen ./...
Spring의 런타임 DI와 달리 컴파일 타임에 의존성 그래프가 생성된다.
7. Spring 개발자가 그리워할 것들과 대안#
Spring Boot Auto-Configuration#
Spring: @SpringBootApplication 하나로 수십 개 빈 자동 설정
Go 대안: 직접 설정하거나 라이브러리별 기본값 사용
func main() {
// 모든 것을 명시적으로 연결
config := loadConfig()
db := connectDB(config.Database)
repo := repository.NewUserRepository(db)
service := service.NewUserService(repo)
handler := handler.NewUserHandler(service)
r := gin.Default()
handler.RegisterRoutes(r)
r.Run()
}
이게 더 많은 코드처럼 보이지만, 무슨 일이 일어나는지 명확하다.
Spring Security#
Spring: 어노테이션 기반의 선언적 보안
Go 대안: 미들웨어 직접 구현 또는 라이브러리 조합
// JWT 미들웨어 예시
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
claims, err := validateToken(token)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
return
}
c.Set("userID", claims.UserID)
c.Next()
}
}
// 사용
r.GET("/protected", AuthMiddleware(), handler.Protected)
라이브러리:
Spring Data JPA#
Spring: Repository 인터페이스만 선언하면 구현 자동 생성
Go 대안: 직접 구현 또는 GORM 활용
// Spring 스타일 (Go로 불가능)
// interface UserRepository extends JpaRepository<User, Long> {
// List<User> findByEmailContaining(String email);
// }
// Go 방식
type UserRepository struct {
db *gorm.DB
}
func (r *UserRepository) FindByEmailContaining(email string) ([]User, error) {
var users []User
err := r.db.Where("email LIKE ?", "%"+email+"%").Find(&users).Error
return users, err
}
Spring Batch#
Go 대안: 직접 구현하거나 작업 스케줄러 활용
// 단순 배치 처리
func processBatch(items []Item, batchSize int) error {
for i := 0; i < len(items); i += batchSize {
end := min(i+batchSize, len(items))
batch := items[i:end]
if err := process(batch); err != nil {
return fmt.Errorf("batch %d failed: %w", i/batchSize, err)
}
}
return nil
}
복잡한 배치는 외부 도구(Temporal, Apache Airflow)를 활용하는 것이 현실적이다.
트랜잭션 관리#
Spring: @Transactional 어노테이션
Go 대안: 명시적 트랜잭션 관리
func (s *OrderService) CreateOrder(ctx context.Context, order Order) error {
tx := s.db.WithContext(ctx).Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
if err := tx.Create(&order).Error; err != nil {
tx.Rollback()
return err
}
if err := tx.Create(&order.Items).Error; err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
트랜잭션 헬퍼:
func WithTransaction(db *gorm.DB, fn func(tx *gorm.DB) error) error {
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
if err := fn(tx); err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
// 사용
err := WithTransaction(db, func(tx *gorm.DB) error {
if err := tx.Create(&order).Error; err != nil {
return err
}
return tx.Create(&order.Items).Error
})
8. 마무리: Java에서 Go로의 여정#
이 시리즈에서 다룬 것들#
- Part 1: Go의 철학, 언제 Go를 선택하고 언제 Java를 유지할지
- Part 2: 문법 전환 - 변수, 함수, struct, interface
- Part 3: 에러 처리와 nil 안전성
- Part 4: 동시성 - goroutine, channel, context
- Part 5: 프로젝트 구조, 테스트, 생태계
Go 학습 로드맵#
1주차: 기본 문법, 표준 라이브러리 탐험 2-3주차: 에러 처리 패턴, 테스트 작성 4-5주차: 동시성 패턴, channel 활용 6-8주차: 실전 프로젝트 (간단한 API 서버) 2-3개월: 프로덕션 코드 작성, 코드 리뷰
추천 리소스#
공식 문서:
- A Tour of Go: 대화형 튜토리얼
- Effective Go: 관용적 Go 코드 작성법
- Go Blog: 심층 기술 문서
책:
- “Learning Go” by Jon Bodner
- “Concurrency in Go” by Katherine Cox-Buday
커뮤니티:
마지막 조언#
Java에서 Go로 전환하는 것은 단순히 문법을 바꾸는 것이 아니다. 사고방식을 바꾸는 것이다.
- 단순함을 받아들여라: 처음엔 기능이 부족해 보이지만, 그게 의도된 것이다.
- 명시적인 것을 두려워하지 마라:
if err != nil이 귀찮아도, 결국 코드가 더 명확해진다. - goroutine을 자유롭게 써라: Java의 Thread처럼 아끼지 않아도 된다.
- 작은 인터페이스를 선호하라:
io.Reader,io.Writer처럼 1-2개 메서드면 충분하다.
Go는 완벽한 언어가 아니다. 제네릭이 늦게 추가됐고, 에러 처리가 장황하고, 프레임워크 생태계가 Spring만큼 풍부하지 않다. 하지만 특정 영역(네트워크 서비스, CLI 도구, 클라우드 인프라)에서는 Java보다 더 적합한 선택이다.
Java와 Go, 둘 다 도구일 뿐이다. 상황에 맞는 도구를 선택하는 것이 좋은 개발자의 자질이다.