처음부터 견고한 Go 웹 프로젝트 템플릿 구축하기
Min-jun Kim
Dev Intern · Leapcell

소개
빠르게 변화하는 소프트웨어 개발 세계에서 새로운 프로젝트를 시작하는 것은 종종 반복적인 설정 작업을 수반합니다. Go 웹 애플리케이션의 경우, 이는 구성 처리를 위한 견고한 기반을 구축하고, 효과적인 로깅을 구현하며, 명확하고 확장 가능한 디렉터리 구조를 정의하는 것을 포함합니다. 잘 고려된 템플릿 없이는 개발자가 보일러플레이트 코드에 귀중한 시간을 소비할 수 있으며, 이는 프로젝트 전반에 걸쳐 불일치를 초래하고 유지보수를 방해합니다. 이 문서의 목표는 처음부터 견고한 Go 웹 프로젝트 템플릿을 만드는 과정을 안내하여 이러한 과제를 해결하고, 개발 프로세스를 크게 간소화하고 확장 가능하고 유지보수 가능한 애플리케이션을 위한 기반을 마련하는 청사진을 제공하는 것입니다. 이러한 템플릿을 채택함으로써 인프라보다는 핵심 비즈니스 로직에 집중하여 개발을 가속화하고 코드 품질을 향상시킬 수 있습니다.
프로덕션 준비 Go 웹 애플리케이션을 위한 핵심 개념
구현에 들어가기 전에 템플릿 구축을 안내할 몇 가지 주요 용어와 원칙을 정의해 보겠습니다.
구성 관리: 애플리케이션 설정을 코드베이스에서 외부화하는 프로세스입니다. 이를 통해 애플리케이션을 다시 컴파일할 필요 없이 다양한 환경(개발, 스테이징, 프로덕션)에 쉽게 적응할 수 있습니다. 주요 측면에는 환경 변수, 구성 파일(예: YAML, JSON) 및 잠재적으로 동적 구성 소스 처리가 포함됩니다.
로깅: 애플리케이션 수명 주기 내에서 이벤트를 기록하는 관행입니다. 효과적인 로깅은 디버깅, 모니터링 및 감사에 중요합니다. 적절한 로깅 수준(예: DEBUG, INFO, WARN, ERROR), 구문 분석을 쉽게 하기 위한 구조화된 로깅, 다양한 싱크(콘솔, 파일, 중앙 집중식 로깅 시스템)로의 출력을 포함합니다.
디렉터리 구조: 프로젝트 내 파일 및 폴더의 구성입니다. 잘 정의된 디렉터리 구조는 명확성을 촉진하고, 탐색을 단순화하며, 규칙을 시행하여 새로운 팀 구성원이 프로젝트를 이해하고 기존 팀 구성원이 특정 코드를 찾기 쉽게 만듭니다.
프로젝트 템플릿: 새 프로젝트의 시작점으로 사용되는 미리 정의된 파일 및 디렉터리 세트입니다. 모범 사례, 일반 유틸리티 및 초기 구성을 캡슐화하여 설정 시간을 최소화하고 일관성을 보장합니다.
이러한 개념은 프로덕션 준비 애플리케이션을 구축하는 데 기본이 되며 Go 웹 프로젝트 템플릿의 초점 영역이 될 것입니다.
템플릿 구축: 원칙, 구현 및 사용
우리 템플릿은 모듈성, 단순성 및 확장성을 우선시할 것입니다. 실용적인 적용을 시연하기 위해 구성 및 로깅을 위해 인기 있는 Go 라이브러리를 활용할 것입니다.
디렉터리 구조
깔끔하고 직관적인 디렉터리 구조는 유지보수 가능한 프로젝트의 기초입니다. 다음은 제안된 구조와 각 디렉터리에 대한 설명입니다.
.
├── cmd/
│ └── server/ # 웹 서버의 메인 애플리케이션 진입점
│ └── main.go
├── config/ # 구성 파일 및 로딩 로직
│ └── config.go
│ └── config.yaml
├── internal/ # 개인 애플리케이션 및 라이브러리 코드. 이것이 애플리케이션의 핵심입니다.
│ ├── app/ # 애플리케이션별 로직 (예: 서비스, 비즈니스 규칙)
│ │ └── handlers/
│ │ └── handler.go
│ │ └── service/
│ │ └── service.go
│ ├── database/ # 데이터베이스 연결 및 모델
│ │ └── client.go
│ │ └── migrations/
│ │ └── 000001_create_users_table.up.sql
│ │ └── 000001_create_users_table.down.sql
│ └── platform/ # 재사용 가능한 플랫폼별 코드 (예: 인증, 로깅 설정)
│ └── web/ # 웹 프레임워크 설정 및 유틸리티
│ └── server.go
│ └── logger/
│ └── logger.go
├── pkg/ # 외부 프로젝트에서 가져올 수 있는 공개 유틸리티 코드 (선택 사항)
│ └── somepkg/
│ └── somepkg.go
├── scripts/ # 개발, 배포 등에 유용한 스크립트
├── web/ # 웹 자산 (HTML 템플릿, CSS, JavaScript)
│ ├── static/
│ └── templates/
├── Makefile # 기본 빌드 및 실행 명령
├── go.mod # Go 모듈 정의
├── go.sum # Go 모듈 체크섬
└── README.md # 프로젝트 문서
설명:
cmd/
: 실행 가능한 애플리케이션의main
패키지를 포함합니다.cmd/server
는 특히 웹 서버용입니다.config/
: 애플리케이션 설정을 중앙 집중화하여 환경별 구성을 쉽게 관리할 수 있습니다.internal/
: Go의 개인 패키지 강제 적용 방식입니다. 여기의 코드는 외부 프로젝트에서 가져올 수 없으므로 애플리케이션 로직을 캡슐화합니다.app/
: API 요청을 위한 핸들러와 비즈니스 작업을 위한 서비스를 포함한 핵심 비즈니스 로직을 보유합니다.database/
: 데이터베이스 상호 작용, 연결 풀링 및 잠재적으로 ORM/마이그레이션 로직을 관리합니다.platform/
: 웹 서버 설정 및 로거 구성과 같은 재사용 가능한 인프라 코드를 포함합니다.
pkg/
: 외부 애플리케이션에서 안전하게 사용할 수 있는 코드를 위한 것입니다. 프로젝트가 라이브러리가 아니라면 이 디렉터리는 비어 있거나 생략될 수 있습니다.scripts/
: 일반적인 개발 및 배포 작업을 위한 편의 스크립트입니다.web/
: 웹 인터페이스와 직접 관련된 프런트엔드 자산을 저장합니다.
구성 관리
viper
를 사용하여 유연한 구성 관리를 수행하여 YAML 파일, 환경 변수 및 명령줄 플래그에서 읽을 수 있도록 합니다.
config/config.go
:
package config import ( "fmt" "os" "time" "github.com/spf13/viper" ) // AppConfig는 모든 애플리케이션 구성을 보유합니다. type AppConfig struct { Server ServerConfig Database DatabaseConfig Log LogConfig // 필요한 다른 구성 추가 } // ServerConfig는 서버별 구성을 보유합니다. type ServerConfig struct { Port string ReadTimeout time.Duration WriteTimeout time.Duration IdleTimeout time.Duration } // DatabaseConfig는 데이터베이스별 구성을 보유합니다. type DatabaseConfig struct { Host string Port string User string Password string DBName string SSLMode string MaxOpenConns int MaxIdleConns int ConnMaxLifetime time.Duration } // LogConfig는 로깅별 구성을 보유합니다. type LogConfig struct { Level string // 예: "debug", "info", "warn", "error" Format string // 예: "json", "text" Output string // 예: "stdout", "file" FilePath string // Output이 "file"인 경우 } // LoadConfig는 파일 및 환경 변수에서 애플리케이션 구성을 로드합니다. func LoadConfig() (*AppConfig, error) { v import.SetConfigName("config") // 구성 파일 이름 (확장자 제외) v import.SetConfigType("yaml") // 구성 파일 유형 v import.AddConfigPath("./config") // 구성 파일을 찾을 경로 v import.AddConfigPath(".") // 선택적으로 작업 디렉터리에서 구성 찾기 // 기본값 설정 v import.SetDefault("server.port", "8080") v import.SetDefault("server.readTimeout", "5s") v import.SetDefault("server.writeTimeout", "10s") v import.SetDefault("server.idleTimeout", "120s") v import.SetDefault("database.host", "localhost") v import.SetDefault("database.port", "5432") v import.SetDefault("database.user", "user") v import.SetDefault("database.password", "password") v import.SetDefault("database.dbname", "appdb") v import.SetDefault("database.sslmode", "disable") v import.SetDefault("database.maxOpenConns", 25) v import.SetDefault("database.maxIdleConns", 25) v import.SetDefault("database.connMaxLifetime", "5m") v import.SetDefault("log.level", "info") v import.SetDefault("log.format", "json") v import.SetDefault("log.output", "stdout") v import.SetDefault("log.filepath", "./logs/app.log") // "APP_"로 시작하는 환경 변수를 읽도록 Viper 활성화 v import.SetEnvPrefix("APP") v import.AutomaticEnv() if err := v import.ReadInConfig(); err != nil { if _, ok := err.(v import.ConfigFileNotFoundError); ok { fmt.Println("Config file not found, using defaults and environment variables.") } else { return nil, fmt.Errorf("failed to read config file: %w", err) } } var cfg AppConfig if err := v import.Unmarshal(&cfg); err != nil { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } return &cfg, nil }
config/config.yaml
:
server: port: "8080" readTimeout: "5s" writeTimeout: "10s" idleTimeout: "120s" log: level: "info" format: "json" output: "stdout" # filepath: "./logs/app.log" # Output이 'file'인 경우 주석 해제 및 구성 database: host: "db.example.com" port: "5432" user: "admin" password: "securepassword" dbname: "myapplication" sslmode: "require" maxOpenConns: 50 maxIdleConns: 20 connMaxLifetime: "10m"
이 설정은 config.yaml
값을 환경 변수(예: APP_SERVER_PORT=8000
)로 재정의할 수 있도록 합니다.
로깅 설정
고성능 로깅 라이브러리인 zap
을 사용하여 구조화된 로깅을 수행합니다.
internal/platform/logger/logger.go
:
package logger import ( "fmt" "io" "os" "go.uber.org/zap" "go.uber.org/zap/zapcore" "your_module_name/config" // your_module_name을 교체하십시오. ) // InitLogger는 제공된 LogConfig를 기반으로 Zap 로거를 초기화합니다. func InitLogger(cfg *config.LogConfig) (*zap.Logger, error) { var level zapcore.Level if err := level.UnmarshalText([]byte(cfg.Level)); err != nil { return nil, fmt.Errorf("invalid log level: %w", err) } var encoder zapcore.Encoder if cfg.Format == "json" { encoder = zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()) } else if cfg.Format == "text" { encoder = zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()) } else { return nil, fmt.Errorf("unsupported log format: %s", cfg.Format) } var output io.Writer if cfg.Output == "stdout" { output = os.Stdout } else if cfg.Output == "file" { file, err := os.OpenFile(cfg.FilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return nil, fmt.Errorf("failed to open log file: %w", err) } output = file } else { return nil, fmt.Errorf("unsupported log output: %s", cfg.Output) } core := zapcore.NewCore(encoder, zapcore.AddSync(output), level) logger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel)) zap.ReplaceGlobals(logger) // 편의를 위해 전역 로거로 설정 return logger, nil }
이 로거 설정은 AppConfig
를 통해 로그 수준, 형식(JSON 또는 텍스트) 및 출력(stdout 또는 파일)을 구성할 수 있습니다.
웹 서버 설정
표준 net/http
패키지를 사용하여 기본 HTTP 서버를 만듭니다.
internal/platform/web/server.go
:
package web import ( "context" "net/http" "os" "os/signal" "syscall" "time" "go.uber.org/zap" "your_module_name/config" // your_module_name을 교체하십시오. ) // Server는 우리의 HTTP 서버를 나타냅니다. type Server struct { *http.Server Logger *zap.Logger Config *config.AppConfig } // NewServer는 새 HTTP 서버를 생성하고 구성합니다. func NewServer(cfg *config.AppConfig, logger *zap.Logger, router http.Handler) *Server { s := &http.Server{ Addr: ":" + cfg.Server.Port, Handler: router, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, IdleTimeout: cfg.Server.IdleTimeout, } return &Server{ Server: s, Logger: logger, Config: cfg, } } // Run은 HTTP 서버를 시작하고 정상 종료를 처리합니다. func (s *Server) Run() { s.Logger.Info("Starting server", zap.String("port", s.Config.Server.Port)) serverErrors := make(chan error, 1) go func() { if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed { serverErrors <- err } }() // OS 신호를 수신할 채널. osSignals := make(chan os.Signal, 1) signal.Notify(osSignals, syscall.SIGINT, syscall.SIGTERM) select { case err := <-serverErrors: s.Logger.Error("Server error", zap.Error(err)) os.Exit(1) case sig := <-osSignals: s.Logger.Info("Shutting down server...", zap.String("signal", sig.String())) // 대기 중인 요청이 완료될 수 있도록 1분 동안 기다립니다. ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) defer cancel() if err := s.Shutdown(ctx); err != nil { s.Logger.Error("Graceful shutdown failed", zap.Error(err)) s.Close() // 종료 실패 시 강제 종료 } s.Logger.Info("Server stopped") } }