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 | 조회 | O | O |
| POST | 생성 | X | X |
| PUT | 전체 수정 | O | X |
| PATCH | 부분 수정 | X | X |
| DELETE | 삭제 | O | X |
멱등성(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)
})
}