[Go 2주 완성 #08] Day 14: 실전 미니 프로젝트 - REST API 서버 구축

이 글의 핵심

다른 언어에서는 API 서버를 만들 때 프레임워크가 필수처럼 느껴지지만 Go는 표준 라이브러리만으로 충분히 시작할 수 있습니다. 이 글은 2주 과정의 마지막 편으로 저장소를 TodoStore 인터페이스로 분리한 이유, 라우팅과 미들웨어 구성, 운영 전에 보완할 사항, C++와 Go를 어떤 상황에서 나눠 쓸지 정리합니다.

시리즈 안내

📚 Go 2주 완성 시리즈 #08 (최종편) | 전체 목차 보기

이 글은 C++ 개발자를 위한 2주 완성 Go 언어 커리큘럼의 Day 14 내용입니다.

이전: #07 테스팅 ← | → 실무 심화: #09 context·우아한 종료


💡 초보자를 위한 한 줄: Go 표준 라이브러리의 net/http만으로 REST API 서버를 만듭니다. http.HandleFunc로 라우트 등록, json.Encoder/Decoder로 JSON 직렬화, 고루틴으로 백그라운드 작업. 프레임워크 없이도 충분히 동작하는 API 서버를 만들 수 있습니다.

들어가며: “프레임워크 없이 API 서버를 만든다고요?”

C++ 또는 다른 언어에서는 웹 서버를 만들 때 Express(Node.js), Flask(Python), Spring(Java) 같은 프레임워크가 필수처럼 느껴집니다.

Go는 다릅니다. 표준 라이브러리만으로 충분히 실전 API 서버를 만들 수 있습니다:

http.HandleFunc("/api/todos", handleTodos)
http.ListenAndServe(":8080", nil)

지금까지 다룬 변수, 슬라이스, 구조체, 인터페이스, 에러 처리, 고루틴, 채널, 테스팅을 묶어 실전 REST API 서버를 구성해 봅니다. 한 바퀴 완성하면 Go로 서비스 코드를 시작할 때 참고할 뼈대가 됩니다.

프로젝트 개요:

  • TODO API 서버: CRUD 기능 완비
  • 표준 라이브러리만 사용: net/http, encoding/json
  • 동시성 활용: 백그라운드 작업 처리
  • 테스트 포함: 유닛 테스트와 통합 테스트

C++ 개발자 관점: C++ 백그라운드에서 Go로 전환하며 겪은 차이점과 함정을 중심으로 설명합니다. 포인터, 동시성, 메모리 관리 등 핵심 개념을 비교하며 정리했습니다.

실무에서의 체감

C++ 위주로 서버를 다루던 환경에서 Go를 도입할 때 흔히 드는 인상은 문법과 툴체인이 단순해 보인다는 점입니다. 프로덕션에서는 그 단순함이 빌드·배포·동시성 코드 가독성으로 이어지는 경우가 많습니다.

자주 언급되는 장점:

  • 개발 속도: 팀·도메인에 따라 다르지만, 네트워크·CLI 코드를 빠르게 완성하기 쉽습니다.
  • 안정성: GC가 있어 수동 할당 해제 부담이 줄어듭니다.
  • 배포: 단일 바이너리로 옮기기 쉬운 구조입니다.

프로젝트 구조

# 프로젝트 초기화
mkdir todo-api
cd todo-api
go mod init todo-api
# 디렉토리 구조
todo-api/
├── go.mod
├── main.go
├── handler.go
├── handler_test.go
├── store.go
└── store_test.go

데이터 모델과 저장소

모델 정의

// model.go
package main
import "time"
type Todo struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Completed bool      `json:"completed"`
    CreatedAt time.Time `json:"created_at"`
}
type CreateTodoRequest struct {
    Title string `json:"title"`
}
type UpdateTodoRequest struct {
    Completed bool `json:"completed"`
}

저장소 구현

// store.go
package main
import (
    "errors"
    "sync"
    "time"
)
var (
    ErrNotFound = errors.New("todo not found")
)
type TodoStore interface {
    Create(title string) (*Todo, error)
    GetAll() ([]*Todo, error)
    GetByID(id int) (*Todo, error)
    Update(id int, completed bool) error
    Delete(id int) error
}
type InMemoryStore struct {
    mu     sync.RWMutex
    todos  map[int]*Todo
    nextID int
}
func NewInMemoryStore() *InMemoryStore {
    return &InMemoryStore{
        todos:  make(map[int]*Todo),
        nextID: 1,
    }
}
func (s *InMemoryStore) Create(title string) (*Todo, error) {
    s.mu.Lock()
    defer s.mu.Unlock()
    
    todo := &Todo{
        ID:        s.nextID,
        Title:     title,
        Completed: false,
        CreatedAt: time.Now(),
    }
    
    s.todos[s.nextID] = todo
    s.nextID++
    
    return todo, nil
}
func (s *InMemoryStore) GetAll() ([]*Todo, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    
    todos := make([]*Todo, 0, len(s.todos))
    for _, todo := range s.todos {
        todos = append(todos, todo)
    }
    
    return todos, nil
}
func (s *InMemoryStore) GetByID(id int) (*Todo, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    
    todo, ok := s.todos[id]
    if !ok {
        return nil, ErrNotFound
    }
    
    return todo, nil
}
func (s *InMemoryStore) Update(id int, completed bool) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    
    todo, ok := s.todos[id]
    if !ok {
        return ErrNotFound
    }
    
    todo.Completed = completed
    return nil
}
func (s *InMemoryStore) Delete(id int) error {
    s.mu.Lock()
    defer s.mu.Unlock()
    
    if _, ok := s.todos[id]; !ok {
        return ErrNotFound
    }
    
    delete(s.todos, id)
    return nil
}

저장소를 인터페이스로 먼저 정의한 이유는 핸들러가 “데이터가 어디에 저장되는지”를 몰라도 되게 하기 위해서입니다. Go의 인터페이스는 구현 쪽에서 implements를 선언하지 않고 메서드 집합만 맞으면 만족되므로, 나중에 PostgreSQL 구현을 만들 때 핸들러나 인터페이스 정의를 건드릴 필요가 없습니다. Go 커뮤니티에서는 보통 “인터페이스는 사용하는 쪽에서 작게 정의하라”고 하는데, 이 예제처럼 핸들러 패키지가 필요한 메서드만 담은 인터페이스를 갖는 형태가 그 관례에 해당합니다.

sync.RWMutex를 쓴 것은 net/http가 요청마다 별도의 고루틴에서 핸들러를 실행하기 때문입니다. Go의 맵은 동시 쓰기에 안전하지 않아서, 잠금 없이 여러 요청이 동시에 Create를 호출하면 fatal error: concurrent map writes로 프로세스 전체가 종료됩니다. 이 에러는 recover로도 잡히지 않는 런타임 치명 오류라서, 부하가 걸릴 때만 가끔 서버가 죽는 형태로 나타납니다. 읽기가 많은 저장소라 RLock으로 조회끼리는 동시에 진행되게 했지만, 임계 구역이 이 정도로 짧으면 일반 Mutex와의 성능 차이는 거의 없으므로 단순함을 원하면 Mutex로 시작해도 됩니다.

다만 이 구현에는 흔히 놓치는 함정이 하나 있습니다. GetAll과 GetByID가 맵 안의 *Todo 포인터를 그대로 반환한다는 점입니다. 잠금은 맵에서 포인터를 꺼내는 순간까지만 유지되고, 핸들러가 그 포인터로 JSON을 인코딩하는 동안에는 풀려 있습니다. 같은 시점에 다른 요청의 Update가 Completed 필드를 바꾸면 데이터 경쟁이 되고, go test -race나 go run -race로 실행하면 WARNING: DATA RACE가 보고됩니다. 저장소가 Todo 값을 복사해서 반환하도록([]Todo, Todo) 바꾸면 이 문제가 사라지므로, 실제 서비스로 옮길 때는 먼저 그렇게 고치는 것을 권합니다. 또 GetAll은 맵을 순회하므로 결과 순서가 요청마다 달라집니다. 클라이언트가 순서에 기대면 ID로 정렬해서 반환해야 합니다.


HTTP 핸들러 구현

// handler.go
package main
import (
    "encoding/json"
    "errors"
    "fmt"
    "net/http"
    "strconv"
    "strings"
)
type TodoHandler struct {
    store TodoStore
}
func NewTodoHandler(store TodoStore) *TodoHandler {
    return &TodoHandler{store: store}
}
// GET /todos - 모든 TODO 조회
func (h *TodoHandler) GetTodos(w http.ResponseWriter, r *http.Request) {
    todos, err := h.store.GetAll()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(todos)
}
// GET /todos/{id} - 특정 TODO 조회
func (h *TodoHandler) GetTodo(w http.ResponseWriter, r *http.Request) {
    id, err := extractID(r.URL.Path)
    if err != nil {
        http.Error(w, "Invalid ID", http.StatusBadRequest)
        return
    }
    
    todo, err := h.store.GetByID(id)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            http.Error(w, "Todo not found", http.StatusNotFound)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(todo)
}
// POST /todos - 새 TODO 생성
func (h *TodoHandler) CreateTodo(w http.ResponseWriter, r *http.Request) {
    var req CreateTodoRequest
    
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    if req.Title == "" {
        http.Error(w, "Title is required", http.StatusBadRequest)
        return
    }
    
    todo, err := h.store.Create(req.Title)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(todo)
}
// PUT /todos/{id} - TODO 업데이트
func (h *TodoHandler) UpdateTodo(w http.ResponseWriter, r *http.Request) {
    id, err := extractID(r.URL.Path)
    if err != nil {
        http.Error(w, "Invalid ID", http.StatusBadRequest)
        return
    }
    
    var req UpdateTodoRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }
    
    if err := h.store.Update(id, req.Completed); err != nil {
        if errors.Is(err, ErrNotFound) {
            http.Error(w, "Todo not found", http.StatusNotFound)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.WriteHeader(http.StatusNoContent)
}
// DELETE /todos/{id} - TODO 삭제
func (h *TodoHandler) DeleteTodo(w http.ResponseWriter, r *http.Request) {
    id, err := extractID(r.URL.Path)
    if err != nil {
        http.Error(w, "Invalid ID", http.StatusBadRequest)
        return
    }
    
    if err := h.store.Delete(id); err != nil {
        if errors.Is(err, ErrNotFound) {
            http.Error(w, "Todo not found", http.StatusNotFound)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    
    w.WriteHeader(http.StatusNoContent)
}
// 헬퍼: URL에서 ID 추출
func extractID(path string) (int, error) {
    parts := strings.Split(path, "/")
    if len(parts) < 3 {
        return 0, errors.New("invalid path")
    }
    
    id, err := strconv.Atoi(parts[len(parts)-1])
    if err != nil {
        return 0, err
    }
    
    return id, nil
}

핸들러마다 http.Error 뒤에 return을 빠뜨리지 않는 것이 중요합니다. http.Error는 응답을 쓰기만 하고 함수를 끝내지 않기 때문에, return이 없으면 아래 코드가 계속 실행되어 http: superfluous response.WriteHeader call 경고가 로그에 찍히고 에러 메시지 뒤에 JSON이 이어 붙은 응답이 나갑니다. 같은 이유로 w.WriteHeader(http.StatusCreated)는 반드시 Header().Set 다음, 본문 쓰기 이전에 와야 합니다. 상태 코드를 쓴 뒤에 설정한 헤더는 무시됩니다.

json.NewDecoder(r.Body).Decode는 요청 본문 크기를 제한하지 않습니다. 공개 API라면 r.Body = http.MaxBytesReader(w, r.Body, 1<<20)처럼 상한을 두고, 오타가 난 필드를 조용히 무시하지 않게 하려면 dec.DisallowUnknownFields()를 켜는 것이 좋습니다. 그리고 extractID처럼 경로를 직접 잘라 쓰는 방식은 /todos/1/ 같은 끝 슬래시나 /todos/abc에서 동작이 애매해지는데, 이 부분은 다음 절에서 Go 1.22 라우팅으로 대체하는 방법을 설명합니다.


라우팅과 미들웨어

// main.go
package main
import (
    "fmt"
    "log"
    "net/http"
    "time"
)
// 로깅 미들웨어
func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        log.Printf("%s %s", r.Method, r.URL.Path)
        
        next(w, r)
        
        log.Printf("Completed in %v", time.Since(start))
    }
}
// CORS 미들웨어
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return 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")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
        
        if r.Method == http.MethodOptions {
            w.WriteHeader(http.StatusOK)
            return
        }
        
        next(w, r)
    }
}
func main() {
    store := NewInMemoryStore()
    handler := NewTodoHandler(store)
    
    // 라우팅
    mux := http.NewServeMux()
    
    // /todos 엔드포인트
    mux.HandleFunc("/todos", corsMiddleware(loggingMiddleware(
        func(w http.ResponseWriter, r *http.Request) {
            switch r.Method {
            case http.MethodGet:
                handler.GetTodos(w, r)
            case http.MethodPost:
                handler.CreateTodo(w, r)
            default:
                http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
            }
        },
    )))
    
    // /todos/{id} 엔드포인트
    mux.HandleFunc("/todos/", corsMiddleware(loggingMiddleware(
        func(w http.ResponseWriter, r *http.Request) {
            switch r.Method {
            case http.MethodGet:
                handler.GetTodo(w, r)
            case http.MethodPut:
                handler.UpdateTodo(w, r)
            case http.MethodDelete:
                handler.DeleteTodo(w, r)
            default:
                http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
            }
        },
    )))
    
    // 헬스 체크
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        fmt.Fprintln(w, "OK")
    })
    
    // 백그라운드 작업 시작
    go backgroundWorker(store)
    
    // 서버 시작
    addr := ":8080"
    log.Printf("Server starting on %s", addr)
    if err := http.ListenAndServe(addr, mux); err != nil {
        log.Fatal(err)
    }
}

이 코드는 Go 1.21 이하의 ServeMux를 기준으로 작성되어 있습니다. 당시 ServeMux는 경로만 보고 메서드는 구분하지 않았기 때문에, 핸들러 안에서 switch r.Method로 직접 분기하고 /todos/처럼 끝에 슬래시를 붙인 접두사 패턴으로 ID가 붙은 경로를 받아야 했습니다. /todos와 /todos/가 서로 다른 패턴이라는 점도 초보자가 자주 헷갈리는 부분입니다.

Go 1.22부터는 표준 라이브러리만으로 이 부분이 훨씬 간단해졌습니다. mux.HandleFunc("GET /todos/{id}", handler.GetTodo)처럼 메서드와 와일드카드를 패턴에 쓰고, 핸들러에서는 r.PathValue("id")로 값을 꺼냅니다. 등록되지 않은 메서드로 요청하면 405 Method Not Allowed와 Allow 헤더도 자동으로 응답합니다. 제가 새 프로젝트에서 Gin이나 chi 같은 라우터를 바로 넣지 않게 된 가장 큰 이유가 이 변화입니다. 다만 go.mod의 go 지시어가 1.22 미만이면 새 패턴 문법이 예전 방식으로 해석되니(GODEBUG httpmuxgo121), 모듈 버전을 함께 올려야 합니다.

미들웨어는 func(http.HandlerFunc) http.HandlerFunc 형태로 핸들러를 감싸는 함수일 뿐이라서, 위 코드처럼 corsMiddleware(loggingMiddleware(...))로 겹쳐 쓰면 바깥쪽부터 실행됩니다. 로깅 미들웨어는 상태 코드를 기록하지 않는데, http.ResponseWriter는 쓰인 상태 코드를 알려주지 않으므로 필요하면 WriteHeader를 가로채는 래퍼 타입을 만들어야 합니다. CORS 헤더의 Access-Control-Allow-Origin: *는 개발용 설정입니다. 쿠키 인증을 쓰는 API에서는 *와 인증 정보 허용을 함께 쓸 수 없으므로 실제 출처를 명시해야 합니다.

마지막으로 http.ListenAndServe는 읽기·쓰기 타임아웃이 전혀 없는 서버를 띄웁니다. 느리게 헤더를 보내는 클라이언트가 연결을 계속 붙잡을 수 있으므로, 7절의 http.Server{ReadTimeout: ...} 형태와 #09의 우아한 종료를 붙이기 전에는 인터넷에 직접 노출하지 않는 것이 좋습니다.


백그라운드 작업

// background.go
package main
import (
    "log"
    "time"
)
func backgroundWorker(store TodoStore) {
    ticker := time.NewTicker(10 * time.Second)
    defer ticker.Stop()
    
    for {
        select {
        case <-ticker.C:
            // 주기적 작업: 통계 출력
            todos, err := store.GetAll()
            if err != nil {
                log.Printf("Background worker error: %v", err)
                continue
            }
            
            completed := 0
            for _, todo := range todos {
                if todo.Completed {
                    completed++
                }
            }
            
            log.Printf("Stats: Total=%d, Completed=%d, Pending=%d",
                len(todos), completed, len(todos)-completed)
        }
    }
}
// 만료된 TODO 정리 (예시)
func cleanupWorker(store TodoStore) {
    ticker := time.NewTicker(1 * time.Hour)
    defer ticker.Stop()
    
    for range ticker.C {
        todos, err := store.GetAll()
        if err != nil {
            log.Printf("Cleanup error: %v", err)
            continue
        }
        
        now := time.Now()
        for _, todo := range todos {
            // 7일 이상 된 완료 항목 삭제
            if todo.Completed && now.Sub(todo.CreatedAt) > 7*24*time.Hour {
                if err := store.Delete(todo.ID); err != nil {
                    log.Printf("Delete error: %v", err)
                }
            }
        }
    }
}

time.NewTicker와 select로 주기 작업을 만드는 것은 Go에서 흔한 패턴이지만, 이 예제의 워커에는 멈출 방법이 없습니다. main에서 go backgroundWorker(store)로 띄운 고루틴은 프로그램이 끝날 때까지 돌고, 테스트에서 이 함수를 호출하면 테스트가 끝난 뒤에도 고루틴이 남습니다. 실제 코드에서는 ctx context.Context를 인자로 받아 select에 case <-ctx.Done(): return을 추가하는 형태로 바꾸는데, 이 부분이 #09에서 다루는 내용입니다. select에 case가 하나뿐인 현재 코드는 for range ticker.C와 같은 동작이라는 점도 참고하세요.

cleanupWorker는 GetAll로 목록을 받은 뒤 잠금 밖에서 하나씩 Delete를 호출합니다. 그 사이 다른 요청이 같은 항목을 먼저 지우면 ErrNotFound가 반환되는데, 정리 작업에서는 이미 지워진 상태이므로 오류로 기록할 필요가 없습니다. errors.Is(err, ErrNotFound)는 무시하도록 처리하면 로그가 깨끗해집니다.


테스트 작성

저장소 테스트

// store_test.go
package main
import (
    "errors"
    "testing"
)
func TestInMemoryStore_Create(t *testing.T) {
    store := NewInMemoryStore()
    
    todo, err := store.Create("Test todo")
    if err != nil {
        t.Fatal(err)
    }
    
    if todo.ID != 1 {
        t.Errorf("got ID %d; want 1", todo.ID)
    }
    
    if todo.Title != "Test todo" {
        t.Errorf("got title %s; want 'Test todo'", todo.Title)
    }
    
    if todo.Completed {
        t.Error("new todo should not be completed")
    }
}
func TestInMemoryStore_GetAll(t *testing.T) {
    store := NewInMemoryStore()
    
    // 초기에는 비어있음
    todos, err := store.GetAll()
    if err != nil {
        t.Fatal(err)
    }
    if len(todos) != 0 {
        t.Errorf("got %d todos; want 0", len(todos))
    }
    
    // 3개 추가
    store.Create("Todo 1")
    store.Create("Todo 2")
    store.Create("Todo 3")
    
    todos, err = store.GetAll()
    if err != nil {
        t.Fatal(err)
    }
    if len(todos) != 3 {
        t.Errorf("got %d todos; want 3", len(todos))
    }
}
func TestInMemoryStore_Update(t *testing.T) {
    store := NewInMemoryStore()
    
    todo, _ := store.Create("Test")
    
    // 완료로 업데이트
    err := store.Update(todo.ID, true)
    if err != nil {
        t.Fatal(err)
    }
    
    // 확인
    updated, _ := store.GetByID(todo.ID)
    if !updated.Completed {
        t.Error("todo should be completed")
    }
}
func TestInMemoryStore_Delete(t *testing.T) {
    store := NewInMemoryStore()
    
    todo, _ := store.Create("Test")
    
    // 삭제
    err := store.Delete(todo.ID)
    if err != nil {
        t.Fatal(err)
    }
    
    // 확인
    _, err = store.GetByID(todo.ID)
    if !errors.Is(err, ErrNotFound) {
        t.Error("deleted todo should not be found")
    }
}

HTTP 핸들러 테스트

// handler_test.go
package main
import (
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)
func TestCreateTodo(t *testing.T) {
    store := NewInMemoryStore()
    handler := NewTodoHandler(store)
    
    reqBody := CreateTodoRequest{Title: "Test Todo"}
    body, _ := json.Marshal(reqBody)
    
    req := httptest.NewRequest(http.MethodPost, "/todos", bytes.NewBuffer(body))
    req.Header.Set("Content-Type", "application/json")
    
    w := httptest.NewRecorder()
    handler.CreateTodo(w, req)
    
    if w.Code != http.StatusCreated {
        t.Errorf("got status %d; want %d", w.Code, http.StatusCreated)
    }
    
    var todo Todo
    if err := json.NewDecoder(w.Body).Decode(&todo); err != nil {
        t.Fatal(err)
    }
    
    if todo.Title != "Test Todo" {
        t.Errorf("got title %s; want 'Test Todo'", todo.Title)
    }
}
func TestGetTodos(t *testing.T) {
    store := NewInMemoryStore()
    handler := NewTodoHandler(store)
    
    // 데이터 준비
    store.Create("Todo 1")
    store.Create("Todo 2")
    
    req := httptest.NewRequest(http.MethodGet, "/todos", nil)
    w := httptest.NewRecorder()
    
    handler.GetTodos(w, req)
    
    if w.Code != http.StatusOK {
        t.Errorf("got status %d; want %d", w.Code, http.StatusOK)
    }
    
    var todos []*Todo
    if err := json.NewDecoder(w.Body).Decode(&todos); err != nil {
        t.Fatal(err)
    }
    
    if len(todos) != 2 {
        t.Errorf("got %d todos; want 2", len(todos))
    }
}

실행과 배포

로컬 실행

# 개발 모드 (자동 재시작 없음)
go run main.go handler.go store.go background.go
# 또는 빌드 후 실행
go build -o todo-api
./todo-api

API 테스트

# 헬스 체크
curl http://localhost:8080/health
# TODO 생성
curl -X POST http://localhost:8080/todos \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Go"}'
# 모든 TODO 조회
curl http://localhost:8080/todos
# 특정 TODO 조회
curl http://localhost:8080/todos/1
# TODO 업데이트
curl -X PUT http://localhost:8080/todos/1 \
  -H "Content-Type: application/json" \
  -d '{"completed":true}'
# TODO 삭제
curl -X DELETE http://localhost:8080/todos/1

Docker로 배포

# Dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o todo-api
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/todo-api .
EXPOSE 8080
CMD ["./todo-api"]

CMD의 JSON 배열 형식에서는 큰따옴표가 필수입니다. CMD [./todo-api]처럼 따옴표를 빼면 Docker는 이것을 JSON으로 인식하지 못하고 셸 명령 문자열로 실행해서, 컨테이너가 /bin/sh: [./todo-api]: not found를 출력하고 바로 종료됩니다. 또 외부 의존성이 없는 프로젝트에는 go.sum 파일이 생기지 않으므로 COPY go.mod go.sum ./가 파일을 찾지 못해 빌드가 실패할 수 있습니다. 이 경우 COPY go.mod ./로 바꾸거나, 의존성을 추가한 뒤 빌드하면 됩니다.

빌드 단계와 실행 단계를 나눈 멀티 스테이지 구성 덕분에 최종 이미지에는 Go 컴파일러 없이 바이너리만 들어갑니다. 실행 이미지를 scratch나 distroless로 더 줄이려면 CGO_ENABLED=0 go build로 C 라이브러리 의존성이 없는 정적 바이너리를 만들어야 합니다. 그렇지 않으면 exec ./todo-api: no such file or directory처럼 파일이 있는데도 없다고 나오는, 동적 링커를 찾지 못한 오류를 만나게 됩니다.

# Docker 빌드 및 실행
docker build -t todo-api .
docker run -p 8080:8080 todo-api

프로덕션 개선 사항

// 환경 변수로 설정 관리
package main
import (
    "log"
    "net/http"
    "os"
    "time"
)
type Config struct {
    Port         string
    ReadTimeout  time.Duration
    WriteTimeout time.Duration
}
func LoadConfig() *Config {
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }
    
    return &Config{
        Port:         port,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 10 * time.Second,
    }
}
func main() {
    config := LoadConfig()
    
    server := &http.Server{
        Addr:         ":" + config.Port,
        Handler:      mux,
        ReadTimeout:  config.ReadTimeout,
        WriteTimeout: config.WriteTimeout,
    }
    
    log.Printf("Server starting on %s", server.Addr)
    if err := server.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

정리: Day 14 학습 체크리스트

완료해야 할 항목

  • REST API 설계 (CRUD 엔드포인트)
  • net/http로 HTTP 서버 구현
  • JSON 직렬화/역직렬화
  • 에러 처리와 HTTP 상태 코드
  • 미들웨어 패턴 구현
  • 고루틴으로 백그라운드 작업
  • HTTP 핸들러 테스트
  • Docker로 배포

시리즈 전체 흐름

1주 차에는 기본 문법과 철학, 포인터와 자료구조, 구조체와 메서드, 인터페이스와 다형성을 다뤘고, 2주 차에는 에러 처리와 defer, 고루틴과 채널, 의존성 관리와 테스팅을 거쳐 이 글의 REST API 프로젝트로 묶었습니다.

graph TD
    A[C++ 개발자] --> B[Day 1-2: 기본 문법]
    B --> C[Day 3-4: 자료구조]
    C --> D[Day 5-6: 객체지향]
    D --> E[Day 7: 인터페이스]
    E --> F[Day 8-9: 에러 처리]
    F --> G[Day 10-11: 동시성]
    G --> H[Day 12-13: 테스팅]
    H --> I[Day 14: 실전 프로젝트]
    I --> J[Go 개발자!]
    
    style A fill:#ffcccc
    style J fill:#ccffcc

다음 학습 방향

프로젝트를 완성했다면:

  1. 데이터베이스 연동: PostgreSQL, MySQL, MongoDB
  2. 인증/인가: JWT, OAuth2
  3. 고급 패턴: 미들웨어 체인, 의존성 주입
  4. 성능 최적화: 프로파일링, 캐싱
  5. 배포: Kubernetes, Cloud Run, AWS Lambda 추천 프로젝트:
  • CLI 도구 (cobra 라이브러리)
  • 웹 크롤러 (colly 라이브러리)
  • 채팅 서버 (WebSocket)
  • gRPC 서버
  • 마이크로서비스

마무리

C++과 Go, 언제 무엇을 쓸까?

C++를 선택해야 할 때:

  • 극저지연이 필요한 시스템 (HFT, 게임 엔진)
  • 하드웨어 제어 (임베디드, 드라이버)
  • 레거시 코드베이스 유지보수
  • 메모리 사용량이 극도로 제한적인 환경 Go를 선택해야 할 때:
  • 웹 API, 마이크로서비스
  • CLI 도구, DevOps 도구
  • 클라우드 네이티브 애플리케이션
  • 동시성이 중요한 서버
  • 빠른 개발과 배포가 중요한 프로젝트

계속 성장하기

// Go 개발자로서의 다음 단계
package main
func main() {
    skills := []string{
        "Go 고급 패턴",
        "데이터베이스 연동",
        "gRPC와 Protobuf",
        "Kubernetes 연동",
        "성능 최적화",
    }
    
    for _, skill := range skills {
        go learn(skill)  // 병렬로 학습!
    }
}

📚 시리즈 네비게이션

이전 글목차완료!
← #07 테스팅📑 전체 목차🎉 시리즈 완료!

Go 2주 완성 시리즈: 커리큘럼 • #01 기본 문법 • #02 자료구조 • #03 객체지향 • #04 인터페이스 • #05 에러 처리 • #06 고루틴·채널 • #07 테스팅 • #08 REST API • #09 context·우아한 종료

다음 읽을 글:


이 프로젝트는 시리즈에서 다룬 인터페이스, 에러 처리, 고루틴, 테스트를 net/http 기반 CRUD 서버 하나로 묶은 것입니다. 실제 서비스로 가져가려면 #09의 context·우아한 종료를 먼저 붙이는 것이 좋습니다.

자주 묻는 질문 (FAQ)

Q. 저장소를 TodoStore 인터페이스로 분리한 이유는 무엇인가요?

A. 핸들러가 구체 타입이 아니라 TodoStore 인터페이스에 의존하면, 지금의 InMemoryStore를 나중에 PostgreSQL 구현으로 바꿔도 핸들러 코드를 고칠 필요가 없습니다. 테스트에서도 같은 인터페이스를 만족하는 메모리 저장소를 주입해 DB 없이 핸들러를 검증할 수 있습니다. 메모리 구현에 sync.RWMutex를 둔 것은 net/http가 요청마다 고루틴을 띄워 여러 요청이 동시에 맵에 접근하기 때문입니다.

Q. net/http만으로 충분한가요? 프레임워크는 필요 없나요?

A. 간단한 API는 net/http만으로 충분합니다. 복잡한 라우팅이나 미들웨어 체인이 필요해지면 Gin, Echo 같은 프레임워크를 고려하세요.


관련 글