본문으로 건너뛰기

Echo 프레임워크

Echo는 Go의 대표적인 고성능 웹 프레임워크 중 하나입니다. Gin과 함께 가장 많이 사용되며, 미들웨어 커스터마이징과 컨텍스트 확장에 강점이 있습니다. 타입 안전한 데이터 바인딩과 풍부한 내장 미들웨어가 특징입니다.

설치 및 기본 사용

go get github.com/labstack/echo/v4
go get github.com/labstack/echo/v4/middleware
package main

import (
"net/http"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

func main() {
e := echo.New()

// 내장 미들웨어 등록
e.Use(middleware.Logger()) // 요청 로깅
e.Use(middleware.Recover()) // panic 복구

e.GET("/ping", func(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"message": "pong"})
})

e.Logger.Fatal(e.Start(":8080"))
}

라우팅

package main

import (
"net/http"

"github.com/labstack/echo/v4"
)

func main() {
e := echo.New()

// HTTP 메서드별 라우트
e.GET("/users", listUsers)
e.POST("/users", createUser)
e.GET("/users/:id", getUser) // 경로 파라미터
e.PUT("/users/:id", updateUser)
e.DELETE("/users/:id", deleteUser)

// 경로 파라미터 접근
e.GET("/posts/:id/comments/:cid", func(c echo.Context) error {
postID := c.Param("id") // 경로 파라미터
commentID := c.Param("cid")
return c.String(http.StatusOK, "post="+postID+", comment="+commentID)
})

// 쿼리 파라미터
e.GET("/search", func(c echo.Context) error {
q := c.QueryParam("q") // 단일 쿼리 파라미터
page := c.QueryParam("page")
if page == "" { page = "1" }
return c.JSON(http.StatusOK, echo.Map{"q": q, "page": page})
})

// 와일드카드
e.GET("/static/*", func(c echo.Context) error {
return c.String(http.StatusOK, c.Param("*"))
})

e.Logger.Fatal(e.Start(":8080"))
}

func listUsers(c echo.Context) error {
return c.JSON(http.StatusOK, echo.Map{"users": []any{}})
}
func createUser(c echo.Context) error { return c.JSON(http.StatusCreated, echo.Map{"id": 1}) }
func getUser(c echo.Context) error { return c.JSON(http.StatusOK, echo.Map{"id": c.Param("id")}) }
func updateUser(c echo.Context) error { return c.JSON(http.StatusOK, echo.Map{"updated": true}) }
func deleteUser(c echo.Context) error { return c.NoContent(http.StatusNoContent) }

데이터 바인딩 및 유효성 검사

package main

import (
"net/http"

"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
)

// CustomValidator validator 라이브러리 연동
type CustomValidator struct {
validator *validator.Validate
}

func (cv *CustomValidator) Validate(i any) error {
return cv.validator.Struct(i)
}

type CreatePostRequest struct {
Title string `json:"title" validate:"required,min=1,max=200"`
Content string `json:"content" validate:"required"`
Status string `json:"status" validate:"omitempty,oneof=draft published"`
}

type QueryParams struct {
Page int `query:"page" validate:"omitempty,min=1"`
Limit int `query:"limit" validate:"omitempty,min=1,max=100"`
Sort string `query:"sort" validate:"omitempty,oneof=created_at title"`
}

func main() {
e := echo.New()

// validator 등록
e.Validator = &CustomValidator{validator: validator.New()}

// JSON 바인딩 + 유효성 검사
e.POST("/posts", func(c echo.Context) error {
var req CreatePostRequest

// Bind: JSON/Form/XML 자동 감지
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{
"error": echo.Map{"code": "BAD_REQUEST", "message": err.Error()},
})
}

// 유효성 검사
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusUnprocessableEntity, echo.Map{
"error": echo.Map{"code": "VALIDATION_ERROR", "message": err.Error()},
})
}

return c.JSON(http.StatusCreated, echo.Map{"data": req})
})

// 쿼리 파라미터 바인딩
e.GET("/posts", func(c echo.Context) error {
var q QueryParams
if err := c.Bind(&q); err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{"error": err.Error()})
}
if q.Page == 0 { q.Page = 1 }
if q.Limit == 0 { q.Limit = 20 }
return c.JSON(http.StatusOK, echo.Map{"page": q.Page, "limit": q.Limit})
})

e.Logger.Fatal(e.Start(":8080"))
}

내장 미들웨어

package main

import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

func main() {
e := echo.New()

// 요청/응답 로깅
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{
Format: `{"time":"${time_rfc3339}","method":"${method}","uri":"${uri}","status":${status},"latency":"${latency_human}"}` + "\n",
}))

// panic 복구
e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{
StackSize: 1 << 10, // 1KB 스택 트레이스
}))

// CORS
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"https://example.com", "http://localhost:3000"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
AllowHeaders: []string{"Content-Type", "Authorization"},
}))

// 요청 ID
e.Use(middleware.RequestID())

// Gzip 압축
e.Use(middleware.Gzip())

// Body 크기 제한 (2MB)
e.Use(middleware.BodyLimit("2M"))

// Rate Limiting (초당 20 요청)
e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(20)))

// 정적 파일 서빙
e.Static("/static", "public")

e.GET("/", func(c echo.Context) error {
// 요청 ID 접근
id := c.Response().Header().Get(echo.HeaderXRequestID)
return c.JSON(200, echo.Map{"request_id": id})
})

e.Logger.Fatal(e.Start(":8080"))
}

커스텀 컨텍스트 확장

package main

import (
"net/http"

"github.com/labstack/echo/v4"
)

// AppContext echo.Context를 확장한 커스텀 컨텍스트
type AppContext struct {
echo.Context
UserID int64
Role string
}

// 커스텀 컨텍스트 미들웨어
func customContextMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
cc := &AppContext{
Context: c,
UserID: 0,
Role: "guest",
}
return next(cc)
}
}

// 커스텀 컨텍스트를 사용하는 핸들러
func profileHandler(c echo.Context) error {
cc := c.(*AppContext) // 타입 단언
return c.JSON(http.StatusOK, echo.Map{
"user_id": cc.UserID,
"role": cc.Role,
})
}

func main() {
e := echo.New()
e.Use(customContextMiddleware)
e.GET("/profile", profileHandler)
e.Logger.Fatal(e.Start(":8080"))
}

그룹 및 미들웨어 체인

package main

import (
"net/http"
"strings"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

func main() {
e := echo.New()
e.Use(middleware.Logger(), middleware.Recover())

// 공개 라우트 그룹
public := e.Group("/api")
public.POST("/login", loginHandler)
public.POST("/register", registerHandler)

// 인증 필요 그룹
private := e.Group("/api/v1")
private.Use(jwtMiddleware)
private.GET("/me", meHandler)
private.GET("/users", listUsersHandler)

// 관리자 전용 그룹 (두 개의 미들웨어 체인)
admin := e.Group("/api/admin")
admin.Use(jwtMiddleware, adminOnlyMiddleware)
admin.GET("/stats", statsHandler)

e.Logger.Fatal(e.Start(":8080"))
}

func jwtMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
auth := c.Request().Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return c.JSON(http.StatusUnauthorized, echo.Map{
"error": echo.Map{"code": "UNAUTHORIZED", "message": "Bearer 토큰이 필요합니다"},
})
}
// 실제로는 JWT 파싱 후 사용자 정보를 c.Set으로 저장
c.Set("user_id", int64(1))
return next(c)
}
}

func adminOnlyMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// 실제로는 사용자 역할 확인
return next(c)
}
}

func loginHandler(c echo.Context) error { return c.JSON(http.StatusOK, echo.Map{"token": "..."}) }
func registerHandler(c echo.Context) error { return c.JSON(http.StatusCreated, echo.Map{"id": 1}) }
func meHandler(c echo.Context) error { return c.JSON(http.StatusOK, echo.Map{"id": c.Get("user_id")}) }
func listUsersHandler(c echo.Context) error { return c.JSON(http.StatusOK, echo.Map{"users": []any{}}) }
func statsHandler(c echo.Context) error { return c.JSON(http.StatusOK, echo.Map{"users": 100}) }

실전 예제: 파일 업로드 서버

package main

import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

const uploadDir = "./uploads"
const maxFileSize = 10 << 20 // 10MB

func init() {
os.MkdirAll(uploadDir, 0755)
}

// uploadHandler 단일 파일 업로드
func uploadHandler(c echo.Context) error {
// 업로드된 파일 가져오기
file, err := c.FormFile("file")
if err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{
"error": echo.Map{"code": "BAD_REQUEST", "message": "file 필드가 필요합니다"},
})
}

// 파일 크기 제한
if file.Size > maxFileSize {
return c.JSON(http.StatusRequestEntityTooLarge, echo.Map{
"error": echo.Map{"code": "FILE_TOO_LARGE", "message": "파일 크기는 10MB 이하여야 합니다"},
})
}

// 확장자 검증
ext := strings.ToLower(filepath.Ext(file.Filename))
allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".pdf": true}
if !allowedExts[ext] {
return c.JSON(http.StatusBadRequest, echo.Map{
"error": echo.Map{"code": "INVALID_FILE_TYPE", "message": "허용된 파일 형식: jpg, png, gif, pdf"},
})
}

// 파일 열기
src, err := file.Open()
if err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": echo.Map{"code": "SERVER_ERROR"}})
}
defer src.Close()

// 고유한 파일명 생성 (경로 순회 공격 방지)
safeFilename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
dstPath := filepath.Join(uploadDir, safeFilename)

// 파일 저장
dst, err := os.Create(dstPath)
if err != nil {
return c.JSON(http.StatusInternalServerError, echo.Map{"error": echo.Map{"code": "SERVER_ERROR"}})
}
defer dst.Close()

if _, err = io.Copy(dst, src); err != nil {
os.Remove(dstPath) // 실패 시 삭제
return c.JSON(http.StatusInternalServerError, echo.Map{"error": echo.Map{"code": "SAVE_FAILED"}})
}

return c.JSON(http.StatusOK, echo.Map{
"data": echo.Map{
"filename": safeFilename,
"original_name": file.Filename,
"size": file.Size,
"url": "/uploads/" + safeFilename,
},
})
}

// multiUploadHandler 다중 파일 업로드
func multiUploadHandler(c echo.Context) error {
form, err := c.MultipartForm()
if err != nil {
return c.JSON(http.StatusBadRequest, echo.Map{"error": echo.Map{"code": "BAD_REQUEST"}})
}

files := form.File["files"]
results := make([]echo.Map, 0, len(files))

for _, file := range files {
src, err := file.Open()
if err != nil {
continue
}
defer src.Close()

ext := filepath.Ext(file.Filename)
name := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
dst, _ := os.Create(filepath.Join(uploadDir, name))
io.Copy(dst, src)
dst.Close()

results = append(results, echo.Map{
"filename": name,
"original": file.Filename,
"size": file.Size,
})
}

return c.JSON(http.StatusOK, echo.Map{"data": results, "count": len(results)})
}

func main() {
e := echo.New()
e.Use(middleware.Logger(), middleware.Recover())

// 파일 업로드 엔드포인트
e.POST("/upload", uploadHandler)
e.POST("/upload/multiple", multiUploadHandler)

// 업로드된 파일 서빙
e.Static("/uploads", uploadDir)

e.Logger.Fatal(e.Start(":8080"))
}

Gin vs Echo 비교

항목GinEcho
성능매우 빠름빠름
API 스타일c *gin.Contextecho.Context 인터페이스
컨텍스트 확장c.Set/c.Get (map 기반)인터페이스 임베딩으로 타입 안전 확장
내장 미들웨어적음 (생태계 풍부)많음 (JWT, CORS, 압축 등)
바인딩ShouldBind + validatorBind + 커스텀 Validator
에러 처리직접 처리e.HTTPErrorHandler로 중앙화
인기도더 높음높음

고수 팁

1. 중앙 집중식 에러 처리: Echo의 e.HTTPErrorHandler를 오버라이드하면 모든 에러를 한 곳에서 처리할 수 있습니다

e.HTTPErrorHandler = func(err error, c echo.Context) {
code := http.StatusInternalServerError
message := "서버 내부 오류"
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
message = fmt.Sprintf("%v", he.Message)
}
c.JSON(code, echo.Map{"error": echo.Map{"code": http.StatusText(code), "message": message}})
}

2. echo.Map vs 구조체: 빠른 프로토타이핑에는 echo.Map이 편하지만, API 응답이 복잡해지면 구조체를 정의하는 것이 유지보수에 좋습니다

3. 미들웨어 순서: Echo에서 e.Use()로 등록된 미들웨어는 등록 순서대로 실행됩니다. Logger는 Recover 이후에 배치해 panic도 로깅하도록 하세요