이 글은 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로의 여정#

이 시리즈에서 다룬 것들#

  1. Part 1: Go의 철학, 언제 Go를 선택하고 언제 Java를 유지할지
  2. Part 2: 문법 전환 - 변수, 함수, struct, interface
  3. Part 3: 에러 처리와 nil 안전성
  4. Part 4: 동시성 - goroutine, channel, context
  5. Part 5: 프로젝트 구조, 테스트, 생태계

Go 학습 로드맵#

1주차: 기본 문법, 표준 라이브러리 탐험 2-3주차: 에러 처리 패턴, 테스트 작성 4-5주차: 동시성 패턴, channel 활용 6-8주차: 실전 프로젝트 (간단한 API 서버) 2-3개월: 프로덕션 코드 작성, 코드 리뷰

추천 리소스#

공식 문서:

책:

  • “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, 둘 다 도구일 뿐이다. 상황에 맞는 도구를 선택하는 것이 좋은 개발자의 자질이다.