본문으로 건너뛰기

REST API 설계

REST API는 웹 서비스 간 통신을 위한 가장 널리 사용되는 아키텍처 스타일입니다. 올바른 REST API 설계는 클라이언트가 예측 가능하게 서버와 상호작용할 수 있게 하며, 유지보수성과 확장성을 높여줍니다. Go는 간결한 문법과 강력한 표준 라이브러리 덕분에 REST API 서버 구현에 매우 적합합니다.

REST 원칙과 URL 설계

REST(Representational State Transfer)의 핵심은 자원(Resource) 중심 설계 입니다.

URL 설계 원칙

# 좋은 URL 설계 (명사 사용, 계층 구조 표현)
GET /v1/users # 사용자 목록 조회
POST /v1/users # 사용자 생성
GET /v1/users/{id} # 특정 사용자 조회
PUT /v1/users/{id} # 사용자 전체 수정
PATCH /v1/users/{id} # 사용자 부분 수정
DELETE /v1/users/{id} # 사용자 삭제

GET /v1/users/{id}/posts # 사용자의 게시글 목록
GET /v1/users/{id}/posts/{pid} # 사용자의 특정 게시글

# 잘못된 URL 설계 (동사 사용 지양)
GET /getUsers
POST /createUser
GET /getUserById?id=1

HTTP 메서드 의미론

메서드의미멱등성안전성
GET조회OO
POST생성XX
PUT전체 수정OX
PATCH부분 수정XX
DELETE삭제OX

멱등성(Idempotent): 동일한 요청을 여러 번 해도 결과가 동일함 안전성(Safe): 서버 상태를 변경하지 않음

HTTP 상태 코드 올바른 사용

// 상태 코드별 사용 시나리오
const (
// 2xx 성공
StatusOK = 200 // GET, PUT, PATCH 성공
StatusCreated = 201 // POST로 리소스 생성 성공
StatusNoContent = 204 // DELETE 성공 (응답 바디 없음)

// 4xx 클라이언트 오류
StatusBadRequest = 400 // 잘못된 요청 형식
StatusUnauthorized = 401 // 인증 필요
StatusForbidden = 403 // 권한 없음 (인증은 됐지만)
StatusNotFound = 404 // 리소스 없음
StatusMethodNotAllowed = 405 // 허용되지 않은 HTTP 메서드
StatusConflict = 409 // 리소스 충돌 (중복 생성 등)
StatusUnprocessableEntity = 422 // 유효성 검사 실패

// 5xx 서버 오류
StatusInternalServerError = 500 // 서버 내부 오류
StatusServiceUnavailable = 503 // 서비스 일시 불가
)

표준 에러 응답 구조체

일관된 에러 응답은 클라이언트가 에러를 예측 가능하게 처리할 수 있게 합니다.

package main

import (
"encoding/json"
"net/http"
)

// APIError 표준 에러 응답 구조체
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
Details []ErrorDetail `json:"details,omitempty"`
}

// ErrorDetail 필드별 유효성 오류 상세
type ErrorDetail struct {
Field string `json:"field"`
Message string `json:"message"`
}

// APIResponse 표준 응답 래퍼
type APIResponse[T any] struct {
Data T `json:"data,omitempty"`
Error *APIError `json:"error,omitempty"`
Meta *PageMeta `json:"meta,omitempty"`
}

// PageMeta 페이지네이션 메타데이터
type PageMeta struct {
Total int64 `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
HasNext bool `json:"has_next"`
}

// WriteJSON JSON 응답 헬퍼
func WriteJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}

// WriteError 에러 응답 헬퍼
func WriteError(w http.ResponseWriter, status int, code, message string, details ...ErrorDetail) {
resp := APIResponse[any]{
Error: &APIError{
Code: code,
Message: message,
Details: details,
},
}
WriteJSON(w, status, resp)
}

URL 버전 관리

package main

import (
"fmt"
"net/http"
)

func main() {
mux := http.NewServeMux()

// v1 라우트
v1 := http.NewServeMux()
v1.HandleFunc("GET /users", v1GetUsers)
v1.HandleFunc("POST /users", v1CreateUser)

// v2 라우트 (하위 호환성 유지하며 기능 추가)
v2 := http.NewServeMux()
v2.HandleFunc("GET /users", v2GetUsers) // 응답 형식 변경
v2.HandleFunc("POST /users", v2CreateUser)

// 버전 프리픽스로 분리
mux.Handle("/v1/", http.StripPrefix("/v1", v1))
mux.Handle("/v2/", http.StripPrefix("/v2", v2))

fmt.Println("서버 시작: http://localhost:8080")
http.ListenAndServe(":8080", mux)
}

func v1GetUsers(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"users": []map[string]string{
{"id": "1", "name": "홍길동"},
},
})
}

func v2GetUsers(w http.ResponseWriter, r *http.Request) {
// v2는 페이지네이션 메타 포함
WriteJSON(w, http.StatusOK, APIResponse[any]{
Data: []map[string]string{
{"id": "1", "name": "홍길동"},
},
Meta: &PageMeta{Total: 1, Page: 1, Limit: 20, HasNext: false},
})
}

func v1CreateUser(w http.ResponseWriter, r *http.Request) {}
func v2CreateUser(w http.ResponseWriter, r *http.Request) {}

페이지네이션

Offset 기반 페이지네이션

package main

import (
"net/http"
"strconv"
)

type PaginationParams struct {
Page int // 1부터 시작
Limit int
Offset int
}

// ParsePagination 쿼리 파라미터에서 페이지네이션 파싱
func ParsePagination(r *http.Request) PaginationParams {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))

if page < 1 {
page = 1
}
if limit < 1 || limit > 100 {
limit = 20 // 기본값, 최대 100
}

return PaginationParams{
Page: page,
Limit: limit,
Offset: (page - 1) * limit,
}
}

// 사용 예시
func handleGetPosts(w http.ResponseWriter, r *http.Request) {
p := ParsePagination(r)

// DB 쿼리: SELECT * FROM posts LIMIT ? OFFSET ?
// posts, total := db.GetPosts(p.Limit, p.Offset)
posts := []map[string]any{} // 실제로는 DB에서 조회
total := int64(0)

WriteJSON(w, http.StatusOK, APIResponse[any]{
Data: posts,
Meta: &PageMeta{
Total: total,
Page: p.Page,
Limit: p.Limit,
HasNext: int64(p.Page*p.Limit) < total,
},
})
}

Cursor 기반 페이지네이션

대용량 데이터에서 offset 방식은 성능 문제가 있습니다. cursor 방식이 더 효율적입니다.

package main

import (
"encoding/base64"
"fmt"
"net/http"
)

type CursorParams struct {
Cursor string
Limit int
}

// EncodeCursor cursor를 base64로 인코딩 (ID 기반)
func EncodeCursor(id int64) string {
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", id)))
}

// DecodeCursor cursor 디코딩
func DecodeCursor(cursor string) (int64, error) {
b, err := base64.StdEncoding.DecodeString(cursor)
if err != nil {
return 0, err
}
var id int64
_, err = fmt.Sscanf(string(b), "%d", &id)
return id, err
}

// cursor 기반 응답 구조
type CursorResponse[T any] struct {
Data T `json:"data"`
NextCursor string `json:"next_cursor,omitempty"`
HasNext bool `json:"has_next"`
}

func handleGetPostsCursor(w http.ResponseWriter, r *http.Request) {
cursor := r.URL.Query().Get("cursor")

var afterID int64
if cursor != "" {
id, err := DecodeCursor(cursor)
if err != nil {
WriteError(w, http.StatusBadRequest, "INVALID_CURSOR", "잘못된 커서입니다")
return
}
afterID = id
}

// DB 쿼리: SELECT * FROM posts WHERE id > ? ORDER BY id LIMIT ?
_ = afterID
posts := []map[string]any{} // 실제 DB 조회
hasNext := false

var nextCursor string
if hasNext {
// 마지막 항목의 ID로 다음 커서 생성
// nextCursor = EncodeCursor(posts[len(posts)-1].ID)
}

WriteJSON(w, http.StatusOK, CursorResponse[any]{
Data: posts,
NextCursor: nextCursor,
HasNext: hasNext,
})
}

필터링·정렬·검색

package main

import (
"net/http"
"strings"
)

type PostFilter struct {
AuthorID int64
Status string
Search string
SortBy string
SortDir string
}

// ParsePostFilter 쿼리 파라미터에서 필터 파싱
func ParsePostFilter(r *http.Request) PostFilter {
q := r.URL.Query()

sortDir := strings.ToUpper(q.Get("sort_dir"))
if sortDir != "ASC" && sortDir != "DESC" {
sortDir = "DESC"
}

sortBy := q.Get("sort_by")
// 허용된 컬럼만 받기 (SQL 인젝션 방지)
allowedSortFields := map[string]bool{"created_at": true, "title": true, "views": true}
if !allowedSortFields[sortBy] {
sortBy = "created_at"
}

return PostFilter{
Status: q.Get("status"),
Search: q.Get("q"),
SortBy: sortBy,
SortDir: sortDir,
}
}

// 요청 예시:
// GET /v1/posts?status=published&q=golang&sort_by=views&sort_dir=DESC&page=1&limit=10
func handleFilteredPosts(w http.ResponseWriter, r *http.Request) {
filter := ParsePostFilter(r)
p := ParsePagination(r)

_ = filter
_ = p

// DB 쿼리 빌드 후 실행
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

실전 예제: Gin 기반 Blog REST API

package main

import (
"net/http"
"strconv"
"sync"
"time"

"github.com/gin-gonic/gin"
)

// Post 블로그 게시글 모델
type Post struct {
ID int64 `json:"id"`
Title string `json:"title" binding:"required,min=1,max=200"`
Content string `json:"content" binding:"required"`
AuthorID int64 `json:"author_id"`
Status string `json:"status"` // draft, published
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

// CreatePostRequest 게시글 생성 요청
type CreatePostRequest struct {
Title string `json:"title" binding:"required,min=1,max=200"`
Content string `json:"content" binding:"required"`
Status string `json:"status" binding:"omitempty,oneof=draft published"`
}

// UpdatePostRequest 게시글 수정 요청
type UpdatePostRequest struct {
Title *string `json:"title" binding:"omitempty,min=1,max=200"`
Content *string `json:"content"`
Status *string `json:"status" binding:"omitempty,oneof=draft published"`
}

// PostStore 인메모리 저장소
type PostStore struct {
mu sync.RWMutex
posts map[int64]*Post
nextID int64
}

func NewPostStore() *PostStore {
return &PostStore{posts: make(map[int64]*Post), nextID: 1}
}

func (s *PostStore) Create(req CreatePostRequest) *Post {
s.mu.Lock()
defer s.mu.Unlock()

status := req.Status
if status == "" {
status = "draft"
}

post := &Post{
ID: s.nextID,
Title: req.Title,
Content: req.Content,
AuthorID: 1,
Status: status,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
s.posts[s.nextID] = post
s.nextID++
return post
}

func (s *PostStore) GetAll(page, limit int) ([]*Post, int64) {
s.mu.RLock()
defer s.mu.RUnlock()

all := make([]*Post, 0, len(s.posts))
for _, p := range s.posts {
all = append(all, p)
}

total := int64(len(all))
start := (page - 1) * limit
if start >= len(all) {
return []*Post{}, total
}
end := start + limit
if end > len(all) {
end = len(all)
}
return all[start:end], total
}

func (s *PostStore) GetByID(id int64) (*Post, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
p, ok := s.posts[id]
return p, ok
}

func (s *PostStore) Update(id int64, req UpdatePostRequest) (*Post, bool) {
s.mu.Lock()
defer s.mu.Unlock()

post, ok := s.posts[id]
if !ok {
return nil, false
}
if req.Title != nil {
post.Title = *req.Title
}
if req.Content != nil {
post.Content = *req.Content
}
if req.Status != nil {
post.Status = *req.Status
}
post.UpdatedAt = time.Now()
return post, true
}

func (s *PostStore) Delete(id int64) bool {
s.mu.Lock()
defer s.mu.Unlock()

if _, ok := s.posts[id]; !ok {
return false
}
delete(s.posts, id)
return true
}

// PostHandler Gin 핸들러 모음
type PostHandler struct {
store *PostStore
}

func (h *PostHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
if page < 1 { page = 1 }
if limit < 1 || limit > 100 { limit = 20 }

posts, total := h.store.GetAll(page, limit)
c.JSON(http.StatusOK, gin.H{
"data": posts,
"meta": gin.H{
"total": total,
"page": page,
"limit": limit,
"has_next": int64(page*limit) < total,
},
})
}

func (h *PostHandler) Create(c *gin.Context) {
var req CreatePostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": gin.H{"code": "VALIDATION_ERROR", "message": err.Error()},
})
return
}
post := h.store.Create(req)
c.JSON(http.StatusCreated, gin.H{"data": post})
}

func (h *PostHandler) Get(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{"code": "INVALID_ID", "message": "유효하지 않은 ID입니다"},
})
return
}

post, ok := h.store.GetByID(id)
if !ok {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{"code": "NOT_FOUND", "message": "게시글을 찾을 수 없습니다"},
})
return
}
c.JSON(http.StatusOK, gin.H{"data": post})
}

func (h *PostHandler) Update(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{"code": "INVALID_ID", "message": "유효하지 않은 ID입니다"},
})
return
}

var req UpdatePostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": gin.H{"code": "VALIDATION_ERROR", "message": err.Error()},
})
return
}

post, ok := h.store.Update(id, req)
if !ok {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{"code": "NOT_FOUND", "message": "게시글을 찾을 수 없습니다"},
})
return
}
c.JSON(http.StatusOK, gin.H{"data": post})
}

func (h *PostHandler) Delete(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{"code": "INVALID_ID", "message": "유효하지 않은 ID입니다"},
})
return
}

if !h.store.Delete(id) {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{"code": "NOT_FOUND", "message": "게시글을 찾을 수 없습니다"},
})
return
}
c.Status(http.StatusNoContent)
}

func main() {
r := gin.Default()
handler := &PostHandler{store: NewPostStore()}

v1 := r.Group("/v1")
{
posts := v1.Group("/posts")
{
posts.GET("", handler.List)
posts.POST("", handler.Create)
posts.GET("/:id", handler.Get)
posts.PATCH("/:id", handler.Update)
posts.DELETE("/:id", handler.Delete)
}
}

// 헬스체크
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})

r.Run(":8080")
}

Content-Type 협상

package main

import (
"encoding/json"
"encoding/xml"
"net/http"
)

type User struct {
ID int `json:"id" xml:"id"`
Name string `json:"name" xml:"name"`
}

// 클라이언트의 Accept 헤더에 따라 응답 형식 선택
func handleUser(w http.ResponseWriter, r *http.Request) {
user := User{ID: 1, Name: "홍길동"}

accept := r.Header.Get("Accept")
switch accept {
case "application/xml":
w.Header().Set("Content-Type", "application/xml")
xml.NewEncoder(w).Encode(user)
default: // application/json or */*
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
}

고수 팁

1. 일관된 응답 구조 유지: 성공/실패 모두 동일한 래퍼 사용

2. 에러 코드는 문자열로: "NOT_FOUND" 형태가 숫자보다 클라이언트에서 처리하기 쉽습니다

3. 멱등 키(Idempotency-Key): POST 요청 중복 방지를 위해 헤더로 유니크 키 처리

// 멱등 키로 중복 요청 방지
func handleCreateOrder(w http.ResponseWriter, r *http.Request) {
idempotencyKey := r.Header.Get("Idempotency-Key")
if idempotencyKey == "" {
WriteError(w, http.StatusBadRequest, "MISSING_IDEMPOTENCY_KEY",
"Idempotency-Key 헤더가 필요합니다")
return
}
// 캐시에서 이미 처리된 요청인지 확인
// if cached := cache.Get(idempotencyKey); cached != nil { ... }
}

4. 요청 ID 전파: 모든 요청에 고유 ID 부여 후 응답 헤더에 포함

import "github.com/google/uuid"

func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := uuid.New().String()
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r)
})
}