포스트

[Python 100일 챌린지] Day 86 - CRUD 애플리케이션

[Python 100일 챌린지] Day 86 - CRUD 애플리케이션

지금까지 배운 모든 것을 합칠 시간입니다! 🎯

CRUD는 웹 애플리케이션의 4가지 기본 기능입니다:

  • Create (생성) - 새로운 데이터 추가
  • Read (읽기) - 데이터 조회
  • Update (수정) - 기존 데이터 변경
  • Delete (삭제) - 데이터 제거

블로그, 쇼핑몰, SNS… 모든 웹사이트의 핵심 기능이죠!

오늘은 도서 관리 시스템을 만들면서 CRUD를 완벽하게 구현합니다. 폼 처리, 데이터베이스 연동, 검증, 에러 처리까지 실전 그대로입니다! 💡

(40분 완독 ⭐⭐⭐⭐⭐)

🎯 오늘의 학습 목표

  1. 프로젝트 구조 설계하기
  2. Create & Read 구현하기
  3. Update & Delete 구현하기
  4. 검색과 필터링 추가하기

📚 사전 지식


🎯 학습 목표 1: 프로젝트 구조 설계하기

한 줄 설명

프로젝트 구조 = 파일들을 체계적으로 정리해서 코드 관리와 협업을 쉽게 만들기

실생활 비유

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
🏢 회사 조직도:

프로젝트 구조 = 회사의 부서별 정리
- app.py = 사장실 (모든 결정이 이루어지는 곳)
- templates/ = 디자인팀 (HTML 파일들)
- static/ = 자료실 (CSS, 이미지 등 정적 파일)
- database.db = 서류 보관실 (데이터 저장소)

잘 정리된 구조의 장점:
1. 파일 찾기 쉬움 - "HTML은 templates에 있겠구나!"
2. 협업 용이 - 디자이너는 templates만, 개발자는 app.py만
3. 유지보수 편함 - 문제 발생 시 어디를 고칠지 명확

나쁜 구조:
├── file1.py
├── file2.py
├── something.html
├── data.db  ← 뭐가 뭔지 모르겠음!

좋은 구조:
├── app.py              ← 메인 애플리케이션
├── init_db.py          ← DB 초기화
├── templates/          ← HTML 모음
│   └── index.html
└── static/             ← CSS, JS 모음
    └── style.css

프로젝트 폴더 구조

1
2
3
4
5
6
7
8
9
10
11
12
book-manager/
├── app.py                 # Flask 애플리케이션
├── init_db.py             # 데이터베이스 초기화
├── database.db            # SQLite 데이터베이스 (자동 생성)
├── templates/
│   ├── base.html          # 베이스 템플릿
│   ├── index.html         # 도서 목록
│   ├── add_book.html      # 도서 추가
│   ├── edit_book.html     # 도서 수정
│   └── view_book.html     # 도서 상세
└── static/
    └── style.css          # 스타일시트

데이터베이스 스키마 설계

1
2
3
4
5
6
7
8
9
10
11
12
13
-- books 테이블
CREATE TABLE books (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    author TEXT NOT NULL,
    isbn TEXT UNIQUE,
    published_year INTEGER,
    genre TEXT,
    pages INTEGER,
    description TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

데이터베이스 초기화

init_db.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import sqlite3

DATABASE = 'database.db'

def init_database():
    """데이터베이스 초기화 및 테이블 생성"""
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()

    # 기존 테이블 삭제 (개발용)
    cursor.execute('DROP TABLE IF EXISTS books')

    # books 테이블 생성
    cursor.execute('''
        CREATE TABLE books (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            author TEXT NOT NULL,
            isbn TEXT UNIQUE,
            published_year INTEGER,
            genre TEXT,
            pages INTEGER,
            description TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
            updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    ''')

    # 샘플 데이터 삽입
    sample_books = [
        ('파이썬 기초', '홍길동', '978-1234567890', 2024, '프로그래밍', 350, 'Python 입문서'),
        ('Flask 웹 개발', '김철수', '978-0987654321', 2024, '웹개발', 420, 'Flask 프레임워크 가이드'),
        ('데이터 과학 입문', '이영희', '978-1122334455', 2023, '데이터과학', 500, '데이터 분석과 머신러닝'),
    ]

    cursor.executemany('''
        INSERT INTO books (title, author, isbn, published_year, genre, pages, description)
        VALUES (?, ?, ?, ?, ?, ?, ?)
    ''', sample_books)

    conn.commit()
    conn.close()

    print("✅ 데이터베이스 초기화 완료!")
    print(f"📚 {len(sample_books)}개의 샘플 도서가 추가되었습니다.")

if __name__ == '__main__':
    init_database()

실행:

1
python init_db.py

베이스 템플릿 만들기

templates/base.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}도서 관리 시스템{% endblock %}</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: #f5f5f5;
        }

        .navbar {
            background: #2c3e50;
            color: white;
            padding: 1rem 2rem;
            box-shadow: 0 2px 5px rgba(0,0,0,0.1);
        }

        .navbar h1 {
            font-size: 1.5rem;
        }

        .container {
            max-width: 1200px;
            margin: 2rem auto;
            padding: 0 2rem;
        }

        .flash-messages {
            margin: 1rem 0;
        }

        .flash {
            padding: 1rem;
            margin: 0.5rem 0;
            border-radius: 5px;
        }

        .flash-success {
            background: #d4edda;
            color: #155724;
            border: 1px solid #c3e6cb;
        }

        .flash-error {
            background: #f8d7da;
            color: #721c24;
            border: 1px solid #f5c6cb;
        }

        .btn {
            display: inline-block;
            padding: 0.75rem 1.5rem;
            text-decoration: none;
            border-radius: 5px;
            border: none;
            cursor: pointer;
            font-size: 1rem;
            transition: background 0.3s;
        }

        .btn-primary {
            background: #007bff;
            color: white;
        }

        .btn-primary:hover {
            background: #0056b3;
        }

        .btn-success {
            background: #28a745;
            color: white;
        }

        .btn-success:hover {
            background: #218838;
        }

        .btn-warning {
            background: #ffc107;
            color: #000;
        }

        .btn-warning:hover {
            background: #e0a800;
        }

        .btn-danger {
            background: #dc3545;
            color: white;
        }

        .btn-danger:hover {
            background: #c82333;
        }

        .btn-secondary {
            background: #6c757d;
            color: white;
        }

        .btn-secondary:hover {
            background: #5a6268;
        }
    </style>
</head>
<body>
    <div class="navbar">
        <h1>📚 도서 관리 시스템</h1>
    </div>

    <div class="container">
        <!-- Flash 메시지 -->
        {% with messages = get_flashed_messages(with_categories=true) %}
            {% if messages %}
                <div class="flash-messages">
                    {% for category, message in messages %}
                        <div class="flash flash-{{ category }}">
                            {{ message }}
                        </div>
                    {% endfor %}
                </div>
            {% endif %}
        {% endwith %}

        <!-- 메인 콘텐츠 -->
        {% block content %}{% endblock %}
    </div>
</body>
</html>

🎯 학습 목표 2: Create & Read 구현하기

한 줄 설명

Create & Read = 데이터를 생성(추가)하고 조회(읽기)하는 기능 (CRUD의 C와 R)

실생활 비유

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
📖 도서관 신간 등록과 열람:

Create (생성) = 새 책 등록
- 사서: "어떤 책을 추가하시겠습니까?"
- 나: "제목, 저자, ISBN 입력" (폼 작성)
- 사서: "등록 완료!" (INSERT INTO books ...)
- 책이 서가에 추가됨

Read (조회) = 책 찾아보기
- 전체 목록: SELECT * FROM books (모든 책 보기)
- 상세 정보: SELECT * FROM books WHERE id = 1 (1번 책만 보기)
- 카드 형식으로 예쁘게 표시 (HTML 템플릿)

과정:
1. 홈페이지 접속 → 모든 도서 목록 표시 (Read)
2. "새 도서 추가" 버튼 클릭 → 입력 폼 표시
3. 정보 입력 후 제출 → 데이터베이스에 저장 (Create)
4. 목록으로 리다이렉트 → 새로 추가된 책 확인!

Flask 애플리케이션 기본 구조

app.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from flask import Flask, render_template, request, redirect, url_for, flash
import sqlite3
from datetime import datetime

app = Flask(__name__)
app.secret_key = 'your-secret-key-change-this'

DATABASE = 'database.db'

def get_db():
    """데이터베이스 연결"""
    db = sqlite3.connect(DATABASE)
    db.row_factory = sqlite3.Row  # 딕셔너리처럼 접근 가능
    return db

if __name__ == '__main__':
    app.run(debug=True)

Read - 도서 목록 조회

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@app.route('/')
def index():
    """도서 목록 페이지"""
    db = get_db()
    cursor = db.cursor()

    cursor.execute('''
        SELECT * FROM books
        ORDER BY created_at DESC
    ''')

    books = cursor.fetchall()
    db.close()

    return render_template('index.html', books=books)

templates/index.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
{% extends "base.html" %}

{% block title %}도서 목록 - 도서 관리 시스템{% endblock %}

{% block content %}
<style>
    .header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 2rem;
    }

    .books-grid {
        display: grid;
        grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
        gap: 1.5rem;
    }

    .book-card {
        background: white;
        border-radius: 10px;
        padding: 1.5rem;
        box-shadow: 0 2px 5px rgba(0,0,0,0.1);
        transition: transform 0.3s, box-shadow 0.3s;
    }

    .book-card:hover {
        transform: translateY(-5px);
        box-shadow: 0 5px 15px rgba(0,0,0,0.2);
    }

    .book-title {
        font-size: 1.3rem;
        color: #2c3e50;
        margin-bottom: 0.5rem;
    }

    .book-author {
        color: #7f8c8d;
        margin-bottom: 1rem;
    }

    .book-info {
        display: flex;
        gap: 1rem;
        margin: 1rem 0;
        font-size: 0.9rem;
        color: #555;
    }

    .book-actions {
        display: flex;
        gap: 0.5rem;
        margin-top: 1rem;
    }

    .book-actions a,
    .book-actions button {
        flex: 1;
        text-align: center;
        font-size: 0.9rem;
        padding: 0.5rem;
    }

    .empty-state {
        text-align: center;
        padding: 3rem;
        color: #7f8c8d;
    }
</style>

<div class="header">
    <h2>📖 전체 도서 ({{ books|length }}권)</h2>
    <a href="{{ url_for('add_book') }}" class="btn btn-primary">➕ 새 도서 추가</a>
</div>

{% if books %}
    <div class="books-grid">
        {% for book in books %}
            <div class="book-card">
                <h3 class="book-title">{{ book['title'] }}</h3>
                <p class="book-author">저자: {{ book['author'] }}</p>

                <div class="book-info">
                    <span>📅 {{ book['published_year'] or 'N/A' }}</span>
                    <span>📑 {{ book['pages'] or 'N/A' }}쪽</span>
                </div>

                {% if book['genre'] %}
                    <p style="color: #3498db;">🏷️ {{ book['genre'] }}</p>
                {% endif %}

                <div class="book-actions">
                    <a href="{{ url_for('view_book', book_id=book['id']) }}" class="btn btn-primary">보기</a>
                    <a href="{{ url_for('edit_book', book_id=book['id']) }}" class="btn btn-warning">수정</a>
                    <form method="POST" action="{{ url_for('delete_book', book_id=book['id']) }}" style="flex: 1;">
                        <button type="submit" class="btn btn-danger" style="width: 100%;"
                                onclick="return confirm('정말 삭제하시겠습니까?')">삭제</button>
                    </form>
                </div>
            </div>
        {% endfor %}
    </div>
{% else %}
    <div class="empty-state">
        <h3>📚 등록된 도서가 없습니다</h3>
        <p>새 도서를 추가해보세요!</p>
    </div>
{% endif %}
{% endblock %}

Read - 도서 상세 조회

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@app.route('/book/<int:book_id>')
def view_book(book_id):
    """도서 상세 페이지"""
    db = get_db()
    cursor = db.cursor()

    cursor.execute('SELECT * FROM books WHERE id = ?', (book_id,))
    book = cursor.fetchone()
    db.close()

    if not book:
        flash('도서를 찾을 수 없습니다', 'error')
        return redirect(url_for('index'))

    return render_template('view_book.html', book=book)

templates/view_book.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
{% extends "base.html" %}

{% block title %}{{ book['title'] }} - 도서 관리 시스템{% endblock %}

{% block content %}
<style>
    .book-detail {
        background: white;
        border-radius: 10px;
        padding: 2rem;
        box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        max-width: 800px;
        margin: 0 auto;
    }

    .book-header {
        border-bottom: 2px solid #3498db;
        padding-bottom: 1rem;
        margin-bottom: 2rem;
    }

    .book-title-large {
        font-size: 2rem;
        color: #2c3e50;
        margin-bottom: 0.5rem;
    }

    .book-author-large {
        font-size: 1.2rem;
        color: #7f8c8d;
    }

    .info-grid {
        display: grid;
        grid-template-columns: 150px 1fr;
        gap: 1rem;
        margin: 1.5rem 0;
    }

    .info-label {
        font-weight: bold;
        color: #555;
    }

    .description-box {
        background: #f8f9fa;
        padding: 1.5rem;
        border-radius: 5px;
        margin: 1.5rem 0;
    }

    .actions {
        display: flex;
        gap: 1rem;
        margin-top: 2rem;
    }
</style>

<div class="book-detail">
    <div class="book-header">
        <h1 class="book-title-large">📖 {{ book['title'] }}</h1>
        <p class="book-author-large">✍️ {{ book['author'] }}</p>
    </div>

    <div class="info-grid">
        <div class="info-label">ISBN</div>
        <div>{{ book['isbn'] or 'N/A' }}</div>

        <div class="info-label">출판 연도</div>
        <div>{{ book['published_year'] or 'N/A' }}</div>

        <div class="info-label">장르</div>
        <div>{{ book['genre'] or 'N/A' }}</div>

        <div class="info-label">페이지 수</div>
        <div>{{ book['pages'] or 'N/A' }}쪽</div>

        <div class="info-label">등록일</div>
        <div>{{ book['created_at'] }}</div>

        <div class="info-label">수정일</div>
        <div>{{ book['updated_at'] }}</div>
    </div>

    {% if book['description'] %}
        <div class="description-box">
            <h3>📝 설명</h3>
            <p>{{ book['description'] }}</p>
        </div>
    {% endif %}

    <div class="actions">
        <a href="{{ url_for('index') }}" class="btn btn-secondary">← 목록으로</a>
        <a href="{{ url_for('edit_book', book_id=book['id']) }}" class="btn btn-warning">✏️ 수정</a>
        <form method="POST" action="{{ url_for('delete_book', book_id=book['id']) }}">
            <button type="submit" class="btn btn-danger"
                    onclick="return confirm('정말 삭제하시겠습니까?')">🗑️ 삭제</button>
        </form>
    </div>
</div>
{% endblock %}

Create - 도서 추가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@app.route('/book/add', methods=['GET', 'POST'])
def add_book():
    """도서 추가 페이지"""
    if request.method == 'POST':
        # 폼 데이터 받기
        title = request.form.get('title', '').strip()
        author = request.form.get('author', '').strip()
        isbn = request.form.get('isbn', '').strip()
        published_year = request.form.get('published_year', type=int)
        genre = request.form.get('genre', '').strip()
        pages = request.form.get('pages', type=int)
        description = request.form.get('description', '').strip()

        # 검증
        if not title:
            flash('제목을 입력해주세요', 'error')
            return redirect(url_for('add_book'))

        if not author:
            flash('저자를 입력해주세요', 'error')
            return redirect(url_for('add_book'))

        # 데이터베이스에 추가
        db = get_db()
        cursor = db.cursor()

        try:
            cursor.execute('''
                INSERT INTO books (title, author, isbn, published_year, genre, pages, description)
                VALUES (?, ?, ?, ?, ?, ?, ?)
            ''', (title, author, isbn or None, published_year, genre or None, pages, description or None))

            db.commit()
            book_id = cursor.lastrowid

            flash(f'"{title}" 도서가 추가되었습니다!', 'success')
            return redirect(url_for('view_book', book_id=book_id))

        except sqlite3.IntegrityError:
            flash('이미 존재하는 ISBN입니다', 'error')

        finally:
            db.close()

    return render_template('add_book.html')

templates/add_book.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
{% extends "base.html" %}

{% block title %}새 도서 추가 - 도서 관리 시스템{% endblock %}

{% block content %}
<style>
    .form-container {
        background: white;
        border-radius: 10px;
        padding: 2rem;
        box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        max-width: 700px;
        margin: 0 auto;
    }

    .form-group {
        margin-bottom: 1.5rem;
    }

    .form-group label {
        display: block;
        font-weight: bold;
        color: #555;
        margin-bottom: 0.5rem;
    }

    .form-group input,
    .form-group select,
    .form-group textarea {
        width: 100%;
        padding: 0.75rem;
        border: 1px solid #ddd;
        border-radius: 5px;
        font-size: 1rem;
    }

    .form-group textarea {
        resize: vertical;
        min-height: 100px;
    }

    .form-actions {
        display: flex;
        gap: 1rem;
        margin-top: 2rem;
    }

    .required {
        color: red;
    }
</style>

<div class="form-container">
    <h2>➕ 새 도서 추가</h2>
    <br>

    <form method="POST">
        <div class="form-group">
            <label for="title">제목 <span class="required">*</span></label>
            <input type="text" id="title" name="title" required>
        </div>

        <div class="form-group">
            <label for="author">저자 <span class="required">*</span></label>
            <input type="text" id="author" name="author" required>
        </div>

        <div class="form-group">
            <label for="isbn">ISBN</label>
            <input type="text" id="isbn" name="isbn" placeholder="978-1234567890">
        </div>

        <div class="form-group">
            <label for="published_year">출판 연도</label>
            <input type="number" id="published_year" name="published_year" min="1000" max="2100">
        </div>

        <div class="form-group">
            <label for="genre">장르</label>
            <select id="genre" name="genre">
                <option value="">선택하세요</option>
                <option value="소설">소설</option>
                <option value="시"></option>
                <option value="에세이">에세이</option>
                <option value="자기계발">자기계발</option>
                <option value="프로그래밍">프로그래밍</option>
                <option value="과학">과학</option>
                <option value="역사">역사</option>
                <option value="기타">기타</option>
            </select>
        </div>

        <div class="form-group">
            <label for="pages">페이지 수</label>
            <input type="number" id="pages" name="pages" min="1">
        </div>

        <div class="form-group">
            <label for="description">설명</label>
            <textarea id="description" name="description"></textarea>
        </div>

        <div class="form-actions">
            <button type="submit" class="btn btn-success">✅ 추가하기</button>
            <a href="{{ url_for('index') }}" class="btn btn-secondary">취소</a>
        </div>
    </form>
</div>
{% endblock %}

🎯 학습 목표 3: Update & Delete 구현하기

한 줄 설명

Update & Delete = 기존 데이터를 수정하거나 삭제하는 기능 (CRUD의 U와 D)

실생활 비유

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
✏️ 도서 정보 수정과 폐기:

Update (수정) = 책 정보 업데이트
- 사서: "수정할 책을 선택하세요"
- 나: "이 책 제목을 바꾸고 싶어요"
- 사서: 기존 정보를 폼에 채워서 보여줌 (GET)
- 나: 수정 후 저장 버튼 클릭 (POST)
- 사서: UPDATE books SET title = '새 제목' WHERE id = 1

Delete (삭제) = 책 폐기
- 사서: "정말 삭제하시겠습니까?" (확인 메시지)
- 나: "네, 삭제합니다"
- 사서: DELETE FROM books WHERE id = 1
- 책이 서가에서 제거됨

주의사항:
⚠️ 삭제는 되돌릴 수 없음! → confirm() 대화상자로 재확인
⚠️ WHERE 조건 필수! → WHERE 없으면 전체 삭제!

Update - 도서 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@app.route('/book/<int:book_id>/edit', methods=['GET', 'POST'])
def edit_book(book_id):
    """도서 수정 페이지"""
    db = get_db()
    cursor = db.cursor()

    if request.method == 'POST':
        # 폼 데이터 받기
        title = request.form.get('title', '').strip()
        author = request.form.get('author', '').strip()
        isbn = request.form.get('isbn', '').strip()
        published_year = request.form.get('published_year', type=int)
        genre = request.form.get('genre', '').strip()
        pages = request.form.get('pages', type=int)
        description = request.form.get('description', '').strip()

        # 검증
        if not title or not author:
            flash('제목과 저자는 필수입니다', 'error')
            return redirect(url_for('edit_book', book_id=book_id))

        # 데이터 수정
        try:
            cursor.execute('''
                UPDATE books
                SET title = ?,
                    author = ?,
                    isbn = ?,
                    published_year = ?,
                    genre = ?,
                    pages = ?,
                    description = ?,
                    updated_at = CURRENT_TIMESTAMP
                WHERE id = ?
            ''', (title, author, isbn or None, published_year, genre or None,
                  pages, description or None, book_id))

            db.commit()

            flash(f'"{title}" 도서가 수정되었습니다', 'success')
            return redirect(url_for('view_book', book_id=book_id))

        except sqlite3.IntegrityError:
            flash('이미 존재하는 ISBN입니다', 'error')

        finally:
            db.close()

    # GET: 기존 데이터 조회
    cursor.execute('SELECT * FROM books WHERE id = ?', (book_id,))
    book = cursor.fetchone()
    db.close()

    if not book:
        flash('도서를 찾을 수 없습니다', 'error')
        return redirect(url_for('index'))

    return render_template('edit_book.html', book=book)

templates/edit_book.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
{% extends "base.html" %}

{% block title %}{{ book['title'] }} 수정 - 도서 관리 시스템{% endblock %}

{% block content %}
<style>
    .form-container {
        background: white;
        border-radius: 10px;
        padding: 2rem;
        box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        max-width: 700px;
        margin: 0 auto;
    }

    .form-group {
        margin-bottom: 1.5rem;
    }

    .form-group label {
        display: block;
        font-weight: bold;
        color: #555;
        margin-bottom: 0.5rem;
    }

    .form-group input,
    .form-group select,
    .form-group textarea {
        width: 100%;
        padding: 0.75rem;
        border: 1px solid #ddd;
        border-radius: 5px;
        font-size: 1rem;
    }

    .form-group textarea {
        resize: vertical;
        min-height: 100px;
    }

    .form-actions {
        display: flex;
        gap: 1rem;
        margin-top: 2rem;
    }
</style>

<div class="form-container">
    <h2>✏️ 도서 수정</h2>
    <br>

    <form method="POST">
        <div class="form-group">
            <label for="title">제목 *</label>
            <input type="text" id="title" name="title" value="{{ book['title'] }}" required>
        </div>

        <div class="form-group">
            <label for="author">저자 *</label>
            <input type="text" id="author" name="author" value="{{ book['author'] }}" required>
        </div>

        <div class="form-group">
            <label for="isbn">ISBN</label>
            <input type="text" id="isbn" name="isbn" value="{{ book['isbn'] or '' }}">
        </div>

        <div class="form-group">
            <label for="published_year">출판 연도</label>
            <input type="number" id="published_year" name="published_year"
                   value="{{ book['published_year'] or '' }}" min="1000" max="2100">
        </div>

        <div class="form-group">
            <label for="genre">장르</label>
            <select id="genre" name="genre">
                <option value="">선택하세요</option>
                <option value="소설" {% if book['genre'] == '소설' %}selected{% endif %}>소설</option>
                <option value="시" {% if book['genre'] == '' %}selected{% endif %}></option>
                <option value="에세이" {% if book['genre'] == '에세이' %}selected{% endif %}>에세이</option>
                <option value="자기계발" {% if book['genre'] == '자기계발' %}selected{% endif %}>자기계발</option>
                <option value="프로그래밍" {% if book['genre'] == '프로그래밍' %}selected{% endif %}>프로그래밍</option>
                <option value="과학" {% if book['genre'] == '과학' %}selected{% endif %}>과학</option>
                <option value="역사" {% if book['genre'] == '역사' %}selected{% endif %}>역사</option>
                <option value="기타" {% if book['genre'] == '기타' %}selected{% endif %}>기타</option>
            </select>
        </div>

        <div class="form-group">
            <label for="pages">페이지 수</label>
            <input type="number" id="pages" name="pages" value="{{ book['pages'] or '' }}" min="1">
        </div>

        <div class="form-group">
            <label for="description">설명</label>
            <textarea id="description" name="description">{{ book['description'] or '' }}</textarea>
        </div>

        <div class="form-actions">
            <button type="submit" class="btn btn-success">✅ 저장하기</button>
            <a href="{{ url_for('view_book', book_id=book['id']) }}" class="btn btn-secondary">취소</a>
        </div>
    </form>
</div>
{% endblock %}

Delete - 도서 삭제

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@app.route('/book/<int:book_id>/delete', methods=['POST'])
def delete_book(book_id):
    """도서 삭제"""
    db = get_db()
    cursor = db.cursor()

    # 도서 정보 가져오기 (삭제 전)
    cursor.execute('SELECT title FROM books WHERE id = ?', (book_id,))
    book = cursor.fetchone()

    if book:
        cursor.execute('DELETE FROM books WHERE id = ?', (book_id,))
        db.commit()
        flash(f'"{book["title"]}" 도서가 삭제되었습니다', 'success')
    else:
        flash('도서를 찾을 수 없습니다', 'error')

    db.close()
    return redirect(url_for('index'))

🎯 학습 목표 4: 검색과 필터링 추가하기

한 줄 설명

검색과 필터링 = 많은 데이터 중에서 원하는 것만 골라서 보기 (조건부 조회)

실생활 비유

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
🔍 도서관 검색 시스템:

검색 (LIKE) = 키워드로 찾기
- 사용자: "파이썬이 들어간 책 찾아줘"
- SQL: WHERE title LIKE '%파이썬%'
- 결과: "파이썬 기초", "파이썬으로 배우는 알고리즘" 등

필터링 (WHERE) = 조건으로 거르기
- 사용자: "프로그래밍 장르만 보여줘"
- SQL: WHERE genre = '프로그래밍'
- 결과: 프로그래밍 책들만 표시

복합 검색 = 여러 조건 조합
- "프로그래밍 장르 중에서 '파이썬'이 들어간 책"
- SQL: WHERE genre = '프로그래밍' AND title LIKE '%파이썬%'

실제 사용:
1. 검색창에 "Flask" 입력
2. 장르 드롭다운에서 "웹개발" 선택
3. 검색 버튼 클릭
4. → WHERE genre = '웹개발' AND title LIKE '%Flask%'

검색 기능 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@app.route('/search')
def search():
    """도서 검색"""
    query = request.args.get('q', '').strip()
    genre = request.args.get('genre', '')

    if not query and not genre:
        return redirect(url_for('index'))

    db = get_db()
    cursor = db.cursor()

    # 동적 SQL 쿼리 생성
    sql = 'SELECT * FROM books WHERE 1=1'
    params = []

    if query:
        sql += ' AND (title LIKE ? OR author LIKE ?)'
        params.extend([f'%{query}%', f'%{query}%'])

    if genre:
        sql += ' AND genre = ?'
        params.append(genre)

    sql += ' ORDER BY created_at DESC'

    cursor.execute(sql, params)
    books = cursor.fetchall()
    db.close()

    return render_template('search_results.html',
                           books=books,
                           query=query,
                           genre=genre)

templates/index.html에 검색 폼 추가:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<div class="header">
    <h2>📖 전체 도서 ({{ books|length }}권)</h2>
    <a href="{{ url_for('add_book') }}" class="btn btn-primary">➕ 새 도서 추가</a>
</div>

<!-- 검색 폼 추가 -->
<div style="background: white; padding: 1.5rem; border-radius: 10px; margin-bottom: 2rem;">
    <form method="GET" action="{{ url_for('search') }}" style="display: flex; gap: 1rem;">
        <input type="text" name="q" placeholder="제목 또는 저자 검색"
               style="flex: 2; padding: 0.75rem; border: 1px solid #ddd; border-radius: 5px;">

        <select name="genre" style="flex: 1; padding: 0.75rem; border: 1px solid #ddd; border-radius: 5px;">
            <option value="">모든 장르</option>
            <option value="소설">소설</option>
            <option value="시"></option>
            <option value="에세이">에세이</option>
            <option value="자기계발">자기계발</option>
            <option value="프로그래밍">프로그래밍</option>
            <option value="과학">과학</option>
            <option value="역사">역사</option>
        </select>

        <button type="submit" class="btn btn-primary">🔍 검색</button>
    </form>
</div>

templates/search_results.html:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
{% extends "base.html" %}

{% block title %}검색 결과 - 도서 관리 시스템{% endblock %}

{% block content %}
<div style="margin-bottom: 2rem;">
    <h2>🔍 검색 결과</h2>
    <p>
        {% if query %}"{{ query }}"에 대한 {% endif %}
        {% if genre %}장르: {{ genre }} {% endif %}
        검색 결과 ({{ books|length }}권)
    </p>
    <a href="{{ url_for('index') }}" class="btn btn-secondary">← 전체 목록</a>
</div>

{% if books %}
    <div class="books-grid">
        {% for book in books %}
            <!-- index.html과 동일한 카드 -->
            <div class="book-card">
                <h3 class="book-title">{{ book['title'] }}</h3>
                <p class="book-author">저자: {{ book['author'] }}</p>

                <div class="book-info">
                    <span>📅 {{ book['published_year'] or 'N/A' }}</span>
                    <span>📑 {{ book['pages'] or 'N/A' }}쪽</span>
                </div>

                {% if book['genre'] %}
                    <p style="color: #3498db;">🏷️ {{ book['genre'] }}</p>
                {% endif %}

                <div class="book-actions">
                    <a href="{{ url_for('view_book', book_id=book['id']) }}" class="btn btn-primary">보기</a>
                    <a href="{{ url_for('edit_book', book_id=book['id']) }}" class="btn btn-warning">수정</a>
                </div>
            </div>
        {% endfor %}
    </div>
{% else %}
    <div style="text-align: center; padding: 3rem; color: #7f8c8d;">
        <h3>검색 결과가 없습니다</h3>
        <p>다른 검색어를 입력해보세요</p>
    </div>
{% endif %}
{% endblock %}

통계 페이지 추가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@app.route('/stats')
def stats():
    """통계 페이지"""
    db = get_db()
    cursor = db.cursor()

    # 전체 도서 수
    cursor.execute('SELECT COUNT(*) as count FROM books')
    total_books = cursor.fetchone()['count']

    # 장르별 통계
    cursor.execute('''
        SELECT genre, COUNT(*) as count
        FROM books
        WHERE genre IS NOT NULL
        GROUP BY genre
        ORDER BY count DESC
    ''')
    genre_stats = cursor.fetchall()

    # 최근 추가된 도서
    cursor.execute('''
        SELECT * FROM books
        ORDER BY created_at DESC
        LIMIT 5
    ''')
    recent_books = cursor.fetchall()

    db.close()

    return render_template('stats.html',
                           total_books=total_books,
                           genre_stats=genre_stats,
                           recent_books=recent_books)

⚠️ 주의사항

1. SQL Injection 방지

1
2
3
4
5
6
7
# ❌ 위험
query = request.args.get('q')
cursor.execute(f"SELECT * FROM books WHERE title LIKE '%{query}%'")

# ✅ 안전
query = request.args.get('q')
cursor.execute("SELECT * FROM books WHERE title LIKE ?", (f'%{query}%',))

2. 트랜잭션 관리

1
2
3
4
5
6
7
8
9
10
11
12
# ✅ 올바른 패턴
db = get_db()
try:
    cursor = db.cursor()
    cursor.execute("INSERT INTO books ...")
    db.commit()  # 성공 시 저장
    flash('추가 성공', 'success')
except Exception as e:
    db.rollback()  # 실패 시 롤백
    flash(f'오류: {str(e)}', 'error')
finally:
    db.close()  # 반드시 닫기

3. 입력 검증

1
2
3
4
# ✅ 서버 측 검증 필수
if not title or len(title) > 200:
    flash('제목은 1-200자여야 합니다', 'error')
    return redirect(url_for('add_book'))

🧪 연습 문제

문제: 대출 기능 추가하기

도서 대출 기능을 추가하세요:

  1. loans 테이블 생성 (book_id, borrower_name, borrowed_date, return_date)
  2. 도서 상세 페이지에 “대출하기” 버튼
  3. 대출 중인 도서는 “대출 불가” 표시
  4. 반납 기능
✅ 정답

1. 데이터베이스 초기화 (init_db.py에 추가):

1
2
3
4
5
6
7
8
9
10
11
# init_db.py에 loans 테이블 추가
cursor.execute('''
    CREATE TABLE IF NOT EXISTS loans (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        book_id INTEGER NOT NULL,
        borrower_name TEXT NOT NULL,
        borrowed_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        return_date TIMESTAMP,
        FOREIGN KEY (book_id) REFERENCES books(id)
    )
''')

2. Flask 라우트 (app.py에 추가):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
@app.route('/book/<int:book_id>')
def view_book(book_id):
    """도서 상세 페이지 (대출 정보 포함)"""
    db = get_db()
    cursor = db.cursor()

    # 도서 정보
    cursor.execute('SELECT * FROM books WHERE id = ?', (book_id,))
    book = cursor.fetchone()

    if not book:
        flash('도서를 찾을 수 없습니다', 'error')
        return redirect(url_for('index'))

    # 대출 정보 확인 (return_date가 NULL인 것 = 대출 중)
    cursor.execute('''
        SELECT * FROM loans
        WHERE book_id = ? AND return_date IS NULL
    ''', (book_id,))
    loan = cursor.fetchone()

    db.close()

    return render_template('view_book.html', book=book, loan=loan)

@app.route('/book/<int:book_id>/borrow', methods=['POST'])
def borrow_book(book_id):
    """도서 대출"""
    borrower_name = request.form.get('borrower_name', '').strip()

    if not borrower_name:
        flash('대출자 이름을 입력해주세요', 'error')
        return redirect(url_for('view_book', book_id=book_id))

    db = get_db()
    cursor = db.cursor()

    # 이미 대출 중인지 확인
    cursor.execute('''
        SELECT * FROM loans
        WHERE book_id = ? AND return_date IS NULL
    ''', (book_id,))

    if cursor.fetchone():
        flash('이미 대출 중인 도서입니다', 'error')
        db.close()
        return redirect(url_for('view_book', book_id=book_id))

    # 대출 처리
    cursor.execute('''
        INSERT INTO loans (book_id, borrower_name)
        VALUES (?, ?)
    ''', (book_id, borrower_name))

    db.commit()
    db.close()

    flash('대출이 완료되었습니다!', 'success')
    return redirect(url_for('view_book', book_id=book_id))

@app.route('/book/<int:book_id>/return', methods=['POST'])
def return_book(book_id):
    """도서 반납"""
    db = get_db()
    cursor = db.cursor()

    # 대출 중인 기록 찾기
    cursor.execute('''
        SELECT * FROM loans
        WHERE book_id = ? AND return_date IS NULL
    ''', (book_id,))
    loan = cursor.fetchone()

    if not loan:
        flash('대출 기록이 없습니다', 'error')
        db.close()
        return redirect(url_for('view_book', book_id=book_id))

    # 반납 처리 (return_date 업데이트)
    cursor.execute('''
        UPDATE loans
        SET return_date = CURRENT_TIMESTAMP
        WHERE id = ?
    ''', (loan['id'],))

    db.commit()
    db.close()

    flash('반납이 완료되었습니다!', 'success')
    return redirect(url_for('view_book', book_id=book_id))

@app.route('/loans')
def view_loans():
    """전체 대출 기록"""
    db = get_db()
    cursor = db.cursor()

    # 현재 대출 중인 도서
    cursor.execute('''
        SELECT loans.*, books.title, books.author
        FROM loans
        JOIN books ON loans.book_id = books.id
        WHERE loans.return_date IS NULL
        ORDER BY loans.borrowed_date DESC
    ''')
    current_loans = cursor.fetchall()

    # 반납 완료된 도서
    cursor.execute('''
        SELECT loans.*, books.title, books.author
        FROM loans
        JOIN books ON loans.book_id = books.id
        WHERE loans.return_date IS NOT NULL
        ORDER BY loans.return_date DESC
        LIMIT 10
    ''')
    past_loans = cursor.fetchall()

    db.close()

    return render_template('loans.html',
                         current_loans=current_loans,
                         past_loans=past_loans)

3. 템플릿 수정 (templates/view_book.html):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{% extends "base.html" %}

{% block content %}
<div class="book-detail">
    <h1>{{ book['title'] }}</h1>
    <p>저자: {{ book['author'] }}</p>

    <!-- 대출 상태 표시 -->
    {% if loan %}
        <div style="background: #fff3cd; padding: 1rem; margin: 1rem 0; border-radius: 5px;">
            <h3>📚 대출 중</h3>
            <p>대출자: {{ loan['borrower_name'] }}</p>
            <p>대출일: {{ loan['borrowed_date'] }}</p>

            <form method="POST" action="{{ url_for('return_book', book_id=book['id']) }}">
                <button type="submit" class="btn btn-success"
                        onclick="return confirm('반납하시겠습니까?')">
                    📥 반납하기
                </button>
            </form>
        </div>
    {% else %}
        <div style="background: #d4edda; padding: 1rem; margin: 1rem 0; border-radius: 5px;">
            <h3>✅ 대출 가능</h3>

            <form method="POST" action="{{ url_for('borrow_book', book_id=book['id']) }}">
                <input type="text" name="borrower_name"
                       placeholder="대출자 이름" required
                       style="padding: 0.5rem; margin-right: 0.5rem;">
                <button type="submit" class="btn btn-primary">📤 대출하기</button>
            </form>
        </div>
    {% endif %}

    <a href="{{ url_for('index') }}" class="btn btn-secondary">← 목록으로</a>
</div>
{% endblock %}

4. 대출 기록 페이지 (templates/loans.html):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
{% extends "base.html" %}

{% block content %}
<h2>📚 대출 기록</h2>

<h3>현재 대출 중 ({{ current_loans|length }}권)</h3>
<table border="1" style="width: 100%; margin: 1rem 0;">
    <tr>
        <th>도서명</th>
        <th>저자</th>
        <th>대출자</th>
        <th>대출일</th>
    </tr>
    {% for loan in current_loans %}
    <tr>
        <td>{{ loan['title'] }}</td>
        <td>{{ loan['author'] }}</td>
        <td>{{ loan['borrower_name'] }}</td>
        <td>{{ loan['borrowed_date'] }}</td>
    </tr>
    {% else %}
    <tr><td colspan="4">대출 중인 도서가 없습니다</td></tr>
    {% endfor %}
</table>

<h3>최근 반납 기록</h3>
<table border="1" style="width: 100%; margin: 1rem 0;">
    <tr>
        <th>도서명</th>
        <th>대출자</th>
        <th>대출일</th>
        <th>반납일</th>
    </tr>
    {% for loan in past_loans %}
    <tr>
        <td>{{ loan['title'] }}</td>
        <td>{{ loan['borrower_name'] }}</td>
        <td>{{ loan['borrowed_date'] }}</td>
        <td>{{ loan['return_date'] }}</td>
    </tr>
    {% else %}
    <tr><td colspan="4">반납 기록이 없습니다</td></tr>
    {% endfor %}
</table>
{% endblock %}

설명:

  • return_date IS NULL: 대출 중인 도서 (아직 반납 안 됨)
  • return_date IS NOT NULL: 반납 완료된 도서
  • JOIN으로 대출 정보와 도서 정보를 함께 조회
  • 대출 가능/불가능 상태를 시각적으로 표시

📝 요약

이번 Day 86에서 학습한 내용:

  1. CRUD 개념: Create, Read, Update, Delete
  2. 완전한 애플리케이션: 도서 관리 시스템 구현
  3. 검색과 필터링: SQL LIKE, WHERE 조건
  4. UX 개선: Flash 메시지, 에러 처리, 검증

핵심 패턴:

1
2
3
4
5
6
7
8
9
10
11
# Create
cursor.execute('INSERT INTO books (...) VALUES (...)', data)

# Read
cursor.execute('SELECT * FROM books WHERE id = ?', (id,))

# Update
cursor.execute('UPDATE books SET ... WHERE id = ?', (*data, id))

# Delete
cursor.execute('DELETE FROM books WHERE id = ?', (id,))

📚 다음 학습

Day 87: REST API 만들기 ⭐⭐⭐⭐⭐

내일은 모바일 앱이나 다른 서비스에서 사용할 수 있는 REST API를 만듭니다!


“CRUD는 모든 웹 애플리케이션의 기초입니다!” 🚀

Day 86/100 Phase 9: 웹 개발 입문 #100DaysOfPython
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.