V 실전: vweb REST API + ORM — 데이터베이스 연동
4편(상)에서 만든 Todo API는 할일을 메모리에 저장했다. 서버를 재시작하면 모든 데이터가 사라지는 치명적인 문제가 있었다. 이번 편에서는 V의 내장 ORM과 SQLite를 이용해서 데이터를 영구 저장하고, 수정(PUT)과 삭제(DELETE)를 추가해서 CRUD를 완성한다.
이번 편에서 완성하는 것
GET /api/todos → 전체 할일 조회 (이전 편에서 구현)
GET /api/todos/:id → 개별 할일 조회 (이전 편에서 구현)
POST /api/todos → 새 할일 추가 (이전 편에서 구현)
PUT /api/todos/:id → 할일 수정 ⭐ 이번 편에서 추가
DELETE /api/todos/:id → 할일 삭제 ⭐ 이번 편에서 추가
데이터 저장도 메모리 → SQLite 데이터베이스로 업그레이드한다.
ORM이란?
ORM(Object-Relational Mapping) 은 구조체와 데이터베이스 테이블을 연결하는 기술이다.
비유하자면 통역사다. 프로그래머는 V 언어로 말하고, 데이터베이스는 SQL이라는 언어를 쓴다. ORM이 중간에서 V 코드를 SQL로 번역해준다.
프로그래머 (V 코드) ORM (통역사) 데이터베이스 (SQL)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ │ │ │ │ │
│ V 구조체 │─────→│ 자동 번역 │─────→│ SQL 실행 │
│ V 문법 │ │ │ │ │
│ │←─────│ 결과 변환 │←─────│ 결과 반환 │
└──────────────┘ └──────────────┘ └──────────────┘
ORM 없이 (직접 SQL 작성)
INSERT INTO todos (title, completed) VALUES ('V 언어 배우기', false);
SELECT * FROM todos WHERE id = 1;
UPDATE todos SET completed = true WHERE id = 1;
DELETE FROM todos WHERE id = 1;
ORM 사용 (V 코드로)
// INSERT
sql db { insert todo into Todo }
// SELECT
result := sql db { select from Todo where id == 1 }
// UPDATE
sql db { update Todo set completed = true where id == 1 }
// DELETE
sql db { delete from Todo where id == 1 }
SQL을 문자열로 적는 대신, V 문법 안에서 데이터베이스 작업을 할 수 있다. V의 ORM은 SQL 주입(injection) 공격도 자동으로 방지해준다.
다른 언어의 ORM과 비교
| 언어 | ORM | 특징 |
|---|---|---|
| V | 내장 ORM | 언어에 포함, 별도 설치 불필요 |
| Python | SQLAlchemy, Django ORM | 외부 라이브러리 |
| Go | GORM, Ent | 외부 라이브러리 |
| Rust | Diesel, SeaORM | 외부 라이브러리 |
| Java | Hibernate, JPA | 외부 프레임워크 |
V의 ORM은 언어에 내장되어 있다는 점이 독특하다. 별도의 라이브러리를 설치할 필요 없이, import db.sqlite만 하면 바로 사용할 수 있다.
1단계: SQLite 연결
SQLite란?
SQLite는 파일 하나가 곧 데이터베이스인 초경량 DB다. 별도의 서버 설치가 필요 없어서, 작은 프로젝트나 프로토타입에 아주 적합하다. 모바일 앱(Android, iOS)에서도 널리 쓰인다.
일반 DB (MySQL, PostgreSQL): SQLite:
┌─────────┐ ┌─────────┐ ┌─────────────────────┐
│ 앱 │─────→│ DB 서버 │ │ 앱 │
│ │←─────│ (별도) │ │ └─ todos.db (파일) │
└─────────┘ └─────────┘ └─────────────────────┘
별도 서버 필요 파일 하나로 끝
V에서 SQLite 연결
import db.sqlite
fn main() {
// 데이터베이스 파일 열기 (없으면 자동 생성)
db := sqlite.connect('todos.db') or {
println('DB 연결 실패: ${err}')
return
}
defer { db.close() or {} }
println('SQLite 연결 성공!')
}
sqlite.connect('todos.db') — todos.db 파일을 열거나, 없으면 새로 만든다. 이 한 줄로 데이터베이스가 준비된다.
defer { db.close() or {} } — 문법 시리즈 3편에서 배운 defer다. 함수가 끝날 때 데이터베이스 연결을 자동으로 닫는다.
2단계: 구조체 = 테이블
V의 ORM에서는 구조체 정의가 곧 테이블 설계다. 구조체의 필드가 테이블의 컬럼이 된다.
테이블 정의
import db.sqlite
@[table: 'todos']
struct Todo {
id int @[primary; sql: serial] // 자동 증가 PK
title string
completed bool
}
속성(attribute)을 하나씩 살펴보자.
@[table: 'todos'] — 이 구조체가 데이터베이스의 todos 테이블에 매핑된다는 뜻이다. 생략하면 구조체 이름(소문자)이 테이블 이름이 된다.
@[primary; sql: serial] — primary는 "이 필드가 기본 키(Primary Key)"라는 뜻이고, sql: serial은 "새 행을 추가할 때 자동으로 1, 2, 3... 증가"라는 뜻이다. 우리가 직접 ID를 지정할 필요가 없다.
V 구조체 정의: → 자동 생성되는 SQL 테이블:
┌─────────────────────────────┐ CREATE TABLE todos (
│ struct Todo { │ id INTEGER PRIMARY KEY AUTOINCREMENT,
│ id int @[primary] │ title TEXT,
│ title string │ completed BOOLEAN
│ completed bool │ );
│ } │
└─────────────────────────────┘
테이블 생성
import db.sqlite
@[table: 'todos']
struct Todo {
id int @[primary; sql: serial]
title string
completed bool
}
fn main() {
db := sqlite.connect('todos.db') or {
println('DB 연결 실패: ${err}')
return
}
defer { db.close() or {} }
// 테이블이 없으면 자동 생성
sql db {
create table Todo
} or {
println('테이블 생성 실패: ${err}')
return
}
println('테이블 생성 완료!')
}
sql db { create table Todo } — Todo 구조체의 정의를 바탕으로 데이터베이스에 테이블을 만든다. 테이블이 이미 있으면 아무 일도 하지 않는다.
sql db { ... } 블록 안에서 V의 ORM 문법을 사용한다. 이 블록은 컴파일 시점에 SQL로 변환된다.
3단계: CRUD — 데이터 다루기
ORM으로 데이터를 생성(Create), 조회(Read), 수정(Update), 삭제(Delete)하는 방법을 하나씩 배워보자.
Create — 데이터 추가
fn add_todo(db sqlite.DB, title string) !Todo {
new_todo := Todo{
title: title
completed: false
// id는 지정하지 않는다 — serial이 자동 증가
}
sql db {
insert new_todo into Todo
}!
// 방금 추가한 할일 조회 (마지막 행)
result := sql db {
select from Todo order by id desc limit 1
}!
return result.first()
}
sql db { insert new_todo into Todo } — new_todo 구조체를 Todo 테이블에 삽입한다. SQL로 치면 INSERT INTO todos (title, completed) VALUES ('...', false)다. id는 serial이므로 자동으로 할당된다.
Read — 데이터 조회
// 전체 조회
fn get_all_todos(db sqlite.DB) ![]Todo {
result := sql db {
select from Todo order by id
}!
return result
}
// ID로 개별 조회
fn get_todo_by_id(db sqlite.DB, todo_id int) !Todo {
result := sql db {
select from Todo where id == todo_id
}!
if result.len == 0 {
return error('할일 #${todo_id}번을 찾을 수 없습니다')
}
return result.first()
}
select from Todo — 모든 행을 조회한다. where, order by, limit 도 사용할 수 있다.
ORM 조회 문법을 정리하면 이렇다.
| ORM 문법 | SQL 대응 | 설명 |
|---|---|---|
select from Todo | SELECT * FROM todos | 전체 조회 |
select from Todo where id == 1 | SELECT * FROM todos WHERE id = 1 | 조건 조회 |
select from Todo order by id desc | SELECT * FROM todos ORDER BY id DESC | 정렬 |
select from Todo limit 10 | SELECT * FROM todos LIMIT 10 | 개수 제한 |
V 문법(이중 등호 ==, 키워드 순서)이 SQL과 약간 다르지만, 의미는 거의 같다. V 코드를 아는 사람이라면 직관적으로 읽을 수 있다.
Update — 데이터 수정
fn update_todo(db sqlite.DB, todo_id int, title string, completed bool) ! {
sql db {
update Todo set title = title, completed = completed where id == todo_id
}!
}
update Todo set 필드 = 값 where 조건 — 조건에 맞는 행의 필드를 수정한다. 여러 필드를 쉼표로 구분해서 한 번에 수정할 수 있다.
Delete — 데이터 삭제
fn delete_todo(db sqlite.DB, todo_id int) ! {
sql db {
delete from Todo where id == todo_id
}!
}
delete from Todo where 조건 — 조건에 맞는 행을 삭제한다. where를 빠뜨리면 전체 행이 삭제되므로 주의해야 한다.
CRUD 전체 정리
| 동작 | ORM 문법 | HTTP 메서드 |
|---|---|---|
| Create | insert todo into Todo | POST |
| Read | select from Todo where ... | GET |
| Update | update Todo set ... where ... | PUT |
| Delete | delete from Todo where ... | DELETE |
4단계: vweb + ORM = 완성
이전 편의 vweb 서버와 ORM을 합쳐서 CRUD API 서버를 완성하자.
완성 코드
import vweb
import json
import db.sqlite
// 할일 구조체 (= 데이터베이스 테이블)
@[table: 'todos']
struct Todo {
id int @[primary; sql: serial]
title string
completed bool
}
// 할일 생성 요청
struct CreateTodoRequest {
title string
}
// 할일 수정 요청
struct UpdateTodoRequest {
title string
completed bool
}
// API 응답
struct ApiResponse {
success bool
message string
}
// 웹 서버 본체
struct App {
vweb.Context
pub mut:
db sqlite.DB
}
// GET / — 홈
fn (mut app App) index() vweb.Result {
return app.text('📝 Todo API Server v2.0 (with SQLite)\n\nEndpoints:\n GET /api/todos - 전체 조회\n GET /api/todos/:id - 개별 조회\n POST /api/todos - 추가\n PUT /api/todos/:id - 수정\n DELETE /api/todos/:id - 삭제')
}
// GET /api/todos — 전체 조회
@['/api/todos']
fn (mut app App) get_todos() vweb.Result {
todos := sql app.db {
select from Todo order by id
} or {
app.set_status(500, 'Internal Server Error')
return app.json(ApiResponse{ success: false, message: 'DB 조회 실패' })
}
return app.json(todos)
}
// GET /api/todos/:id — 개별 조회
@['/api/todos/:id']
fn (mut app App) get_todo(id int) vweb.Result {
result := sql app.db {
select from Todo where id == id limit 1
} or {
app.set_status(500, 'Internal Server Error')
return app.json(ApiResponse{ success: false, message: 'DB 조회 실패' })
}
if result.len == 0 {
app.set_status(404, 'Not Found')
return app.json(ApiResponse{ success: false, message: '할일 #${id}번을 찾을 수 없습니다' })
}
return app.json(result.first())
}
// POST /api/todos — 추가
@['/api/todos'; post]
fn (mut app App) create_todo() vweb.Result {
req := json.decode(CreateTodoRequest, app.req.data) 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{
title: req.title
completed: false
}
sql app.db {
insert new_todo into Todo
} or {
app.set_status(500, 'Internal Server Error')
return app.json(ApiResponse{ success: false, message: 'DB 저장 실패' })
}
// 방금 추가한 할일 가져오기
created := sql app.db {
select from Todo order by id desc limit 1
} or {
return app.json(ApiResponse{ success: true, message: '추가 완료' })
}
app.set_status(201, 'Created')
return app.json(created.first())
}
// PUT /api/todos/:id — 수정
@['/api/todos/:id'; put]
fn (mut app App) update_todo(id int) vweb.Result {
// 존재하는지 확인
existing := sql app.db {
select from Todo where id == id limit 1
} or {
app.set_status(500, 'Internal Server Error')
return app.json(ApiResponse{ success: false, message: 'DB 조회 실패' })
}
if existing.len == 0 {
app.set_status(404, 'Not Found')
return app.json(ApiResponse{ success: false, message: '할일 #${id}번을 찾을 수 없습니다' })
}
// 요청 본문 파싱
req := json.decode(UpdateTodoRequest, app.req.data) or {
app.set_status(400, 'Bad Request')
return app.json(ApiResponse{ success: false, message: '잘못된 요청 형식입니다' })
}
title := req.title
completed := req.completed
// DB 업데이트
sql app.db {
update Todo set title = title, completed = completed where id == id
} or {
app.set_status(500, 'Internal Server Error')
return app.json(ApiResponse{ success: false, message: 'DB 수정 실패' })
}
// 수정된 결과 반환
updated := sql app.db {
select from Todo where id == id limit 1
} or {
return app.json(ApiResponse{ success: true, message: '수정 완료' })
}
return app.json(updated.first())
}
// DELETE /api/todos/:id — 삭제
@['/api/todos/:id'; delete]
fn (mut app App) delete_todo(id int) vweb.Result {
// 존재하는지 확인
existing := sql app.db {
select from Todo where id == id limit 1
} or {
app.set_status(500, 'Internal Server Error')
return app.json(ApiResponse{ success: false, message: 'DB 조회 실패' })
}
if existing.len == 0 {
app.set_status(404, 'Not Found')
return app.json(ApiResponse{ success: false, message: '할일 #${id}번을 찾을 수 없습니다' })
}
// 삭제
sql app.db {
delete from Todo where id == id
} or {
app.set_status(500, 'Internal Server Error')
return app.json(ApiResponse{ success: false, message: 'DB 삭제 실패' })
}
return app.json(ApiResponse{ success: true, message: '할일 #${id}번이 삭제되었습니다' })
}
fn main() {
// DB 연결
db := sqlite.connect('todos.db') or {
println('DB 연결 실패: ${err}')
return
}
// 테이블 생성
sql db {
create table Todo
} or {
println('테이블 생성 실패: ${err}')
return
}
println('🚀 Todo API 서버 v2.0 시작: http://localhost:8080')
println(' 데이터베이스: todos.db (SQLite)')
vweb.run(app: &App{ db: db }, port: 8080)
}
이전 편과 달라진 점
| 항목 | v1.0 (이전 편) | v2.0 (이번 편) |
|---|---|---|
| 데이터 저장 | 메모리 ([]Todo) | SQLite (파일) |
| 서버 재시작 시 | 데이터 소멸 | 데이터 유지 |
| ID 관리 | next_id 수동 증가 | serial 자동 증가 |
| CRUD | GET + POST | GET + POST + PUT + DELETE |
| 에러 처리 | 기본 | DB 에러까지 처리 |
가장 큰 변화는 App 구조체에서 []Todo 배열이 사라지고 sqlite.DB가 자리 잡은 것이다.
// v1.0 — 메모리 저장
struct App {
vweb.Context
pub mut:
todos []Todo // 메모리에 저장 (서버 종료 시 소멸)
next_id int = 1
}
// v2.0 — DB 저장
struct App {
vweb.Context
pub mut:
db sqlite.DB // 파일에 영구 저장
}
실행 및 테스트
# 서버 시작
v run todo_server.v
# 🚀 Todo API 서버 v2.0 시작: http://localhost:8080
# 데이터베이스: todos.db (SQLite)
# 1. 할일 추가 (POST)
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "V 언어 배우기"}'
# {"id":1,"title":"V 언어 배우기","completed":false}
# 2. 하나 더 추가
curl -X POST http://localhost:8080/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "ORM 실습하기"}'
# {"id":2,"title":"ORM 실습하기","completed":false}
# 3. 전체 조회 (GET)
curl http://localhost:8080/api/todos
# [{"id":1,"title":"V 언어 배우기","completed":false},
# {"id":2,"title":"ORM 실습하기","completed":false}]
# 4. 할일 수정 (PUT) — 완료 표시
curl -X PUT http://localhost:8080/api/todos/1 \
-H "Content-Type: application/json" \
-d '{"title": "V 언어 배우기", "completed": true}'
# {"id":1,"title":"V 언어 배우기","completed":true}
# 5. 할일 삭제 (DELETE)
curl -X DELETE http://localhost:8080/api/todos/2
# {"success":true,"message":"할일 #2번이 삭제되었습니다"}
# 6. 서버를 재시작해도 데이터가 남아있다! ⭐
# (Ctrl+C로 종료 후 다시 v run todo_server.v)
curl http://localhost:8080/api/todos
# [{"id":1,"title":"V 언어 배우기","completed":true}]
6번 테스트가 핵심이다. 서버를 재시작해도 데이터가 살아있다. SQLite가 todos.db 파일에 데이터를 영구 저장하기 때문이다.
문법 시리즈 연결
| 활용된 문법 | 배운 편 | 이번 편에서 |
|---|---|---|
구조체 속성 (@[...]) | 6편, 실전 2편 | @[table], @[primary; sql: serial] |
defer | 3편 | defer { db.close() } |
or {} 에러 처리, ! 전파 | 8편 | ORM/DB 에러 처리 |
| 구조체, 임베디드 구조체 | 6편 | App + vweb.Context |
| vweb 라우팅, JSON 응답 | 실전 4편(상) | CRUD 엔드포인트 |
json.decode | 실전 2편 | 요청 본문 파싱 |
📝 정리
- [x] ORM — 구조체 = 테이블, V 문법으로 SQL 대체, SQL 주입 자동 방지
- [x] SQLite —
sqlite.connect()로 파일 DB 연결, 서버 설치 불필요 - [x] 테이블 정의 —
@[table],@[primary; sql: serial]속성 - [x] CRUD ORM 문법 —
insert,select,update,deleteinsql db { } - [x] PUT 핸들러 —
@['/path/:id'; put], 존재 확인 → 파싱 → 수정 → 반환 - [x] DELETE 핸들러 —
@['/path/:id'; delete], 존재 확인 → 삭제 → 메시지 - [x] 데이터 영구 저장 — 서버 재시작해도
todos.db파일에 데이터 유지
다음 편 예고
비교 2편: V vs Rust vs Go — 에러 처리 철학 대결
다시 비교 시간! 이번에는 세 언어의 에러 처리 방식을 심층 비교한다. V의
!T+or {}, Go의if err != nil, Rust의Result<T, E>+?연산자 — 같은 문제를 세 언어로 풀면서 각자의 철학을 살펴본다.