Devin.KR

데이터 모델 정하기 - sqlite3 로 표와 관계 만들기

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서 공간 예약 서비스의 기획서와 실행 명령을 정했다. 이제 기획서에 적힌 정보를 어떤 모양으로 저장할지 결정한다. 문장으로는 “모임이 공간을 예약한다”라고 간단히 말할 수 있지만, 프로그램은 모임 이름과 공간 이름, 예약 시간을 구분해서 저장해야 한다. 이 구분이 흐리면 이름을 고칠 때 여러 예약을 함께 수정하거나, 존재하지 않는 공간을 예약하는 일이 생긴다.

이 장에서는 Python에 포함된 sqlite3 모듈로 세 개의 표를 만든다. 데이터베이스(database)는 정보를 일정한 구조로 저장하고 조회하는 장치다. SQLite는 별도 서버 없이 사용할 수 있는 데이터베이스이며, sqlite3는 Python에서 SQLite를 다루는 통로다. 완성 프로그램은 메모리에 표를 만들고 예시 데이터를 넣은 다음, 예약에 연결된 모임과 공간을 찾아 표 형태로 출력한다. 프로그램이 끝나면 데이터도 사라진다.

  • 기획서에서 저장할 대상인 모임·공간·예약을 찾아낸다.
  • 표와 열을 정하고, 기본 키와 외래 키로 관계를 표현한다.
  • AI가 제안한 구조에서 빠진 제약, 중복된 정보, 모호한 이름을 검토한다.
  • sqlite3로 표를 만들고 예시 데이터를 넣으며, 잘못된 데이터가 거부되는지 확인한다.
  • 세 표를 연결해서 사람이 읽을 수 있는 예약 목록을 출력한다.

문제 상황

동아리와 스터디 모임이 함께 쓰는 공간의 예약 목록을 파일 하나로 관리한다고 가정한다. 각 줄에 모임 이름, 공간 이름, 시작 시각, 종료 시각을 적었다. 처음에는 편하지만 예약이 늘자 같은 공간 이름이 여러 줄에 반복된다. “작은방”을 “작은 모임방”으로 바꾸려면 관련된 모든 줄을 찾아야 한다. 일부만 바꾸면 두 이름이 서로 다른 공간처럼 보인다.

모임 이름도 같은 문제가 생긴다. “책읽기 모임”이 이름을 바꾸면 과거 예약까지 수정해야 한다. 공간 이름을 잘못 입력한 예약이 실제 공간을 가리키는지도 알기 어렵다. 예약 목록에 공간 정원을 매번 적으면 정원이 변경될 때 어느 값이 현재 값인지 판단하기 힘들어진다.

이번 실습의 기획 문장은 다음과 같다. 모임에는 이름이 있다. 공간에는 이름과 정원이 있다. 예약은 모임 하나와 공간 하나에 연결되고 시작 시각과 종료 시각을 가진다. 여러 날짜에 걸친 예약은 이번 예제에서 받지 않는다. 모든 시각은 같은 지역의 시각으로 기록한다.

여기서 저장 구조를 먼저 정한다. 같은 공간에서 시간이 겹치는 예약을 막는 판단은 다음 장에서 함수와 테스트로 다룬다. 이번 장의 표가 시간 겹침까지 막는다고 생각해서는 안 된다. 표가 보장하는 조건과 프로그램이 따로 판단할 조건을 구별해야 다음 작업의 범위도 분명해진다.

기획서의 명사를 표와 열로 바꾸기

기획서에서 반복해서 등장하는 명사는 저장할 대상을 찾는 출발점이다. 이번에는 모임, 공간, 예약이 서로 다른 대상이다. 모임의 이름은 모임에 속하고, 공간의 정원은 공간에 속한다. 예약에는 두 대상을 연결하는 정보와 시간이 속한다. 문장에 나온 명사를 모두 표로 만드는 규칙은 아니다. 시작 시각처럼 대상의 성질을 나타내는 값은 열로 두는 편이 자연스럽다.

표(table)는 같은 종류의 정보를 모은 구조다. 행(row)은 그 표에 저장한 한 건이고, 열(column)은 각 행에 기록할 항목이다. 공간 표에서 “작은방, 정원 6명”은 한 행이며, 이름과 정원은 각각 열이다. 스키마(schema)는 이런 표 이름, 열 이름, 값의 종류와 저장 조건을 정한 설계다.

세 대상의 정보는 해당 대상의 표에 나누어 저장한다
대상표 이름주요 열한 행의 뜻
모임groupsid, name등록된 모임 하나
공간spacesid, name, capacity예약할 공간 하나
예약reservationsid, group_id, space_id, starts_at, ends_at모임이 공간을 쓰는 일정 하나

표 이름은 여러 행을 담는다는 뜻으로 복수형을 쓴다. 열 이름은 소문자와 밑줄로 통일한다. starts_at과 ends_at은 각각 시작 시각과 종료 시각을 뜻한다. date라는 이름만 쓰면 예약 날짜인지, 등록 날짜인지 알기 어렵다. 이름을 길게 만드는 것보다 한 가지 뜻으로 읽히게 만드는 것이 중요하다.

예약 표에는 group_name과 space_name을 두지 않는다. 대신 모임 번호와 공간 번호를 저장한다. 조회할 때 그 번호로 이름을 찾는다. 공간 이름을 수정하더라도 예약이 가리키는 공간 번호는 유지된다. 이 설계에서 예약 목록에 표시되는 이름은 현재 이름이다. 예약 당시의 이름을 보존해야 한다는 요구가 생기면 별도의 설계가 필요하지만, 지금의 기획에는 그 요구가 없다.

예약은 이름을 복사하지 않고 모임 번호와 공간 번호로 두 표를 참조한다

정원은 공간 표에 한 번만 저장한다. 예약 표에 정원을 복사하지 않으므로 같은 공간의 정원이 예약마다 달라지는 문제를 줄일 수 있다. 이처럼 같은 사실을 여러 곳에 저장하지 않도록 나누는 이유는 수정할 곳을 분명하게 만들기 위해서다. 표를 많이 만드는 것 자체가 목적은 아니다.

기본 키·외래 키·제약으로 관계 지키기

기본 키(primary key)는 표 안에서 각 행을 구분하는 값이다. 이 예제에서는 모든 표의 id를 정수 기본 키로 둔다. 모임 이름은 바뀔 수 있으므로 예약이 연결할 기준으로 쓰지 않는다. 예시 데이터에서는 번호를 직접 지정한다. 따라서 실행할 때마다 같은 번호와 같은 출력이 나온다.

외래 키(foreign key)는 다른 표의 행을 참조하는 열이다. reservations의 group_id는 groups의 id를, space_id는 spaces의 id를 참조한다. 하나의 모임이 여러 예약에 연결될 수 있지만, 예약 한 건은 모임 하나를 가리킨다. 공간도 같은 관계를 가진다. 이번 구조에는 여러 모임이 하나의 예약을 공동으로 소유하는 기능이 없다.

제약(constraint)은 저장할 데이터가 지켜야 하는 조건이다. NOT NULL은 값이 비어 있는 상태인 NULL을 허용하지 않는다. UNIQUE는 같은 값이 중복되는 것을 막는다. SQLite의 CHECK는 지정한 식의 평가 결과가 0이면 저장을 거부한다. 결과가 NULL이면 허용하므로 NULL을 막으려면 NOT NULL도 필요하다. 외래 키 제약은 참조하는 번호가 실제로 존재하는지 확인한다.

제약은 이번 기획에서 허용할 데이터의 범위를 정한다
대상조건표현확인할 예
모든 번호행을 구분한다INTEGER PRIMARY KEY같은 번호의 두 행을 저장할 수 없다
모임 이름NULL을 허용하지 않는다NOT NULL이름 없이 모임을 등록할 수 없다
공간 이름같은 이름을 쓰지 않는다NOT NULL UNIQUE작은방을 두 번 등록할 수 없다
정원양의 정수다NOT NULL과 CHECK0명이나 2.5명을 거부한다
예약의 연결모임과 공간이 존재한다NOT NULL과 REFERENCES없는 모임 번호를 거부한다
예약 시각같은 날짜이며 시작이 앞선다NOT NULL과 CHECK종료가 시작보다 이르면 거부한다

모임 이름에는 UNIQUE를 붙이지 않는다. 이름이 같은 서로 다른 모임을 허용한다는 가정이다. 공간은 사용자가 이름으로 구별한다는 가정 아래 이름 중복을 막는다. 두 결정 모두 기술이 자동으로 정해 주는 답이 아니다. AI가 모든 name에 UNIQUE를 붙였다면 어떤 이름이 실제로 중복될 수 있는지 기획과 대조해야 한다.

SQLite에서는 INTEGER라고 선언한 일반 열에도 다른 종류의 값이 들어갈 수 있다. 따라서 정원에는 typeof(capacity) = 'integer'라는 검사도 넣는다. 열에 INTEGER라는 글자가 있다는 이유만으로 모든 입력이 정수로 제한된다고 판단하지 않는다.

외래 키 검사는 연결마다 PRAGMA foreign_keys = ON으로 켠다. PRAGMA는 SQLite의 동작 설정을 읽거나 바꾸는 명령이다. 완성 코드에서는 연결 직후, 데이터 저장을 시작하기 전에 설정한다. 이어서 설정값을 읽어 실제로 켜졌는지도 확인한다. 표에 REFERENCES를 적는 일과 검사를 활성화하는 일은 함께 필요하다.

시각은 2026-10-07 09:00처럼 연도부터 분까지 고정된 형식의 문자열로 저장한다. 같은 형식이라면 문자열 순서로 시간의 앞뒤를 비교할 수 있다. 다만 완성 코드의 길이 검사와 날짜 부분 비교는 실제 달력에서 유효한 날짜인지를 보장하지 않는다. 예시 데이터는 미리 정한 유효한 값이며, 사용자 입력의 날짜 해석과 오류 처리는 해당 주제를 다룰 때 보강한다.

AI의 스키마 제안을 질문과 실행으로 검토하기

AI에는 먼저 대상과 가정을 적고 구조를 제안하게 한다. 처음부터 많은 기능을 요청하면 아직 정하지 않은 운영 규칙까지 코드에 섞일 수 있다. 요청에 이번 작업의 경계를 적으면 검토할 항목을 줄일 수 있다.

모임·공간·예약을 저장할 SQLite 스키마를 제안하라. 모임 이름은 중복될 수 있고 공간 이름은 중복될 수 없다. 정원은 양의 정수다. 예약은 존재하는 모임과 공간을 참조한다. 시각은 같은 지역의 YYYY-MM-DD HH:MM 형식이며 같은 날짜 안에서 시작이 종료보다 앞선다. 시간 겹침 판단은 이번 작업에서 제외하라. 각 제약의 이유와 아직 보장하지 못하는 조건도 설명하라.

답을 받으면 코드가 길거나 설명이 자연스럽다는 이유로 채택하지 않는다. 다음 질문을 기획서와 함께 확인한다.

  • 빠진 제약은 없는가. 예약의 연결 번호에 NULL이 들어갈 수 있거나 정원이 음수여도 저장되지 않는가.
  • 같은 사실을 중복 저장하는가. 예약마다 공간 이름과 정원을 복사하고 있지 않은가.
  • 이름이 한 가지 뜻으로 읽히는가. room과 space를 섞거나 start와 created_at을 혼동하지 않는가.
  • 아직 구현하지 않은 조건을 구현했다고 설명하는가. 시작이 종료보다 앞선다는 검사만으로 시간 겹침까지 막는다고 말하지 않는가.

설명과 코드가 다르면 실행 결과를 근거로 다시 요청한다. 이 장의 프로그램은 정상 데이터를 조회할 뿐 아니라 잘못된 저장 세 가지를 시도한다. 오류가 예상대로 발생했을 때만 확인 메시지를 출력한다. 오류가 발생하지 않으면 프로그램을 중단해 구조의 누락을 드러낸다.

없는 모임 번호를 넣었는데 저장이 성공했다. 연결 직후 외래 키 검사를 켜는 부분과 설정값 확인을 추가하라. 표 이름이나 예시 데이터는 바꾸지 말고, 수정한 이유를 설명하라.

수정 전후 차이를 보여 주는 diff도 검토한다. 요청한 수정 외에 공간 이름의 중복 조건이 사라지거나 열 이름이 바뀌지 않았는지 읽는다. 확인 메시지 세 개는 선택한 조건 세 개의 실행 증거다. 모든 조건이 검증되었다는 뜻은 아니다. 저장 구조에 관한 주장도 실제로 확인한 범위만큼만 받아들인다.

스키마 제안은 기획 대조와 변경 검토를 거친 뒤 정상 저장과 거부 확인으로 검증한다

완성 코드

다음 내용을 main.py에 저장한다. 외부 파일이나 패키지는 필요 없다. SQL은 데이터베이스에 표 생성, 저장, 조회 등을 요청하는 언어다. 코드 안의 여러 줄 문자열이 SQLite에 전달할 SQL이며, Python 프로그램 전체는 한 파일로 실행된다.

import sqlite3


SCHEMA = """
CREATE TABLE groups (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL
);

CREATE TABLE spaces (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL UNIQUE,
    capacity INTEGER NOT NULL
        CHECK (typeof(capacity) = 'integer' AND capacity > 0)
);

CREATE TABLE reservations (
    id INTEGER PRIMARY KEY,
    group_id INTEGER NOT NULL REFERENCES groups(id),
    space_id INTEGER NOT NULL REFERENCES spaces(id),
    starts_at TEXT NOT NULL CHECK (length(starts_at) = 16),
    ends_at TEXT NOT NULL CHECK (length(ends_at) = 16),
    CHECK (substr(starts_at, 1, 10) = substr(ends_at, 1, 10)),
    CHECK (starts_at < ends_at)
);
"""


def create_schema(connection):
    connection.execute("PRAGMA foreign_keys = ON")
    enabled = connection.execute("PRAGMA foreign_keys").fetchone()[0]
    if enabled != 1:
        raise RuntimeError("외래 키 검사를 켜지 못했다.")
    connection.executescript(SCHEMA)


def insert_examples(connection):
    with connection:
        connection.executemany(
            "INSERT INTO groups (id, name) VALUES (?, ?)",
            [(1, "책읽기 모임"), (2, "코딩 스터디")],
        )
        connection.executemany(
            "INSERT INTO spaces (id, name, capacity) VALUES (?, ?, ?)",
            [(1, "작은방", 6), (2, "큰방", 12)],
        )
        connection.executemany(
            """
            INSERT INTO reservations
                (id, group_id, space_id, starts_at, ends_at)
            VALUES (?, ?, ?, ?, ?)
            """,
            [
                (1, 1, 1, "2026-10-07 09:00", "2026-10-07 10:00"),
                (2, 2, 2, "2026-10-07 10:00", "2026-10-07 11:30"),
            ],
        )


def expect_rejection(connection, label, sql, values):
    try:
        with connection:
            connection.execute(sql, values)
            raise AssertionError(f"제약 확인 실패: {label}")
    except sqlite3.IntegrityError:
        print(f"제약 확인: {label} 거부")


def check_constraints(connection):
    expect_rejection(
        connection,
        "없는 모임",
        """
        INSERT INTO reservations
            (id, group_id, space_id, starts_at, ends_at)
        VALUES (?, ?, ?, ?, ?)
        """,
        (3, 999, 1, "2026-10-07 13:00", "2026-10-07 14:00"),
    )
    expect_rejection(
        connection,
        "정원 0",
        "INSERT INTO spaces (id, name, capacity) VALUES (?, ?, ?)",
        (3, "연습방", 0),
    )
    expect_rejection(
        connection,
        "공간 이름 중복",
        "INSERT INTO spaces (id, name, capacity) VALUES (?, ?, ?)",
        (3, "작은방", 8),
    )


def print_reservations(connection):
    rows = connection.execute(
        """
        SELECT r.id, g.name, s.name, r.starts_at, r.ends_at
        FROM reservations AS r
        JOIN groups AS g ON g.id = r.group_id
        JOIN spaces AS s ON s.id = r.space_id
        ORDER BY r.starts_at, r.id
        """
    ).fetchall()
    print()
    print("| 예약 번호 | 모임 | 공간 | 시작 | 종료 |")
    print("| --- | --- | --- | --- | --- |")
    for row in rows:
        print("| " + " | ".join(str(value) for value in row) + " |")


def main():
    connection = sqlite3.connect(":memory:")
    try:
        create_schema(connection)
        insert_examples(connection)
        check_constraints(connection)
        print_reservations(connection)
    finally:
        connection.close()


if __name__ == "__main__":
    main()

줄별 해설

import sqlite3는 Python에 포함된 모듈을 가져온다. SCHEMA는 세 표를 만드는 명령을 담은 문자열이다. CREATE TABLE 다음에 표 이름을 적고 괄호 안에서 열과 제약을 선언한다. id INTEGER PRIMARY KEY는 정수 번호를 행의 식별자로 사용한다. spaces.name의 UNIQUE는 공간 이름 중복을 막는다.

capacity의 CHECK는 두 조건을 AND로 연결한다. 저장되는 값의 종류가 정수이고 값이 0보다 커야 한다. starts_at과 ends_at의 length 검사는 문자열 길이를 제한한다. substr은 문자열의 일부를 꺼내는 SQL 함수다. 앞의 열 글자를 비교해 두 시각의 날짜 부분이 같은지 확인하고, 마지막 CHECK로 시작이 종료보다 앞선지 확인한다.

create_schema는 외래 키 검사를 먼저 켠다. fetchone은 조회 결과에서 행 하나를 가져오며, [0]은 그 행의 첫 번째 값을 읽는다. 값이 1이 아니면 표를 만드는 작업을 진행하지 않는다. executescript는 문자열에 담긴 여러 SQL 명령을 순서대로 실행한다.

insert_examples의 with connection은 여러 저장을 하나의 트랜잭션(transaction)으로 묶는다. 트랜잭션은 함께 확정하거나 함께 취소할 작업의 단위다. 블록이 정상적으로 끝나면 저장을 확정하고, 예외로 끝나면 취소한다. 이 문법은 연결을 닫지는 않는다. 연결 종료는 main의 finally에서 처리한다.

모임과 공간을 예약보다 먼저 넣는 이유는 예약이 이미 존재하는 번호를 참조해야 하기 때문이다. executemany는 같은 저장 명령을 여러 묶음의 값에 적용한다. VALUES의 물음표는 값이 들어갈 자리다. 실제 값은 SQL 문자열에 이어 붙이지 않고 튜플로 따로 전달한다. 열 이름을 명시했으므로 값의 순서도 그 열 목록을 기준으로 읽을 수 있다.

expect_rejection은 잘못된 저장을 시도하는 작은 확인 함수다. 제약을 어기면 sqlite3.IntegrityError가 발생한다. with 블록은 해당 시도의 변경을 취소하고, except가 예상한 오류를 받아 확인 메시지를 출력한다. 반대로 저장이 성공하면 AssertionError를 발생시킨다. 이때도 변경을 취소하며, 예상과 다른 성공을 숨기지 않고 프로그램을 멈춘다.

정상 예시 데이터는 insert_examples가 끝날 때 이미 확정된다. 따라서 이후의 잘못된 저장 시도를 취소해도 예시 데이터는 남는다. check_constraints는 없는 모임 번호, 정원 0, 중복된 공간 이름을 각각 확인한다. 각 입력의 나머지 값은 정상으로 두어 어떤 제약을 확인하려는지 쉽게 읽을 수 있게 했다.

print_reservations는 조인(join)으로 세 표를 연결한다. 조인은 관련된 행을 연결해서 한 번에 조회하는 방법이다. AS r, AS g, AS s는 SQL 안에서 표 이름을 짧게 부르는 별칭이다. g.id = r.group_id와 s.id = r.space_id가 연결 조건이다. 예약 번호와 두 이름, 두 시각만 SELECT로 선택한다.

ORDER BY는 결과의 순서를 지정한다. 시작 시각이 같으면 예약 번호로 다시 정렬한다. 정렬 조건이 없으면 삽입한 순서로 계속 조회된다고 보장할 수 없다. fetchall은 결과 행을 모두 가져온다. 반복문은 각 값을 문자열로 바꾸고 세로줄로 구분해 표를 출력한다. 값이 두 건인 이번 예제에는 이 방식으로 충분하다.

main은 메모리 데이터베이스 연결을 만들고 생성, 저장, 확인, 조회 순으로 실행한다. finally는 작업 중 예외가 발생해도 연결을 닫는다. 파일 마지막의 조건문은 main.py를 직접 실행했을 때 main을 호출한다. 파일을 다른 프로그램에서 가져올 때는 자동으로 예시를 실행하지 않는다.

실행 결과

main.py가 있는 디렉터리에서 다음 명령을 실행한다. 첫 명령은 문법을 검사하며, 정상이라면 별도 출력이 없다. 두 번째 명령은 경고를 오류로 취급하면서 프로그램을 실행하고, 마지막 명령은 기본 설정으로 프로그램을 실행한다. 뒤의 두 명령 모두 실행 결과를 출력한다.

python3 -m py_compile main.py
python3 -W error main.py
python3 main.py

뒤의 두 명령은 각각 다음과 같은 결과를 출력한다. 경고를 오류로 취급하는 실행도 같은 결과로 끝나야 한다.

제약 확인: 없는 모임 거부
제약 확인: 정원 0 거부
제약 확인: 공간 이름 중복 거부

| 예약 번호 | 모임 | 공간 | 시작 | 종료 |
| --- | --- | --- | --- | --- |
| 1 | 책읽기 모임 | 작은방 | 2026-10-07 09:00 | 2026-10-07 10:00 |
| 2 | 코딩 스터디 | 큰방 | 2026-10-07 10:00 | 2026-10-07 11:30 |

잘못된 저장을 세 번 시도했지만 예약 목록은 두 건이다. 메모리 데이터베이스를 사용하므로 다시 실행해도 이전 실행의 데이터가 누적되지 않는다. 시각을 현재 시각에서 가져오지 않고 고정했으며 조회 순서도 지정했으므로 출력이 일정하다.

실무에서 자주 틀리는 것

외래 키 선언만 하고 검사를 켜지 않는다

다음 코드는 연결을 만든 뒤 외래 키 검사를 켜지 않는다. REFERENCES가 있어도 연결의 검사 설정을 확인하지 않으면 의도한 거부 동작을 얻지 못할 수 있다. 두 예시는 각각 독립적으로 실행할 수 있다.

import sqlite3

connection = sqlite3.connect(":memory:")
connection.close()

연결 직후 설정을 켜고 설정값을 확인한다. 데이터를 저장하는 트랜잭션이 시작된 뒤에 켜려 하지 않는다.

import sqlite3

connection = sqlite3.connect(":memory:")
try:
    connection.execute("PRAGMA foreign_keys = ON")
    enabled = connection.execute("PRAGMA foreign_keys").fetchone()[0]
    if enabled != 1:
        raise RuntimeError("외래 키 검사를 켜지 못했다.")
finally:
    connection.close()

NOT NULL이 빈 문자열도 막는다고 생각한다

NULL과 빈 문자열은 다르다. 다음 표에는 이름으로 빈 문자열을 저장할 수 있다. NOT NULL은 값이 없다는 상태를 막지만 문자열 내용까지 검사하지 않는다.

import sqlite3

with sqlite3.connect(":memory:") as connection:
    connection.execute("CREATE TABLE sample (name TEXT NOT NULL)")
    connection.execute("INSERT INTO sample (name) VALUES (?)", ("",))
    print(connection.execute("SELECT count(*) FROM sample").fetchone()[0])
connection.close()

빈 문자열과 일반 공백만으로 된 이름을 막으려면 별도의 조건을 추가한다. 다음 코드는 해당 입력의 거부를 확인한다. trim의 기본 동작은 문자열 양 끝의 일반 공백을 제거하며 모든 종류의 공백 문자를 처리하는 검증은 아니다.

import sqlite3

with sqlite3.connect(":memory:") as connection:
    connection.execute(
        "CREATE TABLE sample "
        "(name TEXT NOT NULL CHECK (length(trim(name)) > 0))"
    )
    try:
        connection.execute("INSERT INTO sample (name) VALUES (?)", ("   ",))
    except sqlite3.IntegrityError:
        print("공백 이름 거부")
connection.close()

완성 코드의 이름 열은 NOT NULL까지만 적용했다. 이 차이를 알아야 현재 스키마가 보장하는 범위를 정확히 설명할 수 있다.

값을 SQL 문자열에 직접 이어 붙인다

이름에 작은따옴표가 있으면 직접 이어 붙인 SQL이 깨질 수 있다. 다음 예시는 문제가 발생하는 입력을 실제로 사용한다.

import sqlite3

with sqlite3.connect(":memory:") as connection:
    connection.execute("CREATE TABLE sample (name TEXT)")
    name = "토요일 '함께' 모임"
    try:
        connection.execute("INSERT INTO sample VALUES ('" + name + "')")
    except sqlite3.OperationalError:
        print("이름을 이어 붙인 SQL 실패")
connection.close()

물음표와 별도의 값 전달을 사용하면 이름을 SQL 문법과 분리할 수 있다. 값이 하나인 튜플은 쉼표를 붙여 (name,)으로 쓴다.

import sqlite3

with sqlite3.connect(":memory:") as connection:
    connection.execute("CREATE TABLE sample (name TEXT)")
    name = "토요일 '함께' 모임"
    connection.execute("INSERT INTO sample VALUES (?)", (name,))
    print(connection.execute("SELECT name FROM sample").fetchone()[0])
connection.close()

조인의 연결 조건을 빠뜨린다

두 표를 함께 적기만 하면 관련된 행끼리 연결되지 않는다. 다음 예시는 예약 두 건과 공간 두 건의 가능한 조합을 모두 만들어 네 행을 출력한다.

import sqlite3

with sqlite3.connect(":memory:") as connection:
    connection.executescript(
        "CREATE TABLE reservations (space_id INTEGER);"
        "CREATE TABLE spaces (id INTEGER, name TEXT);"
        "INSERT INTO reservations VALUES (1), (2);"
        "INSERT INTO spaces VALUES (1, '작은방'), (2, '큰방');"
    )
    rows = connection.execute(
        "SELECT s.name FROM reservations AS r CROSS JOIN spaces AS s"
    ).fetchall()
    print(len(rows))
connection.close()

예약의 공간 번호와 공간의 기본 키를 비교하는 조건을 넣으면 각 예약이 가리키는 공간만 연결되어 두 행이 나온다.

import sqlite3

with sqlite3.connect(":memory:") as connection:
    connection.executescript(
        "CREATE TABLE reservations (space_id INTEGER);"
        "CREATE TABLE spaces (id INTEGER, name TEXT);"
        "INSERT INTO reservations VALUES (1), (2);"
        "INSERT INTO spaces VALUES (1, '작은방'), (2, '큰방');"
    )
    rows = connection.execute(
        "SELECT s.name FROM reservations AS r "
        "JOIN spaces AS s ON s.id = r.space_id"
    ).fetchall()
    print(len(rows))
connection.close()

한눈에 보기

저장 구조를 검토할 때 확인할 질문과 실행 근거
항목역할검토 질문이번 코드의 근거
표와 열정보의 소속을 나눈다정원은 어디에 속하는가spaces에만 저장한다
기본 키행을 구분한다이름이 바뀌어도 연결되는가정수 id로 참조한다
외래 키존재하는 행을 참조한다검사 설정도 켰는가없는 모임 저장을 거부한다
CHECK값의 조건을 제한한다열의 선언만으로 충분한가정원 0 저장을 거부한다
UNIQUE중복을 제한한다중복 금지가 기획에 있는가공간 이름 중복을 거부한다
조인과 정렬관계와 출력 순서를 지정한다연결 조건과 순서가 명시적인가번호로 연결하고 시각·번호로 정렬한다

현재 스키마는 예약의 연결과 일부 값 조건을 지킨다. 실제 날짜의 유효성, 이름 입력의 상세 규칙, 공간 사용 시간의 겹침은 모두 확인하지 않는다. 다음 장에서는 예약 충돌을 판단하는 함수를 만들고, 경계가 맞닿는 예약과 겹치는 예약을 테스트로 구분한다.

sqlite3의 연결과 트랜잭션 동작은 Python 공식 sqlite3 문서에서, 외래 키 설정은 SQLite 공식 외래 키 문서에서 사실을 확인할 수 있다. 문서는 동작을 확인하는 근거로 사용하고 서비스의 규칙은 기획서에서 결정한다.

연습 문제

  1. 공간 정원이 2.5일 때 저장이 거부되는지 check_constraints에 확인을 추가하라. 기존 예시 데이터와 예약 목록은 유지하라.
  2. 종료 시각이 시작 시각보다 이른 예약을 넣어 거부를 확인하라. 연결 번호와 날짜는 정상 값으로 두라.
  3. 예약 목록에 공간 정원을 함께 출력하라. 예약 표에는 새 열을 추가하지 말고 조인 조회와 출력 머리글만 수정하라.
  4. AI가 예약 표에 space_name을 추가하고 시간 겹침도 CHECK로 해결했다고 설명했다. 현재 기획과 코드에 비추어 확인할 질문 두 가지를 작성하라.

정답과 해설

첫 번째 문제는 정수 검사에 대한 실행 근거를 추가하는 작업이다. check_constraints 안에 다음 호출을 넣는다. 공간 번호와 이름은 기존 값과 겹치지 않게 정한다.

    expect_rejection(
        connection,
        "정원 소수",
        "INSERT INTO spaces (id, name, capacity) VALUES (?, ?, ?)",
        (3, "연습방", 2.5),
    )

정원 2.5는 양수지만 정수가 아니다. typeof 검사가 없었다면 양수 검사만으로는 이 값을 막지 못한다. 새 확인 메시지가 추가되어도 조회되는 예약은 두 건으로 유지된다.

두 번째 문제도 check_constraints 안에 다음 호출을 추가한다. 시작과 종료의 길이, 날짜, 연결 번호는 유효하므로 시간의 앞뒤 조건을 확인하는 입력이 된다.

    expect_rejection(
        connection,
        "시간 역전",
        """
        INSERT INTO reservations
            (id, group_id, space_id, starts_at, ends_at)
        VALUES (?, ?, ?, ?, ?)
        """,
        (3, 1, 1, "2026-10-07 14:00", "2026-10-07 13:00"),
    )

시작과 종료를 같은 값으로 바꾸어도 거부된다. 이번 기획에서는 이용 시간이 없는 예약도 허용하지 않기 때문이다. 시간 겹침 검사는 다른 예약 행과 비교하는 별도의 문제다.

세 번째 문제는 print_reservations 함수를 다음 코드로 바꾸면 된다. 정원은 이미 연결한 공간 행에서 가져온다. 행을 출력하는 반복문은 열 개수가 늘어나도 그대로 사용할 수 있다.

def print_reservations(connection):
    rows = connection.execute(
        """
        SELECT r.id, g.name, s.name, s.capacity, r.starts_at, r.ends_at
        FROM reservations AS r
        JOIN groups AS g ON g.id = r.group_id
        JOIN spaces AS s ON s.id = r.space_id
        ORDER BY r.starts_at, r.id
        """
    ).fetchall()
    print()
    print("| 예약 번호 | 모임 | 공간 | 정원 | 시작 | 종료 |")
    print("| --- | --- | --- | --- | --- | --- |")
    for row in rows:
        print("| " + " | ".join(str(value) for value in row) + " |")

첫 번째 예약에는 정원 6, 두 번째 예약에는 정원 12가 표시된다. 예약 표에 정원을 복사하지 않았으므로 공간 정원을 수정하면 다음 조회에 변경된 값이 나타난다.

네 번째 문제의 질문은 주장과 저장 구조를 직접 연결해야 한다. 다음과 같이 작성할 수 있다.

space_id가 이미 공간을 참조하는데 space_name을 예약에 추가한 이유는 무엇인가. 공간 이름 변경 시 두 값이 어긋나지 않도록 어떻게 관리할 것인가.

제시한 CHECK가 다른 예약의 시간을 어디에서 비교하는가. 같은 공간에 겹치는 시간의 예약 두 건을 저장하는 확인 코드를 실행하면 두 번째 저장이 실제로 거부되는가.

이번 스키마의 CHECK는 한 예약의 시작과 종료만 비교한다. 설명에 시간 겹침 방지가 적혀 있어도 현재 코드에는 그 근거가 없다. AI의 답을 수정할 때는 설명만 바꾸는지 코드의 범위를 바꾸는지 구분하고, 변경 내용을 읽은 뒤 다시 실행해 확인한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.