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 | 데이터 삭제 | "주문 취소합니다" |
이번 편에서는 GET과 POST를 다룬다. (다음 편에서 PUT과 DELETE도 추가한다.)
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)에 GET과 POST를 각각 다른 핸들러에 연결한 것에 주목하자. 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편 | App에 vweb.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의 내장 ORM과 SQLite를 이용해서 데이터를 영구적으로 저장하고, PUT(수정)과 DELETE(삭제) 기능도 추가해서 CRUD를 완성한다.