2026๋…„ 7์›” 21์ผ ํ™”์š”์ผ

database.py



import os
from contextlib import contextmanager # contextmanager ์ž„ํฌํŠธ
from typing import Generator

from dotenv import load_dotenv

from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import sessionmaker, declarative_base, Session # Session ํƒ€์ž… ํžŒํŠธ ์ถ”๊ฐ€

load_dotenv()

# ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ๋กœ๋”ฉ ์‹œ ๋ณ€์ˆ˜๋ช… ์ถ•์•ฝ ๋Œ€์‹  ๋ช…์‹œ์ ์ธ ์ด๋ฆ„ ์‚ฌ์šฉ ๊ถŒ์žฅ
MYSQL_USER = os.environ.get("MYSQL_USER")
MYSQL_PASSWORD = os.environ.get("MYSQL_PASSWORD")
MYSQL_HOST = os.environ.get("MYSQL_ALEMBIC_HOST") # alembic ์ „์šฉ ํ˜ธ์ŠคํŠธ๊ฐ€ ์•„๋‹ˆ๋ผ๋ฉด MYSQL_HOST๋กœ ํ†ต์ผ ๊ฐ€๋Šฅ
MYSQL_PORT = os.environ.get("MYSQL_ALEMBIC_PORT")
MYSQL_DB = os.environ.get("MYSQL_DATABASE")

# MySQL ์—ฐ๊ฒฐ ์„ค์ • (์‚ฌ์šฉ์ž, ๋น„๋ฐ€๋ฒˆํ˜ธ, ํ˜ธ์ŠคํŠธ, DB์ด๋ฆ„ ์ˆ˜์ •)
# ์˜ˆ: mysql+aiomysql://user:password@localhost:3306/dbname
SQLALCHEMY_DATABASE_URL = f"mysql+aiomysql://{MYSQL_USER}:{MYSQL_PASSWORD}@{MYSQL_HOST}:{MYSQL_PORT}/{MYSQL_DB}"
# SQLite ์‚ฌ์šฉ ์‹œ: SQLALCHEMY_DATABASE_URL = "sqlite:///./myapi.db"

# create_engine ์„ค์ •: MySQL ์—ฐ๊ฒฐ ์‹œ pool_pre_ping์€ ์ข‹์€ ๊ด€๋ก€์ž…๋‹ˆ๋‹ค.
engine = create_async_engine(
    SQLALCHEMY_DATABASE_URL, 
    echo=True,
    pool_pre_ping=True,  # ์—ฐ๊ฒฐ ์œ ํšจ์„ฑ ์ฒดํฌ (MySQL ์—ฐ๊ฒฐ ๋Š๊น€ ๋ฐฉ์ง€)
    pool_recycle=3600,   # ์—ฐ๊ฒฐ ์žฌ์‚ฌ์šฉ ์‹œ๊ฐ„ ์„ค์ •    
    # SQLite ์‚ฌ์šฉ ์‹œ: connect_args={"check_same_thread": False}
)

# ์„ธ์…˜ ์ƒ์„ฑ์„ ์œ„ํ•œ ํŒฉํ† ๋ฆฌ
async_session = async_sessionmaker(
    bind=engine, 
    class_=AsyncSession, 
    expire_on_commit=False
)


# Base ๋ชจ๋ธ ํด๋ž˜์Šค ์ƒ์„ฑ
Base = declarative_base()

# ์ฐธ๊ณ : Alembic ์‚ฌ์šฉ ์‹œ ํ•„์š”ํ•œ ์ฃผ์„๋“ค์€ ์ œ๊ฑฐํ•˜๊ฑฐ๋‚˜ ๋ณ„๋„์˜ alembic config ํŒŒ์ผ๋กœ ์˜ฎ๊ธฐ๋Š” ๊ฒƒ์ด ๊น”๋”ํ•ฉ๋‹ˆ๋‹ค.


Cargo.toml



# curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# rustup update
# cargo update

[package]
name = "sheets"
version = "0.1.0"
edition = "2024"
default-run = "sheets"

[[bin]]
name = "sheets"
path = "src/main.rs"

[profile.dev.package.backtrace]
opt-level = 3

[dependencies]
tokio = { version = "1", features = ["full"] }
futures = "0.3"
itertools = "0.12"

serde = "1"
serde_json = "1"

rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs", "logging", "tls12"] }

google-sheets4 = "*"
yup-oauth2 = "12"
Inflector = "0.11.4"




src/main.rs



use std::path::Path;
use tokio::fs::File;
use tokio::io::AsyncWriteExt; // ๋น„๋™๊ธฐ ์“ฐ๊ธฐ๋ฅผ ์œ„ํ•œ ํŠธ๋ ˆ์ดํŠธ

mod gspread;
use crate::gspread::{SpreadsheetExporter, create_hub};

/// ์ƒ์„ฑ๋œ SQL ๋ฌธ์„ ๋น„๋™๊ธฐ์ ์œผ๋กœ .sql ํŒŒ์ผ์— ์ €์žฅ
async fn save_sql_to_file_async(sql: &str, file_name: &str) -> tokio::io::Result<()> {
    // 1. ํŒŒ์ผ ๊ฒฝ๋กœ ์„ค์ •
    let path = Path::new(file_name);

    // 2. ๋น„๋™๊ธฐ ํŒŒ์ผ ์ƒ์„ฑ (Tokio fs ์‚ฌ์šฉ)
    let mut file = File::create(path).await?;

    // 3. ๋น„๋™๊ธฐ ๋ฐ์ดํ„ฐ ์“ฐ๊ธฐ (์ „์ฒด ๋ฐ์ดํ„ฐ๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ ๊ธฐ๋ก)
    file.write_all(sql.as_bytes()).await?;

    // 4. ๋ฒ„ํผ ๋น„์šฐ๊ธฐ ๋ฐ ๋™๊ธฐํ™” (๊ถŒ์žฅ ๊ด€๋ก€)
    file.flush().await?;

    println!("✅ [Async] SQL ํŒŒ์ผ ์ €์žฅ ์™„๋ฃŒ: {:?}", path.display());
    Ok(())
}

#[tokio::main]
async fn main() -> tokio::io::Result<()> {
    rustls::crypto::aws_lc_rs::default_provider()
        .install_default()
        .expect("Failed to install crypto provider");

    let hub = create_hub("./sheets-xxxxxx-xxxx.json").await;
    let exporter =
        SpreadsheetExporter::new(hub, "xxxxxx-xx-xxxxxx".into());

    let schema = exporter.export_schema().await;
    save_sql_to_file_async(&schema, "output_schema.sql").await?;

    let data = exporter.export_data().await;
    save_sql_to_file_async(&data, "output_data.sql").await?;

    println!("{}\n{}", schema, data);

    Ok(())
}


src/gspread.rs



//extern crate hyper;
//extern crate hyper_rustls;
//extern crate hyper_util;
extern crate google_sheets4 as sheets4;
use sheets4::{Sheets, hyper_rustls, hyper_util, yup_oauth2};

use serde_json::Value;
use std::collections::HashMap;
use futures::future::join_all;
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_rustls::HttpsConnector;

use inflector::Inflector; // ํŒจํ‚ค์ง€: id = "0.11" (๋„ค์ด๋ฐ ๋ณ€ํ™˜์šฉ)

pub type SheetsHub = Sheets>;

//#[derive(Default, Debug, Clone, PartialEq)]
pub struct SpreadsheetExporter {
    hub: SheetsHub,
    spreadsheet_id: String,
}

impl SpreadsheetExporter {
    pub fn new(hub: SheetsHub, spreadsheet_id: String) -> Self {
        Self { hub, spreadsheet_id }
    }

    // Value ํƒ€์ž…์„ String์œผ๋กœ ์•ˆ์ „ํ•˜๊ฒŒ ๋ณ€ํ™˜ํ•˜๋Š” ํ—ฌํผ ํ•จ์ˆ˜
    fn val_to_string(val: &Value) -> String {
        match val {
            Value::String(s) => s.clone(),
            Value::Number(n) => n.to_string(),
            Value::Bool(b) => b.to_string(),
            _ => val.to_string().replace('"', ""),
        }
    }

    // ์‹œํŠธ์—์„œ ๊ฐ€์ ธ์˜จ Vec>๋ฅผ ์ •์ œ๋œ HashMap ๋ฆฌ์ŠคํŠธ๋กœ ๋ณ€ํ™˜
    fn clean_rows(&self, values: &Vec>) -> Vec> {
        if values.is_empty() { return vec![]; }
        let headers: Vec = values[0].iter().map(Self::val_to_string).collect();
        let mut result = Vec::new();

        for row in values.iter().skip(1) {
            if row.is_empty() || Self::val_to_string(&row[0]).trim().is_empty() { break; }
            let mut row_map = HashMap::new();
            for (i, header) in headers.iter().enumerate() {
                let val = row.get(i).map(Self::val_to_string).unwrap_or_default();
                row_map.insert(header.clone(), val);
            }
            result.push(row_map);
        }
        result
    }

    fn split_column(&self, raw_text: &str) -> String {
        raw_text.split(',').map(|s| format!("`{}`", s.trim())).collect::>().join(", ")
    }

    fn format_sql_value(&self, val: &str) -> String {
        let t = val.trim();
        if t.is_empty() || t.to_uppercase() == "NULL" { return "NULL".to_string(); }
        if t.parse::().is_ok() { t.to_string() } else { format!("'{}'", t.replace("'", "''")) }
    }

    async fn export_schema_unit(&self, row: HashMap) -> String {
        let schema = row.get("SCHEMA").cloned().unwrap_or_default();
        let table_name = row.get("TABLE_NAME").cloned().unwrap_or_default();
        let engine = row.get("ENGINE").cloned().unwrap_or_default();
        let auto_increment = row.get("AUTO_INCREMENT").cloned().unwrap_or_default();
        let charset = row.get("CHARSET").cloned().unwrap_or_default();
        let collate = row.get("COLLATE").cloned().unwrap_or_default();
        let ss_id = match row.get("SPREADSHEET_ID") {
            Some(id) => id,
            None => return format!("-- Error: Missing ID for {}", table_name),
        };

        let result = self.hub.spreadsheets().values_batch_get(ss_id)
            .add_ranges("COLUMN!A:Z")
            .add_ranges("INDEX!A:Z")
            .add_ranges("FOREIGN_KEY!A:Z")
            .doit().await;

        let (_, response) = match result {
            Ok(res) => res,
            Err(e) => return format!("-- API Error in {}: {}\n", table_name, e),
        };

        let v_ranges = response.value_ranges.unwrap_or_default();
        let mut all_defs = Vec::new();

        // 1. COLUMN ์ฒ˜๋ฆฌ
        if let Some(vals) = v_ranges.get(0).and_then(|r| r.values.as_ref()) {
            for r in self.clean_rows(vals) {
                let f = r.get("FIELD").cloned().unwrap_or_default();
                let t = r.get("TYPE").cloned().unwrap_or_default();
                let s = r.get("SIGNED").cloned().unwrap_or_default();
                let l = r.get("LENGTH").filter(|s| !s.is_empty()).map_or("".to_string(), |v| format!("({})", v));
                let n = if ["NOT", "NO", "FALSE", "0"].contains(&r.get("NULL").unwrap_or(&"".into()).to_uppercase().as_str()) { "NOT NULL" } else { "" };
                let d = r.get("DEFAULT").filter(|s| !s.is_empty()).map_or("".to_string(), |v| format!("DEFAULT '{}'", v.replace("'", "''")));
                let c = r.get("COMMENT").filter(|s| !s.is_empty()).map_or("".to_string(), |v| format!("COMMENT '{}'", v.replace("'", "''")));
                all_defs.push(format!("  `{}` {} {}{} {} {} {} {}", f.to_snake_case(), t, s, l, n, d, r.get("EXTRA").unwrap_or(&"".into()), c).trim().to_string());
            }
        }

        // 2. INDEX ์ฒ˜๋ฆฌ
        if let Some(vals) = v_ranges.get(1).and_then(|r| r.values.as_ref()) {
            for r in self.clean_rows(vals) {
                let itype = r.get("TYPE").unwrap_or(&"".into()).to_uppercase();
                let cols = self.split_column(r.get("COLUMN").unwrap_or(&"".into()));
                if itype == "PRIMARY" { all_defs.push(format!("PRIMARY KEY ({})", cols.to_snake_case())); }
                else {
                    let prefix = if itype == "KEY" { "".into() } else { format!("{} ", itype) };
                    all_defs.push(format!("{}KEY `{}` ({})", prefix, r.get("NAME").unwrap_or(&"".into()).to_snake_case(), cols));
                }
            }
        }

        // 3. FOREIGN KEY ์ฒ˜๋ฆฌ
        if let Some(vals) = v_ranges.get(2).and_then(|r| r.values.as_ref()) {
            for r in self.clean_rows(vals) {
                let mut fk = format!("CONSTRAINT `{}` FOREIGN KEY (`{}`) REFERENCES `{}` (`{}`)",
                    r.get("NAME").unwrap_or(&"".to_string()).to_snake_case(),
                    r.get("COLUMN").unwrap_or(&"".to_string()).to_snake_case(),
                    r.get("REF_TABLE").unwrap_or(&"".to_string()).to_plural().to_snake_case(),
                    r.get("REF_COLUMN").unwrap_or(&"".to_string()).to_snake_case());
                if let Some(u) = r.get("ON_UPDATE").filter(|s| !s.is_empty()) { fk.push_str(&format!(" ON UPDATE {}", u)); }
                if let Some(d) = r.get("ON_DELETE").filter(|s| !s.is_empty()) { fk.push_str(&format!(" ON DELETE {}", d)); }
                all_defs.push(fk);
            }
        }
        
        let schema_name = schema.to_snake_case();
        let table_name = table_name.to_plural().to_snake_case();        
        format!("CREATE DATABASE IF NOT EXISTS {};\nUSE {};\nDROP TABLE IF EXISTS `{}`;\nCREATE TABLE `{}` (\n{}\n) ENGINE={} AUTO_INCREMENT={} DEFAULT CHARSET={} COLLATE={};\n",
            schema_name, schema_name, table_name, table_name, all_defs.join(",\n"), engine, auto_increment, charset, collate)
    }

    async fn export_data_unit(&self, row: HashMap) -> String {
        let table_name = row.get("TABLE_NAME").cloned().unwrap_or_default();
        let ss_id = row.get("SPREADSHEET_ID").expect("No ID");

        let result = self.hub.spreadsheets().values_get(ss_id, "DATA!A:Z").doit().await;
        let (_, response) = match result {
            Ok(res) => res,
            Err(_) => return "".into(),
        };

        let values = response.values.unwrap_or_default();
        if values.len() <= 1 { return "".into(); }

        let headers: Vec = values[0].iter().map(Self::val_to_string).collect();
        let mut insert_rows = Vec::new();

        for r in self.clean_rows(&values) {
            let row_vals: Vec = headers.iter()
                .map(|h| self.format_sql_value(r.get(h).unwrap_or(&"".into())))
                .collect();
            insert_rows.push(format!("({})", row_vals.join(", ")));
        }

        let h_sql = headers.iter().map(|h| format!("`{}`", h.to_snake_case())).collect::>().join(", ");
        format!("INSERT INTO `{}` ({})\nVALUES\n{};\n", table_name.to_plural(), h_sql, insert_rows.join(",\n"))
    }

    async fn execute_parallel(&self, unit_func: F) -> String 
    where 
        F: Fn(HashMap) -> Fut,
        Fut: std::future::Future,
    {
        let result = self.hub.spreadsheets().values_get(&self.spreadsheet_id, "SCHEMA!A:Z").doit().await;
        let (_, response) = match result {
            Ok(res) => res,
            Err(e) => return format!("Critical Error: {}", e),
        };

        let values = response.values.unwrap_or_default();
        if values.len() < 2 { return "No data found.".to_string(); }

        let headers: Vec = values[0].iter().map(Self::val_to_string).collect();
        let mut tasks = Vec::new();
        for row in values.iter().skip(1) {
            if !row.is_empty() && Self::val_to_string(&row[0]).to_uppercase().trim() == "TRUE" {
                let mut row_map = HashMap::new();
                for (i, h) in headers.iter().enumerate() {
                    row_map.insert(h.clone(), row.get(i).map(Self::val_to_string).unwrap_or_default());
                }
                tasks.push(unit_func(row_map));
            }
        }
        
        join_all(tasks).await.join("\n")
    }

    pub async fn export_schema(&self) -> String {
        self.execute_parallel(|r| async { self.export_schema_unit(r).await }).await
    }
    pub async fn export_data(&self) -> String {
        self.execute_parallel(|row| async { self.export_data_unit(row).await }).await
    }
}

// 3. ์ตœ์‹  hyper v1 ๋ฐ hyper-util ๊ธฐ๋ฐ˜ ์ธ์ฆ ์„ค์ •
pub async fn create_hub(service_account_key: &str) -> SheetsHub {
  // Get an ApplicationSecret instance by some means. It contains the `client_id` and
  // `client_secret`, among other things.
  //parse_service_account_key
  let secret: yup_oauth2::ServiceAccountKey = yup_oauth2::read_service_account_key(service_account_key)
    .await
    .expect("client secret could not be read");
    
  let connector = hyper_rustls::HttpsConnectorBuilder::new()
    .with_native_roots()
    .unwrap()
    .https_or_http()
    .enable_http1()
    .enable_http2()
    .build();

  let client = hyper_util::client::legacy::Client::builder(
    hyper_util::rt::TokioExecutor::new()
    )
    .build(connector);

  let auth = yup_oauth2::ServiceAccountAuthenticator::builder(
  secret,
    )
    .build()
    .await
    .unwrap();

    Sheets::new(client, auth)
}



main.py



from database import async_session
from models.answer import Answer
from models import Question
from sqlalchemy import select, insert
from sqlalchemy.exc import SQLAlchemyError

import asyncio
import SpreadsheetExporter

async def create_user_with_transaction(username: str):
    # 1. ์„ธ์…˜ ์ƒ์„ฑ
    async with async_session() as session:
        # 2. ํŠธ๋žœ์žญ์…˜ ์‹œ์ž‘ (๋น„๋™๊ธฐ ์ปจํ…์ŠคํŠธ ๋งค๋‹ˆ์ €)
        async with session.begin():
            try:
                # ๋ฐ์ดํ„ฐ ์ƒ์„ฑ ์ž‘์—…
                new_user = Answer(username=username)
                session.add(new_user)
                
                # ์ถ”๊ฐ€ ์ž‘์—… (์˜ˆ: ๋กœ๊ทธ ๊ธฐ๋ก ๋“ฑ)
                # ์ด ๋ธ”๋ก ์•ˆ์—์„œ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด ์ „์ฒด ์ž‘์—…์ด ๋กค๋ฐฑ๋ฉ๋‹ˆ๋‹ค.
                
            except Exception as e:
                # session.begin()์„ ์‚ฌ์šฉํ•˜๋ฉด ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ์ž๋™ ๋กค๋ฐฑ๋˜์ง€๋งŒ, 
                # ์ถ”๊ฐ€์ ์ธ ๋กœ๊น…์ด ํ•„์š”ํ•˜๋ฉด ์—ฌ๊ธฐ์„œ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
                print(f"์—๋Ÿฌ ๋ฐœ์ƒ: {e}")
                raise

        # ๋ธ”๋ก์„ ๋‚˜๊ฐ€๋ฉด ์ž๋™์œผ๋กœ commit ์™„๋ฃŒ
    print("ํŠธ๋žœ์žญ์…˜์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ปค๋ฐ‹๋˜์—ˆ์Šต๋‹ˆ๋‹ค.")

async def manual_transaction_example(username: str):
    session = async_session()
    try:
        # ์ž‘์—… ์ˆ˜ํ–‰
        new_user = Answer(username=username)
        session.add(new_user)
        
        # ๋ช…์‹œ์  ์ปค๋ฐ‹
        await session.commit()
        print("์ปค๋ฐ‹ ์„ฑ๊ณต")
    except SQLAlchemyError as e:
        # ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ๋ช…์‹œ์  ๋กค๋ฐฑ
        await session.rollback()
        print(f"๋กค๋ฐฑ ์‹คํ–‰: {e}")
    finally:
        # ์„ธ์…˜ ๋‹ซ๊ธฐ
        await session.close()


async def create_user_and_post(username: str, title: str):
    async with async_session() as session:
        async with session.begin():
            # 1. ์‚ฌ์šฉ์ž ์ƒ์„ฑ
            user = Answer(username=username)
            session.add(user)
            await session.flush()  # DB์— ์ž„์‹œ ๋ฐ˜์˜ํ•˜์—ฌ user.id๋ฅผ ํ™•๋ณด

            # 2. ํ•ด๋‹น ์‚ฌ์šฉ์ž์˜ ํฌ์ŠคํŠธ ์ƒ์„ฑ
            post = Question(title=title, owner_id=user.id)
            session.add(post)
            
            # ์—ฌ๊ธฐ์„œ ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ User ์ƒ์„ฑ๋„ ์ทจ์†Œ๋จ

async def fast_bulk_insert(user_data_list: list):
    async with async_session() as session:
        async with session.begin():
            # Core์˜ insert ๊ตฌ๋ฌธ ์‚ฌ์šฉ
            stmt = insert(Answer).values(user_data_list)
            await session.execute(stmt)

import gspread_asyncio

# from google-auth package
from google.oauth2.service_account import Credentials 

# First, set up a callback function that fetches our credentials off the disk.
# gspread_asyncio needs this to re-authenticate when credentials expire.
def get_creds():
  # To obtain a service account JSON file, follow these steps:
  # https://gspread.readthedocs.io/en/latest/oauth2.html#for-bots-using-service-account

  #path = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS')
  path = "./sheets-xxxxxx-xxxxxxxxxxxx.json"
  creds = Credentials.from_service_account_file(path)
  scoped = creds.with_scopes([
      "https://spreadsheets.google.com/feeds",
      "https://www.googleapis.com/auth/spreadsheets",
      "https://www.googleapis.com/auth/drive",
  ])
  return scoped


if __name__ == "__main__":
  # Execute when the module is not initialized from an import statement.
  #main()

  # --- ์‚ฌ์šฉ ์˜ˆ์‹œ ---
  async def main():
    # Create an AsyncioGspreadClientManager object which
    # will give us access to the Spreadsheet API.
    agcm = gspread_asyncio.AsyncioGspreadClientManager(get_creds)

    # Always authorize first.
    # If you have a long-running program call authorize() repeatedly.
    agc = await agcm.authorize()

    spreadsheet_id = "xxxxxxxxx-xxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    exporter = SpreadsheetExporter.SpreadsheetExporter(agc, spreadsheet_id)

    print("--- SQL ์Šคํ‚ค๋งˆ ์ถ”์ถœ ์‹œ์ž‘ (๋ณ‘๋ ฌ ์ฒ˜๋ฆฌ) ---")
    # ๋‚ด๋ถ€์ ์œผ๋กœ _execute_parallel์„ ํ˜ธ์ถœํ•˜์—ฌ SCHEMA ์‹œํŠธ์˜ TRUE ํ•ญ๋ชฉ์„ ๋ณ‘๋ ฌ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
    schema_sql = await exporter.export_schema()
    print(f"{schema_sql}")

    with open("output_schema.sql", "w", encoding="utf-8") as f:
        f.write(schema_sql)
    print("✅ ์Šคํ‚ค๋งˆ ์ถ”์ถœ ์™„๋ฃŒ: output_schema.sql")

    print("\n--- ๋ฐ์ดํ„ฐ INSERT ๊ตฌ๋ฌธ ์ถ”์ถœ ์‹œ์ž‘ (๋ณ‘๋ ฌ ์ฒ˜๋ฆฌ) ---")
    # ๋‚ด๋ถ€์ ์œผ๋กœ _execute_parallel์„ ํ˜ธ์ถœํ•˜์—ฌ DATA ์‹œํŠธ์˜ TRUE ํ•ญ๋ชฉ์„ ๋ณ‘๋ ฌ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
    data_sql = await exporter.export_data()
    print(f"{data_sql}")

    with open("output_data.sql", "w", encoding="utf-8") as f:
        f.write(data_sql)
    print("✅ ๋ฐ์ดํ„ฐ ์ถ”์ถœ ์™„๋ฃŒ: output_data.sql")
    
  # Turn on debugging if you're new to asyncio!
  asyncio.run(main(), debug=True)    



SpreadsheetExporter.py



import pandas as pd
import numpy as np
import asyncio
from gspread.exceptions import SpreadsheetNotFound, APIError, GSpreadException

class SpreadsheetExporter:
    def __init__(self, agc, spreadsheet_id):
        """
        :param agc: gspread_asyncio์˜ AsyncioGspreadClient
        :param spreadsheet_id: ๋ฉ”์ธ SCHEMA ์‹œํŠธ ID
        """
        self.agc = agc
        self.spreadsheet_id = spreadsheet_id

    def _split_column(self, raw_text):
        # 1. ์›๋ณธ ๋ฌธ์ž์—ด ์ •์˜
        #raw_text = "reactable_type, reactable_id, id"
        # 2. ์‰ผํ‘œ(,)๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๋ถ„๋ฆฌํ•˜์—ฌ ๋ฆฌ์ŠคํŠธ์— ์ €์žฅ (๊ณต๋ฐฑ ์ œ๊ฑฐ ํฌํ•จ)
        column_list = [item.strip() for item in raw_text.split(',')]

        # 3. ๊ฐ ์š”์†Œ๋ฅผ ๋ฐฑํ‹ฑ(`)์œผ๋กœ ๊ฐ์‹ผ ํ›„ ๋‹ค์‹œ ํ•ฉ์น˜๊ธฐ
        result = ", ".join([f"`{item}`" for item in column_list])

        # 4. ๊ฒฐ๊ณผ ์ถœ๋ ฅ
        #print(result)
        return result
    
    async def _export_schema_unit(self, row):
        """๊ฐœ๋ณ„ ํ…Œ์ด๋ธ”์˜ ์Šคํ‚ค๋งˆ๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๋น„๋™๊ธฐ ๋‹จ์œ„ ์ž‘์—…"""
        try:
            # ๊ตฌ์กฐ ๋ถ„ํ•ด ํ• ๋‹น (์‹œํŠธ ์ปฌ๋Ÿผ๋ช…์— ๋งž์ถฐ ์กฐ์ • ํ•„์š”)
            table_name = row['TABLE_NAME']
            spreadsheet_id = row['SPREADSHEET_ID']
            engine = row.get('ENGINE', 'InnoDB')
            auto_inc = row.get('AUTO_INCREMENT', '1')
            charset = row.get('CHARSET', 'utf8mb4')
            collate = row.get('COLLATE', 'utf8mb4_unicode_ci')

            ss = await self.agc.open_by_key(spreadsheet_id)
            
            # --- COLUMN, INDEX, FK ์‹œํŠธ ๋ฐ์ดํ„ฐ๋ฅผ ๋™์‹œ์— ๊ฐ€์ ธ์˜ค๊ธฐ (๋ณ‘๋ ฌ) ---
            ws_names = ["COLUMN", "INDEX", "FOREIGN_KEY"]
            worksheets = await asyncio.gather(*[ss.worksheet(name) for name in ws_names])
            data_lists = await asyncio.gather(*[ws.get_all_values() for ws in worksheets])
            
            col_data, idx_data, fk_data = data_lists
            all_defs = []

            # 1. COLUMN ์ฒ˜๋ฆฌ
            if len(col_data) > 1:
                df = pd.DataFrame(col_data[1:], columns=col_data[0])

                #df = df[df.iloc[:, 0] != ""].copy()

                """
                # 1. ์ฒซ ๋ฒˆ์งธ ์—ด์ด ๊ณต๋ฐฑ์ธ์ง€ ํ™•์ธ (True/False)
                is_empty = (df.iloc[:, 0] == "")

                # 2. cummax()๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ•œ ๋ฒˆ True๊ฐ€ ๋‚˜์˜ค๋ฉด ๊ทธ ์ดํ›„๋Š” ๊ณ„์† True๊ฐ€ ๋˜๋„๋ก ํ•จ
                # 3. ๊ทธ ๋ฐ˜๋Œ€(~)์ธ ํ–‰๋“ค๋งŒ ๋‚จ๊น€
                df = df[~is_empty.cummax()].copy()
                """

                # 2. ์ฒซ ๋ฒˆ์งธ ์—ด์— ๊ธ€์ž๊ฐ€ '์žˆ๋Š”์ง€' ํ™•์ธ (๊ธ€์ž ์žˆ์œผ๋ฉด True, ๋น„์—ˆ์œผ๋ฉด False)
                is_not_empty = (df.iloc[:, 0] != "")

                # 3. cumprod()๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ•œ ๋ฒˆ False(๊ณต๋ฐฑ)๋ฅผ ๋งŒ๋‚˜๋Š” ์ˆœ๊ฐ„ ๊ทธ ๋’ค๋Š” ๋ฌด์กฐ๊ฑด False๋กœ ๊ณ ์ •
                # (์ค‘๊ฐ„์— ์ฒซ ์นธ์ด ๋น„๋ฉด ๋‹ค์Œ ํ–‰์— ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์–ด๋„ ๋ฌด์‹œํ•˜๋Š” ํ•ต์‹ฌ ๋กœ์ง)
                df = df[is_not_empty.cumprod().astype(bool)].copy()

                #for row in df.itertuples():
                #print(f"์ด๋ฆ„: {row.์ด๋ฆ„}, ์ ์ˆ˜: {row.์ ์ˆ˜}")
                type_sql = df.apply(lambda r: f"{r['TYPE']}({r['LENGTH']})" if r.get('LENGTH') else r['TYPE'], axis=1)
                null_sql = df['NULL'].apply(lambda x: "NOT NULL" if str(x).upper() in ["NOT", "NO", "FALSE", "0"] else "")
                default_sql = df['DEFAULT'].apply(lambda x: f"DEFAULT '{str(x).replace("'", "''")}'" if x else "")
                comment_sql = df['COMMENT'].apply(lambda x: f"COMMENT '{str(x)[:1024].replace("'", "''")}'" if x else "")
                
                lines = "  `" + df['FIELD'] + "` " + type_sql + " " + null_sql + " " + default_sql + " " + df['EXTRA'] + " " + comment_sql
                all_defs.extend(lines.str.strip().tolist())

            # 2. INDEX ์ฒ˜๋ฆฌ
            if len(idx_data) > 1:
                df = pd.DataFrame(idx_data[1:], columns=idx_data[0])

                #df = df[df.iloc[:, 0] != ""].copy()
                # 1. ์ฒซ ๋ฒˆ์งธ ์—ด์ด ๊ณต๋ฐฑ์ธ์ง€ ํ™•์ธ (True/False)
                is_empty = (df.iloc[:, 0] == "")

                # 2. cummax()๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ•œ ๋ฒˆ True๊ฐ€ ๋‚˜์˜ค๋ฉด ๊ทธ ์ดํ›„๋Š” ๊ณ„์† True๊ฐ€ ๋˜๋„๋ก ํ•จ
                # 3. ๊ทธ ๋ฐ˜๋Œ€(~)์ธ ํ–‰๋“ค๋งŒ ๋‚จ๊น€
                df = df[~is_empty.cummax()].copy()

                idx_lines = df.apply(lambda r: f"PRIMARY KEY (`{r['COLUMN']}`)" if r['TYPE'].upper() == "PRIMARY" 
                                     #else f"{r['TYPE']+' ' if r['TYPE'].upper()!='KEY' else ''}KEY `{r['NAME']}` (`{r['COLUMN']}`)", axis=1)
                                     else f"{r['TYPE']+' ' if r['TYPE'].upper()!='KEY' else ''}KEY `{r['NAME']}` ({self._split_column(r['COLUMN'])})", axis=1)

                all_defs.extend(idx_lines.tolist())

            # 3. FOREIGN KEY ์ฒ˜๋ฆฌ
            if len(fk_data) > 1:
                df = pd.DataFrame(fk_data[1:], columns=fk_data[0])

                #df = df[df.iloc[:, 0] != ""].copy()
                # 1. ์ฒซ ๋ฒˆ์งธ ์—ด์ด ๊ณต๋ฐฑ์ธ์ง€ ํ™•์ธ (True/False)
                is_empty = (df.iloc[:, 0] == "")

                # 2. cummax()๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ•œ ๋ฒˆ True๊ฐ€ ๋‚˜์˜ค๋ฉด ๊ทธ ์ดํ›„๋Š” ๊ณ„์† True๊ฐ€ ๋˜๋„๋ก ํ•จ
                # 3. ๊ทธ ๋ฐ˜๋Œ€(~)์ธ ํ–‰๋“ค๋งŒ ๋‚จ๊น€
                df = df[~is_empty.cummax()].copy()

                fk_lines = "CONSTRAINT `" + df['NAME'] + "` FOREIGN KEY (`" + df['COLUMN'] + "`) REFERENCES `" + df['REF_TABLE'] + "` (`" + df['REF_COLUMN'] + "`)"
                fk_lines += df['ON_UPDATE'].apply(lambda x: f" ON UPDATE {x}" if x else "")
                fk_lines += df['ON_DELETE'].apply(lambda x: f" ON DELETE {x}" if x else "")
                all_defs.extend(fk_lines.tolist())

            return (f"DROP TABLE IF EXISTS `{table_name}`;\n"
                    f"CREATE TABLE `{table_name}` (\n"
                    f"{',\n'.join(all_defs)}\n"
                    f") ENGINE={engine} AUTO_INCREMENT={auto_inc} DEFAULT CHARSET={charset} COLLATE={collate};\n")
        except Exception as e:
            return f"-- Error in {row.get('TABLE_NAME')}: {e}\n"

    async def _export_data_unit(self, row):
        """๊ฐœ๋ณ„ ํ…Œ์ด๋ธ”์˜ ๋ฐ์ดํ„ฐ๋ฅผ INSERT ๋ฌธ์œผ๋กœ ์ƒ์„ฑํ•˜๋Š” ๋น„๋™๊ธฐ ๋‹จ์œ„ ์ž‘์—…"""
        try:
            table_name = row['TABLE_NAME']
            ss = await self.agc.open_by_key(row['SPREADSHEET_ID'])
            ws = await ss.worksheet("DATA")
            data = await ws.get_all_values()
            
            if len(data) <= 1: return ""

            df = pd.DataFrame(data[1:], columns=data[0])

            #df = df[df.iloc[:, 0] != ""].copy()
            # 1. ์ฒซ ๋ฒˆ์งธ ์—ด์ด ๊ณต๋ฐฑ์ธ์ง€ ํ™•์ธ (True/False)
            is_empty = (df.iloc[:, 0] == "")

            # 2. cummax()๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ•œ ๋ฒˆ True๊ฐ€ ๋‚˜์˜ค๋ฉด ๊ทธ ์ดํ›„๋Š” ๊ณ„์† True๊ฐ€ ๋˜๋„๋ก ํ•จ
            # 3. ๊ทธ ๋ฐ˜๋Œ€(~)์ธ ํ–‰๋“ค๋งŒ ๋‚จ๊น€
            df = df[~is_empty.cummax()].copy()

            # ๋ฐ์ดํ„ฐ ๋ฒกํ„ฐํ™” ์ฒ˜๋ฆฌ (Pandas)
            for col in df.columns:
                df[col] = df[col].astype(str).str.replace("'", "''")
                mask = ~df[col].str.match(r'^-?\d+(\.\d+)?$') # ์ˆซ์ž๊ฐ€ ์•„๋‹ˆ๋ฉด ๋”ฐ์˜ดํ‘œ
                df.loc[mask, col] = "'" + df[col].loc[mask] + "'"
                df.loc[df[col] == "''", col] = "NULL"

            values_sql = "(" + df.agg(', '.join, axis=1) + ")"
            header = ", ".join([f"`{h}`" for h in data[0]])
            return f"INSERT INTO `{table_name}` ({header})\nVALUES\n{',\n'.join(values_sql)};\n"
        except Exception as e:
            return f"-- Data Error in {row.get('TABLE_NAME')}: {e}\n"

    async def export_schema(self):
        """๋ฉ”์ธ ์‹คํ–‰ ํ•จ์ˆ˜: ๋ชจ๋“  ํ…Œ์ด๋ธ” ์Šคํ‚ค๋งˆ ๋ณ‘๋ ฌ ์ฒ˜๋ฆฌ"""
        return await self._execute_parallel(self._export_schema_unit)

    async def export_data(self):
        """๋ฉ”์ธ ์‹คํ–‰ ํ•จ์ˆ˜: ๋ชจ๋“  ํ…Œ์ด๋ธ” ๋ฐ์ดํ„ฐ ๋ณ‘๋ ฌ ์ฒ˜๋ฆฌ"""
        return await self._execute_parallel(self._export_data_unit)

    async def _execute_parallel(self, unit_func):
        """SCHEMA ์‹œํŠธ๋ฅผ ์ฝ๊ณ  ๋ณ‘๋ ฌ๋กœ ํƒœ์Šคํฌ๋ฅผ ์‹คํ–‰ํ•˜๋Š” ๊ณตํ†ต ๋กœ์ง"""
        try:
            main_ss = await self.agc.open_by_key(self.spreadsheet_id)
            schema_ws = await main_ss.worksheet("SCHEMA")
            raw_data = await schema_ws.get_all_values()
            
            if len(raw_data) < 2: return "No data found."

            df_main = pd.DataFrame(raw_data[1:], columns=raw_data[0])

            #df = df[df.iloc[:, 0] != ""].copy()
            # 1. ์ฒซ ๋ฒˆ์งธ ์—ด์ด ๊ณต๋ฐฑ์ธ์ง€ ํ™•์ธ (True/False)
            is_empty = (df_main.iloc[:, 0] == "")

            # 2. cummax()๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ•œ ๋ฒˆ True๊ฐ€ ๋‚˜์˜ค๋ฉด ๊ทธ ์ดํ›„๋Š” ๊ณ„์† True๊ฐ€ ๋˜๋„๋ก ํ•จ
            # 3. ๊ทธ ๋ฐ˜๋Œ€(~)์ธ ํ–‰๋“ค๋งŒ ๋‚จ๊น€
            df_main = df_main[~is_empty.cummax()].copy()

            # CHK ์ปฌ๋Ÿผ์ด TRUE์ธ ๋Œ€์ƒ๋งŒ ์„ ๋ณ„
            active_targets = df_main[df_main.iloc[:, 0].str.upper().str.strip() == "TRUE"]

            # --- ํ•ต์‹ฌ: asyncio.gather๋ฅผ ํ†ตํ•œ ๋ณ‘๋ ฌ ์‹คํ–‰ ---
            tasks = [unit_func(row) for _, row in active_targets.iterrows()]
            #tasks = [unit_func(row) for row in active_targets.itertuples()]
            results = await asyncio.gather(*tasks)
            
            return "\n".join(results)
        except Exception as e:
            return f"Critical Error: {e}"


"""
⚡ ์ตœ์ ํ™” ํฌ์ธํŠธ ์„ค๋ช…
๊ณ„์ธต์  ๋ณ‘๋ ฌํ™” (Double asyncio.gather):
์ƒ์œ„ ๋ ˆ๋ฒจ: _execute_parallel์—์„œ ์—ฌ๋Ÿฌ ํ…Œ์ด๋ธ”(Table A, Table B...)์„ ๋™์‹œ์— ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.
ํ•˜์œ„ ๋ ˆ๋ฒจ: _export_schema_unit ๋‚ด๋ถ€์—์„œ ํ•œ ํ…Œ์ด๋ธ”์˜ COLUMN, INDEX, FK ์‹œํŠธ 3๊ฐœ๋ฅผ ๋™์‹œ์— ์ฝ์–ด์˜ต๋‹ˆ๋‹ค. (๊ธฐ์กด ๋Œ€๋น„ ์‹œํŠธ ๋กœ๋”ฉ ์†๋„ ์•ฝ 3๋ฐฐ ํ–ฅ์ƒ)
Pandas ๋ฒกํ„ฐํ™” ์—ฐ์‚ฐ: for ๋ฃจํ”„ ์—†์ด ์ˆ˜์ฒœ ์ค„์˜ INSERT ๊ฐ’์ด๋‚˜ DDL ๊ตฌ๋ฌธ์„ ํ•œ ๋ฒˆ์— ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
๋ฉ”๋ชจ๋ฆฌ ํšจ์œจ: df[df.iloc[:, 0] != ""] ์ฝ”๋“œ๋ฅผ ํ†ตํ•ด ๊ตฌ๊ธ€ ์‹œํŠธ ํ•˜๋‹จ์˜ ๋ถˆํ•„์š”ํ•œ ๋นˆ ํ–‰์„ ์ฆ‰์‹œ ์ œ๊ฑฐํ•˜์—ฌ ๋ฉ”๋ชจ๋ฆฌ ๋‚ญ๋น„๋ฅผ ๋ฐฉ์ง€ํ•ฉ๋‹ˆ๋‹ค.
์•ˆ์ •์„ฑ: asyncio.gather ๋„์ค‘ ํ•˜๋‚˜์˜ ์‹œํŠธ์—์„œ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•ด๋„ ์ „์ฒด ํ”„๋กœ์„ธ์Šค๊ฐ€ ์ฃฝ์ง€ ์•Š๋„๋ก ๊ฐœ๋ณ„ ์œ ๋‹› ํ•จ์ˆ˜์— try-except๋ฅผ ๋ฐฐ์น˜ํ•˜์—ฌ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€๋ฅผ SQL ์ฃผ์„(-- Error...) ํ˜•ํƒœ๋กœ ๋ฐ˜ํ™˜ํ•˜๊ฒŒ ํ–ˆ์Šต๋‹ˆ๋‹ค.
์ด์ œ ์ด ์ฝ”๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ๊ตฌ๊ธ€ API ํ• ๋‹น๋Ÿ‰(Quota) ๋‚ด์—์„œ ๊ฐ€์žฅ ๋น ๋ฅธ ์†๋„๋กœ SQL ์ถ”์ถœ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. gspread-asyncio ๊ณต์‹ ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•˜์—ฌ AsyncioGspreadClient๋ฅผ ๋„˜๊ฒจ์ฃผ์‹œ๋ฉด ๋ฐ”๋กœ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค.
"""

"""
"reactable_type, reactable_id, id" ๋ฌธ์ž์—ด์„ , ๋กœ ๋ถ„๋ฆฌํ›„ ๋ฆฌ์ŠคํŠธ์— ์ €์žฅํ•œํ›„ 
"`reactable_type`, `reactable_id`, `id`" ํ˜•์‹์œผ๋กœ ํ™”๋ฉด์— ์ถœ๋ ฅํ•ด์ฃผ๋Š” ํŒŒ์ด์ฌ ์ฝ”๋“œ๋ฅผ ์ƒ์„ฑํ•ด์ค˜

# 1. ์›๋ณธ ๋ฌธ์ž์—ด ์ •์˜
raw_text = "reactable_type, reactable_id, id"

# 2. ์‰ผํ‘œ(,)๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๋ถ„๋ฆฌํ•˜์—ฌ ๋ฆฌ์ŠคํŠธ์— ์ €์žฅ (๊ณต๋ฐฑ ์ œ๊ฑฐ ํฌํ•จ)
column_list = [item.strip() for item in raw_text.split(',')]

# 3. ๊ฐ ์š”์†Œ๋ฅผ ๋ฐฑํ‹ฑ(`)์œผ๋กœ ๊ฐ์‹ผ ํ›„ ๋‹ค์‹œ ํ•ฉ์น˜๊ธฐ
result = ", ".join([f"`{item}`" for item in column_list])

# 4. ๊ฒฐ๊ณผ ์ถœ๋ ฅ
print(result)
"""

app/domain/sheet/__init__.py



# domain/sheet/__init__.py

# ํ•˜์œ„ ํŒŒ์ผ๋“ค(router, schemas, crud)์„ ํŒจํ‚ค์ง€ ๋ ˆ๋ฒจ๋กœ ๋Œ์–ด์˜ฌ๋ฆฝ๋‹ˆ๋‹ค.
from . import crud, router, schemas

# ์™ธ๋ถ€์—์„œ 'from domain.answer import *'๋ฅผ ํ•˜๊ฑฐ๋‚˜ ๊ฐ€์ ธ๊ฐˆ ์ˆ˜ ์žˆ๋Š” ๋ฒ”์œ„๋ฅผ ๋ช…์‹œํ•ฉ๋‹ˆ๋‹ค.
__all__ = ["router", "schemas", "crud"]


ํŒŒ์ด์ฌ ๋„ค์ด๋ฒ„ ๋‰ด์Šค ํฌ๋กค๋ง ์ดˆ๋ณด์ž๋„ 5๋ถ„ ๋งŒ์— ๊ตฌํ˜„ํ•˜๋Š” ๋ฐฉ๋ฒ•

์•ˆ๋…•ํ•˜์„ธ์š”! ์˜ค๋Š˜์€ ํŒŒ์ด์ฌ(Python) ์„ ํ™œ์šฉํ•˜์—ฌ ๋„ค์ด๋ฒ„ ๋‰ด์Šค ๋ฐ์ดํ„ฐ๋ฅผ ํฌ๋กค๋ง ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ์•Œ์•„๋ณด๊ฒ ์Šต๋‹ˆ๋‹ค. ๋น…๋ฐ์ดํ„ฐ ๋ถ„์„์ด๋‚˜ ๋งˆ์ผ€ํŒ… ์กฐ์‚ฌ๋ฅผ ํ•  ๋•Œ ํŠน์ • ํ‚ค์›Œ๋“œ์˜ ๋‰ด์Šค ๊ธฐ์‚ฌ ์ œ๋ชฉ๊ณผ ๋งํฌ๋ฅผ ์ˆ˜์ง‘ํ•˜๋Š” ์ž‘์—…์ด ํ•„์ˆ˜์ ์ธ๋ฐ์š”. ์ด๋ฒˆ ...