๐ Go ์ธ์ด Day 2: Database & Testing ๋ง์คํฐํ๊ธฐ
๐ Todayโs Goal
โ์ด์ ๋ง๋ TODO API์ PostgreSQL์ ์ฐ๋ํ๊ณ , ํ๋ก๋์ ๊ธ ํ ์คํธ๋ฅผ ์์ฑํ๋ค!โ
๐ฏ ํ์ต ๋ชฉํ (8์๊ฐ)
- PostgreSQL ์ฐ๋ & ๋ง์ด๊ทธ๋ ์ด์ (2์๊ฐ)
- Context ํจํด ์๋ฒฝ ์ดํด (1์๊ฐ)
- ํ ์คํธ ์์ฑ (2์๊ฐ)
- Docker ๋ฐฐํฌ & ํ๊ฒฝ ์ค์ (2์๊ฐ)
- ์ค์ ํ๋ก์ ํธ ์์ฑ (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.mod1.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(