V 실전: vweb으로 REST API 만들기 (상)

V 실전: vweb으로 REST API 만들기 (상)

지금까지 파일을 정리하고, JSON을 다루고, Go와 비교까지 했다. 이제 프로그래밍의 꽃인 웹 서버를 만들 차례다. V는 별도의 프레임워크를 설치할 필요 없이, 내장 웹 프레임워크 vweb을 제공한다. 이번 편에서는 vweb의 기초를 배우고, 할일(Todo) API의 전반부를 완성한다.

이번 편에서 만드는 것

브라우저나 API 클라이언트에서 접속하면 할일 목록을 JSON으로 응답하는 REST API 서버다.

GET  /api/todos         → 전체 할일 목록 조회
GET  /api/todos/1       → 1번 할일 조회
POST /api/todos         → 새 할일 추가

웹 서버란?

코드를 작성하기 전에, 웹 서버가 무엇인지 간단히 짚고 넘어가자.

웹 서버는 "요청을 받고, 응답을 보내는 프로그램"이다. 비유하자면 식당과 같다.

손님(클라이언트)         식당(웹 서버)
    │                       │
    ├─ "메뉴 보여주세요" ──→  │  요청(Request)
    │                       │
    │  ←── 메뉴판 전달 ─────┤  응답(Response)
    │                       │
    ├─ "김치찌개 주세요" ──→  │  요청(Request)
    │                       │
    │  ←── 김치찌개 전달 ───┤  응답(Response)
    │                       │

웹에서는 이 대화가 HTTP 프로토콜로 이루어진다.

요소 식당 비유 HTTP
클라이언트 손님 브라우저, 앱, API 클라이언트
서버 식당 웹 서버 (vweb)
요청 "김치찌개 주세요" GET /api/todos
응답 김치찌개 {"todos": [...]} (JSON 데이터)

HTTP 메서드

손님이 식당에서 하는 행동이 여러 가지이듯, HTTP에도 "무엇을 하려는지" 나타내는 메서드(method) 가 있다.

HTTP 메서드 의미 식당 비유
GET 데이터 조회 "메뉴판 보여주세요"
POST 데이터 생성 "김치찌개 주문합니다"
PUT 데이터 전체 수정 "주문 변경합니다 (전체)"
DELETE 데이터 삭제 "주문 취소합니다"

이번 편에서는 GETPOST를 다룬다. (다음 편에서 PUTDELETE도 추가한다.)

REST API란?

REST API는 URL과 HTTP 메서드를 조합해서 데이터를 다루는 규칙이다. 예를 들어:

GET    /api/todos       →  모든 할일 "조회"
GET    /api/todos/1     →  1번 할일 "조회"
POST   /api/todos       →  새 할일 "생성"
PUT    /api/todos/1     →  1번 할일 "수정"
DELETE /api/todos/1     →  1번 할일 "삭제"

URL이 "무엇을" (todos), HTTP 메서드가 "어떻게" (GET=조회, POST=생성)를 나타낸다. 이 패턴을 CRUD(Create, Read, Update, Delete) 라고 부른다. 대부분의 웹 애플리케이션은 이 네 가지 동작의 조합이다.


1단계: vweb 첫 번째 서버

최소한의 웹 서버

vweb으로 동작하는 가장 간단한 웹 서버를 만들어보자.

import vweb

// App 구조체 — 웹 서버의 "본체"
struct App {
    vweb.Context        // vweb의 기능을 가져온다
}

// '/' 경로에 대한 핸들러
fn (mut app App) index() vweb.Result {
    return app.text('Hello, vweb!')
}

fn main() {
    // 포트 8080에서 서버 시작
    vweb.run(app: &App{}, port: 8080)
}

코드가 짧지만, 이 안에 웹 서버의 핵심이 다 들어있다. 한 줄씩 살펴보자.

struct App { vweb.Context }

App은 웹 서버의 "본체"다. 문법 시리즈 6편에서 배운 임베디드 구조체(Embedded Struct) 를 사용한다. vweb.Context를 임베드하면, App이 vweb의 모든 기능(요청 파싱, 응답 전송 등)을 자동으로 갖게 된다.

App 구조체
┌─────────────────────────┐
│ vweb.Context (임베디드)  │ ← 요청/응답 처리 기능
│   .text()               │
│   .json()               │
│   .html()               │
│   .req (요청 정보)       │
│   ...                   │
├─────────────────────────┤
│ (나중에 추가할 필드들)   │ ← 데이터베이스, 설정 등
└─────────────────────────┘

fn (mut app App) index() vweb.Result

이것은 라우트 핸들러(route handler) 다. App의 메서드로 정의하되, 이름이 index이면 자동으로 루트 경로 / 에 매핑된다. 메서드 이름이 곧 URL이 되는 것이다. 식당 비유로 말하면, "손님이 /로 왔을 때 이 함수가 응대한다"는 뜻이다.

반환 타입 vweb.Result는 "HTTP 응답"을 나타낸다.

app.text('Hello, vweb!')

순수 텍스트를 응답으로 보낸다. 브라우저에 "Hello, vweb!"이라는 글자가 표시된다.

vweb.run(app: &App{}, port: 8080)

서버를 포트 8080에서 시작한다. 이 줄이 실행되면 프로그램이 멈추지 않고 계속 대기하면서 요청을 기다린다. &App{}&는 포인터를 뜻하는데, vweb이 내부적으로 요청을 처리하기 위해 필요하다.

실행하기

v run server.v

터미널에 별다른 메시지 없이 프로그램이 대기 상태에 들어간다. 이제 브라우저를 열고 http://localhost:8080에 접속하면 "Hello, vweb!"이 표시된다.

서버를 중지하려면 터미널에서 Ctrl+C를 누른다.


2단계: 라우팅 — URL과 함수 연결

자동 라우팅

vweb에서는 메서드 이름이 곧 URL이 된다. 이것을 자동 라우팅이라고 한다.

import vweb

struct App {
    vweb.Context
}

// GET /          → 메서드 이름 'index'가 '/'에 매핑
fn (mut app App) index() vweb.Result {
    return app.text('홈페이지입니다')
}

// GET /about     → 메서드 이름 'about'이 '/about'에 매핑
fn (mut app App) about() vweb.Result {
    return app.text('소개 페이지입니다')
}

// GET /contact   → 메서드 이름 'contact'이 '/contact'에 매핑
fn (mut app App) contact() vweb.Result {
    return app.text('연락처 페이지입니다')
}

fn main() {
    vweb.run(app: &App{}, port: 8080)
}
메서드 이름          URL 매핑
index()         →   /
about()         →   /about
contact()       →   /contact

index는 특별한 이름으로, 루트 경로 /에 자동 매핑된다. 나머지 메서드는 이름 그대로 URL이 된다. 별도의 라우팅 설정 파일이 필요 없다.

사용자 지정 라우팅

메서드 이름과 다른 URL을 쓰고 싶을 때는 @['/경로'] 속성을 사용한다.

import vweb

struct App {
    vweb.Context
}

// URL을 직접 지정 — '/api/health'
@['/api/health']
fn (mut app App) health_check() vweb.Result {
    return app.text('OK')
}

// 여러 단계의 경로도 가능
@['/api/v1/status']
fn (mut app App) api_status() vweb.Result {
    return app.text('서버 정상 동작 중')
}

fn main() {
    vweb.run(app: &App{}, port: 8080)
}

@['/api/health']는 "이 메서드를 /api/health URL에 연결해라"라는 뜻이다. 메서드 이름(health_check)과 URL(/api/health)이 달라도 된다. API를 만들 때 /api/... 형태의 URL을 쓰는 것이 일반적이므로, 사용자 지정 라우팅이 필수다.

URL 파라미터 — 동적 경로

URL에 변하는 값이 들어갈 때가 있다. 예를 들어 /api/todos/1, /api/todos/2처럼 할일 번호가 URL에 포함되는 경우다. 이럴 때 URL 파라미터를 사용한다.

import vweb

struct App {
    vweb.Context
}

// :id 부분이 URL 파라미터 — 어떤 값이든 들어올 수 있다
@['/api/todos/:id']
fn (mut app App) get_todo(id int) vweb.Result {
    return app.text('할일 #${id}번을 조회합니다')
}

fn main() {
    vweb.run(app: &App{}, port: 8080)
}

:id플레이스홀더(자리 표시자) 다. URL에서 해당 위치의 값이 함수 매개변수 id로 자동 전달된다.

요청: GET /api/todos/1    →  id = 1
요청: GET /api/todos/42   →  id = 42
요청: GET /api/todos/100  →  id = 100

매개변수 타입을 int로 선언했으므로, vweb이 자동으로 문자열을 정수로 변환해준다. string으로 선언하면 문자열 그대로 받을 수도 있다.


3단계: JSON 응답 — API의 핵심

REST API는 대부분 JSON으로 응답한다. vweb에서 JSON 응답을 보내는 방법을 알아보자.

기본 JSON 응답

import vweb
import json

struct App {
    vweb.Context
}

struct Message {
    status string
    text   string
}

@['/api/hello']
fn (mut app App) api_hello() vweb.Result {
    msg := Message{
        status: 'ok'
        text: '안녕하세요!'
    }
    return app.json(msg)
}

fn main() {
    vweb.run(app: &App{}, port: 8080)
}

app.json(msg) — 구조체를 자동으로 JSON으로 변환해서 응답한다. 2편에서 배운 json.encode()를 vweb이 내부적으로 호출해주는 것이다. 이 응답을 받으면 클라이언트는 다음과 같은 JSON을 받게 된다.

{
    "status": "ok",
    "text": "안녕하세요!"
}

Content-Type: application/json 헤더도 자동으로 설정된다. 클라이언트가 "이 응답은 JSON이다"라고 인식할 수 있게 해준다.

응답 종류 정리

vweb에서 쓸 수 있는 응답 메서드를 정리하면 이렇다.

메서드 응답 형태 사용 상황
app.text('...') 순수 텍스트 간단한 문자열 응답
app.json(구조체) JSON API 응답 (가장 자주 사용)
app.html('...') HTML 웹 페이지
app.redirect('/경로') 리다이렉트 다른 페이지로 이동

4단계: POST 요청 처리 — 데이터 받기

GET이 "데이터를 달라"는 요청이라면, POST는 "데이터를 보낼 테니 받아라"는 요청이다.

HTTP 메서드 지정

기본적으로 vweb의 핸들러는 GET 요청만 처리한다. POST 요청을 처리하려면 @[post] 속성을 추가한다.

import vweb
import json

struct App {
    vweb.Context
}

struct Todo {
    id        int
    title     string
    completed bool
}

struct CreateTodoRequest {
    title string
}

// GET — 할일 목록 조회
@['/api/todos']
fn (mut app App) get_todos() vweb.Result {
    todos := [
        Todo{ id: 1, title: 'V 언어 배우기', completed: true },
        Todo{ id: 2, title: 'REST API 만들기', completed: false },
    ]
    return app.json(todos)
}

// POST — 새 할일 추가
@['/api/todos'; post]
fn (mut app App) create_todo() vweb.Result {
    // 요청 본문(body)에서 JSON 읽기
    body := app.req.data
    req := json.decode(CreateTodoRequest, body) or {
        // JSON이 잘못되면 400 Bad Request 응답
        app.set_status(400, 'Bad Request')
        return app.text('잘못된 요청입니다')
    }

    // 새 할일 생성 (지금은 임시 데이터)
    new_todo := Todo{
        id: 3
        title: req.title
        completed: false
    }

    return app.json(new_todo)
}

fn main() {
    vweb.run(app: &App{}, port: 8080)
}

핵심 부분을 하나씩 짚어보자.

@['/api/todos'; post] — 이 핸들러가 POST /api/todos 요청을 처리한다는 뜻이다. 세미콜론(;)으로 경로와 HTTP 메서드를 구분한다. @['/api/todos']만 쓰면 GET으로 자동 설정된다.

같은 URL(/api/todos)에 GETPOST를 각각 다른 핸들러에 연결한 것에 주목하자. URL은 같지만 HTTP 메서드로 구분된다. REST API의 기본 원칙이다.

app.req.data — 클라이언트가 보낸 요청 본문(body) 을 문자열로 읽는다. POST 요청에서는 JSON 데이터가 body에 담겨 온다.

json.decode(CreateTodoRequest, body) — 2편에서 배운 JSON 디코딩이다! body 문자열을 CreateTodoRequest 구조체로 변환한다.

app.set_status(400, 'Bad Request') — HTTP 상태 코드를 설정한다. 상태 코드는 식당에서의 응답과 같은 것이다.

상태 코드 의미 식당 비유
200 성공 (기본값) "주문하신 음식 나왔습니다"
201 생성 성공 "새 주문이 접수되었습니다"
400 잘못된 요청 "죄송합니다, 그런 메뉴는 없어요"
404 찾을 수 없음 "죄송합니다, 3번 테이블은 없어요"
500 서버 에러 "죄송합니다, 주방에 문제가 생겼어요"

API 테스트하기

서버를 실행한 후, 다른 터미널에서 curl 명령으로 API를 테스트할 수 있다.

# GET — 할일 목록 조회
curl http://localhost:8080/api/todos

# 응답:
# [{"id":1,"title":"V 언어 배우기","completed":true},
#  {"id":2,"title":"REST API 만들기","completed":false}]

# POST — 새 할일 추가
curl -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "vweb 마스터하기"}'

# 응답:
# {"id":3,"title":"vweb 마스터하기","completed":false}

curl이 익숙하지 않다면, 브라우저에서 http://localhost:8080/api/todos를 열면 GET 요청의 결과를 볼 수 있다. POST 테스트에는 Postman, Insomnia 같은 API 테스트 도구를 사용해도 좋다.


5단계: 합치기 — 할일 API 서버 (전반부)

지금까지 배운 것을 모두 합쳐서, 메모리에 할일 목록을 관리하는 API 서버를 만들자. (다음 편에서 데이터베이스를 추가한다.)

완성 코드

import vweb
import json

// 할일 데이터 구조체
struct Todo {
    id        int
    title     string
    completed bool
}

// 할일 생성 요청
struct CreateTodoRequest {
    title string
}

// API 응답 래퍼
struct ApiResponse {
    success bool
    message string
}

// 웹 서버 본체
struct App {
    vweb.Context
pub mut:
    todos  []Todo     // 할일 목록 (메모리 저장)
    next_id int = 1   // 다음 할일의 ID
}

// GET / — 홈
fn (mut app App) index() vweb.Result {
    return app.text('📝 Todo API Server v1.0\n\nEndpoints:\n  GET  /api/todos     - 전체 조회\n  GET  /api/todos/:id - 개별 조회\n  POST /api/todos     - 새로 추가')
}

// GET /api/todos — 전체 할일 조회
@['/api/todos']
fn (mut app App) get_todos() vweb.Result {
    return app.json(app.todos)
}

// GET /api/todos/:id — 개별 할일 조회
@['/api/todos/:id']
fn (mut app App) get_todo(id int) vweb.Result {
    // 해당 ID의 할일 찾기
    for todo in app.todos {
        if todo.id == id {
            return app.json(todo)
        }
    }

    // 못 찾으면 404
    app.set_status(404, 'Not Found')
    return app.json(ApiResponse{
        success: false
        message: '할일 #${id}번을 찾을 수 없습니다'
    })
}

// POST /api/todos — 새 할일 추가
@['/api/todos'; post]
fn (mut app App) create_todo() vweb.Result {
    // 요청 본문에서 JSON 파싱
    body := app.req.data
    req := json.decode(CreateTodoRequest, body) or {
        app.set_status(400, 'Bad Request')
        return app.json(ApiResponse{
            success: false
            message: '잘못된 요청 형식입니다'
        })
    }

    // 제목이 비어있는지 검증
    if req.title.trim_space() == '' {
        app.set_status(400, 'Bad Request')
        return app.json(ApiResponse{
            success: false
            message: '제목을 입력해주세요'
        })
    }

    // 새 할일 생성
    new_todo := Todo{
        id: app.next_id
        title: req.title
        completed: false
    }
    app.todos << new_todo
    app.next_id += 1

    // 201 Created 상태 코드로 응답
    app.set_status(201, 'Created')
    return app.json(new_todo)
}

fn main() {
    mut app := &App{}

    // 초기 데이터 추가
    app.todos << Todo{ id: app.next_id, title: 'V 언어 배우기', completed: true }
    app.next_id += 1
    app.todos << Todo{ id: app.next_id, title: 'REST API 만들기', completed: false }
    app.next_id += 1

    println('🚀 Todo API 서버 시작: http://localhost:8080')
    vweb.run(app: app, port: 8080)
}

완성 코드 핵심 설명

App에 데이터 저장:

struct App {
    vweb.Context
pub mut:
    todos  []Todo       // id 할일 목록
    next_id int = 1     // 자동 증가 ID
}

App 구조체에 todos 배열과 next_id를 추가했다. pub mut:으로 선언한 이유는, vweb 핸들러에서 이 값들을 읽고 수정해야 하기 때문이다. 지금은 메모리에 저장하므로 서버를 재시작하면 데이터가 사라진다. (다음 편에서 데이터베이스로 영구 저장한다.)

개별 할일 조회와 404 처리:

@['/api/todos/:id']
fn (mut app App) get_todo(id int) vweb.Result {
    for todo in app.todos {
        if todo.id == id {
            return app.json(todo)      // 찾으면 바로 반환
        }
    }
    // 못 찾으면 404
    app.set_status(404, 'Not Found')
    return app.json(ApiResponse{ success: false, message: '...' })
}

for문으로 배열을 순회하면서 ID가 일치하는 할일을 찾는다. 찾으면 즉시 return하고, 루프가 끝까지 돌았는데 못 찾으면 404를 반환한다.

입력 검증:

if req.title.trim_space() == '' {
    app.set_status(400, 'Bad Request')
    return app.json(ApiResponse{ success: false, message: '제목을 입력해주세요' })
}

사용자가 빈 제목을 보내면 에러를 반환한다. trim_space()로 공백만 있는 경우도 걸러낸다. 실전 API에서 입력 검증은 필수다.

배열에 추가:

app.todos << new_todo       // 배열 끝에 추가 (5편에서 배운 << 연산자)
app.next_id += 1            // ID 증가

5편에서 배운 << 연산자로 새 할일을 배열에 추가하고, ID를 하나 올린다.

실행 및 테스트

# 서버 시작
v run todo_server.v
# 🚀 Todo API 서버 시작: http://localhost:8080
# 테스트 1: 전체 할일 조회
curl http://localhost:8080/api/todos
# [{"id":1,"title":"V 언어 배우기","completed":true},
#  {"id":2,"title":"REST API 만들기","completed":false}]

# 테스트 2: 개별 할일 조회
curl http://localhost:8080/api/todos/1
# {"id":1,"title":"V 언어 배우기","completed":true}

# 테스트 3: 존재하지 않는 할일
curl http://localhost:8080/api/todos/999
# {"success":false,"message":"할일 #999번을 찾을 수 없습니다"}

# 테스트 4: 새 할일 추가
curl -X POST http://localhost:8080/api/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "vweb 마스터하기"}'
# {"id":3,"title":"vweb 마스터하기","completed":false}

# 테스트 5: 추가 후 전체 조회 — 3개가 된다
curl http://localhost:8080/api/todos
# [{"id":1,...}, {"id":2,...}, {"id":3,"title":"vweb 마스터하기",...}]

서버가 정상적으로 동작한다! 할일을 조회하고, 추가하고, 없는 할일을 요청하면 404를 반환한다.


문법 시리즈 연결

활용된 문법 배운 편 이번 편에서
구조체, 임베디드 구조체 6편 Appvweb.Context 임베드
메서드 6편 fn (mut app App) get_todos()
배열, << 연산자 5편 app.todos << new_todo
for...in 순회 4편 할일 검색 루프
JSON encode/decode 실전 2편 요청/응답 JSON 처리
or {} 에러 처리 8편 JSON 파싱 실패 처리

📝 정리

이번 글에서 배운 핵심 포인트를 체크리스트로 정리한다.

  • [x] 웹 서버 원리 — 요청(Request)을 받아 응답(Response)을 보내는 프로그램
  • [x] vweb 기본 구조App 구조체에 vweb.Context 임베드, vweb.run()으로 시작
  • [x] 자동 라우팅 — 메서드 이름 = URL (index/, about/about)
  • [x] 사용자 지정 라우팅@['/api/path'] 속성으로 URL 지정
  • [x] URL 파라미터@['/api/todos/:id'], 함수 매개변수로 자동 전달
  • [x] JSON 응답app.json(구조체)로 자동 변환
  • [x] POST 처리@['/path'; post], app.req.data로 body 읽기
  • [x] HTTP 상태 코드app.set_status(404, 'Not Found')
  • [x] 입력 검증 — 빈 값, 잘못된 JSON 등 사전 검사 필수

다음 편 예고

실전 4편(하): vweb REST API + ORM — 데이터베이스 연동

지금은 할일을 메모리에 저장하고 있어서, 서버를 재시작하면 데이터가 사라진다. 다음 편에서는 V의 내장 ORMSQLite를 이용해서 데이터를 영구적으로 저장하고, PUT(수정)과 DELETE(삭제) 기능도 추가해서 CRUD를 완성한다.