33장. 미니 프로젝트
지금까지 다룬 개념을 한자리에 모아 실제로 굴러가는 프로그램을 두 개 만들어 본다.
각 단계마다 “어느 챕터에서 본 내용을 쓰는지” 짧게 짚어 가며 진행한다. 지금까지의 학습이 점이 아니라 선으로 이어지는 순간이다.
목표:
- 프로젝트 두 개를 처음부터 끝까지 만들어 보기
- CLI 할 일 관리 도구 (
todo) - JSON API 서버
- CLI 할 일 관리 도구 (
- 모듈 구성, 에러 처리, 테스트, 동시성을 한꺼번에 적용
- “여기서부터 어디로 더 갈 수 있는가” 감 잡기
프로젝트 1: CLI 할 일 관리 도구 todo
콘솔에서 일을 추가하고, 목록 보고, 완료 표시하고, 지우는 작고 단단한 프로그램을 만든다.
데이터는 JSON 파일에 저장한다. 프로그램을 다시 켜도 목록이 유지된다.
33.1 요구사항 정리
가장 먼저 만들 것의 윤곽을 정한다.
| 명령 | 동작 |
|---|---|
todo add "장보기" | 새 일을 추가 |
todo list | 전체 목록 출력 |
todo done 3 | 3번 일을 완료 처리 |
todo rm 3 | 3번 일을 삭제 |
조건:
- 데이터는 사용자 홈 디렉터리의
~/.todo.json에 저장 - 잘못된 명령엔 친절한 안내 메시지
- 잘못된 ID 엔 명확한 에러
33.2 프로젝트 구조
작업 디렉터리를 만들고 모듈을 초기화한다 (2장, 20장).
mkdir todo
cd todo
go mod init example.com/todo
다음과 같은 구조로 잡는다.
todo/
├── go.mod
├── main.go
└── internal/
└── tasks/
├── store.go
└── store_test.go
main.go— 명령 파싱과 출력 담당internal/tasks— 데이터 모델과 저장/로드 로직internal/아래는 외부 모듈이 import 할 수 없다 (20장)
33.3 데이터 모델
먼저 한 개의 할 일이 어떻게 생겼는지 정한다 (13장, 30장).
internal/tasks/store.go:
package tasks
import "time"
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}
- 필드는 외부에서 읽을 수 있도록 대문자
- JSON 키는 소문자 / 스네이크 케이스
time.Time은 RFC3339 문자열로 직렬화된다 (28장, 30장)
전체 데이터셋도 구조체로 둔다.
type Store struct {
Tasks []Task `json:"tasks"`
NextID int `json:"next_id"`
}
NextID는 다음에 부여할 ID- 삭제가 일어나도 ID 가 재사용되지 않도록 따로 관리
33.4 저장과 로드
JSON 파일을 읽고 쓰는 함수를 만든다 (29장, 30장).
package tasks
import (
"encoding/json"
"errors"
"io/fs"
"os"
)
func Load(path string) (*Store, error) {
data, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
// 처음 실행이면 빈 저장소
return &Store{NextID: 1}, nil
}
if err != nil {
return nil, err
}
var s Store
if err := json.Unmarshal(data, &s); err != nil {
return nil, err
}
if s.NextID == 0 {
s.NextID = 1
}
return &s, nil
}
func (s *Store) Save(path string) error {
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
핵심 포인트:
- 처음 실행일 때 파일이 없는 건 에러가 아니다
errors.Is(err, fs.ErrNotExist)로 분기 (21장)
MarshalIndent로 사람도 읽기 좋게 저장- 쓰기 권한은
0644(소유자만 쓰기, 모두 읽기)
33.5 명령 파싱
os.Args 만으로도 충분한 규모다 (간단한 CLI 라면).
main.go:
package main
import (
"fmt"
"os"
"path/filepath"
"example.com/todo/internal/tasks"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
path := storePath()
store, err := tasks.Load(path)
if err != nil {
fmt.Fprintln(os.Stderr, "데이터 로드 실패:", err)
os.Exit(1)
}
cmd := os.Args[1]
args := os.Args[2:]
switch cmd {
case "add":
err = cmdAdd(store, args)
case "list":
err = cmdList(store, args)
case "done":
err = cmdDone(store, args)
case "rm":
err = cmdRm(store, args)
default:
usage()
os.Exit(1)
}
if err != nil {
fmt.Fprintln(os.Stderr, "오류:", err)
os.Exit(1)
}
if err := store.Save(path); err != nil {
fmt.Fprintln(os.Stderr, "저장 실패:", err)
os.Exit(1)
}
}
func storePath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".todo.json")
}
func usage() {
fmt.Println(`사용법:
todo add "할 일 제목"
todo list
todo done <id>
todo rm <id>`)
}
명령이 늘면 flag 패키지로 바꾸면 된다.
지금 단계는 switch 만으로 충분하다.
33.6 각 명령 구현
add
func cmdAdd(s *tasks.Store, args []string) error {
if len(args) == 0 {
return fmt.Errorf("제목을 적어 주세요")
}
title := strings.Join(args, " ")
t := tasks.Task{
ID: s.NextID,
Title: title,
CreatedAt: time.Now(),
}
s.Tasks = append(s.Tasks, t)
s.NextID++
fmt.Printf("추가됨 #%d: %s\n", t.ID, t.Title)
return nil
}
- 여러 단어를 한 제목으로 묶기 위해
strings.Join사용 (27장)
list
func cmdList(s *tasks.Store, args []string) error {
if len(s.Tasks) == 0 {
fmt.Println("(비어 있음)")
return nil
}
for _, t := range s.Tasks {
mark := "[ ]"
if t.Done {
mark = "[x]"
}
fmt.Printf("%s #%d %s\n", mark, t.ID, t.Title)
}
return nil
}
done
func cmdDone(s *tasks.Store, args []string) error {
if len(args) == 0 {
return fmt.Errorf("ID 를 적어 주세요")
}
id, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("잘못된 ID: %q", args[0])
}
for i := range s.Tasks {
if s.Tasks[i].ID == id {
s.Tasks[i].Done = true
fmt.Printf("완료 #%d\n", id)
return nil
}
}
return fmt.Errorf("ID %d 를 찾을 수 없습니다", id)
}
strconv.Atoi로 문자열 → 정수 변환 (27장)- 인덱스 순회를
range로 하되, 값을 수정하려면s.Tasks[i]식으로 직접 접근 (11장)
rm
func cmdRm(s *tasks.Store, args []string) error {
if len(args) == 0 {
return fmt.Errorf("ID 를 적어 주세요")
}
id, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("잘못된 ID: %q", args[0])
}
for i, t := range s.Tasks {
if t.ID == id {
s.Tasks = append(s.Tasks[:i], s.Tasks[i+1:]...)
fmt.Printf("삭제됨 #%d\n", id)
return nil
}
}
return fmt.Errorf("ID %d 를 찾을 수 없습니다", id)
}
- 슬라이스에서 한 요소 제거하는 표준 패턴 (11장)
33.7 에러 처리 흐름
전체적으로 21장의 관례를 따른다.
- 함수는
(결과, error)를 반환 - 호출자가 에러를 받아 적절히 처리
- 사용자에게는
stderr로 메시지를 보냄 - 종료 코드는 0 이 아니어야 셸 스크립트에서 감지 가능
fmt.Fprintln(os.Stderr, "오류:", err)
os.Exit(1)
log.Fatal도 비슷한 효과를 내지만, CLI 도구에서는 보통 stderr + Exit(1) 조합이 깔끔하다.
33.8 테스트 작성
저장/로드 로직만이라도 테스트해 둔다 (32장).
internal/tasks/store_test.go:
package tasks
import (
"path/filepath"
"testing"
)
func TestSaveLoad(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "todo.json")
s1 := &Store{NextID: 2}
s1.Tasks = append(s1.Tasks, Task{ID: 1, Title: "사기"})
if err := s1.Save(path); err != nil {
t.Fatalf("Save: %v", err)
}
s2, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(s2.Tasks) != 1 {
t.Fatalf("Tasks 길이 %d, want 1", len(s2.Tasks))
}
if s2.Tasks[0].Title != "사기" {
t.Errorf("Title=%q", s2.Tasks[0].Title)
}
if s2.NextID != 2 {
t.Errorf("NextID=%d", s2.NextID)
}
}
func TestLoadMissingFile(t *testing.T) {
s, err := Load("/tmp/does-not-exist-xxx.json")
if err != nil {
t.Fatalf("없는 파일은 에러가 아니어야 함: %v", err)
}
if s.NextID != 1 {
t.Errorf("초기 NextID=%d, want 1", s.NextID)
}
}
t.TempDir() 는 테스트가 끝나면 자동으로 지워진다 (32장).
go test ./...
./... 는 “이 모듈의 모든 패키지” 라는 뜻이다.
33.9 빌드와 사용
go build -o todo
./todo add "장보기"
./todo add "운동 가기"
./todo list
./todo done 1
./todo list
./todo rm 2
작은 도구 하나가 완성됐다. 배운 거의 모든 개념이 한 번씩 등장한 셈이다.
프로젝트 2: JSON API 서버
이번엔 똑같은 “할 일” 데이터를 HTTP 위에서 다루는 서버를 만든다.
33.10 요구사항
| 메서드 | 경로 | 동작 |
|---|---|---|
| GET | /tasks | 목록 조회 |
| POST | /tasks | 새 일 추가 |
| GET | /tasks/{id} | 한 건 조회 |
| PUT | /tasks/{id} | 한 건 수정 |
| DELETE | /tasks/{id} | 삭제 |
- 데이터는 메모리에 두기 (단순화)
- 동시 요청에서도 안전해야 함 (23장)
- 모든 요청을 로깅
- Ctrl+C 로 깔끔하게 종료
go 1.22 부터는 표준 net/http 의 라우팅이 강해져
경로 패턴에 {id} 같은 변수를 직접 받을 수 있다 (31장).
이 책은 그 방식을 쓴다.
33.11 프로젝트 구조
mkdir todo-api
cd todo-api
go mod init example.com/todo-api
todo-api/
├── go.mod
├── main.go
└── internal/
└── server/
├── store.go # 메모리 저장소 (동시성 보호)
├── handlers.go # 핸들러 함수들
└── middleware.go
33.12 동시 접근에 안전한 저장소
여러 요청이 동시에 들어올 수 있으므로
공유 데이터는 sync.RWMutex 로 보호한다 (19장, 23장).
internal/server/store.go:
package server
import (
"errors"
"sync"
"time"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
}
type Store struct {
mu sync.RWMutex
tasks map[int]Task
nextID int
}
func NewStore() *Store {
return &Store{
tasks: make(map[int]Task),
nextID: 1,
}
}
var ErrNotFound = errors.New("not found")
func (s *Store) List() []Task {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
out = append(out, t)
}
return out
}
func (s *Store) Get(id int) (Task, error) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.tasks[id]
if !ok {
return Task{}, ErrNotFound
}
return t, nil
}
func (s *Store) Create(title string) Task {
s.mu.Lock()
defer s.mu.Unlock()
t := Task{
ID: s.nextID,
Title: title,
CreatedAt: time.Now(),
}
s.tasks[t.ID] = t
s.nextID++
return t
}
func (s *Store) Update(id int, title string, done bool) (Task, error) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
if !ok {
return Task{}, ErrNotFound
}
t.Title = title
t.Done = done
s.tasks[id] = t
return t, nil
}
func (s *Store) Delete(id int) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.tasks[id]; !ok {
return ErrNotFound
}
delete(s.tasks, id)
return nil
}
핵심:
- 읽기 전용 동작은
RLock, 변경은Lock - 잠금 범위는 짧게 유지
- 외부에 슬라이스 / 맵을 그대로 노출하지 않고 복사본을 줌
락을 들고 있는 동안에는 HTTP 응답 같은 외부 작업을 절대 하지 않는다. 한쪽이 막히면 전체가 막힌다 (23장).
33.13 JSON 응답 도우미
핸들러마다 응답 코드를 정하고 JSON 을 쓰는 공통 함수를 둔다 (30장).
internal/server/handlers.go:
package server
import (
"encoding/json"
"net/http"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
type errorResp struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, errorResp{Error: msg})
}
33.14 핸들러 구현
같은 파일 안에 이어서 적는다.
type Server struct {
store *Store
}
func NewServer(s *Store) *Server {
return &Server{store: s}
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.store.List())
}
type createReq struct {
Title string `json:"title"`
}
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
var req createReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "잘못된 JSON")
return
}
if req.Title == "" {
writeError(w, http.StatusBadRequest, "title 이 비었습니다")
return
}
t := s.store.Create(req.Title)
writeJSON(w, http.StatusCreated, t)
}
func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
t, err := s.store.Get(id)
if err != nil {
writeError(w, http.StatusNotFound, "없는 id")
return
}
writeJSON(w, http.StatusOK, t)
}
type updateReq struct {
Title string `json:"title"`
Done bool `json:"done"`
}
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
var req updateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "잘못된 JSON")
return
}
t, err := s.store.Update(id, req.Title, req.Done)
if err != nil {
writeError(w, http.StatusNotFound, "없는 id")
return
}
writeJSON(w, http.StatusOK, t)
}
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
if err := s.store.Delete(id); err != nil {
writeError(w, http.StatusNotFound, "없는 id")
return
}
w.WriteHeader(http.StatusNoContent)
}
func pathID(w http.ResponseWriter, r *http.Request) (int, bool) {
raw := r.PathValue("id")
id, err := strconv.Atoi(raw)
if err != nil {
writeError(w, http.StatusBadRequest, "잘못된 id")
return 0, false
}
return id, true
}
r.PathValue("id")는 Go 1.22+ 의 표준 라우터 기능
33.15 라우팅
internal/server/server.go (또는 같은 파일에 이어서):
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", s.handleList)
mux.HandleFunc("POST /tasks", s.handleCreate)
mux.HandleFunc("GET /tasks/{id}", s.handleGet)
mux.HandleFunc("PUT /tasks/{id}", s.handleUpdate)
mux.HandleFunc("DELETE /tasks/{id}", s.handleDelete)
return loggingMiddleware(mux)
}
메서드와 경로를 한 줄로 지정할 수 있어 깔끔하다.
loggingMiddleware 는 다음 절에서 만든다.
33.16 로깅 미들웨어
요청 한 건마다 메서드, 경로, 처리 시간을 남긴다.
internal/server/middleware.go:
package server
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()
next.ServeHTTP(w, r)
log.Printf("%s %s %s",
r.Method, r.URL.Path, time.Since(start))
})
}
미들웨어는 “들어오는 핸들러를 감싸서 새 핸들러를 돌려주는 함수” 다. 책임을 한 군데에 모으는 강력한 패턴이다.
33.17 그래스풀 종료
서버는 처리 중인 요청이 있을 때 바로 꺼지면 안 된다. SIGINT (Ctrl+C) 가 오면 새 요청은 막고, 진행 중인 요청은 끝까지 처리한 뒤 종료한다 (25장).
main.go:
package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
"example.com/todo-api/internal/server"
)
func main() {
store := server.NewStore()
srv := server.NewServer(store)
httpServer := &http.Server{
Addr: ":8080",
Handler: srv.Routes(),
}
// SIGINT / SIGTERM 을 채널로 감지
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
// 서버를 별도 고루틴에서 시작
go func() {
log.Println("listening on :8080")
err := httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
// 시그널 대기
<-ctx.Done()
log.Println("shutting down...")
// 진행 중인 요청에 5초 여유
shutdownCtx, cancel := context.WithTimeout(
context.Background(), 5*time.Second)
defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.Println("shutdown:", err)
}
log.Println("bye")
}
흐름 정리:
- 서버를 고루틴에서 시작
- 메인은 시그널을 기다림 (
<-ctx.Done()) - 시그널이 오면
Shutdown호출 - 5초 안에 모든 요청이 끝나길 기다린 뒤 종료
33.18 사용해 보기
서버를 띄운다.
go run .
다른 터미널에서 curl 로 두드려 본다.
curl -X POST http://localhost:8080/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"장보기"}'
curl http://localhost:8080/tasks
curl -X PUT http://localhost:8080/tasks/1 \
-H 'Content-Type: application/json' \
-d '{"title":"장보기","done":true}'
curl http://localhost:8080/tasks/1
curl -X DELETE http://localhost:8080/tasks/1
서버 측 로그도 같이 흐를 것이다.
2026/05/24 10:00:00 listening on :8080
2026/05/24 10:00:05 POST /tasks 412.5µs
2026/05/24 10:00:08 GET /tasks 134.2µs
...
33.19 정리 — 다음 단계 안내
작지만 알찬 두 프로그램이 완성됐다. 지금까지의 챕터를 거의 모두 한 번씩 호출한 셈이다.
| 챕터 영역 | 어디서 썼나 |
|---|---|
| 변수, 타입, 함수 | 도처에 |
| 구조체와 JSON 태그 | Task 모델 |
| 슬라이스 / 맵 | 저장소, 응답 본문 |
| 에러 처리 | CLI, 핸들러, 저장소 |
| 패키지 분리 | internal/... 구성 |
| 동시성과 락 | API 서버 저장소 |
| context, signal | 그래스풀 종료 |
| 테스트 | store_test.go |
여기서 어디로 더 갈 수 있나
다음 주제 중 끌리는 것을 골라 이 두 프로젝트를 확장해 가는 것이 좋은 학습이다.
- 데이터베이스 연결
database/sql+ SQLite 또는 PostgreSQL- 또는 ORM (예:
gorm,ent)
- 인증 / 권한
- 헤더 토큰 검사 미들웨어
- JWT
- 설정 관리
- 환경변수 /
flag/ 설정 파일
- 환경변수 /
- 로깅 고도화
log/slog로 구조화 로깅
- 배포
- Dockerfile 작성
- 단일 바이너리 + systemd
- 테스트 강화
- 핸들러 단위 테스트 (
httptest) - 통합 테스트
- 핸들러 단위 테스트 (
한꺼번에 다 할 필요는 없다. 하나씩 하나씩 더해 가다 보면 어느새 “회사에서 쓰는 그 서버” 와 비슷한 모양이 된다.
지금까지 정말 수고했다. 이제 남은 건 직접 만들고 싶었던 그 무언가뿐이다.