diff --git a/_chapter17/section68/.air.toml b/_chapter17/section68/.air.toml new file mode 100644 index 0000000..90bc40b --- /dev/null +++ b/_chapter17/section68/.air.toml @@ -0,0 +1,43 @@ +root = '.' +tmp_dir = 'tmp' + +[build] +cmd = "go build -o ./tmp/main ." #Go 프로젝트를 빌드 +bin = "./tmp/main" #빌드된 실행 파일 경로 +#실행 파일 실행 시 필요한 환경 변수와 인자 설정 -> 80번 포트 사용하도록 인수 지정 +full_bin = "APP_ENV=dev APP_USER = air ./tmp/main 80" + +# 파일 변경 감지 설정 -> 변경을 감지할 파일 확장자 +include_ext = ["go", "tpl", "tmpl", "html"] +# 감지하지 않을 디렉터리 목록 +exclude_dir = ["assets", "tmp", "vendor", "frontend/node_modules", "_tools", "cert", "testutil"] +include_dir = [] +exclude_file = [] +exclude_regex = ["_test.go"] +exclude_unchanged = true +exclude_underscore = false +follow_symlink = false + +#로깅 설정 : 로그파일 경로, 파일 변경 감지 후 재빌드 대기 시간 +log = "air.log" +delay = 1000 + +#오류 처리 설정 +stop_on_error = true +send_interrupt = false +kill_delay = 500 + +[log] +time = false + +[color] +#로그 출력 시 사용할 색상 설정 +main = "magenta" +watcher = "cyan" +build = "yellow" +runner = "green" + +#프로그램 종료 시 임시 디렉터리 삭제 여부 +[misc] +clean_on_exit = true + diff --git a/_chapter17/section68/.dockerignore b/_chapter17/section68/.dockerignore new file mode 100644 index 0000000..d6b0aef --- /dev/null +++ b/_chapter17/section68/.dockerignore @@ -0,0 +1,2 @@ +.git +.DS_Store \ No newline at end of file diff --git a/_chapter17/section68/Dockerfile b/_chapter17/section68/Dockerfile new file mode 100644 index 0000000..b81f4b1 --- /dev/null +++ b/_chapter17/section68/Dockerfile @@ -0,0 +1,43 @@ +#최종 배포할 실행 파일 만드는 과정 +#multi-stage build에서 이 단계의 이름을 'deploy-builder'로 지정 +FROM golang:1.23.1-bullseye AS deploy-builder + +#app 디렉토리 생성 및 작업 디렉토리로 설정 +WORKDIR /app + +#종속성 파일(go.mod go.sum)만 복사해서 캐시 활용 +#소스코드가 변경되어도 종속성이 변경되지 않았다면 도커의 캐시 활용 +COPY go.mod go.sum ./ +#프로젝트의 모든 종속성 다운로드 +RUN go mod download + +#전체 소스 파일을 /app으로 복사하고 최적화된 바이너리 생성 +COPY . . +RUN go build -trimpath -ldflags="-w -s" -o app + +# 최종 배포 단계 : 실제 운영 환경에서 실행될 최소한의 이미지 생성 +#경량화된 Degian 이미지 사용 +FROM debian:bullseye-slim AS deploy + +#시스템 패키지 최신화 +RUN apt-get update + +#첫 번째 단계에서 만든 실행 파일만 복사 +COPY --from=deploy-builder /app/app . + +#컨테이너 시작 시 애플리케이션 자동 실행 +CMD ["./app"] + +#개발자의 로컬 환경을 위한 설정 +FROM golang:1.23 AS dev + +#/app 디렉토리를 작업공간으로 설정 +WORKDIR /app + +#air 도구 설치 (코드 변경 시 자동 재빌드 지원) +RUN go install github.com/air-verse/air@latest + +#개발 서버 자동 시작 +CMD ["air"] + +## docker-compose down -v <= 실행중인 컨테이너를 멈추고 매핑된 볼륨들을 제거 \ No newline at end of file diff --git a/_chapter17/section68/Makefile b/_chapter17/section68/Makefile new file mode 100644 index 0000000..f2841c9 --- /dev/null +++ b/_chapter17/section68/Makefile @@ -0,0 +1,29 @@ +.PHONY: help build build-local up down logs ps test +.DEFAULT_GOAL := help + +DOCKER_TAG := latest +build: ## Build the docker image + docker build -t gleaming9/go_todo_app:${DOCKER_TAG} \ + --target deploy ./ + +build-local: ## Build the docker image for local development + docker compose build --no-cache + +up: ## 자동 새로고침을 사용한 도커 컴포즈 실행 + docker compose up -d + +down: ## 도커 컴포즈 종료 + docker compose down + +logs: ## 도커 컴포즈 로그 출력 + docker compose logs -f + +ps: ## 실행중인 컨테이너 확인 + docker compose ps + +test: ## 테스트 실행 + go test -race -shuffle=on ./... + +help: ## 옵션 보기 + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' \ No newline at end of file diff --git a/_chapter17/section68/config/config.go b/_chapter17/section68/config/config.go new file mode 100644 index 0000000..0b6f5f6 --- /dev/null +++ b/_chapter17/section68/config/config.go @@ -0,0 +1,19 @@ +package config + +import "github.com/caarlos0/env/v6" + +// Config 구조체는 애플리케이션 설정을 위한 구조체이다. +// 환경 변수에서 값을 가져와서 필드를 초기화한다. +type Config struct { + Env string `env:"TODO_ENV" envDefault:"dev"` + Port int `env:"PORT" envDefault:"80"` +} + +// New 함수는 Config 구조체를 생성하고 환경 변수를 파싱하여 필드를 초기화 +func New() (*Config, error) { + cfg := &Config{} // config 구조체 초기화 + if err := env.Parse(cfg); err != nil { + return nil, err + } + return cfg, nil +} diff --git a/_chapter17/section68/config/config_test.go b/_chapter17/section68/config/config_test.go new file mode 100644 index 0000000..6ffbbae --- /dev/null +++ b/_chapter17/section68/config/config_test.go @@ -0,0 +1,23 @@ +package config + +import ( + "fmt" + "testing" +) + +func TestNeow(t *testing.T) { + wantPort := 3333 + t.Setenv("PORT", fmt.Sprint(wantPort)) + + got, err := New() + if err != nil { + t.Fatalf("cannot create config: %v", err) + } + if got.Port != wantPort { + t.Errorf("want %d, but %d", wantPort, got.Port) + } + wantEnv := "dev" + if got.Env != wantEnv { + t.Errorf("want %s, but %s", wantEnv, got.Env) + } +} diff --git a/_chapter17/section68/docker-compose.yml b/_chapter17/section68/docker-compose.yml new file mode 100644 index 0000000..16a1ba2 --- /dev/null +++ b/_chapter17/section68/docker-compose.yml @@ -0,0 +1,28 @@ +services: + + # 'app'이라는 이름의 서비스(컨테이너)를 정의 + app: + # 사용할 Docker 이미지 이름 : 'gotodo' + image: gotodo + + build: + #빌드할 파일들이 있는 위치를 지정 : .. <- 한칸 위 폴더에 go.mod 위치 + #go_todo_app 폴더를 컨텍스트로 설정 + context: . + dockerfile: Dockerfile + # target=dev로 설정하여 Dockerfile의 dev stage를 빌드 + args: + target: dev + environment: # 환경 변수 설정 + TODO_ENV: dev + PORT: 8080 + + # 로컬 컴퓨터의 파일과 컨테이너 안의 파일을 연결, 코드 수정 시 바로 컨테이너에 반영 + volumes: + - .:/app # go_todo_app 루트 디렉터리를 컨테이너의 /app에 마운트 + + # 컨테이너가 사용할 포트를 지정 + ports: + # 웹 브라우저에서 localhost:18000으로 접속하면 컨테이너의 80번 포트로 연결 + # 왼쪽은 내 컴퓨터의 포트, 오른쪽은 컨테이너 안의 포트 + - "18000:8080" \ No newline at end of file diff --git a/_chapter17/section68/entity/task.go b/_chapter17/section68/entity/task.go new file mode 100644 index 0000000..ca57051 --- /dev/null +++ b/_chapter17/section68/entity/task.go @@ -0,0 +1,21 @@ +package entity + +import "time" + +type TaskID int64 +type TaskStatus string + +const ( + TaskStatusTodo TaskStatus = "todo" + TaskStatusDoing TaskStatus = "doing" + TaskStatusDone TaskStatus = "done" +) + +type Task struct { + ID TaskID `json:"id"` + Title string `json:"title"` + Status TaskStatus `json:"status"` + Created time.Time `json:"created"` +} + +type Tasks []*Task diff --git a/_chapter17/section68/go.mod b/_chapter17/section68/go.mod new file mode 100644 index 0000000..eb9601d --- /dev/null +++ b/_chapter17/section68/go.mod @@ -0,0 +1,14 @@ +module go_todo_app + +go 1.23.1 + +require ( + github.com/caarlos0/env/v6 v6.10.1 // indirect + github.com/go-chi/chi/v5 v5.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator v9.31.0+incompatible // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/sync v0.9.0 // indirect +) diff --git a/_chapter17/section68/go.sum b/_chapter17/section68/go.sum new file mode 100644 index 0000000..f85d4a2 --- /dev/null +++ b/_chapter17/section68/go.sum @@ -0,0 +1,8 @@ +github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= diff --git a/_chapter17/section68/handler/add_task.go b/_chapter17/section68/handler/add_task.go new file mode 100644 index 0000000..6acd983 --- /dev/null +++ b/_chapter17/section68/handler/add_task.go @@ -0,0 +1,62 @@ +package handler + +import ( + "encoding/json" + "github.com/go-playground/validator" + "go_todo_app/entity" + "go_todo_app/store" + "net/http" + "time" +) + +type AddTask struct { + Store *store.TaskStore + Validator *validator.Validate +} + +func (at *AddTask) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + //요청 본문을 JSON으로 파싱하여 Title 필드를 가져옵니다. + var b struct { + // Title 필드는 필수입니다. + Title string `json:"title" validate:"required"` + } + if err := json.NewDecoder(r.Body).Decode(&b); err != nil { + // JSON 파싱 중 오류가 발생하면 500 상태 코드와 오류 메시지를 반환 + RespondJSON(ctx, w, &ErrResponse{ + Message: err.Error(), + }, http.StatusBadRequest) + return + } + // Validator를 사용하여 Title 필드가 있는지 검증 + // Unmarshal한 타입에 대한 검증을 위해 Validator를 사용, JSON 구조가 방대하거나 복잡한 경우 자주 사용 + if err := at.Validator.Struct(b); err != nil { + // 필수 필드가 없으면 400 상태 코드와 오류 메시지를 반환 + RespondJSON(ctx, w, &ErrResponse{ + Message: err.Error(), + }, http.StatusBadRequest) + return + } + + // Task 구조체를 생성하여, Title, Status, Created 필드를 설정 + t := &entity.Task{ + Title: b.Title, // 입력받은 Title을 설정 + Status: "todo", // 초기 상태는 "todo"로 설정 + Created: time.Now(), // 생성된 시간을 현재 시간으로 설정 + } + // TaskStore에 Task를 추가하고 ID를 반환 + id, err := store.Tasks.Add(t) + if err != nil { + // Task 추가 중 오류가 발생하면 500 상태 코드와 오류 메시지를 반환 + RespondJSON(ctx, w, &ErrResponse{ + Message: err.Error(), + }, http.StatusInternalServerError) + return + } + // Task의 ID를 JSON 응답으로 반환 + rsp := struct { + ID int `json:"id"` // JSON 응답에서 ID 필드의 이름을 "id"로 설정 + }{ID: int(id)} + RespondJSON(ctx, w, rsp, http.StatusOK) +} diff --git a/_chapter17/section68/handler/add_task_test.go b/_chapter17/section68/handler/add_task_test.go new file mode 100644 index 0000000..df95916 --- /dev/null +++ b/_chapter17/section68/handler/add_task_test.go @@ -0,0 +1,66 @@ +package handler + +import ( + "bytes" + "github.com/go-playground/validator" + "go_todo_app/entity" + "go_todo_app/store" + "go_todo_app/testutil" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAddTask(t *testing.T) { + type want struct { + status int // 예상되는 HTTP 상태 코드 + rspFile string // 예상되는 응답 JSON 파일 경로 + } + //테스트 케이스 정의 + tests := map[string]struct { + reqFile string // 요청 JSON 파일 경로 + want want // 예상되는 응답 + }{ // 익명 구조체 정의 부분 + "ok": { // 정상적인 요청에 대한 테스트 케이스 + reqFile: "testdata/add_task/ok_req.json.golden", + want: want{ + status: http.StatusOK, + rspFile: "testdata/add_task/ok_rsp.json.golden", + }, + }, + "badRequest": { // 잘못된 요청에 대한 테스트 케이스 + reqFile: "testdata/add_task/bad_req.json.golden", + want: want{ + status: http.StatusBadRequest, + rspFile: "testdata/add_task/bad_rsp.json.golden", + }, + }, + } + // 각 테스트 케이스에 대한 반복 실행 + for n, tt := range tests { + tt := tt // tt 변수를 로컬로 복사하여 병렬 실행 시 데이터 경합 방지 + t.Run(n, func(t *testing.T) { + t.Parallel() // 각 테스트 케이스를 병렬로 실행 + + //가짜 응답 기록기 + w := httptest.NewRecorder() + //가짜 요청 생성 + r := httptest.NewRequest( + http.MethodPost, + "/tasks", + bytes.NewReader(testutil.LoadFile(t, tt.reqFile)), + ) + + sut := AddTask{Store: &store.TaskStore{ + Tasks: map[entity.TaskID]*entity.Task{}, // 빈 TaskStore를 사용하여 저장 + }, Validator: validator.New()} // 입력 검증을 위한 validator 생성 + // AddTask 핸들러 실행 + sut.ServeHTTP(w, r) + + // 예상 응답과 비교 + resp := w.Result() + testutil.AssertResponse(t, + resp, tt.want.status, testutil.LoadFile(t, tt.want.rspFile)) + }) + } +} diff --git a/_chapter17/section68/handler/list_task.go b/_chapter17/section68/handler/list_task.go new file mode 100644 index 0000000..6e25513 --- /dev/null +++ b/_chapter17/section68/handler/list_task.go @@ -0,0 +1,43 @@ +package handler + +import ( + "go_todo_app/entity" + "go_todo_app/store" + "net/http" +) + +// 모든 Task를 가져오는 핸들러 +type ListTask struct { + // Task를 저장하는 store + Store *store.TaskStore +} + +// task 구조체는 JSON 데이터 형식을 정의 +type task struct { + ID entity.TaskID `json:"id"` + Title string `json:"title"` + Status entity.TaskStatus `json:"status"` +} + +func (lt *ListTask) ServeHTTP(w http.ResponseWriter, r *http.Request) { + //요청 컨텍스트를 가져옴 + ctx := r.Context() + + // 모든 Task를 가져옴 + tasks := lt.Store.All() + + // Task를 JSON으로 변환 + rsp := []task{} + + // 저장된 각 태스크를 순회하며 rsp에 추가 + for _, t := range tasks { + rsp = append(rsp, task{ + ID: t.ID, + Title: t.Title, + Status: t.Status, + }) + } + + // JSON 형식으로 응답 반환 + RespondJSON(ctx, w, rsp, http.StatusOK) +} diff --git a/_chapter17/section68/handler/response.go b/_chapter17/section68/handler/response.go new file mode 100644 index 0000000..4858ee8 --- /dev/null +++ b/_chapter17/section68/handler/response.go @@ -0,0 +1,42 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +type ErrResponse struct { + Message string `json:"message"` + Details []string `json:"details,omitempty"` +} + +//HTTP 핸들러 내에서 귀찮은 JSON 응답 작성을 간략화한다. + +func RespondJSON(ctx context.Context, w http.ResponseWriter, body any, status int) { + // 응답 헤더에 JSON 형식임을 지정 + w.Header().Set("Content-Type", "application/json; charset=utf-8") + bodyBytes, err := json.Marshal(body) + if err != nil { + fmt.Printf("encode response error: %v", err) + // 서버 오류(500) 상태 코드를 반환합니다. + w.WriteHeader(http.StatusInternalServerError) + + // ErrResponse 구조체를 사용해서 응답을 반환 + rsp := ErrResponse{ + Message: http.StatusText(http.StatusInternalServerError), + } + // 오류 응답을 json 형태로 인코딩하여 클라이언트한테 전송 + if err := json.NewEncoder(w).Encode(rsp); err != nil { + // 인코딩마저 실패하면 로그를 출력 + fmt.Printf("write error response error: %v", err) + } + return + } + + w.WriteHeader(status) + if _, err := fmt.Fprintf(w, "%s", bodyBytes); err != nil { + fmt.Printf("write response error: %v", err) + } +} diff --git a/_chapter17/section68/handler/testdata/add_task/bad_req.json.golden b/_chapter17/section68/handler/testdata/add_task/bad_req.json.golden new file mode 100644 index 0000000..3014ad5 --- /dev/null +++ b/_chapter17/section68/handler/testdata/add_task/bad_req.json.golden @@ -0,0 +1,3 @@ +{ + "title": "Implement a handler" +} \ No newline at end of file diff --git a/_chapter17/section68/handler/testdata/add_task/bad_rsp.json.golden b/_chapter17/section68/handler/testdata/add_task/bad_rsp.json.golden new file mode 100644 index 0000000..b4293df --- /dev/null +++ b/_chapter17/section68/handler/testdata/add_task/bad_rsp.json.golden @@ -0,0 +1,3 @@ +{ + "message": "Key: 'Title' Error:Field validation for 'Title' failed on the 'required' tag" +} \ No newline at end of file diff --git a/_chapter17/section68/handler/testdata/add_task/ok_req.json.golden b/_chapter17/section68/handler/testdata/add_task/ok_req.json.golden new file mode 100644 index 0000000..2be9a23 --- /dev/null +++ b/_chapter17/section68/handler/testdata/add_task/ok_req.json.golden @@ -0,0 +1,3 @@ +{ + "title": "Implement a handler" +} diff --git a/_chapter17/section68/handler/testdata/add_task/ok_rsp.json.golden b/_chapter17/section68/handler/testdata/add_task/ok_rsp.json.golden new file mode 100644 index 0000000..bc1fb0f --- /dev/null +++ b/_chapter17/section68/handler/testdata/add_task/ok_rsp.json.golden @@ -0,0 +1,3 @@ +{ + "id": 1 +} \ No newline at end of file diff --git a/_chapter17/section68/main.go b/_chapter17/section68/main.go new file mode 100644 index 0000000..063aa56 --- /dev/null +++ b/_chapter17/section68/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "fmt" + "go_todo_app/config" + "log" + "net" + "os" +) + +func run(ctx context.Context) error { + cfg, err := config.New() + if err != nil { + return err + } + + l, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.Port)) + if err != nil { + log.Fatalf("failed to listen port %d: %v", cfg.Port, err) + } + url := fmt.Sprintf("http://%s", l.Addr().String()) + log.Printf("start with: %v", url) + + mux := NewMux() // NewMux 함수를 사용하여 HTTP 핸들러를 생성한다. + s := NewServer(l, mux) // NewServer 함수를 사용하여 서버를 생성한다. + return s.Run(ctx) // Run 메서드를 사용하여 서버를 실행한다. + + // 이를 통해서 결합도를 낮춘다! +} + +func main() { + if err := run(context.Background()); err != nil { + log.Printf("failed to terminate server: %v", err) + os.Exit(1) + } +} diff --git a/_chapter17/section68/mux.go b/_chapter17/section68/mux.go new file mode 100644 index 0000000..30677c6 --- /dev/null +++ b/_chapter17/section68/mux.go @@ -0,0 +1,15 @@ +package main + +import "net/http" + +// 어떤 처리를 어떤 URL 패스로 공개할지 라우팅하는 NewMux 함수 구현 +func NewMux() http.Handler { + mux := http.NewServeMux() + // HTTP 서버가 실행중인지 확인하는 /health 엔드포인트 + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + // 정적 분석 오류를 회피하기 위해 명시적으로 반환값을 버린다. + _, _ = w.Write([]byte(`{"status": "ok"}`)) + }) + return mux +} diff --git a/_chapter17/section68/mux_test.go b/_chapter17/section68/mux_test.go new file mode 100644 index 0000000..4d9ccab --- /dev/null +++ b/_chapter17/section68/mux_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewMux(t *testing.T) { + w := httptest.NewRecorder() // 테스트용 레코드 생성 + r := httptest.NewRequest(http.MethodGet, "/health", nil) // 테스트용 요청 생성 + sut := NewMux() // NewMux 함수를 사용하여 테스트 대상을 생성한다. + sut.ServeHTTP(w, r) // ServeHTTP 메서드를 사용하여 요청을 처리한다. + resp := w.Result() // 테스트용 레코드의 결과를 가져온다. + t.Cleanup(func() { _ = resp.Body.Close() }) // 테스트 종료 시 레코드의 바디를 닫는다. + if resp.StatusCode != http.StatusOK { + t.Error("want status code 200, but", resp.StatusCode) + } + got, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + want := `{"status": "ok"}` + if string(got) != want { + t.Errorf("want %q, but got %q", want, got) + } +} diff --git a/_chapter17/section68/server.go b/_chapter17/section68/server.go new file mode 100644 index 0000000..38b2d06 --- /dev/null +++ b/_chapter17/section68/server.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "golang.org/x/sync/errgroup" + "log" + "net" + "net/http" + "os" + "os/signal" + "syscall" +) + +type Server struct { + srv *http.Server + l net.Listener +} + +func NewServer(l net.Listener, mux http.Handler) *Server { + return &Server{ + srv: &http.Server{ + Handler: mux, + }, + l: l, + } +} + +// Server 타입의 Run 메서드 구현 +func (s *Server) Run(ctx context.Context) error { + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + eg, ctx := errgroup.WithContext(ctx) + eg.Go(func() error { + if err := s.srv.Serve(s.l); err != nil && + err != http.ErrServerClosed { + log.Printf("failed to close: %+v", err) + return err + } + return nil + }) + + <-ctx.Done() + if err := s.srv.Shutdown(context.Background()); err != nil { + log.Printf("failed to shutdown: %+v", err) + } + + return eg.Wait() +} diff --git a/_chapter17/section68/server_test.go b/_chapter17/section68/server_test.go new file mode 100644 index 0000000..d1a6e3a --- /dev/null +++ b/_chapter17/section68/server_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "fmt" + "golang.org/x/sync/errgroup" + "io" + "net" + "net/http" + "testing" +) + +func TestServer_Run(t *testing.T) { + + // net/http 에서는 포트 번호에 0을 지정하면 사용 가능한 포트 번호를 동적으로 선택합니다. + l, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatalf("failed to listen port %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + eg, ctx := errgroup.WithContext(ctx) + + mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:]) + }) + + eg.Go(func() error { + s := NewServer(l, mux) + return s.Run(ctx) + }) + + in := "message" + url := fmt.Sprintf("http://%s/%s", l.Addr().String(), in) + // 어떤 포트 번호로 리슨중인지 확인합니다. + t.Logf("try request to %q", url) + rsp, err := http.Get(url) + + // 이후 코드 동일 + if err != nil { + t.Errorf("failed to get: %+v", err) + } + defer rsp.Body.Close() + + got, err := io.ReadAll(rsp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + // HTTP 서버의 반환값을 검증합니다. + want := fmt.Sprintf("Hello, %s!", in) + if string(got) != want { + t.Errorf("want %q, but got %q", want, got) + } + + // run 함수를 종료합니다. + cancel() + if err := eg.Wait(); err != nil { + t.Fatal(err) + } +} diff --git a/_chapter17/section68/store/store.go b/_chapter17/section68/store/store.go new file mode 100644 index 0000000..6f09d45 --- /dev/null +++ b/_chapter17/section68/store/store.go @@ -0,0 +1,38 @@ +package store + +import ( + "errors" + "go_todo_app/entity" +) + +var ( + Tasks = &TaskStore{Tasks: map[entity.TaskID]*entity.Task{}} + ErrNotFound = errors.New("not found") +) + +type TaskStore struct { + LastID entity.TaskID + Tasks map[entity.TaskID]*entity.Task +} + +func (ts *TaskStore) Add(t *entity.Task) (entity.TaskID, error) { + ts.LastID++ + t.ID = ts.LastID + ts.Tasks[t.ID] = t + return t.ID, nil +} + +func (ts *TaskStore) Get(id entity.TaskID) (*entity.Task, error) { + if ts, ok := ts.Tasks[id]; ok { + return ts, nil + } + return nil, ErrNotFound +} + +func (ts *TaskStore) All() entity.Tasks { + tasks := make([]*entity.Task, len(ts.Tasks)) + for i, t := range ts.Tasks { + tasks[i-1] = t + } + return tasks +} diff --git a/_chapter17/section68/testutil/handler.go b/_chapter17/section68/testutil/handler.go new file mode 100644 index 0000000..be14dd5 --- /dev/null +++ b/_chapter17/section68/testutil/handler.go @@ -0,0 +1,62 @@ +package testutil + +import ( + "encoding/json" + "github.com/google/go-cmp/cmp" + "io" + "net/http" + "os" + "testing" +) + +// 두 JSON이 동일한지 확인하는 테스트 헬퍼 함수 + +func AssertJSON(t *testing.T, want, got []byte) { + t.Helper() + var jw, jg any + if err := json.Unmarshal(want, &jw); err != nil { + t.Fatalf("failed to unmarshal want %q: %v", want, err) + } + if err := json.Unmarshal(got, &jg); err != nil { + t.Fatalf("failed to unmarshal got %q: %v", got, err) + } + if diff := cmp.Diff(jw, jg); diff != "" { + t.Errorf("got differs: (-got +want)\n%s", diff) + } +} + +// 예상 상태 코드와 JSON 응답 본문이 일치하는지 확인하는 테스트 헬퍼 함수 + +func AssertResponse(t *testing.T, got *http.Response, status int, body []byte) { + t.Helper() // 테스트 헬퍼 함수임을 표시 + + // 테스트 종료 후 got.Body를 자동으로 닫기 위해 Cleanup 함수를 설정 + t.Cleanup(func() { _ = got.Body.Close() }) + + // 응답 바디를 읽어 gb에 저장 + gb, err := io.ReadAll(got.Body) + if err != nil { + t.Fatal(err) + } + // 실제 상태 코드가 예상 상태 코드와 다른 경우 실패 + if got.StatusCode != status { + t.Fatalf("want status %d, but got %d, body: %q", status, got.StatusCode, gb) + } + + // 예상 본문과 실제 본문이 모두 빈 경우 + if len(gb) == 0 && len(body) == 0 { + // 어느 쪽이든 응답 바디가 없으므로 AssertJSON을 호출하지 않는다. + return + } + // JSON 응답 본문을 비교하여 일치하는지 확인 + AssertJSON(t, body, gb) +} + +func LoadFile(t *testing.T, path string) []byte { + t.Helper() + bt, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read file %q: %v", path, err) + } + return bt +} diff --git a/_chapter17/section70/.air.toml b/_chapter17/section70/.air.toml new file mode 100644 index 0000000..90bc40b --- /dev/null +++ b/_chapter17/section70/.air.toml @@ -0,0 +1,43 @@ +root = '.' +tmp_dir = 'tmp' + +[build] +cmd = "go build -o ./tmp/main ." #Go 프로젝트를 빌드 +bin = "./tmp/main" #빌드된 실행 파일 경로 +#실행 파일 실행 시 필요한 환경 변수와 인자 설정 -> 80번 포트 사용하도록 인수 지정 +full_bin = "APP_ENV=dev APP_USER = air ./tmp/main 80" + +# 파일 변경 감지 설정 -> 변경을 감지할 파일 확장자 +include_ext = ["go", "tpl", "tmpl", "html"] +# 감지하지 않을 디렉터리 목록 +exclude_dir = ["assets", "tmp", "vendor", "frontend/node_modules", "_tools", "cert", "testutil"] +include_dir = [] +exclude_file = [] +exclude_regex = ["_test.go"] +exclude_unchanged = true +exclude_underscore = false +follow_symlink = false + +#로깅 설정 : 로그파일 경로, 파일 변경 감지 후 재빌드 대기 시간 +log = "air.log" +delay = 1000 + +#오류 처리 설정 +stop_on_error = true +send_interrupt = false +kill_delay = 500 + +[log] +time = false + +[color] +#로그 출력 시 사용할 색상 설정 +main = "magenta" +watcher = "cyan" +build = "yellow" +runner = "green" + +#프로그램 종료 시 임시 디렉터리 삭제 여부 +[misc] +clean_on_exit = true + diff --git a/_chapter17/section70/.dockerignore b/_chapter17/section70/.dockerignore new file mode 100644 index 0000000..d6b0aef --- /dev/null +++ b/_chapter17/section70/.dockerignore @@ -0,0 +1,2 @@ +.git +.DS_Store \ No newline at end of file diff --git a/_chapter17/section70/Dockerfile b/_chapter17/section70/Dockerfile new file mode 100644 index 0000000..b81f4b1 --- /dev/null +++ b/_chapter17/section70/Dockerfile @@ -0,0 +1,43 @@ +#최종 배포할 실행 파일 만드는 과정 +#multi-stage build에서 이 단계의 이름을 'deploy-builder'로 지정 +FROM golang:1.23.1-bullseye AS deploy-builder + +#app 디렉토리 생성 및 작업 디렉토리로 설정 +WORKDIR /app + +#종속성 파일(go.mod go.sum)만 복사해서 캐시 활용 +#소스코드가 변경되어도 종속성이 변경되지 않았다면 도커의 캐시 활용 +COPY go.mod go.sum ./ +#프로젝트의 모든 종속성 다운로드 +RUN go mod download + +#전체 소스 파일을 /app으로 복사하고 최적화된 바이너리 생성 +COPY . . +RUN go build -trimpath -ldflags="-w -s" -o app + +# 최종 배포 단계 : 실제 운영 환경에서 실행될 최소한의 이미지 생성 +#경량화된 Degian 이미지 사용 +FROM debian:bullseye-slim AS deploy + +#시스템 패키지 최신화 +RUN apt-get update + +#첫 번째 단계에서 만든 실행 파일만 복사 +COPY --from=deploy-builder /app/app . + +#컨테이너 시작 시 애플리케이션 자동 실행 +CMD ["./app"] + +#개발자의 로컬 환경을 위한 설정 +FROM golang:1.23 AS dev + +#/app 디렉토리를 작업공간으로 설정 +WORKDIR /app + +#air 도구 설치 (코드 변경 시 자동 재빌드 지원) +RUN go install github.com/air-verse/air@latest + +#개발 서버 자동 시작 +CMD ["air"] + +## docker-compose down -v <= 실행중인 컨테이너를 멈추고 매핑된 볼륨들을 제거 \ No newline at end of file diff --git a/_chapter17/section70/Makefile b/_chapter17/section70/Makefile new file mode 100644 index 0000000..f2841c9 --- /dev/null +++ b/_chapter17/section70/Makefile @@ -0,0 +1,29 @@ +.PHONY: help build build-local up down logs ps test +.DEFAULT_GOAL := help + +DOCKER_TAG := latest +build: ## Build the docker image + docker build -t gleaming9/go_todo_app:${DOCKER_TAG} \ + --target deploy ./ + +build-local: ## Build the docker image for local development + docker compose build --no-cache + +up: ## 자동 새로고침을 사용한 도커 컴포즈 실행 + docker compose up -d + +down: ## 도커 컴포즈 종료 + docker compose down + +logs: ## 도커 컴포즈 로그 출력 + docker compose logs -f + +ps: ## 실행중인 컨테이너 확인 + docker compose ps + +test: ## 테스트 실행 + go test -race -shuffle=on ./... + +help: ## 옵션 보기 + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' \ No newline at end of file diff --git a/_chapter17/section70/config/config.go b/_chapter17/section70/config/config.go new file mode 100644 index 0000000..0b6f5f6 --- /dev/null +++ b/_chapter17/section70/config/config.go @@ -0,0 +1,19 @@ +package config + +import "github.com/caarlos0/env/v6" + +// Config 구조체는 애플리케이션 설정을 위한 구조체이다. +// 환경 변수에서 값을 가져와서 필드를 초기화한다. +type Config struct { + Env string `env:"TODO_ENV" envDefault:"dev"` + Port int `env:"PORT" envDefault:"80"` +} + +// New 함수는 Config 구조체를 생성하고 환경 변수를 파싱하여 필드를 초기화 +func New() (*Config, error) { + cfg := &Config{} // config 구조체 초기화 + if err := env.Parse(cfg); err != nil { + return nil, err + } + return cfg, nil +} diff --git a/_chapter17/section70/config/config_test.go b/_chapter17/section70/config/config_test.go new file mode 100644 index 0000000..6ffbbae --- /dev/null +++ b/_chapter17/section70/config/config_test.go @@ -0,0 +1,23 @@ +package config + +import ( + "fmt" + "testing" +) + +func TestNeow(t *testing.T) { + wantPort := 3333 + t.Setenv("PORT", fmt.Sprint(wantPort)) + + got, err := New() + if err != nil { + t.Fatalf("cannot create config: %v", err) + } + if got.Port != wantPort { + t.Errorf("want %d, but %d", wantPort, got.Port) + } + wantEnv := "dev" + if got.Env != wantEnv { + t.Errorf("want %s, but %s", wantEnv, got.Env) + } +} diff --git a/_chapter17/section70/docker-compose.yml b/_chapter17/section70/docker-compose.yml new file mode 100644 index 0000000..16a1ba2 --- /dev/null +++ b/_chapter17/section70/docker-compose.yml @@ -0,0 +1,28 @@ +services: + + # 'app'이라는 이름의 서비스(컨테이너)를 정의 + app: + # 사용할 Docker 이미지 이름 : 'gotodo' + image: gotodo + + build: + #빌드할 파일들이 있는 위치를 지정 : .. <- 한칸 위 폴더에 go.mod 위치 + #go_todo_app 폴더를 컨텍스트로 설정 + context: . + dockerfile: Dockerfile + # target=dev로 설정하여 Dockerfile의 dev stage를 빌드 + args: + target: dev + environment: # 환경 변수 설정 + TODO_ENV: dev + PORT: 8080 + + # 로컬 컴퓨터의 파일과 컨테이너 안의 파일을 연결, 코드 수정 시 바로 컨테이너에 반영 + volumes: + - .:/app # go_todo_app 루트 디렉터리를 컨테이너의 /app에 마운트 + + # 컨테이너가 사용할 포트를 지정 + ports: + # 웹 브라우저에서 localhost:18000으로 접속하면 컨테이너의 80번 포트로 연결 + # 왼쪽은 내 컴퓨터의 포트, 오른쪽은 컨테이너 안의 포트 + - "18000:8080" \ No newline at end of file diff --git a/_chapter17/section70/entity/task.go b/_chapter17/section70/entity/task.go new file mode 100644 index 0000000..ca57051 --- /dev/null +++ b/_chapter17/section70/entity/task.go @@ -0,0 +1,21 @@ +package entity + +import "time" + +type TaskID int64 +type TaskStatus string + +const ( + TaskStatusTodo TaskStatus = "todo" + TaskStatusDoing TaskStatus = "doing" + TaskStatusDone TaskStatus = "done" +) + +type Task struct { + ID TaskID `json:"id"` + Title string `json:"title"` + Status TaskStatus `json:"status"` + Created time.Time `json:"created"` +} + +type Tasks []*Task diff --git a/_chapter17/section70/go.mod b/_chapter17/section70/go.mod new file mode 100644 index 0000000..eb9601d --- /dev/null +++ b/_chapter17/section70/go.mod @@ -0,0 +1,14 @@ +module go_todo_app + +go 1.23.1 + +require ( + github.com/caarlos0/env/v6 v6.10.1 // indirect + github.com/go-chi/chi/v5 v5.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator v9.31.0+incompatible // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + golang.org/x/sync v0.9.0 // indirect +) diff --git a/_chapter17/section70/go.sum b/_chapter17/section70/go.sum new file mode 100644 index 0000000..f85d4a2 --- /dev/null +++ b/_chapter17/section70/go.sum @@ -0,0 +1,8 @@ +github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= diff --git a/_chapter17/section70/handler/add_task.go b/_chapter17/section70/handler/add_task.go new file mode 100644 index 0000000..6acd983 --- /dev/null +++ b/_chapter17/section70/handler/add_task.go @@ -0,0 +1,62 @@ +package handler + +import ( + "encoding/json" + "github.com/go-playground/validator" + "go_todo_app/entity" + "go_todo_app/store" + "net/http" + "time" +) + +type AddTask struct { + Store *store.TaskStore + Validator *validator.Validate +} + +func (at *AddTask) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + //요청 본문을 JSON으로 파싱하여 Title 필드를 가져옵니다. + var b struct { + // Title 필드는 필수입니다. + Title string `json:"title" validate:"required"` + } + if err := json.NewDecoder(r.Body).Decode(&b); err != nil { + // JSON 파싱 중 오류가 발생하면 500 상태 코드와 오류 메시지를 반환 + RespondJSON(ctx, w, &ErrResponse{ + Message: err.Error(), + }, http.StatusBadRequest) + return + } + // Validator를 사용하여 Title 필드가 있는지 검증 + // Unmarshal한 타입에 대한 검증을 위해 Validator를 사용, JSON 구조가 방대하거나 복잡한 경우 자주 사용 + if err := at.Validator.Struct(b); err != nil { + // 필수 필드가 없으면 400 상태 코드와 오류 메시지를 반환 + RespondJSON(ctx, w, &ErrResponse{ + Message: err.Error(), + }, http.StatusBadRequest) + return + } + + // Task 구조체를 생성하여, Title, Status, Created 필드를 설정 + t := &entity.Task{ + Title: b.Title, // 입력받은 Title을 설정 + Status: "todo", // 초기 상태는 "todo"로 설정 + Created: time.Now(), // 생성된 시간을 현재 시간으로 설정 + } + // TaskStore에 Task를 추가하고 ID를 반환 + id, err := store.Tasks.Add(t) + if err != nil { + // Task 추가 중 오류가 발생하면 500 상태 코드와 오류 메시지를 반환 + RespondJSON(ctx, w, &ErrResponse{ + Message: err.Error(), + }, http.StatusInternalServerError) + return + } + // Task의 ID를 JSON 응답으로 반환 + rsp := struct { + ID int `json:"id"` // JSON 응답에서 ID 필드의 이름을 "id"로 설정 + }{ID: int(id)} + RespondJSON(ctx, w, rsp, http.StatusOK) +} diff --git a/_chapter17/section70/handler/add_task_test.go b/_chapter17/section70/handler/add_task_test.go new file mode 100644 index 0000000..df95916 --- /dev/null +++ b/_chapter17/section70/handler/add_task_test.go @@ -0,0 +1,66 @@ +package handler + +import ( + "bytes" + "github.com/go-playground/validator" + "go_todo_app/entity" + "go_todo_app/store" + "go_todo_app/testutil" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAddTask(t *testing.T) { + type want struct { + status int // 예상되는 HTTP 상태 코드 + rspFile string // 예상되는 응답 JSON 파일 경로 + } + //테스트 케이스 정의 + tests := map[string]struct { + reqFile string // 요청 JSON 파일 경로 + want want // 예상되는 응답 + }{ // 익명 구조체 정의 부분 + "ok": { // 정상적인 요청에 대한 테스트 케이스 + reqFile: "testdata/add_task/ok_req.json.golden", + want: want{ + status: http.StatusOK, + rspFile: "testdata/add_task/ok_rsp.json.golden", + }, + }, + "badRequest": { // 잘못된 요청에 대한 테스트 케이스 + reqFile: "testdata/add_task/bad_req.json.golden", + want: want{ + status: http.StatusBadRequest, + rspFile: "testdata/add_task/bad_rsp.json.golden", + }, + }, + } + // 각 테스트 케이스에 대한 반복 실행 + for n, tt := range tests { + tt := tt // tt 변수를 로컬로 복사하여 병렬 실행 시 데이터 경합 방지 + t.Run(n, func(t *testing.T) { + t.Parallel() // 각 테스트 케이스를 병렬로 실행 + + //가짜 응답 기록기 + w := httptest.NewRecorder() + //가짜 요청 생성 + r := httptest.NewRequest( + http.MethodPost, + "/tasks", + bytes.NewReader(testutil.LoadFile(t, tt.reqFile)), + ) + + sut := AddTask{Store: &store.TaskStore{ + Tasks: map[entity.TaskID]*entity.Task{}, // 빈 TaskStore를 사용하여 저장 + }, Validator: validator.New()} // 입력 검증을 위한 validator 생성 + // AddTask 핸들러 실행 + sut.ServeHTTP(w, r) + + // 예상 응답과 비교 + resp := w.Result() + testutil.AssertResponse(t, + resp, tt.want.status, testutil.LoadFile(t, tt.want.rspFile)) + }) + } +} diff --git a/_chapter17/section70/handler/list_task.go b/_chapter17/section70/handler/list_task.go new file mode 100644 index 0000000..6e25513 --- /dev/null +++ b/_chapter17/section70/handler/list_task.go @@ -0,0 +1,43 @@ +package handler + +import ( + "go_todo_app/entity" + "go_todo_app/store" + "net/http" +) + +// 모든 Task를 가져오는 핸들러 +type ListTask struct { + // Task를 저장하는 store + Store *store.TaskStore +} + +// task 구조체는 JSON 데이터 형식을 정의 +type task struct { + ID entity.TaskID `json:"id"` + Title string `json:"title"` + Status entity.TaskStatus `json:"status"` +} + +func (lt *ListTask) ServeHTTP(w http.ResponseWriter, r *http.Request) { + //요청 컨텍스트를 가져옴 + ctx := r.Context() + + // 모든 Task를 가져옴 + tasks := lt.Store.All() + + // Task를 JSON으로 변환 + rsp := []task{} + + // 저장된 각 태스크를 순회하며 rsp에 추가 + for _, t := range tasks { + rsp = append(rsp, task{ + ID: t.ID, + Title: t.Title, + Status: t.Status, + }) + } + + // JSON 형식으로 응답 반환 + RespondJSON(ctx, w, rsp, http.StatusOK) +} diff --git a/_chapter17/section70/handler/response.go b/_chapter17/section70/handler/response.go new file mode 100644 index 0000000..4858ee8 --- /dev/null +++ b/_chapter17/section70/handler/response.go @@ -0,0 +1,42 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +type ErrResponse struct { + Message string `json:"message"` + Details []string `json:"details,omitempty"` +} + +//HTTP 핸들러 내에서 귀찮은 JSON 응답 작성을 간략화한다. + +func RespondJSON(ctx context.Context, w http.ResponseWriter, body any, status int) { + // 응답 헤더에 JSON 형식임을 지정 + w.Header().Set("Content-Type", "application/json; charset=utf-8") + bodyBytes, err := json.Marshal(body) + if err != nil { + fmt.Printf("encode response error: %v", err) + // 서버 오류(500) 상태 코드를 반환합니다. + w.WriteHeader(http.StatusInternalServerError) + + // ErrResponse 구조체를 사용해서 응답을 반환 + rsp := ErrResponse{ + Message: http.StatusText(http.StatusInternalServerError), + } + // 오류 응답을 json 형태로 인코딩하여 클라이언트한테 전송 + if err := json.NewEncoder(w).Encode(rsp); err != nil { + // 인코딩마저 실패하면 로그를 출력 + fmt.Printf("write error response error: %v", err) + } + return + } + + w.WriteHeader(status) + if _, err := fmt.Fprintf(w, "%s", bodyBytes); err != nil { + fmt.Printf("write response error: %v", err) + } +} diff --git a/_chapter17/section70/handler/testdata/add_task/bad_req.json.golden b/_chapter17/section70/handler/testdata/add_task/bad_req.json.golden new file mode 100644 index 0000000..3014ad5 --- /dev/null +++ b/_chapter17/section70/handler/testdata/add_task/bad_req.json.golden @@ -0,0 +1,3 @@ +{ + "title": "Implement a handler" +} \ No newline at end of file diff --git a/_chapter17/section70/handler/testdata/add_task/bad_rsp.json.golden b/_chapter17/section70/handler/testdata/add_task/bad_rsp.json.golden new file mode 100644 index 0000000..b4293df --- /dev/null +++ b/_chapter17/section70/handler/testdata/add_task/bad_rsp.json.golden @@ -0,0 +1,3 @@ +{ + "message": "Key: 'Title' Error:Field validation for 'Title' failed on the 'required' tag" +} \ No newline at end of file diff --git a/_chapter17/section70/handler/testdata/add_task/ok_req.json.golden b/_chapter17/section70/handler/testdata/add_task/ok_req.json.golden new file mode 100644 index 0000000..2be9a23 --- /dev/null +++ b/_chapter17/section70/handler/testdata/add_task/ok_req.json.golden @@ -0,0 +1,3 @@ +{ + "title": "Implement a handler" +} diff --git a/_chapter17/section70/handler/testdata/add_task/ok_rsp.json.golden b/_chapter17/section70/handler/testdata/add_task/ok_rsp.json.golden new file mode 100644 index 0000000..bc1fb0f --- /dev/null +++ b/_chapter17/section70/handler/testdata/add_task/ok_rsp.json.golden @@ -0,0 +1,3 @@ +{ + "id": 1 +} \ No newline at end of file diff --git a/_chapter17/section70/main.go b/_chapter17/section70/main.go new file mode 100644 index 0000000..063aa56 --- /dev/null +++ b/_chapter17/section70/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "fmt" + "go_todo_app/config" + "log" + "net" + "os" +) + +func run(ctx context.Context) error { + cfg, err := config.New() + if err != nil { + return err + } + + l, err := net.Listen("tcp", fmt.Sprintf(":%d", cfg.Port)) + if err != nil { + log.Fatalf("failed to listen port %d: %v", cfg.Port, err) + } + url := fmt.Sprintf("http://%s", l.Addr().String()) + log.Printf("start with: %v", url) + + mux := NewMux() // NewMux 함수를 사용하여 HTTP 핸들러를 생성한다. + s := NewServer(l, mux) // NewServer 함수를 사용하여 서버를 생성한다. + return s.Run(ctx) // Run 메서드를 사용하여 서버를 실행한다. + + // 이를 통해서 결합도를 낮춘다! +} + +func main() { + if err := run(context.Background()); err != nil { + log.Printf("failed to terminate server: %v", err) + os.Exit(1) + } +} diff --git a/_chapter17/section70/mux.go b/_chapter17/section70/mux.go new file mode 100644 index 0000000..c7c00cb --- /dev/null +++ b/_chapter17/section70/mux.go @@ -0,0 +1,40 @@ +package main + +import ( + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator" + "go_todo_app/handler" + "go_todo_app/store" + "net/http" +) + +// 어떤 처리를 어떤 URL 패스로 공개할지 라우팅하는 NewMux 함수 구현 +func NewMux() http.Handler { + mux := chi.NewRouter() // 새로운 라우터 인스턴스 생성 + + // /health 경로에 대한 핸들러를 추가하여 애플리케이션 상태를 확인 + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"status": "ok"}`)) // JSON 형식의 상태 응답을 반환 + }) + + // 입력값 검증을 위한 validator 인스턴스 생성 + v := validator.New() + + // /tasks 경로에 POST 요청을 처리하기 위한 AddTask 핸들러 추가 + mux.Handle("/tasks", &handler.AddTask{Store: store.Tasks, Validator: v}) + + // AddTask 핸들러 인스턴스를 생성하여 /tasks 경로에 POST 요청 처리 + at := &handler.AddTask{Store: store.Tasks, Validator: v} + + // /tasks 경로에 POST 요청을 처리하기 위한 AddTask 핸들러 추가 + mux.Post("/tasks", at.ServeHTTP) + + // ListTask 핸들러 인스턴스를 생성하여 /tasks 경로에 GET 요청 처리 + lt := &handler.ListTask{Store: store.Tasks} + // /tasks 경로에 GET 요청이 들어오면 lt.ServeHTTP를 호출하여 모든 Task 목록을 반환 + mux.Get("/tasks", lt.ServeHTTP) + + // 설정이 완료된 라우터 반환 + return mux +} diff --git a/_chapter17/section70/mux_test.go b/_chapter17/section70/mux_test.go new file mode 100644 index 0000000..4d9ccab --- /dev/null +++ b/_chapter17/section70/mux_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestNewMux(t *testing.T) { + w := httptest.NewRecorder() // 테스트용 레코드 생성 + r := httptest.NewRequest(http.MethodGet, "/health", nil) // 테스트용 요청 생성 + sut := NewMux() // NewMux 함수를 사용하여 테스트 대상을 생성한다. + sut.ServeHTTP(w, r) // ServeHTTP 메서드를 사용하여 요청을 처리한다. + resp := w.Result() // 테스트용 레코드의 결과를 가져온다. + t.Cleanup(func() { _ = resp.Body.Close() }) // 테스트 종료 시 레코드의 바디를 닫는다. + if resp.StatusCode != http.StatusOK { + t.Error("want status code 200, but", resp.StatusCode) + } + got, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + want := `{"status": "ok"}` + if string(got) != want { + t.Errorf("want %q, but got %q", want, got) + } +} diff --git a/_chapter17/section70/server.go b/_chapter17/section70/server.go new file mode 100644 index 0000000..38b2d06 --- /dev/null +++ b/_chapter17/section70/server.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "golang.org/x/sync/errgroup" + "log" + "net" + "net/http" + "os" + "os/signal" + "syscall" +) + +type Server struct { + srv *http.Server + l net.Listener +} + +func NewServer(l net.Listener, mux http.Handler) *Server { + return &Server{ + srv: &http.Server{ + Handler: mux, + }, + l: l, + } +} + +// Server 타입의 Run 메서드 구현 +func (s *Server) Run(ctx context.Context) error { + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + eg, ctx := errgroup.WithContext(ctx) + eg.Go(func() error { + if err := s.srv.Serve(s.l); err != nil && + err != http.ErrServerClosed { + log.Printf("failed to close: %+v", err) + return err + } + return nil + }) + + <-ctx.Done() + if err := s.srv.Shutdown(context.Background()); err != nil { + log.Printf("failed to shutdown: %+v", err) + } + + return eg.Wait() +} diff --git a/_chapter17/section70/server_test.go b/_chapter17/section70/server_test.go new file mode 100644 index 0000000..d1a6e3a --- /dev/null +++ b/_chapter17/section70/server_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "fmt" + "golang.org/x/sync/errgroup" + "io" + "net" + "net/http" + "testing" +) + +func TestServer_Run(t *testing.T) { + + // net/http 에서는 포트 번호에 0을 지정하면 사용 가능한 포트 번호를 동적으로 선택합니다. + l, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatalf("failed to listen port %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + eg, ctx := errgroup.WithContext(ctx) + + mux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:]) + }) + + eg.Go(func() error { + s := NewServer(l, mux) + return s.Run(ctx) + }) + + in := "message" + url := fmt.Sprintf("http://%s/%s", l.Addr().String(), in) + // 어떤 포트 번호로 리슨중인지 확인합니다. + t.Logf("try request to %q", url) + rsp, err := http.Get(url) + + // 이후 코드 동일 + if err != nil { + t.Errorf("failed to get: %+v", err) + } + defer rsp.Body.Close() + + got, err := io.ReadAll(rsp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + // HTTP 서버의 반환값을 검증합니다. + want := fmt.Sprintf("Hello, %s!", in) + if string(got) != want { + t.Errorf("want %q, but got %q", want, got) + } + + // run 함수를 종료합니다. + cancel() + if err := eg.Wait(); err != nil { + t.Fatal(err) + } +} diff --git a/_chapter17/section70/store/store.go b/_chapter17/section70/store/store.go new file mode 100644 index 0000000..6f09d45 --- /dev/null +++ b/_chapter17/section70/store/store.go @@ -0,0 +1,38 @@ +package store + +import ( + "errors" + "go_todo_app/entity" +) + +var ( + Tasks = &TaskStore{Tasks: map[entity.TaskID]*entity.Task{}} + ErrNotFound = errors.New("not found") +) + +type TaskStore struct { + LastID entity.TaskID + Tasks map[entity.TaskID]*entity.Task +} + +func (ts *TaskStore) Add(t *entity.Task) (entity.TaskID, error) { + ts.LastID++ + t.ID = ts.LastID + ts.Tasks[t.ID] = t + return t.ID, nil +} + +func (ts *TaskStore) Get(id entity.TaskID) (*entity.Task, error) { + if ts, ok := ts.Tasks[id]; ok { + return ts, nil + } + return nil, ErrNotFound +} + +func (ts *TaskStore) All() entity.Tasks { + tasks := make([]*entity.Task, len(ts.Tasks)) + for i, t := range ts.Tasks { + tasks[i-1] = t + } + return tasks +} diff --git a/_chapter17/section70/testutil/handler.go b/_chapter17/section70/testutil/handler.go new file mode 100644 index 0000000..be14dd5 --- /dev/null +++ b/_chapter17/section70/testutil/handler.go @@ -0,0 +1,62 @@ +package testutil + +import ( + "encoding/json" + "github.com/google/go-cmp/cmp" + "io" + "net/http" + "os" + "testing" +) + +// 두 JSON이 동일한지 확인하는 테스트 헬퍼 함수 + +func AssertJSON(t *testing.T, want, got []byte) { + t.Helper() + var jw, jg any + if err := json.Unmarshal(want, &jw); err != nil { + t.Fatalf("failed to unmarshal want %q: %v", want, err) + } + if err := json.Unmarshal(got, &jg); err != nil { + t.Fatalf("failed to unmarshal got %q: %v", got, err) + } + if diff := cmp.Diff(jw, jg); diff != "" { + t.Errorf("got differs: (-got +want)\n%s", diff) + } +} + +// 예상 상태 코드와 JSON 응답 본문이 일치하는지 확인하는 테스트 헬퍼 함수 + +func AssertResponse(t *testing.T, got *http.Response, status int, body []byte) { + t.Helper() // 테스트 헬퍼 함수임을 표시 + + // 테스트 종료 후 got.Body를 자동으로 닫기 위해 Cleanup 함수를 설정 + t.Cleanup(func() { _ = got.Body.Close() }) + + // 응답 바디를 읽어 gb에 저장 + gb, err := io.ReadAll(got.Body) + if err != nil { + t.Fatal(err) + } + // 실제 상태 코드가 예상 상태 코드와 다른 경우 실패 + if got.StatusCode != status { + t.Fatalf("want status %d, but got %d, body: %q", status, got.StatusCode, gb) + } + + // 예상 본문과 실제 본문이 모두 빈 경우 + if len(gb) == 0 && len(body) == 0 { + // 어느 쪽이든 응답 바디가 없으므로 AssertJSON을 호출하지 않는다. + return + } + // JSON 응답 본문을 비교하여 일치하는지 확인 + AssertJSON(t, body, gb) +} + +func LoadFile(t *testing.T, path string) []byte { + t.Helper() + bt, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read file %q: %v", path, err) + } + return bt +} diff --git a/go.mod b/go.mod index 83fc570..eb9601d 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.23.1 require ( github.com/caarlos0/env/v6 v6.10.1 // indirect + github.com/go-chi/chi/v5 v5.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator v9.31.0+incompatible // indirect diff --git a/go.sum b/go.sum index f4b0e56..a84c0a6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II= github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= diff --git a/handler/add_task.go b/handler/add_task.go index d57b34b..6acd983 100644 --- a/handler/add_task.go +++ b/handler/add_task.go @@ -1,11 +1,62 @@ package handler import ( + "encoding/json" "github.com/go-playground/validator" + "go_todo_app/entity" "go_todo_app/store" + "net/http" + "time" ) type AddTask struct { Store *store.TaskStore Validator *validator.Validate } + +func (at *AddTask) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + //요청 본문을 JSON으로 파싱하여 Title 필드를 가져옵니다. + var b struct { + // Title 필드는 필수입니다. + Title string `json:"title" validate:"required"` + } + if err := json.NewDecoder(r.Body).Decode(&b); err != nil { + // JSON 파싱 중 오류가 발생하면 500 상태 코드와 오류 메시지를 반환 + RespondJSON(ctx, w, &ErrResponse{ + Message: err.Error(), + }, http.StatusBadRequest) + return + } + // Validator를 사용하여 Title 필드가 있는지 검증 + // Unmarshal한 타입에 대한 검증을 위해 Validator를 사용, JSON 구조가 방대하거나 복잡한 경우 자주 사용 + if err := at.Validator.Struct(b); err != nil { + // 필수 필드가 없으면 400 상태 코드와 오류 메시지를 반환 + RespondJSON(ctx, w, &ErrResponse{ + Message: err.Error(), + }, http.StatusBadRequest) + return + } + + // Task 구조체를 생성하여, Title, Status, Created 필드를 설정 + t := &entity.Task{ + Title: b.Title, // 입력받은 Title을 설정 + Status: "todo", // 초기 상태는 "todo"로 설정 + Created: time.Now(), // 생성된 시간을 현재 시간으로 설정 + } + // TaskStore에 Task를 추가하고 ID를 반환 + id, err := store.Tasks.Add(t) + if err != nil { + // Task 추가 중 오류가 발생하면 500 상태 코드와 오류 메시지를 반환 + RespondJSON(ctx, w, &ErrResponse{ + Message: err.Error(), + }, http.StatusInternalServerError) + return + } + // Task의 ID를 JSON 응답으로 반환 + rsp := struct { + ID int `json:"id"` // JSON 응답에서 ID 필드의 이름을 "id"로 설정 + }{ID: int(id)} + RespondJSON(ctx, w, rsp, http.StatusOK) +} diff --git a/handler/add_task_test.go b/handler/add_task_test.go new file mode 100644 index 0000000..df95916 --- /dev/null +++ b/handler/add_task_test.go @@ -0,0 +1,66 @@ +package handler + +import ( + "bytes" + "github.com/go-playground/validator" + "go_todo_app/entity" + "go_todo_app/store" + "go_todo_app/testutil" + "net/http" + "net/http/httptest" + "testing" +) + +func TestAddTask(t *testing.T) { + type want struct { + status int // 예상되는 HTTP 상태 코드 + rspFile string // 예상되는 응답 JSON 파일 경로 + } + //테스트 케이스 정의 + tests := map[string]struct { + reqFile string // 요청 JSON 파일 경로 + want want // 예상되는 응답 + }{ // 익명 구조체 정의 부분 + "ok": { // 정상적인 요청에 대한 테스트 케이스 + reqFile: "testdata/add_task/ok_req.json.golden", + want: want{ + status: http.StatusOK, + rspFile: "testdata/add_task/ok_rsp.json.golden", + }, + }, + "badRequest": { // 잘못된 요청에 대한 테스트 케이스 + reqFile: "testdata/add_task/bad_req.json.golden", + want: want{ + status: http.StatusBadRequest, + rspFile: "testdata/add_task/bad_rsp.json.golden", + }, + }, + } + // 각 테스트 케이스에 대한 반복 실행 + for n, tt := range tests { + tt := tt // tt 변수를 로컬로 복사하여 병렬 실행 시 데이터 경합 방지 + t.Run(n, func(t *testing.T) { + t.Parallel() // 각 테스트 케이스를 병렬로 실행 + + //가짜 응답 기록기 + w := httptest.NewRecorder() + //가짜 요청 생성 + r := httptest.NewRequest( + http.MethodPost, + "/tasks", + bytes.NewReader(testutil.LoadFile(t, tt.reqFile)), + ) + + sut := AddTask{Store: &store.TaskStore{ + Tasks: map[entity.TaskID]*entity.Task{}, // 빈 TaskStore를 사용하여 저장 + }, Validator: validator.New()} // 입력 검증을 위한 validator 생성 + // AddTask 핸들러 실행 + sut.ServeHTTP(w, r) + + // 예상 응답과 비교 + resp := w.Result() + testutil.AssertResponse(t, + resp, tt.want.status, testutil.LoadFile(t, tt.want.rspFile)) + }) + } +} diff --git a/handler/list_task.go b/handler/list_task.go new file mode 100644 index 0000000..6e25513 --- /dev/null +++ b/handler/list_task.go @@ -0,0 +1,43 @@ +package handler + +import ( + "go_todo_app/entity" + "go_todo_app/store" + "net/http" +) + +// 모든 Task를 가져오는 핸들러 +type ListTask struct { + // Task를 저장하는 store + Store *store.TaskStore +} + +// task 구조체는 JSON 데이터 형식을 정의 +type task struct { + ID entity.TaskID `json:"id"` + Title string `json:"title"` + Status entity.TaskStatus `json:"status"` +} + +func (lt *ListTask) ServeHTTP(w http.ResponseWriter, r *http.Request) { + //요청 컨텍스트를 가져옴 + ctx := r.Context() + + // 모든 Task를 가져옴 + tasks := lt.Store.All() + + // Task를 JSON으로 변환 + rsp := []task{} + + // 저장된 각 태스크를 순회하며 rsp에 추가 + for _, t := range tasks { + rsp = append(rsp, task{ + ID: t.ID, + Title: t.Title, + Status: t.Status, + }) + } + + // JSON 형식으로 응답 반환 + RespondJSON(ctx, w, rsp, http.StatusOK) +} diff --git a/handler/testdata/add_task/bad_req.json.golden b/handler/testdata/add_task/bad_req.json.golden new file mode 100644 index 0000000..3014ad5 --- /dev/null +++ b/handler/testdata/add_task/bad_req.json.golden @@ -0,0 +1,3 @@ +{ + "title": "Implement a handler" +} \ No newline at end of file diff --git a/handler/testdata/add_task/bad_rsp.json.golden b/handler/testdata/add_task/bad_rsp.json.golden new file mode 100644 index 0000000..b4293df --- /dev/null +++ b/handler/testdata/add_task/bad_rsp.json.golden @@ -0,0 +1,3 @@ +{ + "message": "Key: 'Title' Error:Field validation for 'Title' failed on the 'required' tag" +} \ No newline at end of file diff --git a/handler/testdata/add_task/ok_req.json.golden b/handler/testdata/add_task/ok_req.json.golden new file mode 100644 index 0000000..2be9a23 --- /dev/null +++ b/handler/testdata/add_task/ok_req.json.golden @@ -0,0 +1,3 @@ +{ + "title": "Implement a handler" +} diff --git a/handler/testdata/add_task/ok_rsp.json.golden b/handler/testdata/add_task/ok_rsp.json.golden new file mode 100644 index 0000000..bc1fb0f --- /dev/null +++ b/handler/testdata/add_task/ok_rsp.json.golden @@ -0,0 +1,3 @@ +{ + "id": 1 +} \ No newline at end of file diff --git a/mux.go b/mux.go index 30677c6..c7c00cb 100644 --- a/mux.go +++ b/mux.go @@ -1,15 +1,40 @@ package main -import "net/http" +import ( + "github.com/go-chi/chi/v5" + "github.com/go-playground/validator" + "go_todo_app/handler" + "go_todo_app/store" + "net/http" +) // 어떤 처리를 어떤 URL 패스로 공개할지 라우팅하는 NewMux 함수 구현 func NewMux() http.Handler { - mux := http.NewServeMux() - // HTTP 서버가 실행중인지 확인하는 /health 엔드포인트 + mux := chi.NewRouter() // 새로운 라우터 인스턴스 생성 + + // /health 경로에 대한 핸들러를 추가하여 애플리케이션 상태를 확인 mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") - // 정적 분석 오류를 회피하기 위해 명시적으로 반환값을 버린다. - _, _ = w.Write([]byte(`{"status": "ok"}`)) + _, _ = w.Write([]byte(`{"status": "ok"}`)) // JSON 형식의 상태 응답을 반환 }) + + // 입력값 검증을 위한 validator 인스턴스 생성 + v := validator.New() + + // /tasks 경로에 POST 요청을 처리하기 위한 AddTask 핸들러 추가 + mux.Handle("/tasks", &handler.AddTask{Store: store.Tasks, Validator: v}) + + // AddTask 핸들러 인스턴스를 생성하여 /tasks 경로에 POST 요청 처리 + at := &handler.AddTask{Store: store.Tasks, Validator: v} + + // /tasks 경로에 POST 요청을 처리하기 위한 AddTask 핸들러 추가 + mux.Post("/tasks", at.ServeHTTP) + + // ListTask 핸들러 인스턴스를 생성하여 /tasks 경로에 GET 요청 처리 + lt := &handler.ListTask{Store: store.Tasks} + // /tasks 경로에 GET 요청이 들어오면 lt.ServeHTTP를 호출하여 모든 Task 목록을 반환 + mux.Get("/tasks", lt.ServeHTTP) + + // 설정이 완료된 라우터 반환 return mux }