급하게 작성한 Go 코드를 어떻게 정리할 수 있을까요?
by
gg582 · 2026-09-21 13:25:40 · 61 views · 9 min read
이 블로그의 비교적 안정적인 서비스 상태를 보면 '원래 이런 블로그 템플릿을 좋아하나보다' 하실 수도 있지만, 사실 2025년까지만 해도 카드형 블로그를 운영하였습니다.
그 때 진행하던 몇 가지 공부 기록과, 조금은 잘 정리되지 않은 지저분한 소스를 GPT-4로 정리하다가 방치하였는데, 이제 와서는 리팩토링 후 재운영이 가능할 것 같아서 한 번 main.go를 뜯어 봤습니다.
그런데 이럴 수가...물론 제가 IncuSpeed같은 인프라형 졸업작품을 손코딩한다고 치이긴 했지만, 메인 함수가 너무 비대한 겁니다. 솔직히 당혹스러움을 감추지 못했습니다. 개구리 올챙이 적 기억 못 한다더니, 이게 대체 뭔가요.
package main
import (
"context"
"crypto/tls"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"fmt"
"github.com/gg582/chi-blog/blog-backend/database"
"github.com/gg582/chi-blog/blog-backend/workerpool"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/gg582/chi-blog/blog-backend/handlers"
"github.com/gg582/chi-blog/blog-backend/utils"
"github.com/spf13/cobra"
"golang.org/x/crypto/acme/autocert"
)
const (
numWorkers = 5
jobQueueSize = 48
)
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func main() {
var chiBlog = &cobra.Command {
Use: "run",
Short: "Run chi-based personal blog",
Long: `Run chi-based personal blog backend at localhost:8080`,
Run: func(cmd *cobra.Command, args []string) {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// --- START OF CHANGES ---
// 1. Rename ImageJobQueue to FileJobQueue
handlers.FileJobQueue = make(chan workerpool.UploadJob, jobQueueSize)
workerpool.NewWorkerPool(numWorkers, handlers.FileJobQueue)
// --- END OF CHANGES ---
// Define your routes
r.Post("/api/posts", handlers.GetPostsHandler)
r.Post("/api/posts/{id}", handlers.GetPostByIDHandler)
r.Get("/api/about", handlers.GetAboutPageHandler)
r.Get("/api/contact", handlers.GetContactPageHandler)
r.Post("/api/new-post/{id}", handlers.CreateNewPostHandler)
// --- START OF CHANGES ---
// 2. Rename route from /api/upload-image to /api/upload-file
// 3. Rename handler from handlers.UploadImage to handlers.UploadFile
r.Post("/api/upload-file", handlers.UploadFile)
// --- END OF CHANGES ---
r.Post("/api/login", handlers.LoginHandler)
fileServer := http.FileServer(http.Dir("./posts/assets"))
r.Handle("/assets/*", http.StripPrefix("/assets/", fileServer))
// Serve React frontend static files from ../blog-frontend/build
staticDir := http.Dir("../blog-frontend/build")
staticFS := http.FileServer(staticDir)
r.Get("/*", func(w http.ResponseWriter, req *http.Request) {
// API routes are already registered above; let chi handle those first.
// For everything else, try to serve a static file.
// If the file doesn't exist (SPA route), serve index.html.
path := req.URL.Path
if path == "/" {
http.ServeFile(w, req, "../blog-frontend/build/index.html")
return
}
// Try to open the requested file
f, err := staticDir.Open(path)
if err != nil {
http.ServeFile(w, req, "../blog-frontend/build/index.html")
return
}
defer f.Close()
// Check if it's a directory
stat, err := f.Stat()
if err != nil || stat.IsDir() {
http.ServeFile(w, req, "../blog-frontend/build/index.html")
return
}
// It's a real file — serve it
staticFS.ServeHTTP(w, req)
})
serverAddr := "0.0.0.0:8080"
useHTTPS := strings.EqualFold(os.Getenv("USE_HTTPS"), "true")
if useHTTPS {
log.Printf("Server starting on %s (HTTPS)...", serverAddr)
} else {
log.Printf("Server starting on %s (HTTP)...", serverAddr)
}
database.InitDatabase()
log.Println("Database loaded.")
// Use HTTPS only when explicitly enabled via USE_HTTPS=true.
certFile := "/etc/letsencrypt/live/chatter.pw/fullchain.pem"
keyFile := "/etc/letsencrypt/live/chatter.pw/privkey.pem"
var err error
if useHTTPS {
certExists := fileExists(certFile) && fileExists(keyFile)
if certExists {
log.Printf("Found existing TLS certificate files for chatter.pw. Starting HTTPS with local certificate on %s.", serverAddr)
err = http.ListenAndServeTLS(serverAddr, certFile, keyFile, r)
} else {
cacheDir := filepath.Join(".", "cert-cache")
if mkErr := os.MkdirAll(cacheDir, 0o700); mkErr != nil {
log.Fatalf("failed to create autocert cache directory %s: %v", cacheDir, mkErr)
}
log.Printf("TLS certificate not found at %s and %s. Requesting Let's Encrypt certificate for chatter.pw...", certFile, keyFile)
manager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist("chatter.pw"),
Cache: autocert.DirCache(cacheDir),
}
challengeAddr := ":80"
challengeListener, listenErr := net.Listen("tcp", challengeAddr)
if listenErr != nil {
log.Fatalf("failed to bind Let's Encrypt challenge server on %s: %v", challengeAddr, listenErr)
}
challengeServer := &http.Server{
Handler: manager.HTTPHandler(nil),
}
challengeErrChan := make(chan error, 1)
go func() {
log.Printf("Starting HTTP-01 challenge server on %s for Let's Encrypt validation.", challengeAddr)
if challengeErr := challengeServer.Serve(challengeListener); challengeErr != nil && challengeErr != http.ErrServerClosed {
challengeErrChan <- challengeErr
}
}()
select {
case challengeErr := <-challengeErrChan:
log.Fatalf("Let's Encrypt challenge server failed before HTTPS startup: %v", challengeErr)
default:
}
autoTLSServer := &http.Server{
Addr: serverAddr,
Handler: r,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: manager.GetCertificate,
},
}
err = autoTLSServer.ListenAndServeTLS("", "")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if shutdownErr := challengeServer.Shutdown(shutdownCtx); shutdownErr != nil && shutdownErr != http.ErrServerClosed {
log.Printf("failed to shutdown challenge server cleanly: %v", shutdownErr)
}
}
} else {
err = http.ListenAndServe(serverAddr, r)
}
if err != nil {
log.Fatalf("server failed to start on %s: %v", serverAddr, err)
}
},
}
var initAdmin = &cobra.Command {
Use: "init",
Short: "Initialize blog admin via cobra",
Long: `Initialize blog admin via cobra. You need to install sqlite3.`,
Run: func(cmd *cobra.Command, args []string) {
log.Println("WARNING: you will add administrator into SQLite. type 'yes' to continue")
var r string
fmt.Scanln(&r)
r = strings.ToLower(r)
if r != "yes" {
log.Println("Quitting without registration...")
os.Exit(1)
}
database.InitDatabase()
rows, err := database.DB.Query("SELECT COUNT(*) FROM blog_users")
if err != nil {
log.Fatalf("Failed to query users: %v", err)
}
defer rows.Close()
// Ensure to call rows.Next() before rows.Scan()
count := 0
if rows.Next() {
rows.Scan(&count)
}
if count == 0 {
log.Println("No users registered, continue...")
log.Println("Please enter your username.")
var username string
fmt.Scanln(&username)
log.Println("Please enter your password.")
var password string
fmt.Scanln(&password)
pwHash, err := utils.HashPassword(password)
if err != nil {
log.Fatalf("Failed to generate password hash: %v", err)
}
_, err = database.DB.Exec("INSERT INTO blog_users (username, password_hash) values (?,?)", username, pwHash)
if err != nil {
log.Fatalf("Failed to insert user info to Database. Please check sqlite3's condition: %v", err)
}
log.Printf("Admin user (%v) created", username)
} else {
log.Println("You can enter only one admin account for this blog.")
os.Exit(1)
}
},
}
chiBlog.AddCommand(initAdmin)
// Execute the blog command
if err := chiBlog.Execute(); err != nil {
log.Println(err)
os.Exit(1)
}
}
심각합니다. 메인 함수 안에 관리자 계정 설정부터 TLS 설정, 온갖 기능이 모듈화 없이 엉켜 있습니다.
제일 먼저 할 수 있는 것은 관리자 계정 설정과 같은 것을 별도 함수로 들어내는 것입니다.
DB 초기화, HTTP, 로컬 인증서 TLS, 자동 인증서 갱신을 빼내기 위해, 폴더 구조를 잡습니다.
yjlee@elegant:~/chi-blog/blog-backend$ tree server/
server/
├── router.go
└── serve.go
1 directory, 2 files
yjlee@elegant:~/chi-blog/blog-backend$ tree cmd/
cmd/
├── init.go
└── root.go
1 directory, 2 files
이 정도면 충분하네요. 각 파일에 분리해 줍시다.
package server
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"os"
"time"
"github.com/gg582/chi-blog/blog-backend/config"
"github.com/gg582/chi-blog/blog-backend/database"
"golang.org/x/crypto/acme/autocert"
)
// Serve starts the blog server according to cfg. It returns only when the
// server stops, with a non-nil error unless shutdown was clean.
func Serve(cfg *config.Config, handler http.Handler) error {
database.InitDatabase(cfg.DBPath)
log.Println("Database loaded.")
scheme := "HTTP"
if cfg.UseHTTPS {
scheme = "HTTPS"
}
log.Printf("Server starting on %s (%s)...", cfg.ServerAddr, scheme)
if !cfg.UseHTTPS {
return listenAndServe(cfg.ServerAddr, handler)
}
if fileExists(cfg.TLSCertFile) && fileExists(cfg.TLSKeyFile) {
log.Printf("Using local TLS certificate for %s.", cfg.TLSDomain)
return http.ListenAndServeTLS(cfg.ServerAddr, cfg.TLSCertFile, cfg.TLSKeyFile, handler)
}
return serveWithAutocert(cfg, handler)
}
// serveWithAutocert obtains a certificate from Let's Encrypt via the HTTP-01
// challenge and serves HTTPS with it.
func serveWithAutocert(cfg *config.Config, handler http.Handler) error {
if err := os.MkdirAll(cfg.ACMECacheDir, 0o700); err != nil {
return fmt.Errorf("create autocert cache directory %s: %w", cfg.ACMECacheDir, err)
}
log.Printf("Requesting Let's Encrypt certificate for %s...", cfg.TLSDomain)
manager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(cfg.TLSDomain),
Cache: autocert.DirCache(cfg.ACMECacheDir),
}
challengeListener, err := net.Listen("tcp", cfg.HTTPChallengeAddr)
if err != nil {
return fmt.Errorf("bind HTTP-01 challenge server on %s: %w", cfg.HTTPChallengeAddr, err)
}
challengeServer := &http.Server{Handler: manager.HTTPHandler(nil)}
challengeErrChan := make(chan error, 1)
go func() {
log.Printf("HTTP-01 challenge server listening on %s.", cfg.HTTPChallengeAddr)
if serveErr := challengeServer.Serve(challengeListener); serveErr != nil && serveErr != http.ErrServerClosed {
challengeErrChan <- serveErr
}
}()
select {
case serveErr := <-challengeErrChan:
return fmt.Errorf("HTTP-01 challenge server failed: %w", serveErr)
default:
}
server := &http.Server{
Addr: cfg.ServerAddr,
Handler: handler,
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: manager.GetCertificate,
},
}
serveErr := server.ListenAndServeTLS("", "")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if shutdownErr := challengeServer.Shutdown(shutdownCtx); shutdownErr != nil && shutdownErr != http.ErrServerClosed {
log.Printf("Failed to shut down challenge server cleanly: %v", shutdownErr)
}
return serveErr
}
func listenAndServe(addr string, handler http.Handler) error {
if err := http.ListenAndServe(addr, handler); err != nil {
return fmt.Errorf("server failed on %s: %w", addr, err)
}
return nil
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
서버 서빙을 위한 함수라도 하나의 거대한 함수를 작성하면 안 됩니다. 반복적인 코드가 될 수 있는 fileExists부터 우선 분리합니다.
그리고 Go언어 특유의 장황한(다르게 말하면 C언어스러운) 오류 처리를 감안해 listenAndServe라는 작은 함수를 하나 만들어 줍니다. 마찬가지로 인증서 있는 함수도 serveWithAutocert를 사용해 줍니다.
코브라 명령은 cmd 패키지를 만들어 몰아 넣어 줍니다. 관리자 등록 명령을 빼 줍니다.
package cmd
import (
"fmt"
"log"
"os"
"strings"
"github.com/gg582/chi-blog/blog-backend/config"
"github.com/gg582/chi-blog/blog-backend/database"
"github.com/gg582/chi-blog/blog-backend/utils"
"github.com/spf13/cobra"
)
func newInitCommand() *cobra.Command {
return &cobra.Command{
Use: "init",
Short: "Register the blog admin account",
Long: "Register the blog admin account. Requires sqlite3.",
Run: func(cmd *cobra.Command, args []string) {
log.Println("WARNING: you will add administrator into SQLite. type 'yes' to continue")
var answer string
fmt.Scanln(&answer)
if strings.ToLower(answer) != "yes" {
log.Println("Quitting without registration...")
os.Exit(1)
}
database.InitDatabase(config.Load().DBPath)
var count int
err := database.DB.QueryRow("SELECT COUNT(*) FROM blog_users").Scan(&count)
if err != nil {
log.Fatalf("Failed to query users: %v", err)
}
if count > 0 {
log.Println("You can enter only one admin account for this blog.")
os.Exit(1)
}
log.Println("Please enter your username.")
var username string
fmt.Scanln(&username)
log.Println("Please enter your password.")
var password string
fmt.Scanln(&password)
pwHash, err := utils.HashPassword(password)
if err != nil {
log.Fatalf("Failed to generate password hash: %v", err)
}
if _, err := database.DB.Exec("INSERT INTO blog_users (username, password_hash) values (?,?)", username, pwHash); err != nil {
log.Fatalf("Failed to insert user info to Database. Please check sqlite3's condition: %v", err)
}
log.Printf("Admin user (%v) created", username)
},
}
}
실행부는 root.go로 뺍니다.
라우트 등록 및 실행은 솔직히 제일 단순하고 반복적입니다. 한 파일에 몰아 넣어서 라우트 확인을 용이하게 합니다. 이것을 메인 함수에 넣어도 되지만, 뚱뚱한 메인 함수를 유지하고 덧붙이다 보면 쉽게 코트 분리도가 나빠집니다.
그 부분을 별도로 분리하는 것은 이런 까닭입니다.
아래는 root.go입니다.
package cmd
import (
"log"
"os"
"github.com/gg582/chi-blog/blog-backend/config"
"github.com/gg582/chi-blog/blog-backend/server"
"github.com/spf13/cobra"
)
func Execute() {
root := &cobra.Command{
Use: "chi-blog",
Short: "Run the chi-based personal blog backend",
Run: func(cmd *cobra.Command, args []string) {
cfg := config.Load()
if err := server.Serve(cfg, server.NewRouter(cfg)); err != nil {
log.Println(err)
os.Exit(1)
}
},
}
root.AddCommand(newInitCommand())
if err := root.Execute(); err != nil {
log.Println(err)
os.Exit(1)
}
}
package server
import (
"net/http"
"path/filepath"
"github.com/gg582/chi-blog/blog-backend/config"
"github.com/gg582/chi-blog/blog-backend/handlers"
"github.com/gg582/chi-blog/blog-backend/workerpool"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/cors"
)
const (
numWorkers = 5
jobQueueSize = 48
)
func NewRouter(cfg *config.Config) http.Handler {
handlers.FileJobQueue = make(chan workerpool.UploadJob, jobQueueSize)
workerpool.NewWorkerPool(numWorkers, handlers.FileJobQueue)
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
if len(cfg.AllowedOrigins) > 0 {
r.Use(cors.New(cors.Options{
AllowedOrigins: cfg.AllowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Requested-With"},
AllowCredentials: true,
MaxAge: 3600,
}).Handler)
}
h := handlers.NewHandlers(cfg.PostsDir, cfg.AssetsDir, cfg.AboutMD, cfg.ContactMD)
r.Post("/api/posts", h.GetPostsHandler)
r.Post("/api/posts/{id}", h.GetPostByIDHandler)
r.Get("/api/about", h.GetAboutPageHandler)
r.Get("/api/contact", h.GetContactPageHandler)
r.Post("/api/new-post/{id}", h.CreateNewPostHandler)
r.Post("/api/upload-file", h.UploadFile)
r.Post("/api/login", handlers.LoginHandler)
r.Handle("/assets/*", http.StripPrefix("/assets/", http.FileServer(http.Dir(cfg.AssetsDir))))
r.Get("/*", spaFileHandler(cfg.StaticDir))
return r
}
// spaFileHandler serves files from staticDir and falls back to index.html
// for paths that match no file, so client-side routes resolve in the SPA.
func spaFileHandler(staticDir string) http.HandlerFunc {
staticFS := http.FileServer(http.Dir(staticDir))
indexHTML := filepath.Join(staticDir, "index.html")
return func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/" {
http.ServeFile(w, req, indexHTML)
return
}
f, err := http.Dir(staticDir).Open(req.URL.Path)
if err != nil {
http.ServeFile(w, req, indexHTML)
return
}
defer f.Close()
if stat, err := f.Stat(); err != nil || stat.IsDir() {
http.ServeFile(w, req, indexHTML)
return
}
staticFS.ServeHTTP(w, req)
}
}
이제 main.go는 이 로직들을 조립해서 새로 작성한 cmd.Execute 함수만 실행하면 됩니다.
package main
import "github.com/gg582/chi-blog/blog-backend/cmd"
func main() {
cmd.Execute()
}
눈에 띄게 얇아졌습니다. 그리고 리팩토링 과정에서 장황하고 읽기 어려운 주석도 겸사겸사 정리할 수 있었습니다.
한 가지 신기한 점
지금의 Go 코딩 스타일과 과거의 스타일을 비교하면 괄목할 정도로 달라졌습니다. 특히 연구실에서는 도커에 들어갈 작은 Go 서버를 많이 만들었고, 심한 경우에는 단일 파일을 go build source_code.go 형식으로 빌드하는 워크플로우, Go 언어 코드를 동적 생성하는 Go 언어 프로그램 등이 있었기 때문에, 구조화된 REST 서버 작성 규칙에 대해서 고민할 일이 적었습니다.
게다가 GPT-4가 만들어 낸 em-dash나 이상한 문장들도 잘 제거했구요. 여러모로 초창기의 GPT가 나빴다는 것을 다시금 체감하네요.
역설적으로 이런 설계 방침은 C언어로 직접 프레임워크를 만들어 보면서 발생한 유지 보수 문제를 직접 해결하다 보니 체득하게 되었다는 겁니다.