🚀 GO 실전 REST API 프로젝트
📋 목차
1. 프로젝트 설정
📁 프로젝트 구조
todo-api/
├── go.mod
├── go.sum
├── main.go
├── config/
│ └── config.go
├── models/
│ ├── todo.go
│ ├── user.go
│ └── response.go
├── handlers/
│ ├── todo.go
│ ├── user.go
│ └── health.go
├── middleware/
│ ├── auth.go
│ ├── logging.go
│ ├── cors.go
│ └── ratelimit.go
├── storage/
│ ├── interface.go
│ ├── memory.go
│ └── postgres.go
├── utils/
│ ├── validator.go
│ └── jwt.go
└── tests/
├── handlers_test.go
└── integration_test.go
🎯 프로젝트 초기화
# 프로젝트 생성
mkdir todo-api && cd todo-api
go mod init github.com/yourusername/todo-api
# 필요한 의존성 설치
go get github.com/gorilla/mux
go get github.com/joho/godotenv
go get github.com/golang-jwt/jwt/v4
go get github.com/go-playground/validator/v10
go get golang.org/x/time⚙️ 설정 구조
// config/config.go
package config
import (
"log"
"os"
"strconv"
"github.com/joho/godotenv"
)
type Config struct {
Port string
Environment string
JWTSecret string
DBHost string
DBPort int
DBUser string
DBPassword string
DBName string
RateLimit int
}
func Load() *Config {
// .env 파일 로드 (옵션)
if err := godotenv.Load(); err != nil {
log.Println("No .env file found")
}
config := &Config{
Port: getEnv("PORT", "8080"),
Environment: getEnv("ENVIRONMENT", "development"),
JWTSecret: getEnv("JWT_SECRET", "your-secret-key"),
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: getEnvAsInt("DB_PORT", 5432),
DBUser: getEnv("DB_USER", "postgres"),
DBPassword: getEnv("DB_PASSWORD", "password"),
DBName: getEnv("DB_NAME", "todoapp"),
RateLimit: getEnvAsInt("RATE_LIMIT", 100),
}
return config
}
func getEnv(key, defaultValue string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return defaultValue
}
func getEnvAsInt(name string, defaultValue int) int {
valueStr := getEnv(name, "")
if value, err := strconv.Atoi(valueStr); err == nil {
return value
}
return defaultValue
}2. 기본 HTTP 서버
🔧 메인 서버 구조
// main.go
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gorilla/mux"
"github.com/yourusername/todo-api/config"
"github.com/yourusername/todo-api/handlers"
"github.com/yourusername/todo-api/middleware"
"github.com/yourusername/todo-api/storage"
)
type Server struct {
config *config.Config
router *mux.Router
storage storage.Storage
}
func NewServer(cfg *config.Config) *Server {
s := &Server{
config: cfg,
router: mux.NewRouter(),
}
// 스토리지 초기화 (메모리 스토리지 사용)
s.storage = storage.NewMemoryStorage()
// 라우터 설정
s.setupRoutes()
s.setupMiddleware()
return s
}
func (s *Server) setupMiddleware() {
// 전역 미들웨어 적용 순서가 중요!
s.router.Use(middleware.LoggingMiddleware)
s.router.Use(middleware.CORSMiddleware)
s.router.Use(middleware.RateLimitMiddleware(s.config.RateLimit))
}
func (s *Server) setupRoutes() {
// Health check
s.router.HandleFunc("/health", handlers.HealthCheck).Methods("GET")
// API v1
api := s.router.PathPrefix("/api/v1").Subrouter()
// Public routes
api.HandleFunc("/auth/login", handlers.Login).Methods("POST")
api.HandleFunc("/auth/register", handlers.Register).Methods("POST")
// Protected routes
protected := api.PathPrefix("").Subrouter()
protected.Use(middleware.AuthMiddleware(s.config.JWTSecret))
// TODO routes
todoHandler := handlers.NewTodoHandler(s.storage)
protected.HandleFunc("/todos", todoHandler.GetTodos).Methods("GET")
protected.HandleFunc("/todos", todoHandler.CreateTodo).Methods("POST")
protected.HandleFunc("/todos/{id}", todoHandler.GetTodo).Methods("GET")
protected.HandleFunc("/todos/{id}", todoHandler.UpdateTodo).Methods("PUT")
protected.HandleFunc("/todos/{id}", todoHandler.DeleteTodo).Methods("DELETE")
// User routes
userHandler := handlers.NewUserHandler(s.storage)
protected.HandleFunc("/users/profile", userHandler.GetProfile).Methods("GET")
protected.HandleFunc("/users/profile", userHandler.UpdateProfile).Methods("PUT")
}
func (s *Server) Start() error {
server := &http.Server{
Addr: ":" + s.config.Port,
Handler: s.router,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
// Graceful shutdown 처리
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("Error during server shutdown: %v", err)
}
}()
log.Printf("Server starting on port %s", s.config.Port)
return server.ListenAndServe()
}
func main() {
cfg := config.Load()
server := NewServer(cfg)
if err := server.Start(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed to start: %v", err)
}
log.Println("Server stopped")
}3. TODO API 구현
📊 데이터 모델
// models/todo.go
package models
import (
"time"
"github.com/go-playground/validator/v10"
)
type Todo struct {
ID int `json:"id"`
UserID int `json:"user_id"`
Title string `json:"title" validate:"required,min=1,max=100"`
Description string `json:"description" validate:"max=500"`
Completed bool `json:"completed"`
Priority Priority `json:"priority" validate:"oneof=low medium high"`
DueDate *time.Time `json:"due_date,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Priority string
const (
PriorityLow Priority = "low"
PriorityMedium Priority = "medium"
PriorityHigh Priority = "high"
)
type CreateTodoRequest struct {
Title string `json:"title" validate:"required,min=1,max=100"`
Description string `json:"description" validate:"max=500"`
Priority Priority `json:"priority" validate:"oneof=low medium high"`
DueDate *time.Time `json:"due_date,omitempty"`
}
type UpdateTodoRequest struct {
Title *string `json:"title,omitempty" validate:"omitempty,min=1,max=100"`
Description *string `json:"description,omitempty" validate:"omitempty,max=500"`
Completed *bool `json:"completed,omitempty"`
Priority *Priority `json:"priority,omitempty" validate:"omitempty,oneof=low medium high"`
DueDate *time.Time `json:"due_date,omitempty"`
}
type TodoFilter struct {
UserID int `json:"user_id"`
Completed *bool `json:"completed,omitempty"`
Priority Priority `json:"priority,omitempty"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// models/user.go
package models
type User struct {
ID int `json:"id"`
Username string `json:"username" validate:"required,min=3,max=20"`
Email string `json:"email" validate:"required,email"`
Password string `json:"-"` // 패스워드는 JSON에서 제외
FullName string `json:"full_name" validate:"max=50"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type LoginRequest struct {
Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required,min=6"`
}
type RegisterRequest struct {
Username string `json:"username" validate:"required,min=3,max=20"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"`
FullName string `json:"full_name" validate:"max=50"`
}
// models/response.go
package models
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
}
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
type PaginatedResponse struct {
Data interface{} `json:"data"`
Pagination Pagination `json:"pagination"`
}
type Pagination struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int `json:"total"`
TotalPage int `json:"total_pages"`
}🗄️ 스토리지 인터페이스
// storage/interface.go
package storage
import (
"github.com/yourusername/todo-api/models"
)
type Storage interface {
// Todo operations
GetTodos(filter models.TodoFilter) ([]models.Todo, int, error)
GetTodoByID(userID, todoID int) (*models.Todo, error)
CreateTodo(todo *models.Todo) error
UpdateTodo(userID, todoID int, updates models.UpdateTodoRequest) (*models.Todo, error)
DeleteTodo(userID, todoID int) error
// User operations
GetUserByID(userID int) (*models.User, error)
GetUserByUsername(username string) (*models.User, error)
CreateUser(user *models.User) error
UpdateUser(userID int, user *models.User) error
}
// storage/memory.go
package storage
import (
"errors"
"sync"
"time"
"github.com/yourusername/todo-api/models"
)
type MemoryStorage struct {
mu sync.RWMutex
todos map[int]*models.Todo
users map[int]*models.User
usersByUsername map[string]*models.User
nextTodoID int
nextUserID int
}
func NewMemoryStorage() *MemoryStorage {
ms := &MemoryStorage{
todos: make(map[int]*models.Todo),
users: make(map[int]*models.User),
usersByUsername: make(map[string]*models.User),
nextTodoID: 1,
nextUserID: 1,
}
// 테스트 데이터 추가
ms.seedData()
return ms
}
func (ms *MemoryStorage) seedData() {
// 테스트 사용자 추가
testUser := &models.User{
ID: 1,
Username: "testuser",
Email: "test@example.com",
FullName: "Test User",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
ms.users[1] = testUser
ms.usersByUsername["testuser"] = testUser
ms.nextUserID = 2
// 테스트 TODO 추가
testTodos := []*models.Todo{
{
ID: 1,
UserID: 1,
Title: "Learn Go",
Description: "Complete Go tutorial",
Priority: models.PriorityHigh,
Completed: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
{
ID: 2,
UserID: 1,
Title: "Build REST API",
Description: "Create a TODO API with Go",
Priority: models.PriorityMedium,
Completed: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
for _, todo := range testTodos {
ms.todos[todo.ID] = todo
}
ms.nextTodoID = 3
}
func (ms *MemoryStorage) GetTodos(filter models.TodoFilter) ([]models.Todo, int, error) {
ms.mu.RLock()
defer ms.mu.RUnlock()
var todos []models.Todo
for _, todo := range ms.todos {
if todo.UserID != filter.UserID {
continue
}
// 완료 상태 필터
if filter.Completed != nil && todo.Completed != *filter.Completed {
continue
}
// 우선순위 필터
if filter.Priority != "" && todo.Priority != filter.Priority {
continue
}
todos = append(todos, *todo)
}
total := len(todos)
// 페이지네이션
start := filter.Offset
end := start + filter.Limit
if start > total {
return []models.Todo{}, total, nil
}
if end > total {
end = total
}
return todos[start:end], total, nil
}
func (ms *MemoryStorage) GetTodoByID(userID, todoID int) (*models.Todo, error) {
ms.mu.RLock()
defer ms.mu.RUnlock()
todo, exists := ms.todos[todoID]
if !exists {
return nil, errors.New("todo not found")
}
if todo.UserID != userID {
return nil, errors.New("access denied")
}
return todo, nil
}
func (ms *MemoryStorage) CreateTodo(todo *models.Todo) error {
ms.mu.Lock()
defer ms.mu.Unlock()
todo.ID = ms.nextTodoID
todo.CreatedAt = time.Now()
todo.UpdatedAt = time.Now()
ms.todos[todo.ID] = todo
ms.nextTodoID++
return nil
}
func (ms *MemoryStorage) UpdateTodo(userID, todoID int, updates models.UpdateTodoRequest) (*models.Todo, error) {
ms.mu.Lock()
defer ms.mu.Unlock()
todo, exists := ms.todos[todoID]
if !exists {
return nil, errors.New("todo not found")
}
if todo.UserID != userID {
return nil, errors.New("access denied")
}
// 업데이트 적용
if updates.Title != nil {
todo.Title = *updates.Title
}
if updates.Description != nil {
todo.Description = *updates.Description
}
if updates.Completed != nil {
todo.Completed = *updates.Completed
}
if updates.Priority != nil {
todo.Priority = *updates.Priority
}
if updates.DueDate != nil {
todo.DueDate = updates.DueDate
}
todo.UpdatedAt = time.Now()
return todo, nil
}
func (ms *MemoryStorage) DeleteTodo(userID, todoID int) error {
ms.mu.Lock()
defer ms.mu.Unlock()
todo, exists := ms.todos[todoID]
if !exists {
return errors.New("todo not found")
}
if todo.UserID != userID {
return errors.New("access denied")
}
delete(ms.todos, todoID)
return nil
}
func (ms *MemoryStorage) GetUserByID(userID int) (*models.User, error) {
ms.mu.RLock()
defer ms.mu.RUnlock()
user, exists := ms.users[userID]
if !exists {
return nil, errors.New("user not found")
}
return user, nil
}
func (ms *MemoryStorage) GetUserByUsername(username string) (*models.User, error) {
ms.mu.RLock()
defer ms.mu.RUnlock()
user, exists := ms.usersByUsername[username]
if !exists {
return nil, errors.New("user not found")
}
return user, nil
}
func (ms *MemoryStorage) CreateUser(user *models.User) error {
ms.mu.Lock()
defer ms.mu.Unlock()
// 사용자명 중복 체크
if _, exists := ms.usersByUsername[user.Username]; exists {
return errors.New("username already exists")
}
user.ID = ms.nextUserID
user.CreatedAt = time.Now()
user.UpdatedAt = time.Now()
ms.users[user.ID] = user
ms.usersByUsername[user.Username] = user
ms.nextUserID++
return nil
}
func (ms *MemoryStorage) UpdateUser(userID int, user *models.User) error {
ms.mu.Lock()
defer ms.mu.Unlock()
existingUser, exists := ms.users[userID]
if !exists {
return errors.New("user not found")
}
existingUser.FullName = user.FullName
existingUser.Email = user.Email
existingUser.UpdatedAt = time.Now()
return nil
}🎛️ TODO 핸들러
// handlers/todo.go
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/gorilla/mux"
"github.com/yourusername/todo-api/models"
"github.com/yourusername/todo-api/storage"
"github.com/yourusername/todo-api/utils"
)
type TodoHandler struct {
storage storage.Storage
validator *utils.Validator
}
func NewTodoHandler(storage storage.Storage) *TodoHandler {
return &TodoHandler{
storage: storage,
validator: utils.NewValidator(),
}
}
func (h *TodoHandler) GetTodos(w http.ResponseWriter, r *http.Request) {
userID := getUserIDFromContext(r.Context())
// 쿼리 파라미터 파싱
filter := models.TodoFilter{
UserID: userID,
Limit: 10,
Offset: 0,
}
// completed 필터
if completedStr := r.URL.Query().Get("completed"); completedStr != "" {
if completed, err := strconv.ParseBool(completedStr); err == nil {
filter.Completed = &completed
}
}
// priority 필터
if priority := r.URL.Query().Get("priority"); priority != "" {
filter.Priority = models.Priority(priority)
}
// 페이지네이션
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil && page > 0 {
filter.Offset = (page - 1) * filter.Limit
}
}
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 && limit <= 100 {
filter.Limit = limit
}
}
todos, total, err := h.storage.GetTodos(filter)
if err != nil {
utils.WriteErrorResponse(w, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error())
return
}
// 페이지네이션 정보
page := (filter.Offset / filter.Limit) + 1
totalPages := (total + filter.Limit - 1) / filter.Limit
response := models.PaginatedResponse{
Data: todos,
Pagination: models.Pagination{
Page: page,
PerPage: filter.Limit,
Total: total,
TotalPage: totalPages,
},
}
utils.WriteSuccessResponse(w, response)
}
func (h *TodoHandler) GetTodo(w http.ResponseWriter, r *http.Request) {
userID := getUserIDFromContext(r.Context())
vars := mux.Vars(r)
todoID, err := strconv.Atoi(vars["id"])
if err != nil {
utils.WriteErrorResponse(w, http.StatusBadRequest, "INVALID_ID", "Invalid todo ID")
return
}
todo, err := h.storage.GetTodoByID(userID, todoID)
if err != nil {
utils.WriteErrorResponse(w, http.StatusNotFound, "TODO_NOT_FOUND", err.Error())
return
}
utils.WriteSuccessResponse(w, todo)
}
func (h *TodoHandler) CreateTodo(w http.ResponseWriter, r *http.Request) {
userID := getUserIDFromContext(r.Context())
var req models.CreateTodoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteErrorResponse(w, http.StatusBadRequest, "INVALID_JSON", "Invalid request body")
return
}
if err := h.validator.Validate(req); err != nil {
utils.WriteValidationErrorResponse(w, err)
return
}
todo := &models.Todo{
UserID: userID,
Title: req.Title,
Description: req.Description,
Priority: req.Priority,
DueDate: req.DueDate,
Completed: false,
}
if err := h.storage.CreateTodo(todo); err != nil {
utils.WriteErrorResponse(w, http.StatusInternalServerError, "CREATE_ERROR", err.Error())
return
}
w.WriteHeader(http.StatusCreated)
utils.WriteSuccessResponse(w, todo)
}
func (h *TodoHandler) UpdateTodo(w http.ResponseWriter, r *http.Request) {
userID := getUserIDFromContext(r.Context())
vars := mux.Vars(r)
todoID, err := strconv.Atoi(vars["id"])
if err != nil {
utils.WriteErrorResponse(w, http.StatusBadRequest, "INVALID_ID", "Invalid todo ID")
return
}
var req models.UpdateTodoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
utils.WriteErrorResponse(w, http.StatusBadRequest, "INVALID_JSON", "Invalid request body")
return
}
if err := h.validator.Validate(req); err != nil {
utils.WriteValidationErrorResponse(w, err)
return
}
todo, err := h.storage.UpdateTodo(userID, todoID, req)
if err != nil {
if err.Error() == "todo not found" || err.Error() == "access denied" {
utils.WriteErrorResponse(w, http.StatusNotFound, "TODO_NOT_FOUND", err.Error())
} else {
utils.WriteErrorResponse(w, http.StatusInternalServerError, "UPDATE_ERROR", err.Error())
}
return
}
utils.WriteSuccessResponse(w, todo)
}
func (h *TodoHandler) DeleteTodo(w http.ResponseWriter, r *http.Request) {
userID := getUserIDFromContext(r.Context())
vars := mux.Vars(r)
todoID, err := strconv.Atoi(vars["id"])
if err != nil {
utils.WriteErrorResponse(w, http.StatusBadRequest, "INVALID_ID", "Invalid todo ID")
return
}
err = h.storage.DeleteTodo(userID, todoID)
if err != nil {
if err.Error() == "todo not found" || err.Error() == "access denied" {
utils.WriteErrorResponse(w, http.StatusNotFound, "TODO_NOT_FOUND", err.Error())
} else {
utils.WriteErrorResponse(w, http.StatusInternalServerError, "DELETE_ERROR", err.Error())
}
return
}
w.WriteHeader(http.StatusNoContent)
}
// 컨텍스트에서 사용자 ID 추출
func getUserIDFromContext(ctx context.Context) int {
if userID, ok := ctx.Value("user_id").(int); ok {
return userID
}
return 0
}
// handlers/health.go
package handlers
func HealthCheck(w http.ResponseWriter, r *http.Request) {
utils.WriteSuccessResponse(w, map[string]interface{}{
"status": "healthy",
"timestamp": time.Now(),
"version": "1.0.0",
})
}4. 미들웨어 구현
🔐 인증 미들웨어
// middleware/auth.go
package middleware
import (
"context"
"net/http"
"strings"
"github.com/yourusername/todo-api/utils"
)
func AuthMiddleware(jwtSecret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Authorization 헤더 확인
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
utils.WriteErrorResponse(w, http.StatusUnauthorized, "MISSING_TOKEN", "Authorization header required")
return
}
// Bearer 토큰 추출
tokenParts := strings.SplitN(authHeader, " ", 2)
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
utils.WriteErrorResponse(w, http.StatusUnauthorized, "INVALID_TOKEN", "Invalid authorization format")
return
}
tokenString := tokenParts[1]
// JWT 토큰 검증
claims, err := utils.ValidateJWT(tokenString, jwtSecret)
if err != nil {
utils.WriteErrorResponse(w, http.StatusUnauthorized, "INVALID_TOKEN", err.Error())
return
}
// 사용자 ID를 컨텍스트에 저장
userID := int(claims["user_id"].(float64))
ctx := context.WithValue(r.Context(), "user_id", userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// middleware/logging.go
package middleware
import (
"log"
"net/http"
"time"
)
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// Response writer wrapper to capture status code
lrw := &loggingResponseWriter{
ResponseWriter: w,
statusCode: 200,
}
next.ServeHTTP(lrw, r)
duration := time.Since(start)
log.Printf("[%s] %s %s %d %v",
r.Method, r.RequestURI, r.RemoteAddr, lrw.statusCode, duration)
})
}
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.statusCode = code
lrw.ResponseWriter.WriteHeader(code)
}
// middleware/cors.go
package middleware
func CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
// middleware/ratelimit.go
package middleware
import (
"net/http"
"sync"
"time"
"golang.org/x/time/rate"
"github.com/yourusername/todo-api/utils"
)
type IPRateLimiter struct {
ips map[string]*rate.Limiter
mu *sync.RWMutex
r rate.Limit
b int
}
func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter {
i := &IPRateLimiter{
ips: make(map[string]*rate.Limiter),
mu: &sync.RWMutex{},
r: r,
b: b,
}
return i
}
func (i *IPRateLimiter) AddIP(ip string) *rate.Limiter {
i.mu.Lock()
defer i.mu.Unlock()
limiter := rate.NewLimiter(i.r, i.b)
i.ips[ip] = limiter
return limiter
}
func (i *IPRateLimiter) GetLimiter(ip string) *rate.Limiter {
i.mu.Lock()
limiter, exists := i.ips[ip]
if !exists {
i.mu.Unlock()
return i.AddIP(ip)
}
i.mu.Unlock()
return limiter
}
var limiter = NewIPRateLimiter(1, 5) // 1 request per second, burst of 5
func RateLimitMiddleware(requestsPerSecond int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
limiter := limiter.GetLimiter(ip)
if !limiter.Allow() {
utils.WriteErrorResponse(w, http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED", "Too many requests")
return
}
next.ServeHTTP(w, r)
})
}
}5. 에러 처리
🛠️ 유틸리티 함수들
// utils/validator.go
package utils
import (
"fmt"
"strings"
"github.com/go-playground/validator/v10"
)
type Validator struct {
validate *validator.Validate
}
func NewValidator() *Validator {
return &Validator{
validate: validator.New(),
}
}
func (v *Validator) Validate(i interface{}) error {
return v.validate.Struct(i)
}
func (v *Validator) GetValidationErrors(err error) []string {
var errors []string
if validationErrors, ok := err.(validator.ValidationErrors); ok {
for _, err := range validationErrors {
switch err.Tag() {
case "required":
errors = append(errors, fmt.Sprintf("%s is required", err.Field()))
case "email":
errors = append(errors, fmt.Sprintf("%s must be a valid email", err.Field()))
case "min":
errors = append(errors, fmt.Sprintf("%s must be at least %s characters", err.Field(), err.Param()))
case "max":
errors = append(errors, fmt.Sprintf("%s must be at most %s characters", err.Field(), err.Param()))
case "oneof":
errors = append(errors, fmt.Sprintf("%s must be one of: %s", err.Field(), err.Param()))
default:
errors = append(errors, fmt.Sprintf("%s is invalid", err.Field()))
}
}
}
return errors
}
// utils/jwt.go
package utils
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v4"
)
type Claims struct {
UserID int `json:"user_id"`
Username string `json:"username"`
jwt.RegisteredClaims
}
func GenerateJWT(userID int, username, secret string) (string, error) {
claims := Claims{
UserID: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func ValidateJWT(tokenString, secret string) (jwt.MapClaims, error) {
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
return claims, nil
}
return nil, errors.New("invalid token")
}
// utils/response.go
package utils
import (
"encoding/json"
"net/http"
"github.com/go-playground/validator/v10"
"github.com/yourusername/todo-api/models"
)
func WriteResponse(w http.ResponseWriter, statusCode int, response models.APIResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(response)
}
func WriteSuccessResponse(w http.ResponseWriter, data interface{}) {
response := models.APIResponse{
Success: true,
Data: data,
}
WriteResponse(w, http.StatusOK, response)
}
func WriteErrorResponse(w http.ResponseWriter, statusCode int, code, message string) {
response := models.APIResponse{
Success: false,
Error: &models.APIError{
Code: code,
Message: message,
},
}
WriteResponse(w, statusCode, response)
}
func WriteValidationErrorResponse(w http.ResponseWriter, err error) {
validator := NewValidator()
errors := validator.GetValidationErrors(err)
response := models.APIResponse{
Success: false,
Error: &models.APIError{
Code: "VALIDATION_ERROR",
Message: "Validation failed",
Details: strings.Join(errors, "; "),
},
}
WriteResponse(w, http.StatusBadRequest, response)
}6. 테스트 작성
🧪 핸들러 테스트
// tests/handlers_test.go
package tests
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/yourusername/todo-api/handlers"
"github.com/yourusername/todo-api/models"
"github.com/yourusername/todo-api/storage"
"github.com/yourusername/todo-api/utils"
)
func setupTestHandler() (*handlers.TodoHandler, storage.Storage) {
testStorage := storage.NewMemoryStorage()
handler := handlers.NewTodoHandler(testStorage)
return handler, testStorage
}
func TestCreateTodo(t *testing.T) {
handler, _ := setupTestHandler()
tests := []struct {
name string
payload models.CreateTodoRequest
expectedStatus int
expectedError string
}{
{
name: "Valid todo creation",
payload: models.CreateTodoRequest{
Title: "Test Todo",
Description: "Test Description",
Priority: models.PriorityMedium,
},
expectedStatus: http.StatusCreated,
},
{
name: "Empty title should fail",
payload: models.CreateTodoRequest{
Title: "",
Description: "Test Description",
Priority: models.PriorityMedium,
},
expectedStatus: http.StatusBadRequest,
expectedError: "VALIDATION_ERROR",
},
{
name: "Invalid priority should fail",
payload: models.CreateTodoRequest{
Title: "Test Todo",
Description: "Test Description",
Priority: "invalid",
},
expectedStatus: http.StatusBadRequest,
expectedError: "VALIDATION_ERROR",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
payload, _ := json.Marshal(tt.payload)
req := httptest.NewRequest("POST", "/todos", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
// Mock user context
ctx := context.WithValue(req.Context(), "user_id", 1)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
handler.CreateTodo(rr, req)
if status := rr.Code; status != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, status)
}
var response models.APIResponse
json.NewDecoder(rr.Body).Decode(&response)
if tt.expectedError != "" {
if response.Success {
t.Errorf("Expected error response, got success")
}
if response.Error == nil || response.Error.Code != tt.expectedError {
t.Errorf("Expected error code %s, got %v", tt.expectedError, response.Error)
}
} else {
if !response.Success {
t.Errorf("Expected success response, got error: %v", response.Error)
}
}
})
}
}
func TestGetTodos(t *testing.T) {
handler, _ := setupTestHandler()
req := httptest.NewRequest("GET", "/todos", nil)
ctx := context.WithValue(req.Context(), "user_id", 1)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
handler.GetTodos(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, status)
}
var response models.APIResponse
json.NewDecoder(rr.Body).Decode(&response)
if !response.Success {
t.Errorf("Expected success response, got error: %v", response.Error)
}
}
func TestGetTodosPagination(t *testing.T) {
handler, _ := setupTestHandler()
req := httptest.NewRequest("GET", "/todos?page=1&limit=1", nil)
ctx := context.WithValue(req.Context(), "user_id", 1)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
handler.GetTodos(rr, req)
if status := rr.Code; status != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, status)
}
var response models.APIResponse
json.NewDecoder(rr.Body).Decode(&response)
if !response.Success {
t.Errorf("Expected success response, got error: %v", response.Error)
}
// Check if pagination is working
data := response.Data.(map[string]interface{})
pagination := data["pagination"].(map[string]interface{})
if pagination["per_page"] != float64(1) {
t.Errorf("Expected per_page 1, got %v", pagination["per_page"])
}
}
// 벤치마크 테스트
func BenchmarkCreateTodo(b *testing.B) {
handler, _ := setupTestHandler()
payload := models.CreateTodoRequest{
Title: "Benchmark Todo",
Description: "Benchmark Description",
Priority: models.PriorityMedium,
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
payloadBytes, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/todos", bytes.NewBuffer(payloadBytes))
req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(req.Context(), "user_id", 1)
req = req.WithContext(ctx)
rr := httptest.NewRecorder()
handler.CreateTodo(rr, req)
}
}🔄 통합 테스트
// tests/integration_test.go
package tests
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
"github.com/yourusername/todo-api/config"
"github.com/yourusername/todo-api/handlers"
"github.com/yourusername/todo-api/middleware"
"github.com/yourusername/todo-api/models"
"github.com/yourusername/todo-api/storage"
"github.com/yourusername/todo-api/utils"
)
func setupTestServer() *httptest.Server {
cfg := &config.Config{
JWTSecret: "test-secret",
}
testStorage := storage.NewMemoryStorage()
router := mux.NewRouter()
router.Use(middleware.CORSMiddleware)
// API routes
api := router.PathPrefix("/api/v1").Subrouter()
// Todo routes with auth
protected := api.PathPrefix("").Subrouter()
protected.Use(middleware.AuthMiddleware(cfg.JWTSecret))
todoHandler := handlers.NewTodoHandler(testStorage)
protected.HandleFunc("/todos", todoHandler.GetTodos).Methods("GET")
protected.HandleFunc("/todos", todoHandler.CreateTodo).Methods("POST")
protected.HandleFunc("/todos/{id}", todoHandler.GetTodo).Methods("GET")
protected.HandleFunc("/todos/{id}", todoHandler.UpdateTodo).Methods("PUT")
protected.HandleFunc("/todos/{id}", todoHandler.DeleteTodo).Methods("DELETE")
return httptest.NewServer(router)
}
func getTestJWT() string {
token, _ := utils.GenerateJWT(1, "testuser", "test-secret")
return token
}
func TestTodoWorkflow(t *testing.T) {
server := setupTestServer()
defer server.Close()
token := getTestJWT()
client := &http.Client{}
// 1. Create Todo
createPayload := models.CreateTodoRequest{
Title: "Integration Test Todo",
Description: "Testing full workflow",
Priority: models.PriorityHigh,
}
payloadBytes, _ := json.Marshal(createPayload)
req, _ := http.NewRequest("POST", server.URL+"/api/v1/todos", bytes.NewBuffer(payloadBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("Failed to create todo: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Errorf("Expected status %d, got %d", http.StatusCreated, resp.StatusCode)
}
var createResponse models.APIResponse
json.NewDecoder(resp.Body).Decode(&createResponse)
if !createResponse.Success {
t.Errorf("Expected success response, got error: %v", createResponse.Error)
}
// Extract created todo ID
todoData := createResponse.Data.(map[string]interface{})
todoID := int(todoData["id"].(float64))
// 2. Get Todo
req, _ = http.NewRequest("GET", server.URL+fmt.Sprintf("/api/v1/todos/%d", todoID), nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
if err != nil {
t.Fatalf("Failed to get todo: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, resp.StatusCode)
}
// 3. Update Todo
updatePayload := models.UpdateTodoRequest{
Title: stringPtr("Updated Integration Test Todo"),
Completed: boolPtr(true),
}
payloadBytes, _ = json.Marshal(updatePayload)
req, _ = http.NewRequest("PUT", server.URL+fmt.Sprintf("/api/v1/todos/%d", todoID), bytes.NewBuffer(payloadBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
if err != nil {
t.Fatalf("Failed to update todo: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, resp.StatusCode)
}
// 4. List Todos
req, _ = http.NewRequest("GET", server.URL+"/api/v1/todos", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
if err != nil {
t.Fatalf("Failed to list todos: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, resp.StatusCode)
}
// 5. Delete Todo
req, _ = http.NewRequest("DELETE", server.URL+fmt.Sprintf("/api/v1/todos/%d", todoID), nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
if err != nil {
t.Fatalf("Failed to delete todo: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Errorf("Expected status %d, got %d", http.StatusNoContent, resp.StatusCode)
}
}
// Helper functions
func stringPtr(s string) *string {
return &s
}
func boolPtr(b bool) *bool {
return &b
}
// 부하 테스트
func TestConcurrentRequests(t *testing.T) {
server := setupTestServer()
defer server.Close()
token := getTestJWT()
client := &http.Client{}
// 동시에 여러 요청 보내기
const numRequests = 50
results := make(chan error, numRequests)
for i := 0; i < numRequests; i++ {
go func() {
createPayload := models.CreateTodoRequest{
Title: fmt.Sprintf("Concurrent Todo %d", i),
Description: "Testing concurrent creation",
Priority: models.PriorityMedium,
}
payloadBytes, _ := json.Marshal(createPayload)
req, _ := http.NewRequest("POST", server.URL+"/api/v1/todos", bytes.NewBuffer(payloadBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
results <- err
return
}
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
results <- fmt.Errorf("unexpected status code: %d", resp.StatusCode)
return
}
results <- nil
}()
}
// 결과 수집
for i := 0; i < numRequests; i++ {
if err := <-results; err != nil {
t.Errorf("Request %d failed: %v", i, err)
}
}
}🏃♂️ 테스트 실행
# 모든 테스트 실행
go test ./...
# 특정 패키지 테스트
go test ./tests
# 벤치마크 테스트
go test -bench=. ./tests
# 커버리지 확인
go test -cover ./...
# 상세한 커버리지 리포트
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
# 레이스 컨디션 검사
go test -race ./...✅ 체크리스트
프로젝트 구조
- 적절한 패키지 구조 설계
- 환경 설정 관리
- 의존성 주입 패턴
- 인터페이스 기반 설계
HTTP 서버
- REST API 엔드포인트 구현
- HTTP 상태 코드 적절히 사용
- JSON 요청/응답 처리
- Graceful shutdown 구현
보안 및 인증
- JWT 기반 인증
- 미들웨어 체인
- CORS 설정
- Rate limiting
에러 처리
- 일관된 에러 응답 형식
- 입력 검증
- 로깅 구현
- 에러 복구 메커니즘
테스트
- 단위 테스트 작성
- 통합 테스트 작성
- 벤치마크 테스트
- 커버리지 확인
완성!
축하합니다! Go로 완전한 REST API를 구현했습니다. 이제 데이터베이스 연동, 도커화, 배포 등으로 확장해보세요!