Go로 간단한 CRUD API 만들기: REST 구조와 테스트 코드까지 연결하기
이전 챕터에서
라우팅 분리와 미들웨어 구조를 잡았다면,
이제는 그 위에 실제로 동작하는 CRUD API를 올려볼 차례다.
이번 챕터의 목표는 단순하다.
- REST 형태의 CRUD API를 한 번 직접 만들어보고
- 비즈니스 로직과 HTTP 레이어를 분리하고
- 그 구조를 테스트 코드로 검증해본다
“완성도 높은 서비스”보다는
실무에서 반복되는 기본 패턴을 익히는 데 초점을 둔다.
예제 시나리오: User 관리 API
예제로는 가장 단순한 User 리소스를 사용한다.
POST /users 사용자 생성
GET /users 사용자 목록 조회
GET /users/{id} 사용자 단건 조회
DB 대신
메모리 저장소(in-memory store) 를 사용해
구조에만 집중한다.
기본 구조 다시 정리
cmd/server/main.go
internal/handler/user.go
internal/service/user.go
internal/store/user.go
- handler: HTTP 요청/응답
- service: 비즈니스 로직
- store: 데이터 접근
이 구조를 유지하는 게 이번 챕터의 핵심이다.
[이미지: Go CRUD API 레이어 구조]
User 모델 정의
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
- JSON 응답을 고려해 tag 추가
- 단순한 구조로 유지
Store 계층: 메모리 저장소
type UserStore struct {
mu sync.Mutex
users map[int]User
next int
}
func NewUserStore() *UserStore {
return &UserStore{
users: make(map[int]User),
next: 1,
}
}
func (s *UserStore) Create(name string) User {
s.mu.Lock()
defer s.mu.Unlock()
user := User{ID: s.next, Name: name}
s.users[s.next] = user
s.next++
return user
}
- Mutex로 동시성 보호
- 실제 DB를 쓰는 구조와 매우 유사하다
Service 계층: 비즈니스 로직
type UserService struct {
store *UserStore
}
func (s *UserService) CreateUser(name string) User {
return s.store.Create(name)
}
이 단계에서는
“왜 굳이 service가 필요하지?”라는 생각이 들 수 있다.
하지만 테스트와 확장 단계에서 차이가 확실히 드러난다.
Handler 계층: HTTP 처리
사용자 생성 (POST /users)
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
user := h.service.CreateUser(req.Name)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
- HTTP 레이어에서는
- 요청 파싱
- 응답 포맷
만 담당한다.
사용자 목록 조회 (GET /users)
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
users := h.service.ListUsers()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
핵심 로직은 service에 있고,
handler는 최대한 얇게 유지한다.
REST 구조에서 중요한 포인트
⚠️ 이 단계에서 꼭 짚고 가야 할 기준
- handler에 로직이 쌓이지 않게 한다
- store는 HTTP를 모르게 한다
- service는 transport(net/http)를 모르게 한다
이 기준이 무너지면
테스트 난이도가 급격히 올라간다.
Handler 테스트 코드 작성하기
이제 이 구조의 장점이 드러난다.
net/http/httptest를 사용해
실제 서버를 띄우지 않고도 테스트할 수 있다.
func TestCreateUser(t *testing.T) {
store := NewUserStore()
service := NewUserService(store)
handler := NewUserHandler(service)
body := `{"name":"alice"}`
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
w := httptest.NewRecorder()
handler.Create(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
- 요청 → handler 직접 호출
- 응답은 Recorder로 검증
[이미지: Go httptest 기반 핸들러 테스트 흐름]
이 구조가 테스트에 유리한 이유
- DB 없이도 테스트 가능
- 외부 서버 필요 없음
- 테스트 속도가 매우 빠름
실무에서는
이 방식으로 수십, 수백 개의 API 테스트를 부담 없이 작성할 수 있다.
CRUD API를 여기까지 만들고 나면
이 단계까지 왔다면,
이미 다음 감각이 생겼을 가능성이 크다.
- “여기에 DB 붙이면 이런 느낌이겠구나”
- “이 구조면 인증 미들웨어 붙이기 쉽겠다”
- “이 상태에서 Gin으로 바꿔도 구조는 그대로 쓰겠네”
이게 바로 이 챕터의 목적이다.
정리
CRUD API 구현의 핵심은
기능보다 구조와 분리 기준이다.
- handler / service / store 분리
- REST 형태의 URL 설계
- httptest 기반 테스트 작성
이 구조를 한 번 몸으로 익혀두면,
이후 어떤 프레임워크를 쓰더라도
코드 중심은 거의 흔들리지 않는다.
다음 챕터에서는 이 CRUD API를 기반으로
DB 연동(sql + repository 패턴) 이나
Gin으로 구조 그대로 옮겨보기 중 하나로 이어가면 좋다.