[Go 2주 완성 #08] Day 14: 실전 미니 프로젝트 - REST API 서버 구축
이 글의 핵심
net/http로 REST API를 만들고 JSON 직렬화·핸들러·고루틴·에러 처리를 한 프로젝트에 묶습니다. 2주 커리큘럼 마무리 실전편, Day 14입니다.
시리즈 안내
📚 Go 2주 완성 시리즈 #08 (최종편) | 전체 목차 보기
이 글은 C++ 개발자를 위한 2주 완성 Go 언어 커리큘럼의 Day 14 내용입니다.
이전: #07 테스팅 ← | → 실무 심화: #09 context·우아한 종료
들어가며: 2주의 결실
지금까지 다룬 변수, 슬라이스, 구조체, 인터페이스, 에러 처리, 고루틴, 채널, 테스팅을 묶어 실전 REST API 서버를 구성해 봅니다. 한 바퀴 완성하면 Go로 서비스 코드를 시작할 때 참고할 뼈대가 됩니다.
프로젝트 개요:
- TODO API 서버: CRUD 기능 완비
- 표준 라이브러리만 사용:
net/http,encoding/json - 동시성 활용: 백그라운드 작업 처리
- 테스트 포함: 유닛 테스트와 통합 테스트
실무에서의 체감
C++ 위주로 서버를 다루던 환경에서 Go를 도입할 때 흔히 드는 인상은 문법과 툴체인이 단순해 보인다는 점입니다. 프로덕션에서는 그 단순함이 빌드·배포·동시성 코드 가독성으로 이어지는 경우가 많습니다.
자주 언급되는 장점:
- 개발 속도: 팀·도메인에 따라 다르지만, 네트워크·CLI 코드를 빠르게 완성하기 쉬운 편입니다.
- 안정성: GC가 있어 수동 할당 해제 부담이 줄어듭니다.
- 배포: 단일 바이너리로 옮기기 쉬운 구조입니다.
목차
1. 프로젝트 구조
# 프로젝트 초기화
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
2. 데이터 모델과 저장소
모델 정의
// 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
}
3. 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
}
4. 라우팅과 미들웨어
// 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)
}
}
5. 백그라운드 작업
// 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)
}
}
}
}
}
6. 테스트 작성
저장소 테스트
// 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))
}
}
7. 실행과 배포
로컬 실행
# 개발 모드 (자동 재시작 없음)
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"]
# Docker 빌드 및 실행
docker build -t todo-api .
docker run -p 8080:8080 todo-api
프로덕션 개선 사항
// 환경 변수로 설정 관리
package main
import (
"os"
"strconv"
)
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로 배포
2주 완성 최종 체크리스트
1주 차:
- ✅ Go 기본 문법과 철학
- ✅ 포인터와 자료구조
- ✅ 구조체와 메서드
- ✅ 인터페이스와 다형성
2주 차:
- ✅ 에러 처리와 defer
- ✅ 고루틴과 채널
- ✅ 의존성 관리와 테스팅
- ✅ 실전 REST API 프로젝트
C++에서 Go로 전환 완료!
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
다음 학습 방향
프로젝트를 완성했다면:
- 데이터베이스 연동: PostgreSQL, MySQL, MongoDB
- 인증/인가: JWT, OAuth2
- 고급 패턴: 미들웨어 체인, 의존성 주입
- 성능 최적화: 프로파일링, 캐싱
- 배포: Kubernetes, Cloud Run, AWS Lambda
추천 프로젝트:
- CLI 도구 (cobra 라이브러리)
- 웹 크롤러 (colly 라이브러리)
- 채팅 서버 (WebSocket)
- gRPC 서버
- 마이크로서비스
마무리: 2주 완성을 축하합니다!
당신이 이룬 것
2주 전, C++만 알던 당신이 이제는:
- ✅ Go 문법을 자유롭게 사용
- ✅ 고루틴으로 동시성 프로그래밍
- ✅ 채널로 안전한 통신
- ✅ 인터페이스로 유연한 설계
- ✅ REST API 서버 구축
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·우아한 종료
다음 읽을 글:
- [Go 심화 #09] context.Context·타임아웃·우아한 종료 — HTTP·배포 시 필수
- C++ 개발자의 뇌 구조로 이해하는 Go 언어 - 더 깊은 개념 매핑
- C++ vs Go 성능·동시성 비교 - 성능 벤치마크
한 줄 요약: 2주간의 학습으로 C++ 개발자에서 Go 개발자로 전환 완료! 이제 클라우드 네이티브 시대의 필수 언어를 마스터했습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- [Go 2주 완성 #07] Day 12~13: 의존성 관리와 테스팅 - CMake보다 쉬운 세상
- [Go 심화 #09] context·우아한 종료
- C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
- C++ REST API 클라이언트 완벽 가이드 | CRUD·인증·에러 처리·프로덕션 패턴 [#21-3]
이 글에서 다루는 키워드 (관련 검색어)
Go REST API, net/http, encoding/json, Go 웹서버, 미니 프로젝트, Golang API, Go 2주 완성, C++ 개발자 Go 등으로 검색하시면 이 글이 도움이 됩니다.
🎉 시리즈 코어(2주) 완료를 축하합니다!
Go 2주 완성 시리즈 본편(#01~#08)을 마치셨습니다. 이제 실전 프로젝트에 Go를 적용해 보세요.
프로덕션으로 한 단계 올리기(강력 권장):
- #09 context.Context·타임아웃·우아한 종료 —
WithTimeout,r.Context,http.Server.Shutdown
더 깊이 배우기:
- C++ 개발자의 뇌 구조로 이해하는 Go - 개념 매핑 심화
- C++ vs Go 성능·동시성 비교 - 성능 벤치마크
다른 시리즈:
- C++ 실전 가이드 시리즈
- C++ 고성능 네트워크 가이드
실전 팁
실무에서 바로 적용할 수 있는 팁입니다.
디버깅 팁
- 문제가 발생하면 먼저 컴파일러 경고를 확인하세요
- 간단한 테스트 케이스로 문제를 재현하세요
성능 팁
- 프로파일링 없이 최적화하지 마세요
- 측정 가능한 지표를 먼저 설정하세요
코드 리뷰 팁
- 코드 리뷰에서 자주 지적받는 부분을 미리 체크하세요
- 팀의 코딩 컨벤션을 따르세요
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 2주간 배운 Go 언어의 모든 것을 통합하여 완전한 REST API 서버를 구축합니다. net/http, JSON 처리, 고루틴 활용, 에러 처리까지 실무에 바로 적용 가능한 프로젝트입니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
관련 글
- [Go 심화 #09] context.Context로 타임아웃·취소·우아한 종료 다루기 — C++와의 비교
- Python REST API | Flask/Django로 API 서버 만들기
- C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
- C++ HTTP 클라이언트 완벽 가이드 | REST API 호출·연결 풀·타임아웃·프로덕션 패턴
- C++ JSON 파싱 완벽 가이드 | nlohmann·RapidJSON·커스텀 타입·에러 처리·프로덕션 패턴