diff --git a/.github/workflows/golangci.yml b/.github/workflows/golangci.yml new file mode 100644 index 0000000..6a9fb1d --- /dev/null +++ b/.github/workflows/golangci.yml @@ -0,0 +1,34 @@ +# 워크플로우 트리거 조건 설정 +on: + + pull_request: + paths: + - "**.go" + - .github/workflows/golangci.yml + +jobs: + golangci-lint: + name: golangci-lint + runs-on: ubuntu-latest + steps: + # 1단계: 코드 체크아웃 + - name : Check out code into the Go module directory + uses: actions/checkout@v3 + + # 2단계: golangci-lint 실행 + - name: golangci-lint + uses: reviewdog/action-golangci-lint@v2 + with: + # GitHub Actions에서 자동으로 제공하는 토큰 + github_token: '${{ secrets.GITHUB_TOKEN }}' + + # golangci-lint 실행 옵션 + golangci_lint_flags: "--config=./.golangci.yml ./..." + # --config: 설정 파일 위치 지정 + # ./...: 현재 디렉토리와 모든 하위 디렉토리의 코드를 검사 + + # lint에서 에러가 발생하면 워크플로우를 실패 처리 + fail_on_error: true + + # 리뷰 결과를 GitHub PR에 코멘트로 남김 + reporter: "github-pr-review" \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..d46c8de --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +# 워크플로우 트리거 설정 +on: + # main 브랜치에 푸시할 때만 워크플로우가 실행되도록 설정 + push: + branches: + - "main" + # 모든 브랜츠에서 PR이 생성되거나 업데이트 될 때 실행 + pull_request: + +# GitHub Actions UI에서 표시될 워크플로우 이름 +name: test +#실행할 작업들을 정의 +jobs: + test: + # Ubuntu 최신 버전을 실행 환경으로 사용 + runs-on: ubuntu-latest + + steps: + # Go 언어 설치 및 설정 + - uses: actions/setup-go@v3 + with: + go-version: '>=1.18' + + # 레포지토리 코드 체크아웃 + - uses: actions/checkout@v3 # 현재 레포지토리의 코드를 가져오는 액션 + + # Go 테스트 실행 + - run: go test ./... -coverprofile=coverage.out + # ./... : 현재 디렉토리와 모든 하위 디렉토리의 테스트 실행 + # -coverprofile=coverage.out : 테스트 결과를 coverage.out 파일에 저장 + + # 테스트 커버리지 리포트 생성 + - name: report coverage + uses: k1LoW/octocov-action@v0 #octocov 액션 사용 + # octocov는 테스트 커버리지를 분석하고 시각화된 리포트를 생성 diff --git a/_chapter14/section56/main.go b/_chapter14/section56/main.go index dd0c05f..57921fd 100644 --- a/_chapter14/section56/main.go +++ b/_chapter14/section56/main.go @@ -1,4 +1,4 @@ -package main +package section56 import ( "context" diff --git a/_chapter14/section56/main_test.go b/_chapter14/section56/main_test.go index cdd6cb4..c69f82e 100644 --- a/_chapter14/section56/main_test.go +++ b/_chapter14/section56/main_test.go @@ -1,4 +1,4 @@ -package main +package section56 import ( "context" diff --git a/_chapter15/.air.toml b/_chapter15/.air.toml new file mode 100644 index 0000000..90bc40b --- /dev/null +++ b/_chapter15/.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/_chapter15/.dockerignore b/_chapter15/.dockerignore new file mode 100644 index 0000000..d6b0aef --- /dev/null +++ b/_chapter15/.dockerignore @@ -0,0 +1,2 @@ +.git +.DS_Store \ No newline at end of file diff --git a/_chapter15/Dockerfile b/_chapter15/Dockerfile new file mode 100644 index 0000000..564430c --- /dev/null +++ b/_chapter15/Dockerfile @@ -0,0 +1,42 @@ +#최종 배포할 실행 파일 만드는 과정 +#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 -trimmpath -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"] + diff --git a/_chapter15/Makefile b/_chapter15/Makefile new file mode 100644 index 0000000..c607d59 --- /dev/null +++ b/_chapter15/Makefile @@ -0,0 +1,28 @@ +.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/_chapter15/docker-compose.yml b/_chapter15/docker-compose.yml new file mode 100644 index 0000000..456a51f --- /dev/null +++ b/_chapter15/docker-compose.yml @@ -0,0 +1,25 @@ +services: + + # 'app'이라는 이름의 서비스(컨테이너)를 정의 + app: + # 사용할 Docker 이미지 이름 : 'gotodo' + image: gotodo + + build: + #빌드할 파일들이 있는 위치를 지정 : .. <- 한칸 위 폴더에 go.mod 위치 + #go_todo_app 폴더를 컨텍스트로 설정 + context: .. + dockerfile: _chapter15/Dockerfile + # target=dev로 설정하여 Dockerfile의 dev stage를 빌드 + args: + target: dev + + # 로컬 컴퓨터의 파일과 컨테이너 안의 파일을 연결, 코드 수정 시 바로 컨테이너에 반영 + volumes: + - ../:/app # go_todo_app 루트 디렉터리를 컨테이너의 /app에 마운트 + + # 컨테이너가 사용할 포트를 지정 + ports: + # 웹 브라우저에서 localhost:18000으로 접속하면 컨테이너의 80번 포트로 연결 + # 왼쪽은 내 컴퓨터의 포트, 오른쪽은 컨테이너 안의 포트 + - "18000:80" \ No newline at end of file diff --git a/_chapter15/main.go b/_chapter15/main.go new file mode 100644 index 0000000..c8d0c78 --- /dev/null +++ b/_chapter15/main.go @@ -0,0 +1,56 @@ +package _chapter15 + +import ( + "context" + "fmt" + "golang.org/x/sync/errgroup" + "log" + "net" + "net/http" + "os" +) + +func run(ctx context.Context, l net.Listener) error { + s := &http.Server{ + // 인수로 받은 net.Listener를 이용하므로 Addr 필드는 지정하지 않습니다. + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:]) + }), + } + eg, ctx := errgroup.WithContext(ctx) + eg.Go(func() error { + // Serve 메서드로 변경합니다. + if err := s.Serve(l); err != nil && + err != http.ErrServerClosed { + log.Printf("failed to close: %+v", err) + return err + } + return nil + }) + + <-ctx.Done() + if err := s.Shutdown(context.Background()); err != nil { + log.Printf("failed to shutdown: %+v", err) + } + + return eg.Wait() +} + +func main() { + // go run . 18080 + if len(os.Args) != 2 { + log.Printf("need port number\n") + os.Exit(1) + } + + p := os.Args[1] + l, err := net.Listen("tcp", ":"+p) + if err != nil { + log.Fatalf("failed to listen port %s: %v", p, err) + } + + if err := run(context.Background(), l); err != nil { + log.Printf("failed to terminate server: %v", err) + os.Exit(1) + } +} diff --git a/_chapter15/main_test.go b/_chapter15/main_test.go new file mode 100644 index 0000000..6a982c5 --- /dev/null +++ b/_chapter15/main_test.go @@ -0,0 +1,53 @@ +package _chapter15 + +import ( + "context" + "fmt" + "golang.org/x/sync/errgroup" + "io" + "net" + "net/http" + "testing" +) + +func TestRun(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) + eg.Go(func() error { + return run(ctx, l) + }) + + 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) + } +}