본문으로 건너뛰기

컨트롤러 ​

CLI로 만든 hello-spine 프로젝트에 GET /user/:id 컨트롤러를 추가하고, 요청 경로의 정수를 JSON 응답으로 반환합니다. 이어서 쿼리, 헤더, 요청 본문을 받는 방법과 응답 타입을 살펴봅니다.

첫 번째 앱의 설치와 프로젝트 생성을 마친 뒤 프로젝트 루트에서 진행하세요. 이미 spine generate resource user를 실행했다면 생성 단계를 건너뛰고 파일을 확인합니다.

CLI로 컨트롤러 생성 ​

bash
spine generate resource user --dry-run

미리보기에서 생성 파일과 등록 코드 변경을 확인한 뒤 아래 명령으로 생성합니다.

bash
spine generate resource user

생성되는 파일 ​

text
src/user/
├── user_controller.go
├── user_service.go
├── user_repository.go
└── user_route.go

생성된 src/user/user_controller.go는 경로의 id를 받아 {"id":1} 형태의 응답을 반환합니다. UserService와 같은 user 패키지에 있으므로 별도 서비스 패키지를 가져올 필요가 없습니다.

go
package user

import (
    "github.com/NARUBROWN/spine/pkg/httpx"
    "github.com/NARUBROWN/spine/pkg/path"
)

type UserController struct { service *UserService }

func NewUserController(service *UserService) *UserController {
    return &UserController{service: service}
}

func (c *UserController) Get(id path.Int) httpx.Response[map[string]any] {
    return httpx.Response[map[string]any]{Body: map[string]any{"id": id.Value}}
}

INFO

resource는 조회 라우트와 의존성 등록을 갖춘 기본 구조를 생성합니다. 데이터베이스, 도메인 모델, 데이터 전달 객체(DTO), 생성이나 수정 기능은 직접 구현해야 합니다.

생성된 라우트 등록 확인 ​

CLI는 src/user/user_route.go에 생성자와 라우트를 등록합니다. BuildApplication()에는 user.Register(app) 호출과 오류 처리를 추가합니다. 아래 코드는 이미 생성된 등록을 보여 주므로 다시 추가하지 마세요.

go
// src/user/user_route.go
package user

import "github.com/NARUBROWN/spine"

func Register(app spine.App) error {
    app.Constructor(NewUserRepository, NewUserService, NewUserController)
    app.Route("GET", "/user/:id", (*UserController).Get)
    return nil
}
go
// internal/application/application.go: BuildApplication
if err := user.Register(app); err != nil {
    return nil, boot.Options{}, err
}

main.go의 BuildApplication → Validate → Run 흐름을 유지합니다. 이후 새 핸들러를 구현하면 해당 패키지의 Register 안에 라우트를 추가하세요.

생성 결과 검증과 실행 ​

프로젝트 루트에서 계약과 로컬 코드를 검사한 뒤 서버를 실행합니다.

bash
spine doctor
spine contract check
spine verify
spine dev

다른 터미널에서 생성된 라우트를 호출합니다.

bash
curl http://localhost:8080/user/1

GET /user/1은 HTTP 200과 {"id":1}을 반환해야 합니다. 이 응답은 경로 바인딩과 라우트 실행을 확인하는 결과이며, 데이터베이스에서 사용자를 조회한 결과는 아닙니다. spine contract check와 spine verify의 성공 여부는 실제 HTTP 응답과 별도로 확인하세요.

확장 예제의 전제 ​

이하 코드는 입력과 응답을 확장하는 서로 독립적인 예제입니다. 생성된 앱 전체를 대체하는 코드가 아니며, 같은 이름의 메서드 여러 개를 한꺼번에 붙여 넣으면 중복 선언 오류가 발생합니다. 필요한 핸들러 하나를 선택해 구현하고 라우트에 등록하세요.

UserResponse, 요청 DTO, HeaderInfo와 서비스의 Get, List, Create, Login 등은 각 예제에 맞게 직접 구현해야 합니다. 타입은 해당 컨트롤러와 같은 패키지에 두고 필요한 Go 표준 패키지와 Spine 패키지를 가져오세요. 아래 서비스 호출에서 생략한 오류 처리는 오류 응답의 예제를 참고해 구현합니다.

핸들러 시그니처 ​

Spine은 핸들러의 파라미터 타입에 맞춰 요청 값을 전달합니다. 이를 입력 바인딩이라고 합니다. 반환 타입은 응답을 만드는 방법을 결정합니다.

지원하는 파라미터 타입 ​

타입설명예시
context.Context요청 컨텍스트ctx context.Context
query.Values쿼리 파라미터q query.Values
query.Pagination페이지네이션page query.Pagination
header.ValuesHTTP 헤더headers header.Values
*struct (DTO)JSON 요청 본문req *CreateUserRequest
*struct (폼 DTO)폼의 텍스트 필드form *CreatePostForm
multipart.UploadedFiles멀티파트 업로드 파일files multipart.UploadedFiles
path.*경로 파라미터userId path.Int
spine.Ctx컨트롤러 컨텍스트spineCtx spine.Ctx

지원하는 반환 타입 ​

타입설명
httpx.Response[T]JSON 또는 문자열 응답 (상태 코드, 헤더, 쿠키 포함)
httpx.Redirect리다이렉트 응답
error오류 응답
httpx.Binary바이트 응답

응답 타입의 포인터와 (지원하는 응답 타입, error) 조합도 사용할 수 있습니다. httpx.Binary는 Content-Type과 바이트 본문을 지정할 때 사용합니다.

입력 받기 ​

쿼리 파라미터 ​

query.Values에서 쿼리 문자열을 읽습니다. 아래 요청에서는 id가 1, name이 alice, active가 true입니다.

go
// GET /user?id=1&name=alice&active=true

func (c *UserController) GetUser(
    ctx context.Context,
    q query.Values,
) httpx.Response[UserResponse] {
    id := q.Int("id", 0)                      // int64, 기본값 0
    name := q.String("name")                  // string
    active := q.GetBoolByKey("active", false) // bool, 기본값 false
    _ = name
    _ = active

    user, _ := c.service.Get(ctx, int(id))
    return httpx.Response[UserResponse]{Body: user}
}

query.Values 메서드 ​

메서드반환 타입설명
Get(key)string첫 번째 값 (없으면 빈 문자열)
String(key)string문자열 값
Int(key, default)int64값이 없거나 정수로 변환할 수 없으면 지정한 기본값
GetBoolByKey(key, default)bool값이 없거나 불리언으로 변환할 수 없으면 지정한 기본값
Has(key)bool키 존재 여부

페이지네이션 ​

query.Pagination은 page와 size 쿼리 파라미터를 정수로 변환합니다. 값이 없거나 정수로 변환할 수 없으면 각각 1과 20을 사용합니다. 음수나 0도 정수로 처리하므로 허용 범위는 앱에서 검증하세요.

go
// GET /user?page=2&size=10

func (c *UserController) ListUsers(
    ctx context.Context,
    page query.Pagination,
) httpx.Response[[]UserResponse] {
    // page.Page = 2 (기본값: 1)
    // page.Size = 10 (기본값: 20)
    users, _ := c.service.List(ctx, page.Page, page.Size)
    return httpx.Response[[]UserResponse]{Body: users}
}

HTTP 헤더 ​

header.Values를 사용해 HTTP 헤더에 접근합니다.

go
import "github.com/NARUBROWN/spine/pkg/header"

func (c *CommonController) CheckHeader(
    headers header.Values,
) httpx.Response[HeaderInfo] {
    return httpx.Response[HeaderInfo]{
        Body: HeaderInfo{
            UserAgent:   headers.Get("User-Agent"),
            ContentType: headers.Get("Content-Type"),
        },
    }
}

header.Values 메서드 ​

메서드반환 타입설명
Get(key)string헤더 값
Has(key)bool헤더 존재 여부

경로 파라미터 ​

라우트 경로의 파라미터를 path.Int, path.String, path.Boolean으로 받습니다. 경로에 선언한 파라미터 순서와 핸들러의 path 타입 파라미터 순서가 대응합니다. Go 변수명은 경로 파라미터 이름과 달라도 됩니다.

go
// GET /user/:id

func (c *UserController) GetUser(
    ctx context.Context,
    userId path.Int,
) httpx.Response[UserResponse] {
    user, _ := c.service.Get(ctx, int(userId.Value))
    return httpx.Response[UserResponse]{Body: user}
}

path 패키지 구조체 ​

go
package path

type Int struct {
    Value int64
}

type String struct {
    Value string
}

type Boolean struct {
    Value bool
}

JSON 요청 본문 ​

JSON 요청 본문을 받을 DTO는 구조체 포인터로 선언하고 필드에 json 태그를 붙입니다. 아래 예제는 Content-Type: application/json 요청의 name과 email을 CreateUserRequest에 바인딩한 뒤 HTTP 201로 응답합니다.

DTO에 form 태그가 하나라도 있으면 폼 DTO로 분류됩니다. 실제 본문 해석은 요청의 Content-Type에도 영향을 받으므로 JSON과 폼의 태그와 요청 형식을 맞추세요.

go
// POST /user
// Body: {"name": "Alice", "email": "alice@example.com"}

func (c *UserController) CreateUser(
    ctx context.Context,
    req *CreateUserRequest,  // ← 포인터로 선언
) httpx.Response[UserResponse] {
    user, _ := c.service.Create(ctx, req.Name, req.Email)
    return httpx.Response[UserResponse]{
        Body: user,
        Options: httpx.ResponseOptions{
            Status: 201,
        },
    }
}
go
// src/user/user_request.go
type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

컨트롤러 컨텍스트 spine.Ctx ​

인터셉터가 ctx.Set으로 저장한 값은 컨트롤러에서 spine.Ctx.Get으로 조회합니다. 컨트롤러에는 조회 기능만 제공됩니다. 아래 예제는 userRole 키에 문자열을 저장했다는 전제이므로, 실제 앱에서는 저장하는 쪽과 읽는 쪽의 타입을 맞추세요.

go
import "github.com/NARUBROWN/spine/pkg/spine"

func (c *UserController) GetUser(
    ctx context.Context,
    userId path.Int,
    spineCtx spine.Ctx,
) httpx.Response[UserResponse] {
    // 인터셉터에서 Set한 값 조회
    if v, ok := spineCtx.Get("userRole"); ok {
        role := v.(string)
        // ...
    }

    user, _ := c.service.Get(ctx, int(userId.Value))
    return httpx.Response[UserResponse]{Body: user}
}

응답 반환 ​

1. httpx.Response[T]로 JSON과 문자열 응답 반환 ​

httpx.Response[T]의 Body에 응답 본문을 넣고 Options에서 상태 코드, 헤더, 쿠키를 설정합니다. 상태 코드를 생략하면 HTTP 200을 사용합니다.

go
import "github.com/NARUBROWN/spine/pkg/httpx"

기본 JSON 응답 ​

go
func (c *UserController) GetUser(
    ctx context.Context,
    userId path.Int,
) httpx.Response[UserResponse] {
    user, _ := c.service.Get(ctx, int(userId.Value))

    return httpx.Response[UserResponse]{
        Body: user,  // 200 OK (기본값)
    }
}

문자열 응답 ​

go
func (c *UserController) Health() httpx.Response[string] {
    return httpx.Response[string]{
        Body: "OK",
    }
}

상태 코드 지정 ​

go
func (c *UserController) CreateUser(
    ctx context.Context,
    req *CreateUserRequest,
) httpx.Response[UserResponse] {
    user, _ := c.service.Create(ctx, req.Name, req.Email)

    return httpx.Response[UserResponse]{
        Body: user,
        Options: httpx.ResponseOptions{
            Status: 201, // Created
        },
    }
}

응답 헤더 추가 ​

go
func (c *UserController) GetUser(
    ctx context.Context,
    userId path.Int,
) httpx.Response[UserResponse] {
    user, _ := c.service.Get(ctx, int(userId.Value))

    return httpx.Response[UserResponse]{
        Body: user,
        Options: httpx.ResponseOptions{
            Headers: map[string]string{
                "X-Custom-Header": "custom-value",
                "Cache-Control":   "max-age=3600",
            },
        },
    }
}

쿠키 설정 ​

go
func (c *AuthController) Login(
    ctx context.Context,
    req *LoginRequest,
) httpx.Response[LoginResponse] {
    token, refreshToken, _ := c.service.Login(ctx, req.Email, req.Password)

    return httpx.Response[LoginResponse]{
        Body: LoginResponse{Success: true},
        Options: httpx.ResponseOptions{
            Cookies: []httpx.Cookie{
                httpx.AccessTokenCookie(token, 15*time.Minute),
                httpx.RefreshTokenCookie(refreshToken, 7*24*time.Hour),
            },
        },
    }
}
go
type Cookie struct {
    Name     string
    Value    string
    Path     string
    Domain   string
    MaxAge   int
    Expires  *time.Time
    HttpOnly bool
    Secure   bool
    SameSite SameSite  // SameSiteLax, SameSiteStrict, SameSiteNone
    Priority string    // "Low" | "Medium" | "High"
}

쿠키 헬퍼 함수 ​

함수설명
httpx.AccessTokenCookie(token, ttl)액세스 토큰 쿠키 생성
httpx.RefreshTokenCookie(token, ttl)갱신 토큰 쿠키 생성
httpx.DefaultRefreshTokenCookie(token)유효 기간이 7일인 갱신 토큰 쿠키 생성
httpx.ClearAccessTokenCookie()액세스 토큰 쿠키 삭제
httpx.ClearRefreshTokenCookie()갱신 토큰 쿠키 삭제

2. httpx.Redirect로 리다이렉트 응답 반환 ​

httpx.Redirect를 사용하면 클라이언트를 다른 URL로 리다이렉트합니다.

기본 리다이렉트 (302 Found) ​

go
func (c *AuthController) OAuthCallback(
    ctx context.Context,
    q query.Values,
) httpx.Redirect {
    code := q.String("code")
    c.service.ProcessOAuthCode(ctx, code)

    return httpx.Redirect{
        Location: "/dashboard",  // 302 Found (기본값)
    }
}

상태 코드 지정 ​

go
import "net/http"

func (c *UserController) MovedPermanently() httpx.Redirect {
    return httpx.Redirect{
        Location: "/new-location",
        Options: httpx.ResponseOptions{
            Status: http.StatusMovedPermanently, // 301
        },
    }
}

리다이렉트와 함께 쿠키 설정 ​

go
func (c *AuthController) Login(
    ctx context.Context,
    req *LoginRequest,
) httpx.Redirect {
    token, _ := c.service.Login(ctx, req.Email, req.Password)

    return httpx.Redirect{
        Location: "/dashboard",
        Options: httpx.ResponseOptions{
            Cookies: []httpx.Cookie{
                httpx.AccessTokenCookie(token, 15*time.Minute),
            },
        },
    }
}

로그아웃 (쿠키 삭제 후 리다이렉트) ​

go
func (c *AuthController) Logout() httpx.Redirect {
    return httpx.Redirect{
        Location: "/login",
        Options: httpx.ResponseOptions{
            Cookies: []httpx.Cookie{
                httpx.ClearAccessTokenCookie(),
                httpx.ClearRefreshTokenCookie(),
            },
        },
    }
}

3. error로 오류 응답 반환 ​

httperr로 오류를 반환하면 지정한 HTTP 상태 코드와 message 필드를 가진 JSON 응답을 만듭니다. 아래 예제는 서비스 오류를 사용자 조회 실패로 처리해 HTTP 404를 반환합니다. 실제 앱에서는 오류의 원인을 구분해 알맞은 상태 코드로 변환하세요.

go
import "github.com/NARUBROWN/spine/pkg/httperr"

func (c *UserController) GetUser(
    ctx context.Context,
    userId path.Int,
) error {
    _, err := c.service.Get(ctx, int(userId.Value))
    if err != nil {
        return httperr.NotFound("유저를 찾을 수 없습니다.")
    }
    return nil
}

httperr 함수 ​

함수상태 코드
httperr.BadRequest(msg)400
httperr.Unauthorized(msg)401
httperr.NotFound(msg)404

위 예제의 오류 응답 본문은 다음과 같습니다.

json
{
  "message": "유저를 찾을 수 없습니다."
}

httpx.Response[T]와 error 함께 사용 ​

성공 응답과 오류를 함께 반환하려면 (httpx.Response[T], error)를 사용합니다. 실패하면 빈 응답 값과 오류를 반환하고, 성공하면 본문이 있는 응답과 nil을 반환합니다. 오류가 있으면 성공 응답 본문은 사용하지 않습니다.

go
func (c *UserController) GetUser(
    ctx context.Context,
    userID path.Int,
) (httpx.Response[UserResponse], error) {
    user, err := c.service.Get(ctx, int(userID.Value))
    if err != nil {
        return httpx.Response[UserResponse]{}, httperr.NotFound("user not found")
    }
    return httpx.Response[UserResponse]{Body: user}, nil
}

응답 타입 선택 ​

상황권장 반환 타입
JSON 응답 (상태 코드/헤더/쿠키 포함)httpx.Response[T]
문자열 응답httpx.Response[string]
리다이렉트httpx.Redirect
바이트 본문과 Content-Type 지정httpx.Binary
오류만 반환error

폼 DTO와 멀티파트 파일 ​

폼의 텍스트 필드는 구조체 포인터인 폼 DTO로 받고, 업로드 파일은 multipart.UploadedFiles로 받습니다. 두 타입을 같은 핸들러의 파라미터로 선언하면 한 요청에서 텍스트와 파일을 함께 처리할 수 있습니다.

이하 예제의 PostController와 FileController는 직접 구현하고 생성자와 라우트를 등록해야 합니다. /posts와 /upload가 등록되어 있어야 아래 curl 요청을 실행할 수 있으며, 파일 업로드 요청에는 지정한 경로에 실제 파일이 있어야 합니다.

1. 폼 텍스트 필드 받기 ​

폼 DTO는 multipart/form-data 또는 application/x-www-form-urlencoded 요청의 텍스트 필드를 받습니다. DTO 필드에 form 태그로 요청 필드명을 지정합니다.

폼 DTO 정의 ​

go
type CreatePostForm struct {
    Title   string `form:"title"`
    Content string `form:"content"`
}

핸들러 구현 ​

go
func (c *PostController) Create(
    form *CreatePostForm, // Form DTO
) httpx.Response[string] {
    fmt.Println("Title  :", form.Title)
    fmt.Println("Content:", form.Content)

    return httpx.Response[string]{Body: "OK"}
}

요청 예시 (curl) ​

bash
curl -X POST http://localhost:8080/posts \
  -F "title=hello" \
  -F "content=spine"

2. 업로드 파일 받기 ​

업로드 파일은 DTO 필드에 넣지 않고 별도의 multipart.UploadedFiles 파라미터로 받습니다. 아래 예제는 파일의 필드명, 파일명, 크기를 로그로 출력합니다.

업로드 파일 타입 ​

github.com/NARUBROWN/spine/pkg/multipart 패키지의 타입을 사용합니다.

go
import "github.com/NARUBROWN/spine/pkg/multipart"

핸들러 구현 ​

go
func (c *FileController) Upload(
    files multipart.UploadedFiles, // Multipart files
) httpx.Response[string] {
    fmt.Println("Files count:", len(files.Files))

    for _, f := range files.Files {
        fmt.Println(
            "field:", f.FieldName,
            "name:", f.Filename,
            "size:", f.Size,
        )
    }

    return httpx.Response[string]{Body: "OK"}
}

UploadedFile 구조체 ​

go
type UploadedFile struct {
    FieldName   string
    Filename    string
    ContentType string
    Size        int64
    Open        func() (io.ReadCloser, error)
}

요청 예시 (curl) ​

bash
curl -X POST http://localhost:8080/upload \
  -F "file=@test1.png" \
  -F "file=@test2.jpg"

3. 폼, 파일, 쿼리 함께 받기 ​

같은 핸들러에서 폼 DTO, 업로드 파일, 페이지네이션을 함께 받을 수 있습니다. 아래 예제는 각 값을 읽어 출력하며 파일을 저장하지는 않습니다.

핸들러 구현 ​

go
func (c *PostController) Upload(
    ctx context.Context,
    form  *CreatePostForm,           // form fields
    files multipart.UploadedFiles,   // multipart files
    page  query.Pagination,          // query
) httpx.Response[string] {
    fmt.Println("[FORM] Title  :", form.Title)
    fmt.Println("[FORM] Content:", form.Content)

    fmt.Println("[QUERY] Page:", page.Page)
    fmt.Println("[QUERY] Size:", page.Size)

    fmt.Println("[FILES] Count:", len(files.Files))

    return httpx.Response[string]{Body: "OK"}
}

입력 타입을 구분하는 기준 ​

JSON과 폼 DTO는 구조체 포인터로 받습니다. 쿼리, 경로, 업로드 파일은 query.Pagination, path.Int, multipart.UploadedFiles처럼 Spine이 지원하는 값 타입으로 받습니다. 임의의 구조체 값을 선언하면 자동으로 바인딩되는 것은 아닙니다.

입력 오류 처리 시 주의사항 ​

경로 값을 정수로 변환하지 못하거나 JSON 본문을 해석하지 못하면 컨트롤러 호출 전에 입력 바인딩이 실패합니다. Spine v0.5.1에서 이 실패가 항상 HTTP 400으로 변환되는 것은 아닙니다. 잘못된 JSON과 정수가 아닌 경로 값은 기본 실행 경로에서 HTTP 500으로 응답할 수 있으므로, 필요한 오류 정책을 구현한 뒤 실제 요청으로 확인하세요.

다음 단계 ​