Claude Code로 Go 개발하기: go.mod, 테스트, 동시성, race 검사
Claude Code로 Go 개발을 안전하게 진행하는 실전 흐름. go.mod, go work, API/CLI, 테스트, context, 동시성을 다룹니다.
Claude Code에 Go 개발을 맡길 때 가장 위험한 요청은 “API 만들어줘” 또는 “테스트 추가해줘”처럼 맥락이 없는 요청입니다. 실행되는 코드는 나올 수 있지만 기존 go.mod, 패키지 경계, 오류 처리 방식, context 전달, 동시성 안전성을 깨뜨릴 수 있습니다. Go는 문법이 단순해서 이런 문제가 작은 diff 안에 숨어 보이기 쉽습니다.
이 글은 Claude Code를 단순 코드 생성기가 아니라 Go 개발 에이전트로 사용하는 흐름을 정리합니다. 저장소 구조 파악, 모듈과 workspace 판단, API/CLI 변경, table-driven test, 오류 wrapping, context cancellation, 동시성 함정, race detector, benchmark, 안전한 프롬프트까지 다룹니다. 모듈은 의존성을 관리하는 단위, workspace는 여러 모듈을 함께 편집하는 작업대, context는 취소와 제한 시간을 하위 함수로 전달하는 장치입니다.
Masa가 작은 Go 작업 API를 Claude Code로 고칠 때 처음에는 “handler를 추가해줘”라고만 요청했습니다. 결과는 로컬에서 동작했지만 테스트가 없고, 취소 동작이 애매하며, goroutine에서 공유 map을 쓰는 위험한 코드가 있었습니다. 요청을 “저장소를 먼저 읽고 go test -race까지 확인해줘”로 바꾸자 리뷰할 포인트가 훨씬 선명해졌습니다.
먼저 저장소 지도를 만든다
Go 개발의 첫 단계는 코드 생성이 아니라 지도 만들기입니다. 어떤 디렉터리가 실행 명령인지, 어떤 패키지가 내부 구현인지, 어떤 go.mod가 의존성을 관리하는지, CI가 무엇을 검사하는지 짧게 정리합니다. Claude Code에는 첫 pass를 읽기 전용으로 맡깁니다.
pwd
find . -name go.mod -o -name go.work -o -name "*.go" | sort | sed -n '1,120p'
go env GOMOD GOWORK
go list -m
go list ./...
go test ./...
첫 요청은 이렇게 좁힙니다.
이 Go 저장소를 조사해 주세요. 아직 파일은 수정하지 마세요.
보고할 내용:
- go.mod와 go.work 존재 여부
- cmd, internal, pkg, api, migrations, testdata의 역할
- 바꾸면 호환성이 깨질 수 있는 public 타입과 함수
- 기존 오류 처리 스타일
- context.Context를 받는 경계
- go test ./... 결과
- 다음 작업에서 안전하게 만질 수 있는 최소 파일 목록
공식 자료는 디렉터리 구성에 Organizing a Go module, 모듈 동작에 Go Modules Reference를 기준으로 삼습니다. Claude Code 자체는 Claude Code overview를 참고합니다.
flowchart LR
A["repo map"] --> B["go.mod / go.work"]
B --> C["API or CLI change"]
C --> D["table-driven tests"]
D --> E["go test -race"]
E --> F["benchmark and review"]
기존 코드 조사 방식은 기존 코드베이스 지도 만들기와 CLAUDE.md 베스트 프랙티스도 함께 볼 수 있습니다. Go에서도 작업 약속을 먼저 고정해야 Claude Code가 관련 없는 파일을 덜 건드립니다.
go.mod와 go work가 흐트러지지 않게 한다
go.mod는 모듈 경로, Go 버전, 의존성을 기록합니다. 작은 애플리케이션은 보통 하나의 go.mod로 충분합니다. 여러 모듈을 동시에 수정해야 하는 저장소에서는 go.work로 Go 명령이 여러 로컬 모듈을 함께 보게 할 수 있습니다. 공식 튜토리얼은 Getting started with multi-module workspaces입니다.
Claude Code가 의존성을 추가하기 전 현재 상태를 확인합니다.
go env GOMOD GOWORK
go list -m all
go mod tidy
go test ./...
정말 여러 모듈을 동시에 편집해야 할 때만 workspace를 만듭니다.
mkdir -p services/taskapi tools/taskctl
cd services/taskapi
go mod init example.com/acme/taskapi
cd ../..
cd tools/taskctl
go mod init example.com/acme/taskctl
cd ../..
go work init ./services/taskapi ./tools/taskctl
go work use ./services/taskapi ./tools/taskctl
go work sync
주의할 점은 go.work를 숨은 의존성 해결 수단처럼 쓰지 않는 것입니다. 로컬에서는 workspace 덕분에 통과하지만 CI나 동료 환경에서는 깨질 수 있습니다. 팀에서 go.work를 커밋할지, 개인 작업 상태로 둘지 결정하고, Claude Code에도 새 모듈이나 의존성을 추가하기 전에 이유를 설명하라고 지시합니다.
API와 CLI는 같은 service 계층을 사용한다
Go에서는 실행 진입점을 cmd/ 아래에 두고, 업무 로직을 internal/로 모으는 구성이 다루기 쉽습니다. 아래 예시는 핵심 Store를 HTTP와 분리해 나중에 CLI에서도 같은 동작을 재사용할 수 있게 합니다. 표준 라이브러리만 사용하므로 cmd/taskapi/main.go에 붙여 넣어 실행할 수 있습니다.
// cmd/taskapi/main.go
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"sync"
"time"
)
var ErrValidation = errors.New("validation failed")
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}
type Store struct {
mu sync.Mutex
next int
tasks map[string]Task
}
func NewStore() *Store {
return &Store{next: 1, tasks: make(map[string]Task)}
}
func (s *Store) Create(ctx context.Context, title string) (Task, error) {
select {
case <-ctx.Done():
return Task{}, fmt.Errorf("create task canceled: %w", ctx.Err())
default:
}
title = strings.TrimSpace(title)
if title == "" {
return Task{}, fmt.Errorf("%w: title is required", ErrValidation)
}
s.mu.Lock()
defer s.mu.Unlock()
task := Task{
ID: fmt.Sprintf("task-%06d", s.next),
Title: title,
Status: "open",
CreatedAt: time.Now().UTC(),
}
s.next++
s.tasks[task.ID] = task
return task, nil
}
func (s *Store) List(ctx context.Context) ([]Task, error) {
select {
case <-ctx.Done():
return nil, fmt.Errorf("list tasks canceled: %w", ctx.Err())
default:
}
s.mu.Lock()
defer s.mu.Unlock()
tasks := make([]Task, 0, len(s.tasks))
for _, task := range s.tasks {
tasks = append(tasks, task)
}
return tasks, nil
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
store := NewStore()
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
tasks, err := store.List(r.Context())
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusOK, tasks)
})
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
task, err := store.Create(r.Context(), body.Title)
if err != nil {
writeError(w, err)
return
}
writeJSON(w, http.StatusCreated, task)
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
logger.Info("taskapi listening", "addr", server.Addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server failed", "error", err)
os.Exit(1)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown failed", "error", err)
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrValidation):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
writeJSON(w, http.StatusRequestTimeout, map[string]string{"error": err.Error()})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
}
실행은 다음처럼 합니다.
go mod init example.com/taskapi
mkdir -p cmd/taskapi
go run ./cmd/taskapi
curl -s http://localhost:8080/tasks
curl -s -X POST http://localhost:8080/tasks \
-H 'content-type: application/json' \
-d '{"title":"write table-driven tests"}'
CLI를 추가할 때도 handler 내부 로직을 복사하지 말고 같은 service 계층을 호출합니다. 관련 내용은 Claude Code로 CLI 도구 개발을 참고하세요.
table-driven test를 완료 조건으로 둔다
table-driven test는 입력과 기대값을 slice에 표처럼 나열하고 같은 테스트 로직을 행마다 실행하는 Go의 흔한 패턴입니다. validation, 오류 분기, 경계값 확인에 잘 맞습니다. Claude Code에는 테스트해야 할 경우를 구체적으로 지시합니다.
// cmd/taskapi/store_test.go
package main
import (
"context"
"errors"
"fmt"
"testing"
)
func TestStoreCreate(t *testing.T) {
tests := []struct {
name string
title string
wantErr error
}{
{name: "valid title", title: "ship release notes"},
{name: "blank title", title: " ", wantErr: ErrValidation},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := NewStore()
task, err := store.Create(context.Background(), tt.title)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("error = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if task.ID == "" || task.Status != "open" {
t.Fatalf("unexpected task: %+v", task)
}
})
}
}
func TestStoreCreateHonorsCanceledContext(t *testing.T) {
store := NewStore()
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := store.Create(ctx, "will not be created")
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
}
func BenchmarkStoreCreate(b *testing.B) {
store := NewStore()
ctx := context.Background()
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := store.Create(ctx, fmt.Sprintf("task-%d", i)); err != nil {
b.Fatal(err)
}
}
}
검증 명령도 완료 조건에 포함합니다.
gofmt -w cmd/taskapi
go test ./...
go test -race ./...
go test -run TestStoreCreate -count=1 ./...
go test -bench=. -benchmem ./...
공식 testing package는 테스트와 benchmark를 설명합니다. benchmark는 Go가 b.N으로 반복 횟수를 조정하는 측정 루프이므로, “느낌상 빨라졌다”를 피하게 해줍니다.
context cancellation을 보존한다
context.Context는 API 경계 사이에서 취소, deadline, 요청 단위 값을 전달합니다. 공식 context package는 서버 요청에서 만들어진 Context를 하위 호출이 받아야 한다고 설명합니다.
Claude Code가 자주 하는 실수는 handler에서 r.Context()를 받았는데 service나 repository에서 다시 context.Background()를 만드는 것입니다. 그러면 취소 체인이 끊기고, 클라이언트가 연결을 끊어도 DB 질의나 외부 API 호출이 계속될 수 있습니다. 프롬프트에는 하위 계층에서 새 background context를 만들지 말고 호출자의 ctx를 전달하라고 씁니다.
또 다른 실수는 context.WithTimeout이 반환한 cancel을 호출하지 않는 것입니다. Claude Code가 파생 Context를 만들면 defer cancel()을 넣고, 관련 변경 후 go vet도 확인하게 합니다.
race detector로 동시성을 확인한다
goroutine은 가볍지만 공유 데이터에는 동기화가 필요합니다. data race는 여러 goroutine이 같은 변수에 동시에 접근하고, 적어도 하나가 쓰기이며, 동기화가 없는 상태입니다. 공식 Data Race Detector는 go test -race로 실행합니다. 단, 실행된 코드 경로의 race만 찾습니다.
공유 map에 동시에 쓰는 코드는 피해야 합니다.
func CountByStatusBad(tasks []Task) map[string]int {
counts := make(map[string]int)
var wg sync.WaitGroup
for _, task := range tasks {
task := task
wg.Add(1)
go func() {
defer wg.Done()
counts[task.Status]++
}()
}
wg.Wait()
return counts
}
공유 상태는 sync.Mutex로 보호합니다.
func CountByStatus(tasks []Task) map[string]int {
counts := make(map[string]int)
var (
mu sync.Mutex
wg sync.WaitGroup
)
for _, task := range tasks {
task := task
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counts[task.Status]++
mu.Unlock()
}()
}
wg.Wait()
return counts
}
프롬프트는 “동시성으로 바꿔줘”에서 멈추면 안 됩니다. 공유 변수, channel close 책임, cancellation 동작, WaitGroup의 Done 보장, go test -race 결과를 함께 확인하게 하세요. 리뷰 관점은 Claude Code 코드 리뷰와도 연결됩니다.
Go용 안전 프롬프트
좋은 Go 프롬프트는 편집 범위, 지켜야 할 계약, 검증 명령, 금지 사항을 명시합니다.
목표: taskapi의 POST /tasks에 validation과 테스트를 추가한다.
편집 허용 파일:
- cmd/taskapi/main.go
- cmd/taskapi/store_test.go
규칙:
- go.mod의 module path를 바꾸지 않는다
- 새 외부 의존성을 추가하지 않는다
- public 타입명과 JSON field명을 바꾸지 않는다
- context.Context는 handler에서 service까지 전달한다
- error는 fmt.Errorf("%w")로 wrap하고 호출 측은 errors.Is로 판정한다
완료 조건:
- gofmt -w cmd/taskapi
- go test ./...
- go test -race ./...
- 변경 내용과 남은 위험을 짧게 보고한다
의존성이 필요할 수 있으면 먼저 멈추게 합니다.
외부 라이브러리가 필요하다고 판단하면 구현 전에 후보, 이유, 표준 라이브러리로 부족한 이유, go.mod 영향도를 설명하세요. 승인 없이 go get을 실행하지 마세요.
이렇게 하면 CLI 프레임워크, router, ORM, mock 라이브러리가 갑자기 들어오는 사고를 줄일 수 있습니다. Go는 표준 라이브러리만으로 충분한 경우가 많으므로 의존성 변경은 항상 리뷰 대상입니다.
실무 유스케이스와 함정
첫 번째 유스케이스는 기존 API에 작은 endpoint를 추가하는 일입니다. handler, service method, table-driven test, go test -race를 한 작업에 포함합니다. 함정은 handler만 추가해 기존 service 경계나 오류 형식을 깨는 것입니다.
두 번째는 CLI와 API의 공통 로직화입니다. 운영 담당 CLI와 관리자 화면 API가 같은 업무 규칙을 쓴다면 internal/service로 모읍니다. 함정은 CLI용으로 복사한 로직이 나중에 API와 달라지는 것입니다.
세 번째는 느린 집계의 병렬화입니다. dashboard가 외부 API 세 개를 호출한다면 goroutine이 도움이 됩니다. 함정은 공유 slice/map 쓰기, 여러 goroutine이 같은 channel을 닫는 설계, 취소 후 goroutine 누수입니다.
네 번째는 성능 개선입니다. Claude Code에 “빠르게 해줘”라고 하기 전에 go test -bench=. -benchmem으로 기준값을 만듭니다. 함정은 측정 없이 cache나 goroutine을 추가해 메모리 사용량과 복잡도만 늘리는 것입니다.
다섯 번째는 multi-module 저장소 작업입니다. go.work는 로컬 편집을 편하게 하지만 go env GOWORK와 CI 실행 위치를 확인해야 합니다. 함정은 로컬 workspace 통과를 프로덕션 근거로 착각하는 것입니다.
수익화 CTA와 다음 단계
Go 개발에 Claude Code를 쓰는 독자는 단순 실험보다 실제 팀 저장소에 안전하게 넣는 방법을 찾는 경우가 많습니다. 개인용 프롬프트와 검증 명령부터 정리하려면 무료 치트시트로 시작하세요. CLAUDE.md, 권한, 리뷰 프롬프트, Go 체크리스트를 함께 정리하려면 제품과 템플릿을 볼 수 있습니다. 팀의 Go API, CLI, CI, 리뷰 규칙까지 포함해 도입하려면 Claude Code 교육 및 도입 상담에서 실제 저장소 기준으로 정리할 수 있습니다.
정리
안정적인 Go 흐름은 먼저 저장소 지도를 만들고, go.mod와 go.work를 확인하고, API/CLI 변경을 service 계층 뒤에 두며, table-driven test, context cancellation, race detector, benchmark를 완료 조건에 넣는 것입니다.
이 흐름을 작은 작업 API에 적용해 보니, 가장 효과가 컸던 변화는 검증 명령을 프롬프트에 직접 넣는 것이었습니다. gofmt, go test ./..., go test -race ./..., go test -bench=. -benchmem이 완료 기준이 되자 Claude Code의 보고가 “구현 완료”에서 “검증 완료, 남은 위험은 이것”으로 바뀌었습니다. 공개 전에는 공식 링크, 내부 링크, 본문 깊이, 코드 fence, updatedDate, heroImage도 함께 확인하세요.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.