파이썬 sqlite3 스레드 오류: 연결 공유와 스레드별 연결을 구분하는 법

핵심은 연결을 만든 스레드와 사용하는 스레드가 다르면 먼저 연결의 소유권 문제로 보고, check_same_thread=False는 쓰기 동기화까지 해결하는 옵션으로 보지 않는 것입니다. 파이썬 sqlite3의 SQLite objects created in a thread can only be used in that same thread 오류는 연결을 만든 스레드와 사용하는 스레드가 달라서 발생합니다. 따라서 이 오류가 보이면 잠금 오류로 바로 단정하기보다 연결 생성 위치와 사용 위치를 먼저 확인해야 합니다.

오류가 의미하는 것

sqlite3.connect()로 만든 연결은 기본적으로 생성한 스레드에서 사용한다는 전제에 놓입니다. Python 공식 sqlite3 문서에 따르면 check_same_thread의 기본값은 True입니다. 예를 들어 모듈 전역에서 연결을 만든 뒤 ThreadPoolExecutor의 작업 함수가 그 연결을 사용하면, 연결을 만든 스레드와 작업을 실행하는 스레드가 달라질 수 있습니다.

이때 확인할 순서는 단순합니다.

  1. sqlite3.connect()가 모듈 import 시점, 애플리케이션 초기화 시점, 요청 처리 함수 중 어디에서 실행되는지 확인합니다.
  2. 실제 execute(), fetchone(), commit()이 어느 스레드에서 호출되는지 로그로 확인합니다.
  3. 연결과 함께 만들어 둔 커서가 다른 스레드의 작업에 전달되는지도 봅니다.

연결을 스레드 안에서 만들었는데 그 연결이나 커서를 바깥으로 반환하는 구조라면, 겉으로는 작업 함수 안에서 쓰더라도 소유권이 어긋날 수 있습니다. 연결 수명과 커서 수명을 같은 작업의 범위 안에 두는 것이 첫 번째 정리 방법입니다.

check_same_thread=False가 해결하는 범위

False로 설정하면 다른 스레드에서 연결을 사용할 수 있지만, 데이터 손상을 피하려면 쓰기 작업을 사용자가 직렬화해야 할 수 있습니다. 다음 설정은 스레드 검사만 끄는 예입니다.

import sqlite3

connection = sqlite3.connect(
    "jobs.db",
    check_same_thread=False,
)

이 옵션을 켰다고 해서 여러 스레드가 같은 연결에 동시에 INSERT, UPDATE, DELETE를 실행해도 된다는 뜻은 아닙니다. SQLite 공식 threading 문서가 설명하듯 실제 허용 범위는 SQLite의 threading mode와 연결을 공유하는 방식에 영향을 받습니다. sqlite3.threadsafety도 실행 환경의 SQLite 스레드 모드에 따라 달라집니다.

운영 코드에서 이 옵션을 검토한다면 적어도 다음 조건을 함께 정해야 합니다.

  • 공유 연결을 쓰는 모든 작업을 같은 threading.Lock으로 보호하고, 트랜잭션 전체를 하나의 임계 구간으로 다룰 것
  • 커서를 여러 작업 사이에 보관하지 않고 작업 안에서 만들고 닫을 것
  • 트랜잭션의 시작과 커밋 또는 롤백 시점을 명확히 할 것
  • 예외가 나도 연결과 잠금이 정리되도록 할 것

공유 연결과 명시적인 잠금이 정말 필요한 제한된 구조가 아니라면, 옵션을 끄는 것보다 스레드별 연결이 보통 더 설명하기 쉽습니다.

선택지 1: 작업자마다 연결 만들기

각 작업자가 자신의 연결을 만들고 작업이 끝난 뒤 닫게 하면 연결의 스레드 소유권을 자연스럽게 맞출 수 있습니다. 연결을 전역 변수로 전달하지 않는 것이 핵심입니다.

다음 코드의 작업자 수와 작업 ID는 동작을 설명하기 위한 예시 값이며, jobs 테이블이 이미 있다고 가정합니다.

from concurrent.futures import ThreadPoolExecutor
import sqlite3


def load_job(job_id: int):
    connection = sqlite3.connect("jobs.db", timeout=5)
    try:
        cursor = connection.execute(
            "SELECT id, status FROM jobs WHERE id = ?",
            (job_id,),
        )
        return cursor.fetchone()
    finally:
        connection.close()


with ThreadPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(load_job, [1, 2, 3]))

조회 결과가 없으면 fetchone()은 None을 반환합니다. 이 구조에서는 연결과 커서의 수명이 함수 안에 있습니다. 다만 연결이 여러 개가 되면 쓰기 충돌은 별개의 문제로 남습니다. SQLite isolation 문서에 따르면 SQLite 데이터베이스에는 동시에 하나의 writer만 존재할 수 있습니다. 여러 스레드가 각자 연결을 가져도 쓰기 자체가 무제한으로 병렬화되는 것은 아닙니다.

읽기와 쓰기를 같은 함수에서 처리한다면 트랜잭션을 오래 붙잡지 않도록 범위를 작게 유지하고, 쓰기 작업이 집중되는 구조라면 다음 선택지를 검토합니다.

선택지 2: 쓰기만 하나의 작업자로 직렬화하기

작업자 스레드들은 결과를 계산하고, 데이터베이스 변경은 별도의 단일 writer가 맡는 구조입니다. 이 방식은 연결을 공유하는 대신 쓰기 요청을 큐에 넣어 순서를 관리합니다.

예를 들어 계산 작업이 끝날 때마다 저장 요청을 큐에 넣고, writer가 자기 스레드에서 만든 연결로 요청을 꺼내 커밋하도록 설계할 수 있습니다. 큐에 넣었다는 사실과 저장이 완료됐다는 사실은 다르므로, 호출 측에 성공이나 실패를 어떻게 전달할지도 정해야 합니다.

종료할 때는 새 요청의 유입을 멈추고 남은 요청을 처리한 뒤 writer를 종료하도록 순서를 설계합니다. 예외로 저장에 실패한 요청의 재시도 여부와 실패 기록, 큐 작업 완료 처리와 연결 정리도 함께 고려해야 합니다. 읽기까지 모두 writer에 맡길지, 읽기는 스레드별 연결로 둘지는 작업량과 일관성 요구에 따라 달라집니다.

잠금 오류와 스레드 연결 오류 구분하기

두 오류는 원인이 다릅니다.

  • SQLite objects created in a thread...는 연결의 생성 스레드와 사용 스레드가 어긋났는지 먼저 봅니다.
  • database is locked는 여러 연결 또는 트랜잭션이 쓰기 잠금을 기다리는 상황인지 봅니다.

Python 문서의 timeout 기본값은 5초입니다. 잠금이 해당 대기 시간 안에 풀리지 않으면 OperationalError가 발생할 수 있으므로, 대기 시간만 늘리는 것으로 쓰기 순서나 트랜잭션 범위 문제를 대신 해결할 수는 없습니다.

따라서 첫 번째 오류에 timeout을 올리거나, 두 번째 오류에 check_same_thread=False를 먼저 적용하는 식의 처방은 맞지 않을 수 있습니다. 로그에는 연결을 만든 지점, 현재 스레드, SQL 작업 종류, 트랜잭션 시작·종료 시점을 함께 남기면 원인을 좁히기 쉽습니다.

어떤 구조를 선택할까

실무에서는 다음 기준으로 결정하면 됩니다.

  • 조회와 간단한 저장을 각 작업이 독립적으로 처리한다면: 작업자 내부에서 연결을 만들고 닫습니다.
  • 쓰기 순서를 통제해야 하거나 쓰기 충돌이 잦다면: 작업자는 결과만 만들고 단일 writer가 저장합니다.
  • 이미 공유 연결 구조를 바꾸기 어려운 작은 구간이라면: check_same_thread=False와 연결 접근 전체를 보호하는 명시적인 잠금을 설계하고, 커서·트랜잭션 수명을 짧게 둡니다.

처음 확인할 곳은 옵션이 아니라 연결 생성 위치입니다. 연결을 누가 만들고, 누가 사용하며, 쓰기 요청이 어떤 순서로 커밋되는지 세 가지가 정리되면 ThreadPoolExecutor, Flask 요청 처리, 예약 작업 중 어떤 실행 방식에서도 구조를 선택하기가 훨씬 쉬워집니다.

출처

Leave a Comment