backend

Go로 간단한 CRUD API 만들기: REST 구조와 테스트 코드까지 연결하기

mirabo01 2026. 1. 31. 22:25

이전 챕터에서
라우팅 분리와 미들웨어 구조를 잡았다면,
이제는 그 위에 실제로 동작하는 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으로 구조 그대로 옮겨보기 중 하나로 이어가면 좋다.