μ΄μ»€λ¨Έμ€ λ°μ΄ν° μμ§, λ³ν, μ μ¬ ν΅ν© νλ μμν¬
λ€μν μ΄μ»€λ¨Έμ€ APIμ μΉλ¬Έμ μλ΅μ μμ§νκ³ DuckDB κΈ°λ° λ³νμ κ±°μ³, BigQuery, PostgreSQL, Google Sheets λ± μΈλΆ μμ€ν μ λ°μ΄ν°λ₯Ό μ μ¬νλ μν¬νλ‘μ°λ₯Ό κ΄λ¦¬νκΈ° μν νλ‘μ νΈλ₯Ό μλ΄νλ€.
- νλ‘μ νΈ κ°μ
- ν΅μ¬ μν€ν μ²
- νμ€ ETL λͺ¨λ ꡬ쑰
- Airflow μλΉμ€ ꡬ쑰
- Airflow μμ μ€μΌμ€λ§
- PostgreSQL μ μ¬ νκ²½
- Airflow Trigger UI
- λΉ λ₯Έ μμ
- μμ μ½λ
LinkMerce νλ‘μ νΈλ μ΄μ»€λ¨Έμ€ νλ«νΌμΌλ‘λΆν° μΌνλͺ° μ΄μμ νμν λ°μ΄ν°λ₯Ό μμ§νκΈ° μν λͺ©μ μ κ°μ§λ€.
Python μ€ν¬λν λ‘μ§μ ꡬνν PyPI ν¨ν€μ§κ° νλ‘μ νΈμ μ€μ¬μ΄ λλ©°, μμ μ€μΌμ€λ§μ μ²λ¦¬νκΈ° μν΄ Apache Airflowλ₯Ό μ κ·Ήμ μΌλ‘ νμ©νλ€.
νμ¬ linkmerce ν¨ν€μ§ λ²μ μ 1.0.6μ΄λ€.
νλ‘μ νΈμμ μ£Όλͺ©ν λΆλΆμ λ€μ 5κ°μ§λ€.
src/linkmerce: μΉ μ€ν¬λν λ° μΈλΆ μμ€ν κ³Όμ λ°μ΄ν° μ°λμ μ§μνλ ν΅μ¬ Python ν¨ν€μ§src/tests: Python ν¨ν€μ§μ λμμ κ²μ¦νκ³ μ€κ° μ€ν κ²°κ³Όλ₯Ό μ μ₯νλ ν μ€νΈ λͺ¨μairflow: μμ μ€μΌμ€λ§, μ€μΌμ€νΈλ μ΄μ , Playwright κΈ°λ° λΈλΌμ°μ μλνκ° λ€μ΄ μλ Dag λͺ¨μpostgres: λ‘컬 PostgreSQL 18 μ μ¬ νκ²½κ³Ό μ΄κΈ° μ€ν€λ§, νν°μ , Parquet νμ₯μ κ΄λ¦¬νλ μ€ν νκ²½airflow_trigger: μΌνλͺ° μ΄μ λ΄λΉμκ° Airflow Dagμ μλμΌλ‘ νΈλ¦¬κ±°ν λ μ¬μ©νλ Streamlit UIμ Google Sheets λ§ν¬/λ²νΌμ© FastAPI μλ²
linkmerce ν¨ν€μ§κ° μ§μνλ μ΄μ»€λ¨Έμ€ κ΄λ ¨ νλ«νΌμ μμ§ λ²μλ λ€μκ³Ό κ°λ€.
| νλ«νΌ κ΅¬λΆ | μμ§ λ²μ |
|---|---|
| CJλνν΅μ΄ eFLEXs | μ¬κ³ |
| μΏ ν‘ κ΄κ³ μΌν° | κ΄κ³ |
| μΏ ν‘ νλ§€μμΌν° | μν, λ§€μΆ |
| μ΄μΉ΄μ΄νΈ API | μν, μ¬κ³ |
| κ΅¬κΈ API | κ΄κ³ |
| λ©ν API | κ΄κ³ |
| λ€μ΄λ² λ©μΈ | κ²μ |
| λ€μ΄λ² μ€ν API | κ²μ |
| μ¬λ°©λ· μμ€ν | μ£Όλ¬Έ, μν |
| λ€μ΄λ² κ²μκ΄κ³ API | κ΄κ³ λ³΄κ³ μ, κ΄κ³ κ³μ½, κ²μλ |
| λ€μ΄λ² κ΄κ³ μ£ΌμΌν° (κ²μκ΄κ³ ) | κ΄κ³ λ³΄κ³ μ |
| λ€μ΄λ² μ±κ³Όν λμ€νλ μ΄ κ΄κ³ | κ΄κ³ λ³΄κ³ μ, κ΄κ³ μμ |
| λ€μ΄λ² μ»€λ¨Έμ€ API | μ£Όλ¬Έ, μν, ν΅κ³ |
| λ€μ΄λ² μΌνννΈλμΌν° | λ§€μΆ, λ°©λ¬Έ ν΅κ³, μΉ΄νλ‘κ·Έ/μν |
linkmerce ν¨ν€μ§λ₯Ό μ΄ν΄νκΈ° μν΄ μ£Όλͺ©ν΄μΌ ν κ²μ Extractor β Transformer μ°κ²°μ΄λ€.
linkmerce ν¨ν€μ§λ ETL νλ‘μΈμ€λ₯Ό [ μΆμΆ(Extract), λ³ν(Transform), μ μ¬(Load) ] 3κ°μ§ λΆλΆμΌλ‘ ꡬλΆνλ€.
Extractorμ Transformerλ κ°κ° μΆμΆκ³Ό λ³ν μν μ λ΄λΉνλ€.
Extractorλ λκΈ° λλ λΉλκΈ° HTTP μΈμ μ νμ©ν HTTP μμ²μ λ΄λΉνλ€. μΌλΆ μμ μ λ§€κ°λ³μ λͺ©λ‘μ λν΄ λ°λ³΅ μμ²νλλ°,Taskλ₯Ό νμ©ν΄ μ΄λ¬ν λμμ μΆμννλ€.Transformerλ HTTP μλ΅ κ²°κ³Όλ₯Ό JSON νμμΌλ‘ νμ±νκ³ , DuckDB ν μ΄λΈμ μ€ν€λ§μ λ§κ² λ³νν΄ μ μ¬νλ€. DuckDB μ°κ²°μ ν΅ν΄ BigQuery, PostgreSQL, Google Sheets κ°μ μΈλΆ μμ€ν μ λ°μ΄ν°λ₯Ό μ μ¬νλ νμ₯ κΈ°λ₯μ λ³λλ‘ μ 곡νλ€.
μ§κΈκΉμ§μ μ€λͺ μ μλ νλ‘ μ 리ν μ μλ€.
| κ³μΈ΅ κ΅¬λΆ | μ± μ | ꡬν κ²½λ‘ |
|---|---|---|
Extractor |
HTTP μΈμ κ΄λ¦¬, μμ² λ©μμ§ λΉλ | src/linkmerce/common/extract.py |
Task |
λ°λ³΅ μμ², μ¬μλ, νμ΄μ§λ€μ΄μ | src/linkmerce/common/tasks.py |
ResponseTransformer |
μλ΅ κ²°κ³Ό νμ±, JSON νμμΌλ‘ λ³ν | src/linkmerce/common/transform.py |
DuckDBTransformer |
DuckDB ν μ΄λΈ μμ± λ° μ μ¬ | src/linkmerce/common/transform.py |
DuckDBConnection |
DuckDB μ°κ²° κ΄λ¦¬, CRUD μμ μ§μ | src/linkmerce/common/load.py |
API Endpoint |
Extractorμ Transformer μ°κ²° |
src/linkmerce/api/common.py |
Extensions |
DuckDB ν μ΄λΈμ μΈλΆ μμ€ν κ³Ό μ°λ | src/linkmerce/extensions/*.py |
κ³μΈ΅ ꡬλΆμ λ°λ₯Έ μν¬νλ‘μ°λ λν λ€μμ νλ¦λλ‘ μ 리ν΄λ³Ό μλ μλ€.
[API Endpoint] (api/...)
β μ¬μ©μ νλΌλ―Έν° μ λ¬
Extractor (core/.../extract.py)
β HTTP μμ² λ° μλ΅ μμ
ResponseTransformer (core/.../transform.py)
β JSON νμμΌλ‘ λ³ν
DuckDBTransformer (core/.../transform.py)
β DuckDB ν
μ΄λΈμ μ μ¬
DuckDBConnection (common/load.py)
β μΈλΆ μμ€ν
μ μ μ¬
BigQueryClient / PostgresClient / WorksheetClient (extensions/...)ETL νλ‘μΈμ€λ₯Ό νλ«νΌ, νΈμ€νΈλͺ , μΉ΄ν κ³ λ¦¬λ‘ κ΅¬μ±λ 3λ¨κ³ νμ κ²½λ‘λ‘ κ΅¬λΆνλ€.
core/{platform}/{hostname}/{category}/
βββ extract.py
βββ transform.py
βββ models.sqlμ΄ κ΅¬μ‘°λ νλμ μΉ΄ν κ³ λ¦¬ μμμ μ± μμ λͺ νν λΆλ¦¬νλ€.
extract.py: HTTP μμ² λ°©μ ꡬνtransform.py: μλ΅ νμ± λ° DuckDB μ μ¬ λ°©μ ꡬνmodels.sql: CREATE, INSERT λ± SQL 쿼리문 μ€κ³
μ΄ ν¨ν΄ λλΆμ νλ«νΌ λλ νΈμ€νΈλ³λ‘ ꡬνμ΄ λ¬λΌλ, μ€ν λ°©μκ³Ό ν μ€νΈ λ°©μμ λΉκ΅μ μΌκ΄λκ² μ μ§λλ€.
ETL νλ‘μΈμ€λ₯Ό μ€νν λλ νΈμμ±μ μν΄ core/ λͺ¨λμ μμλλ‘ νΈμΆνμ§ μκ³ , νλμ API ν¨μλ₯Ό νΈμΆνλ€.
linkmerce ν¨ν€μ§μ κ΄λ ¨λ src/linkmerce/ κ²½λ‘μ λν μμΈ μ€λͺ
μ
λ³λμ λ¬Έμλ₯Ό μ°Έκ³ νλ€.
src/tests κ²½λ‘μμ λ¨μΌ Extractor λλ Transformer λ¨μλ‘ μ μ λμνλμ§ ν
μ€νΈλ₯Ό λ΄λΉνλ€.
test_extract.py:Extractor.extract()μ€ν κ²°κ³Ό μ μ₯test_transform.py:DuckDBTransformer.parse()λ°bulk_insert()μ€ν κ²°κ³Ό μ μ₯conftest.py: κ³΅μ© λ¦¬μμ€ Fixture μ μ,Transformerν μ€νΈλ₯Ό μ§μνλTransformerHarnessμ 곡results/: κ° ν μ€νΈμ μ€ν κ²°κ³Όκ° μ μ₯λλ κ²½λ‘. Git λ²μ κ΄λ¦¬μμλ μ μΈλλ€.
ν
μ€νΈμ κ΄λ ¨λ src/linkmerce/tests/ κ²½λ‘μ λν μμΈ μ€λͺ
μ
λ³λμ λ¬Έμλ₯Ό μ°Έκ³ νλ€.
Airflow μμ€ν
μ ꡬμ±νλ μλΉμ€ λͺ©λ‘μ docker-compose.yaml μ€μ μμ μ μνλ€.
μ΄μ νκ²½μμ Airflow Celery Executor ꡬμ±μ κΈ°μ€μΌλ‘ λ€μ μλΉμ€λ€μ΄ μ€νλκ³ μλ€.
postgresredisplaywrightairflow-apiserverairflow-schedulerairflow-dag-processorairflow-workerairflow-triggererairflow-init
λλ¨Έμ§λ Apache Airflowμμ κΈ°λ³ΈμΌλ‘ μ μν μλΉμ€λ€μ΄μ§λ§, playwright μλΉμ€λ₯Ό μμΈμ μΌλ‘ μΆκ°νλ€.
μΌλΆ μμ
μμλ playwright μλΉμ€λ₯Ό νμ©ν΄ λΈλΌμ°μ λ λλ§μ νμ©ν μΉ μ€ν¬λνμ μννλ€.
Docker Compose μ€νμ λ€μ λͺ
λ Ήμ΄ λλ init.sh μ€ν¬λ¦½νΈλ₯Ό μ€ννλ€.
cd airflow
docker compose up airflow-init
docker compose up -dlinkmerce ν¨ν€μ§λ₯Ό νμ©νλ Airflow Dagμ 곡ν΅μ μΌλ‘ λ€μ νλ¦μ κ°μ§λ€.
airflow_utilsνλ¬κ·ΈμΈμread_config()λλread_credentials()ν¨μλ‘ μ€μ κ³Ό μΈμ¦ μ 보λ₯Ό λΆλ¬μ¨λ€.linkmerce.api.*ν¨μλ₯Ό νΈμΆν΄ μΆμΆ(extract)κ³Ό λ³ν(transform) κ³Όμ μ μννλ€.DuckDBConnectionμ ν΅ν΄ ν μ΄λΈμ μ μ¬λ API μ€ν κ²°κ³Όλ₯Ό λΆλ¬μ¬ μ μλ€.dual_loadνλ¬κ·ΈμΈμΌλ‘ DuckDB ν μ΄λΈμ PostgreSQLμ λ¨Όμ μ μ¬ν λ€ BigQueryμλ μ μ¬νλ€.- Task κ²°κ³Όλ₯Ό
{params: {...}, results: {...}}λμ λλ¦¬λ‘ λ°ννλ€.
μ΄λ¬ν νλ¦μ DagμΌλ‘ ꡬνν κ²½μ° λ€μκ³Ό κ°μ μ½λλ‘ λνλΌ μ μλ€.
with DAG(dag_id="...") as dag:
PATH = "platform.hostname.category"
@task(task_id="...")
def read_configs() -> dict:
from airflow_utils import read_config
return read_config(PATH, tables=True)
@task(task_id="...")
def read_credentials() -> list:
from airflow_utils import read_config
return read_config(PATH, credentials=True)["credentials"]
@task(task_id="...", map_index_template="{{ credentials['id'] }}")
def etl_task(credentials: dict, configs: dict, **kwargs) -> dict:
return main(**credentials, **configs)
def main(tables: dict[str, str], **kwargs) -> dict:
from linkmerce.common.load import DuckDBConnection
from linkmerce.api.platform.hostname import example_api
from dual_load import load_table_from_duckdb
with DuckDBConnection(tzinfo="Asia/Seoul") as conn:
example_api(**kwargs, connection=conn)
return {
"params": {},
"results": {
tables["table"]: load_table_from_duckdb(
connection = conn,
source_table = "table",
target_table = tables["table"],
),
},
}
(etl_task
.partial(configs=read_configs())
.expand(credentials=read_credentials()))Airflowμ κ΄λ ¨λ airflow/ κ²½λ‘μ λν μμΈ μ€λͺ
μ
λ³λμ λ¬Έμλ₯Ό μ°Έκ³ νλ€.
postgres/ κ²½λ‘λ Airflow MetaDBμ λ³κ°λ‘ ETL κ²°κ³Όλ₯Ό μ μ¬νκΈ° μν λ‘컬 PostgreSQL 18 νκ²½μ μ 곡νλ€.
κ΅¬μ± μμλ λ€μκ³Ό κ°λ€.
Dockerfile: PostgreSQL 18,pg_partman, Apache Arrow / Parquet λ°νμ, μ체parquet_ioνμ₯μ ν¬ν¨ν μ΄λ―Έμ§ λΉλinit.sql: νλ«νΌλ³ μ€ν€λ§μ ν μ΄λΈ, μΌλ³ νν°μ μ΄κΈ°νpartman_maintenance.sql: μ΄μ μ€ λ―Έλμ νν°μ μ μμ±νκΈ° μν μ μ§λ³΄μ SQLresources/bq_schemas.json: PostgreSQLinit.sqlκΈ°μ€μΌλ‘ μμ±ν BigQuery μ€ν€λ§ μ°Έκ³ νμΌresources/parquet_io.md:parquet_ioνμ₯μ SQL μΈν°νμ΄μ€μ νμ λ³ν μ μ± μ€λͺ
Airflow Dagμ λλΆλΆ dual_load νλ¬κ·ΈμΈμ ν΅ν΄ PostgreSQL μ μ¬λ₯Ό λ¨Όμ μννκ³ ,
μ±κ³΅νλ©΄ BigQuery μ μ¬λ₯Ό μ΄μ΄μ μννλ€.
PostgreSQLμ λ μ격ν κΈ°λ³Έν€μ νμ
μ μ½μ κ²μ¦νλ 1μ°¨ μ μ¬ λμμΌλ‘ μ¬μ©νκ³ ,
BigQueryλ κΈ°μ‘΄ λΆμ ν
μ΄λΈ μ μ¬ λμμΌλ‘ μ μ§νλ€.
PostgreSQL μ€ν νκ²½μ λν μμΈ μ€λͺ μ λ³λμ λ¬Έμλ₯Ό μ°Έκ³ νλ€.
airflow_trigger/ κ²½λ‘λ Airflow REST APIλ₯Ό νΈμΆνμ¬ Dagμ μλμΌλ‘ μ€ννκΈ° μν 보쑰 UIλ₯Ό μ 곡νλ€.
Airflow UIμ μ§μ μ κ·Όνμ§ μλ μ΄μ λ΄λΉμλ Google Sheets κΈ°λ° μμ
μμ νμν Dagλ§ μμ νκ² μ€ννλλ‘ λΆλ¦¬ν λꡬλ€.
airflow_trigger/fastapi/app.pyλ λ‘컬 λ€νΈμν¬μ λ€λ₯Έ μ»΄ν¨ν°λ Google Sheets λ§ν¬/λ²νΌμμ
νΉμ Dagμ μ€ννκΈ° μν μμ HTTP μλ²λ€. λΈλΌμ°μ μμ /trigger?dag_id=... μ£Όμλ‘ μ μνλ©΄ λ‘λ© νλ©΄μ νμνκ³ ,
Dag runμ΄ μ’
λ£λλ©΄ μλ΄ νμ
μ 보μ¬μ€ λ€ νμ΄μ§ μ’
λ£λ₯Ό μλνλ€.
νλ©΄μ λ
ΈμΆν μμ
λͺ
μ dag_name νλΌλ―Έν°λ‘ μ§μ ν μ μμΌλ©°, μ€μ μ€ν λμμ νμ dag_idλ‘ κ²°μ νλ€.
http://localhost:16160/trigger?dag_id=gsheets_opex&dag_name=μ΄μλΉμ©%20μ
λ°μ΄νΈ
νλ‘κ·Έλ¨μμ JSON μλ΅μ΄ νμνλ©΄ /api/triggerλ₯Ό μ¬μ©νλ€. νκ²½λ³μ, νμ© DAG λͺ©λ‘, Docker μ€ν λ°©λ²μ
λ³λμ λ¬Έμλ₯Ό μ°Έκ³ νλ€.
cd airflow_trigger/fastapi
docker compose up -d --buildairflow_trigger/streamlit/app.pyλ μμ
μΌμ λ²μμ μ°¨μλ₯Ό μ νν΄μ
sabangnet_order Dag runμ μμ±νλ μ΄μμ© UIλ€. Streamlit secretsμμ Airflow μ μ μ 보λ₯Ό μ½κ³ ,
/auth/tokenμΌλ‘ access tokenμ λ°κΈλ°μ λ€ /api/v2/dags/{dag_id}/dagRuns μλν¬μΈνΈλ₯Ό νΈμΆνλ€.
κ°μ logical_dateμ κΈ°μ‘΄ Dag runμ΄ μμΌλ©΄ μμ ν λ€ μλ‘ μ€ννλ―λ‘,
κ°μ 쑰건μ μλ μ€νμ λ€μ μμ²ν μ μλ€. Docker μ€ν, secrets μ€μ , μ
λ ₯κ°κ³Ό Dag run μμ± κ·μΉμ
λ³λμ λ¬Έμλ₯Ό μ°Έκ³ νλ€.
cd airflow_trigger/streamlit
docker compose up -d --buildpip install -e .ν¨ν€μ§ λ©νλ°μ΄ν°λ pyproject.tomlμ μ μλμ΄ μλ€.
cd postgres
./build.sh
docker compose up -dcd airflow
docker compose up airflow-init
docker compose up -dAirflowμμ dual loadλ₯Ό μ€ννλ €λ©΄ postgres, bigquery Airflow Connectionμ λ¨Όμ λ±λ‘ν΄μΌ νλ€.
pytest src/tests/test_extract.py -m extract -v -s
pytest src/tests/test_transform.py -m transform -v -s
pytest src/tests/test_load.py -m load -v -sfrom linkmerce.api.smartstore.api import marketing_channel
from linkmerce.common.load import DuckDBConnection
with DuckDBConnection(tzinfo="Asia/Seoul") as conn:
rows = marketing_channel(
client_id="...",
client_secret="...",
channel_seq="...",
start_date="2025-01-01",
end_date="2025-01-31",
connection=conn,
return_type="json",
)
print(len(rows))μ΄ νΈμΆμ API ν¨μλ₯Ό ν΅ν΄ Extractorμ DuckDBTransformerλ₯Ό μ°μμ μΌλ‘ μ€ννκ³ ,
κ²°κ³Όλ₯Ό DuckDB ν
μ΄λΈμ μ μ¬ν λ€, ν
μ΄λΈ νμ μ‘°ννμ¬ list[dict] νμμΌλ‘ λ°ννλ€.