본문으로 건너뛰기

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)
})
}