๐Ÿš€ Go ์–ธ์–ด Day 2: Database & Testing ๋งˆ์Šคํ„ฐํ•˜๊ธฐ

๐Ÿ“‹ Todayโ€™s Goal

โ€œ์–ด์ œ ๋งŒ๋“  TODO API์— PostgreSQL์„ ์—ฐ๋™ํ•˜๊ณ , ํ”„๋กœ๋•์…˜๊ธ‰ ํ…Œ์ŠคํŠธ๋ฅผ ์ž‘์„ฑํ•œ๋‹ค!โ€

๐ŸŽฏ ํ•™์Šต ๋ชฉํ‘œ (8์‹œ๊ฐ„)

  1. PostgreSQL ์—ฐ๋™ & ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ (2์‹œ๊ฐ„)
  2. Context ํŒจํ„ด ์™„๋ฒฝ ์ดํ•ด (1์‹œ๊ฐ„)
  3. ํ…Œ์ŠคํŠธ ์ž‘์„ฑ (2์‹œ๊ฐ„)
  4. Docker ๋ฐฐํฌ & ํ™˜๊ฒฝ ์„ค์ • (2์‹œ๊ฐ„)
  5. ์‹ค์ „ ํ”„๋กœ์ ํŠธ ์™„์„ฑ (1์‹œ๊ฐ„)

๐Ÿ“š Part 1: Database Layer ๊ตฌ์ถ• (2์‹œ๊ฐ„)

1.1 ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ ์—…๋ฐ์ดํŠธ

go-day2/
โ”œโ”€โ”€ cmd/
โ”‚   โ””โ”€โ”€ server/
โ”‚       โ””โ”€โ”€ main.go
โ”œโ”€โ”€ internal/
โ”‚   โ”œโ”€โ”€ config/
โ”‚   โ”‚   โ””โ”€โ”€ config.go
โ”‚   โ”œโ”€โ”€ database/
โ”‚   โ”‚   โ”œโ”€โ”€ postgres.go
โ”‚   โ”‚   โ””โ”€โ”€ migrations/
โ”‚   โ”œโ”€โ”€ handlers/
โ”‚   โ”‚   โ””โ”€โ”€ todo.go
โ”‚   โ”œโ”€โ”€ models/
โ”‚   โ”‚   โ””โ”€โ”€ todo.go
โ”‚   โ””โ”€โ”€ repository/
โ”‚       โ”œโ”€โ”€ interface.go
โ”‚       โ””โ”€โ”€ postgres/
โ”‚           โ””โ”€โ”€ todo.go
โ”œโ”€โ”€ docker-compose.yml
โ”œโ”€โ”€ Dockerfile
โ”œโ”€โ”€ .env
โ””โ”€โ”€ go.mod

1.2 Configuration ์„ค์ •

// internal/config/config.go
package config
 
import (
    "fmt"
    "os"
    "strconv"
    "time"
)
 
type Config struct {
    Port        string
    DatabaseURL string
    DB          DBConfig
    Server      ServerConfig
}
 
type DBConfig struct {
    Host         string
    Port         int
    User         string
    Password     string
    Database     string
    SSLMode      string
    MaxOpenConns int
    MaxIdleConns int
    MaxLifetime  time.Duration
}
 
type ServerConfig struct {
    ReadTimeout  time.Duration
    WriteTimeout time.Duration
    IdleTimeout  time.Duration
}
 
func Load() (*Config, error) {
    cfg := &Config{
        Port: getEnv("PORT", "8080"),
        DB: DBConfig{
            Host:         getEnv("DB_HOST", "localhost"),
            Port:         getEnvAsInt("DB_PORT", 5432),
            User:         getEnv("DB_USER", "postgres"),
            Password:     getEnv("DB_PASSWORD", "postgres"),
            Database:     getEnv("DB_NAME", "todoapp"),
            SSLMode:      getEnv("DB_SSLMODE", "disable"),
            MaxOpenConns: getEnvAsInt("DB_MAX_OPEN_CONNS", 25),
            MaxIdleConns: getEnvAsInt("DB_MAX_IDLE_CONNS", 5),
            MaxLifetime:  getEnvAsDuration("DB_MAX_LIFETIME", 5*time.Minute),
        },
        Server: ServerConfig{
            ReadTimeout:  getEnvAsDuration("SERVER_READ_TIMEOUT", 15*time.Second),
            WriteTimeout: getEnvAsDuration("SERVER_WRITE_TIMEOUT", 15*time.Second),
            IdleTimeout:  getEnvAsDuration("SERVER_IDLE_TIMEOUT", 60*time.Second),
        },
    }
    
    cfg.DatabaseURL = fmt.Sprintf(
        "postgres://%s:%s@%s:%d/%s?sslmode=%s",
        cfg.DB.User, cfg.DB.Password, cfg.DB.Host, cfg.DB.Port, cfg.DB.Database, cfg.DB.SSLMode,
    )
    
    return cfg, nil
}
 
func getEnv(key, defaultValue string) string {
    if value := os.Getenv(key); value != "" {
        return value
    }
    return defaultValue
}
 
func getEnvAsInt(key string, defaultValue int) int {
    valueStr := getEnv(key, "")
    if value, err := strconv.Atoi(valueStr); err == nil {
        return value
    }
    return defaultValue
}
 
func getEnvAsDuration(key string, defaultValue time.Duration) time.Duration {
    valueStr := getEnv(key, "")
    if value, err := time.ParseDuration(valueStr); err == nil {
        return value
    }
    return defaultValue
}

1.3 Database Connection

// internal/database/postgres.go
package database
 
import (
    "context"
    "database/sql"
    "fmt"
    "time"
    
    _ "github.com/lib/pq"
    "github.com/yourusername/go-day2/internal/config"
)
 
type DB struct {
    *sql.DB
}
 
func NewConnection(cfg *config.DBConfig) (*DB, error) {
    dsn := fmt.Sprintf(
        "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
        cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.Database, cfg.SSLMode,
    )
    
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        return nil, fmt.Errorf("failed to open database: %w", err)
    }
    
    // Connection pool settings
    db.SetMaxOpenConns(cfg.MaxOpenConns)
    db.SetMaxIdleConns(cfg.MaxIdleConns)
    db.SetConnMaxLifetime(cfg.MaxLifetime)
    
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    if err := db.PingContext(ctx); err != nil {
        return nil, fmt.Errorf("failed to ping database: %w", err)
    }
    
    return &DB{db}, nil
}
 
func (db *DB) Migrate() error {
    query := `
    CREATE TABLE IF NOT EXISTS todos (
        id SERIAL PRIMARY KEY,
        title VARCHAR(255) NOT NULL,
        completed BOOLEAN DEFAULT FALSE,
        created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
        updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
    );
    
    CREATE INDEX IF NOT EXISTS idx_todos_completed ON todos(completed);
    CREATE INDEX IF NOT EXISTS idx_todos_created_at ON todos(created_at DESC);
    `
    
    _, err := db.Exec(query)
    return err
}

1.4 Repository Pattern

// internal/repository/interface.go
package repository
 
import (
    "context"
    "github.com/yourusername/go-day2/internal/models"
)
 
type TodoRepository interface {
    Create(ctx context.Context, todo *models.Todo) error
    GetByID(ctx context.Context, id int) (*models.Todo, error)
    GetAll(ctx context.Context, filter TodoFilter) ([]*models.Todo, error)
    Update(ctx context.Context, todo *models.Todo) error
    Delete(ctx context.Context, id int) error
}
 
type TodoFilter struct {
    Completed *bool
    Limit     int
    Offset    int
    OrderBy   string
    Search    string
}
// internal/repository/postgres/todo.go
package postgres
 
import (
    "context"
    "database/sql"
    "fmt"
    "strings"
    
    "github.com/yourusername/go-day2/internal/models"
    "github.com/yourusername/go-day2/internal/repository"
)
 
type TodoRepository struct {
    db *sql.DB
}
 
func NewTodoRepository(db *sql.DB) repository.TodoRepository {
    return &TodoRepository{db: db}
}
 
func (r *TodoRepository) Create(ctx context.Context, todo *models.Todo) error {
    query := `
        INSERT INTO todos (title, completed) 
        VALUES ($1, $2) 
        RETURNING id, created_at, updated_at
    `
    
    err := r.db.QueryRowContext(
        ctx, query, todo.Title, todo.Completed,
    ).Scan(&todo.ID, &todo.CreatedAt, &todo.UpdatedAt)
    
    if err != nil {
        return fmt.Errorf("failed to create todo: %w", err)
    }
    
    return nil
}
 
func (r *TodoRepository) GetByID(ctx context.Context, id int) (*models.Todo, error) {
    query := `
        SELECT id, title, completed, created_at, updated_at 
        FROM todos 
        WHERE id = $1
    `
    
    todo := &models.Todo{}
    err := r.db.QueryRowContext(ctx, query, id).Scan(
        &todo.ID, &todo.Title, &todo.Completed, 
        &todo.CreatedAt, &todo.UpdatedAt,
    )
    
    if err == sql.ErrNoRows {
        return nil, models.ErrTodoNotFound
    }
    if err != nil {
        return nil, fmt.Errorf("failed to get todo: %w", err)
    }
    
    return todo, nil
}
 
func (r *TodoRepository) GetAll(ctx context.Context, filter repository.TodoFilter) ([]*models.Todo, error) {
    query := `SELECT id, title, completed, created_at, updated_at FROM todos WHERE 1=1`
    args := []interface{}{}
    argCount := 0
    
    // Dynamic query building
    if filter.Completed != nil {
        argCount++
        query += fmt.Sprintf(" AND completed = $%d", argCount)
        args = append(args, *filter.Completed)
    }
    
    if filter.Search != "" {
        argCount++
        query += fmt.Sprintf(" AND title ILIKE $%d", argCount)
        args = append(args, "%"+filter.Search+"%")
    }
    
    // Order by
    orderBy := "created_at DESC"
    if filter.OrderBy != "" {
        orderBy = filter.OrderBy
    }
    query += " ORDER BY " + orderBy
    
    // Pagination
    if filter.Limit > 0 {
        argCount++
        query += fmt.Sprintf(" LIMIT $%d", argCount)
        args = append(args, filter.Limit)
    }
    
    if filter.Offset > 0 {
        argCount++
        query += fmt.Sprintf(" OFFSET $%d", argCount)
        args = append(args, filter.Offset)
    }
    
    rows, err := r.db.QueryContext(ctx, query, args...)
    if err != nil {
        return nil, fmt.Errorf("failed to get todos: %w", err)
    }
    defer rows.Close()
    
    todos := []*models.Todo{}
    for rows.Next() {
        todo := &models.Todo{}
        err := rows.Scan(
            &todo.ID, &todo.Title, &todo.Completed,
            &todo.CreatedAt, &todo.UpdatedAt,
        )
        if err != nil {
            return nil, fmt.Errorf("failed to scan todo: %w", err)
        }
        todos = append(todos, todo)
    }
    
    return todos, nil
}
 
func (r *TodoRepository) Update(ctx context.Context, todo *models.Todo) error {
    query := `
        UPDATE todos 
        SET title = $2, completed = $3, updated_at = CURRENT_TIMESTAMP 
        WHERE id = $1
        RETURNING updated_at
    `
    
    err := r.db.QueryRowContext(
        ctx, query, todo.ID, todo.Title, todo.Completed,
    ).Scan(&todo.UpdatedAt)
    
    if err == sql.ErrNoRows {
        return models.ErrTodoNotFound
    }
    if err != nil {
        return fmt.Errorf("failed to update todo: %w", err)
    }
    
    return nil
}
 
func (r *TodoRepository) Delete(ctx context.Context, id int) error {
    query := `DELETE FROM todos WHERE id = $1`
    
    result, err := r.db.ExecContext(ctx, query, id)
    if err != nil {
        return fmt.Errorf("failed to delete todo: %w", err)
    }
    
    rowsAffected, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("failed to get rows affected: %w", err)
    }
    
    if rowsAffected == 0 {
        return models.ErrTodoNotFound
    }
    
    return nil
}

๐ŸŽฏ Part 2: Context Pattern ๋งˆ์Šคํ„ฐ (1์‹œ๊ฐ„)

2.1 Context๋ฅผ ํ™œ์šฉํ•œ Request ID ์ถ”์ 

// internal/middleware/request_id.go
package middleware
 
import (
    "context"
    "net/http"
    
    "github.com/google/uuid"
)
 
type contextKey string
 
const RequestIDKey contextKey = "requestID"
 
func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        requestID := r.Header.Get("X-Request-ID")
        if requestID == "" {
            requestID = uuid.New().String()
        }
        
        ctx := context.WithValue(r.Context(), RequestIDKey, requestID)
        w.Header().Set("X-Request-ID", requestID)
        
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
 
func GetRequestID(ctx context.Context) string {
    if requestID, ok := ctx.Value(RequestIDKey).(string); ok {
        return requestID
    }
    return ""
}

2.2 Context๋ฅผ ํ™œ์šฉํ•œ ๋กœ๊น…

// internal/logger/logger.go
package logger
 
import (
    "context"
    "log"
    
    "github.com/yourusername/go-day2/internal/middleware"
)
 
type Logger struct {
    *log.Logger
}
 
func (l *Logger) WithContext(ctx context.Context) *Logger {
    requestID := middleware.GetRequestID(ctx)
    if requestID != "" {
        return &Logger{
            Logger: log.New(l.Writer(), l.Prefix()+" ["+requestID+"] ", l.Flags()),
        }
    }
    return l
}
 
func (l *Logger) InfoContext(ctx context.Context, msg string) {
    l.WithContext(ctx).Println("INFO:", msg)
}
 
func (l *Logger) ErrorContext(ctx context.Context, msg string) {
    l.WithContext(ctx).Println("ERROR:", msg)
}

๐Ÿงช Part 3: Testing (2์‹œ๊ฐ„)

3.1 Unit Test

// internal/repository/postgres/todo_test.go
package postgres
 
import (
    "context"
    "database/sql"
    "testing"
    "time"
    
    "github.com/DATA-DOG/go-sqlmock"
    "github.com/stretchr/testify/assert"
    "github.com/yourusername/go-day2/internal/models"
)
 
func TestTodoRepository_Create(t *testing.T) {
    db, mock, err := sqlmock.New()
    assert.NoError(t, err)
    defer db.Close()
    
    repo := NewTodoRepository(db)
    
    todo := &models.Todo{
        Title:     "Test Todo",
        Completed: false,
    }
    
    rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).
        AddRow(