V 실전: 할일 관리 웹앱 — V로 풀스택 종합 프로젝트

V 실전: 할일 관리 웹앱 — V로 풀스택 종합 프로젝트

시리즈의 마지막 편이다. 4~5편에서 만든 Todo API는 JSON만 주고받는 백엔드였다. 이번에는 여기에 HTML 프론트엔드를 입혀서, 브라우저에서 할일을 추가하고 체크하고 삭제할 수 있는 진짜 웹앱을 완성한다. V 하나로 백엔드와 프론트엔드를 모두 만든다.

이번 편에서 완성하는 것

브라우저에서 동작하는 할일 관리 웹앱이다.

┌─────────────────────────────────────────┐
│  📝 V Todo App                          │
│─────────────────────────────────────────│
│                                         │
│  [새 할일 입력                ] [추가]   │
│                                         │
│  ☑ V 언어 배우기              [삭제]    │
│  ☐ REST API 만들기            [삭제]    │
│  ☐ 테스트 작성하기            [삭제]    │
│                                         │
│  완료: 1/3                              │
└─────────────────────────────────────────┘

프로젝트 구조

풀스택 웹앱은 파일이 여러 개다. 먼저 전체 구조를 보자.

todo_app/
├── todo_app.v           ← 메인 서버 코드
├── templates/
│   └── index.html       ← HTML 템플릿
└── static/
    └── style.css        ← CSS 스타일
파일 역할 비유
todo_app.v 백엔드 로직 (라우팅, DB) 식당의 주방
templates/index.html 화면 구조 (HTML) 식당의 인테리어
static/style.css 화면 디자인 (CSS) 식당의 장식

vweb이 templates/ 폴더의 HTML을 자동으로 찾고, static/ 폴더의 파일을 자동으로 서빙한다. 별도의 설정이 필요 없다.


1단계: 데이터베이스 설정

5편에서 배운 ORM과 SQLite를 그대로 활용한다.

import db.sqlite

@[table: 'todos']
struct Todo {
    id        int    @[primary; sql: serial]
    title     string
    completed bool
}

fn init_db() !sqlite.DB {
    db := sqlite.connect('todo_app.db')!

    sql db {
        create table Todo
    }!

    return db
}

5편과 동일한 코드다. Todo 구조체가 todos 테이블이 되고, id는 자동 증가한다.


2단계: vweb 템플릿 — HTML 렌더링

4편에서는 app.json()으로 JSON을 응답했다. 이번에는 app.html()HTML 페이지를 응답한다.

템플릿 파일 (templates/index.html)

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>📝 V Todo App</title>
    <link rel="stylesheet" href="/static/style.css">
</head>
<body>
    <div class="container">
        <header>
            <h1>📝 V Todo App</h1>
            <p class="subtitle">V 언어로 만든 할일 관리 웹앱</p>
        </header>

        <!-- 할일 추가 폼 -->
        <form action="/add" method="POST" class="add-form">
            <input
                type="text"
                name="title"
                placeholder="새 할일을 입력하세요..."
                required
                autofocus
            >
            <button type="submit">추가</button>
        </form>

        <!-- 할일 목록 -->
        <ul class="todo-list">
            @for todo in todos
                <li class="todo-item @if todo.completed {completed}">
                    <form action="/toggle/@todo.id" method="POST" class="toggle-form">
                        <button type="submit" class="checkbox">
                            @if todo.completed
                                ☑
                            @else
                                ☐
                            @end
                        </button>
                    </form>
                    <span class="title">@todo.title</span>
                    <form action="/delete/@todo.id" method="POST" class="delete-form">
                        <button type="submit" class="delete-btn">삭제</button>
                    </form>
                </li>
            @end
        </ul>

        <!-- 통계 -->
        <footer>
            <p class="stats">
                전체: @todos.len개
            </p>
        </footer>
    </div>
</body>
</html>

vweb 템플릿 문법

vweb 템플릿은 HTML 안에 V 코드를 삽입하는 방식이다. @ 기호로 V 코드임을 표시한다.

문법 의미 예시
@변수 변수 출력 @todo.title
@for ... in ... / @end 반복문 할일 목록 순회
@if / @else / @end 조건문 완료 여부에 따라 표시 변경

Jinja2(Python)나 EJS(Node.js)를 써본 적이 있다면 매우 익숙할 것이다. 핵심 차이는 V 변수와 V 문법을 그대로 쓴다는 점이다.

하나씩 살펴보자.

@for todo in todos ... @end — V의 for...in 루프와 동일하다. todos 배열의 각 항목을 todo로 순회하면서 HTML을 반복 출력한다.

@if todo.completed ... @else ... @end — V의 if/else와 동일하다. 할일이 완료됐으면 , 아니면 를 표시한다.

@todo.title — 구조체의 필드 값을 HTML에 삽입한다. 자동으로 문자열로 변환된다.

폼과 POST: HTML의 <form> 태그로 서버에 데이터를 보낸다. method="POST"이면 POST 요청이 되고, action="/add"가 요청할 URL이다.


3단계: CSS 스타일링

스타일 없는 HTML은 밋밋하다. CSS로 깔끔한 디자인을 입히자.

스타일 파일 (static/style.css)

/* 기본 설정 */
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    min-height: 100vh;
    display: flex;
    justify-content: center;
    padding: 2rem;
}

/* 메인 컨테이너 */
.container {
    background: white;
    border-radius: 16px;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
    padding: 2rem;
    width: 100%;
    max-width: 500px;
    height: fit-content;
}

/* 헤더 */
header {
    text-align: center;
    margin-bottom: 1.5rem;
}

header h1 {
    font-size: 1.8rem;
    color: #333;
}

.subtitle {
    color: #888;
    font-size: 0.9rem;
    margin-top: 0.3rem;
}

/* 추가 폼 */
.add-form {
    display: flex;
    gap: 0.5rem;
    margin-bottom: 1.5rem;
}

.add-form input {
    flex: 1;
    padding: 0.75rem 1rem;
    border: 2px solid #e0e0e0;
    border-radius: 8px;
    font-size: 1rem;
    transition: border-color 0.2s;
}

.add-form input:focus {
    outline: none;
    border-color: #667eea;
}

.add-form button {
    padding: 0.75rem 1.5rem;
    background: #667eea;
    color: white;
    border: none;
    border-radius: 8px;
    font-size: 1rem;
    cursor: pointer;
    transition: background 0.2s;
}

.add-form button:hover {
    background: #5a6fd6;
}

/* 할일 목록 */
.todo-list {
    list-style: none;
}

.todo-item {
    display: flex;
    align-items: center;
    padding: 0.75rem;
    border-bottom: 1px solid #f0f0f0;
    transition: background 0.2s;
}

.todo-item:hover {
    background: #f8f9ff;
}

.todo-item.completed .title {
    text-decoration: line-through;
    color: #aaa;
}

/* 체크박스 버튼 */
.toggle-form {
    margin-right: 0.75rem;
}

.checkbox {
    background: none;
    border: none;
    font-size: 1.3rem;
    cursor: pointer;
    padding: 0;
}

/* 할일 제목 */
.title {
    flex: 1;
    font-size: 1rem;
    color: #333;
}

/* 삭제 버튼 */
.delete-btn {
    padding: 0.3rem 0.7rem;
    background: #ff6b6b;
    color: white;
    border: none;
    border-radius: 6px;
    font-size: 0.8rem;
    cursor: pointer;
    opacity: 0;
    transition: opacity 0.2s;
}

.todo-item:hover .delete-btn {
    opacity: 1;
}

.delete-btn:hover {
    background: #ee5a5a;
}

/* 통계 */
footer {
    margin-top: 1rem;
    padding-top: 1rem;
    border-top: 1px solid #f0f0f0;
}

.stats {
    text-align: center;
    color: #888;
    font-size: 0.85rem;
}

CSS의 주요 디자인 포인트를 짚어보면 이렇다.

기법 코드 효과
그라디언트 배경 linear-gradient(135deg, ...) 보라색 그라디언트 배경
카드 디자인 border-radius: 16px; box-shadow: ... 둥근 모서리 + 그림자
호버 효과 .todo-item:hover 마우스 올리면 배경 변경
완료 스타일 .completed .title 취소선 + 회색 글자
삭제 버튼 숨기기 opacity: 0 → hover시 1 마우스 올릴 때만 삭제 버튼 표시
포커스 효과 input:focus { border-color } 입력칸 클릭 시 보라색 테두리

vweb은 /static/ 경로로 정적 파일을 자동 서빙한다. HTML에서 <link rel="stylesheet" href="/static/style.css">로 연결하면 된다.


4단계: 서버 코드 — 모든 것을 연결

완성 코드 (todo_app.v)

import vweb
import db.sqlite

// 할일 구조체 (= DB 테이블)
@[table: 'todos']
struct Todo {
    id        int    @[primary; sql: serial]
    title     string
    completed bool
}

// 웹 서버
struct App {
    vweb.Context
pub mut:
    db sqlite.DB
}

// GET / — 메인 페이지 (할일 목록)
fn (mut app App) index() vweb.Result {
    // DB에서 전체 할일 조회
    todos := sql app.db {
        select from Todo order by id desc
    } or {
        []Todo{}
    }

    // HTML 템플릿 렌더링
    return $vweb.html()
}

// POST /add — 새 할일 추가
@['/add'; post]
fn (mut app App) add_todo() vweb.Result {
    title := app.form['title'] or { '' }

    if title.trim_space() == '' {
        return app.redirect('/')
    }

    new_todo := Todo{
        title: title
        completed: false
    }

    sql app.db {
        insert new_todo into Todo
    } or {}

    return app.redirect('/')
}

// POST /toggle/:id — 완료 토글
@['/toggle/:id'; post]
fn (mut app App) toggle_todo(id int) vweb.Result {
    // 현재 상태 조회
    result := sql app.db {
        select from Todo where id == id limit 1
    } or {
        return app.redirect('/')
    }

    if result.len == 0 {
        return app.redirect('/')
    }

    current := result.first()
    new_completed := !current.completed

    // 상태 반전
    sql app.db {
        update Todo set completed = new_completed where id == id
    } or {}

    return app.redirect('/')
}

// POST /delete/:id — 삭제
@['/delete/:id'; post]
fn (mut app App) delete_todo(id int) vweb.Result {
    sql app.db {
        delete from Todo where id == id
    } or {}

    return app.redirect('/')
}

fn main() {
    // DB 초기화
    db := sqlite.connect('todo_app.db') or {
        println('DB 연결 실패: ${err}')
        return
    }

    sql db {
        create table Todo
    } or {}

    println('🚀 Todo 웹앱 시작: http://localhost:8080')

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

핵심 로직 설명

$vweb.html() — 이것이 vweb 템플릿의 핵심이다. 함수 이름과 같은 이름의 HTML 파일(templates/index.html)을 찾아서 렌더링한다. 함수 안에서 선언한 변수(todos)가 자동으로 템플릿에 전달된다.

fn (mut app App) index() vweb.Result {
    todos := sql app.db { select from Todo }   ← 이 변수가
    return $vweb.html()                         ← 템플릿에 자동 전달
}

templates/index.html 에서:
    @for todo in todos    ← 여기서 사용 가능

app.form['title'] — HTML 폼에서 전송된 데이터를 읽는다. <input name="title">의 값이 app.form['title']에 담긴다.

app.redirect('/') — 처리가 끝나면 메인 페이지로 리다이렉트한다. 이것은 PRG(Post-Redirect-Get) 패턴이라고 불리는 웹 개발의 표준 패턴이다.

사용자가 "추가" 클릭
    → POST /add (서버에서 DB에 저장)
    → redirect('/') (메인 페이지로 이동)
    → GET / (업데이트된 목록 표시)

왜 POST 후에 리다이렉트를 할까? 리다이렉트 없이 HTML을 바로 응답하면, 사용자가 브라우저에서 "새로고침"을 눌렀을 때 폼이 다시 전송되어 할일이 중복 추가된다. PRG 패턴은 이 문제를 방지한다.

!current.completed — 완료 토글 로직이다. 현재 상태의 반대값을 DB에 저장한다. truefalse, falsetrue.


5단계: 실행 및 동작 확인

실행

# 서버 시작
v run todo_app.v
# 🚀 Todo 웹앱 시작: http://localhost:8080

브라우저에서 http://localhost:8080에 접속한다.

동작 흐름

1. 브라우저에서 http://localhost:8080 접속
   → GET / 요청
   → index() 호출
   → DB에서 할일 목록 조회
   → HTML 템플릿 렌더링
   → 브라우저에 페이지 표시

2. "V 언어 배우기" 입력 후 "추가" 클릭
   → POST /add 요청 (title="V 언어 배우기")
   → add_todo() 호출
   → DB에 새 할일 저장
   → redirect('/') 응답
   → 브라우저가 자동으로 GET / 재요청
   → 업데이트된 목록 표시

3. ☐ 클릭 (완료 토글)
   → POST /toggle/1 요청
   → toggle_todo(1) 호출
   → DB에서 completed 반전
   → redirect('/')
   → ☑ 로 변경된 목록 표시

4. "삭제" 클릭
   → POST /delete/1 요청
   → delete_todo(1) 호출
   → DB에서 삭제
   → redirect('/')
   → 해당 항목이 사라진 목록 표시

API vs 웹앱 비교

4~5편의 API와 이번 웹앱의 차이를 정리하면 이렇다.

항목 API (4~5편) 웹앱 (이번 편)
응답 형식 JSON HTML
클라이언트 curl, Postman 브라우저
데이터 전송 JSON body HTML 폼
처리 후 동작 JSON 반환 리다이렉트 (PRG)
스타일 없음 CSS
사용 대상 개발자 일반 사용자

시리즈 전체 회고

11편에 걸쳐 V 언어의 실전 프로젝트와 비교 분석을 진행했다. 전체를 돌아보자.

무엇을 만들었나

프로젝트 핵심 기술
1 CLI 파일 정리기 os 모듈, 인자 파싱
2 JSON 설정 관리 도구 json 모듈, 파일 I/O
3 V vs Go 비교 문법, 에러 처리, 바이너리
4 REST API 서버 (상) vweb, 라우팅, JSON 응답
5 REST API + ORM (하) SQLite, CRUD, 데이터 영구 저장
6 V vs Rust vs Go 에러 처리 3언어 에러 철학 비교
7 크로스 플랫폼 스크립트 os.execute, 자동화
8 테스트 실전 assert, _test.v, 테이블 테스트
9 V vs Python 벤치마크 성능 비교, 언어 선택 기준
10 채팅 서버 TCP 소켓, spawn, 채널
11 풀스택 웹앱 vweb 템플릿, CSS, 폼, ORM

V 언어의 강점

이 시리즈를 통해 확인한 V의 실전 강점을 정리한다.

  • 학습 곡선이 낮다 — Go를 아는 사람은 80%를 바로 읽을 수 있다
  • 배터리 포함 — 웹 프레임워크, ORM, JSON, 테스트가 모두 내장
  • 간결한 에러 처리!or {}로 보일러플레이트 최소화
  • 빠른 컴파일v run으로 스크립트처럼 즉시 실행
  • 작은 바이너리 — 배포가 간편하고 리소스 소비가 적다
  • 크로스 플랫폼 — 하나의 코드가 Windows/Mac/Linux에서 동작

V를 더 깊이 배우려면


문법 시리즈 연결 (최종)

활용된 문법 배운 편 이번 편에서
구조체, 속성 6편 Todo, App, @[table]
임베디드 구조체 6편 vweb.Context
ORM (CRUD) 실전 5편 select, insert, update, delete
vweb 라우팅 실전 4편 @['/add'; post]
or {} 에러 처리 8편 DB/폼 에러 처리
배열 5편 todos 배열을 템플릿에 전달
불리언 반전 2편 !current.completed

📝 시리즈 최종 정리

  • [x] vweb 템플릿$vweb.html(), @for, @if, @변수로 HTML 동적 생성
  • [x] 정적 파일 서빙static/ 폴더의 CSS/JS를 자동으로 제공
  • [x] HTML 폼<form action method="POST">, app.form['name']
  • [x] PRG 패턴 — POST 처리 후 app.redirect('/')로 중복 전송 방지
  • [x] 풀스택 구조 — V 하나로 서버(라우팅, DB) + 프론트(HTML, CSS) 모두 구현
  • [x] 시리즈 완성 — 문법 10편 + 실전 11편 = V 언어 21편 완주 🎉

시리즈를 마치며

V 언어 문법 시리즈 10편과 실전 시리즈 11편, 총 21편의 여정이 끝났다. Hello World부터 시작해서, CLI 도구를 만들고, REST API를 만들고, 다른 언어와 비교하고, 채팅 서버를 만들고, 마침내 풀스택 웹앱까지 완성했다.

V는 아직 젊은 언어지만, 간결한 문법, 빠른 컴파일, 내장 도구의 풍부함이라는 명확한 강점을 가지고 있다. 이 시리즈가 V 언어를 시작하는 데 도움이 되었길 바란다. 🚀